package handlers import ( "encoding/json" "fmt" "slices" "strings" "time" "github.com/gofiber/fiber/v2" "wucher/internal/domain/audit" "wucher/internal/service" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/transport/http/dto" "wucher/internal/transport/http/jsonapi" "wucher/internal/transport/http/response" ) type AuditHandler struct { svc *service.AuditLogService } func NewAuditHandler(svc *service.AuditLogService) *AuditHandler { return &AuditHandler{svc: svc} } // ListAuditLogs godoc // @Summary List audit logs // @Description JSON:API list with pagination, filtering and sorting. // @Tags Audit Logs // @Produce json // @Param filter[search] query string false "Search by request_id/layer/action/method/path/message (contains)" // @Param filter[method] query string false "Method filter; single/comma-separated values or ALL/*" Enums(POST,PUT,PATCH,DELETE,UPDATE,ALL,*) // @Param method query string false "Alias for filter[method]" Enums(POST,PUT,PATCH,DELETE,UPDATE,ALL,*) // @Param filter[module] query string false "Module filter; single/comma-separated values or ALL/*" Enums(air_rescuer_checklist,audit_log,auth,base,base_regular,base_hems,base_roster,branding,contact,duty_roster,facility,federal_state,file_manager,flight,flight_data,forces_present,health_insurance_company,helicopter,helicopter_file,hems_roster,hospital,icao,insurance_patient_data,land,master_setting,medicine,mission,opc,patient_data,reserve_ac,role,user,vocation,unknown,ALL,*) // @Param module query string false "Alias for filter[module]" Enums(air_rescuer_checklist,audit_log,auth,base,base_regular,base_hems,base_roster,branding,contact,duty_roster,facility,federal_state,file_manager,flight,flight_data,forces_present,health_insurance_company,helicopter,helicopter_file,hems_roster,hospital,icao,insurance_patient_data,land,master_setting,medicine,mission,opc,patient_data,reserve_ac,role,user,vocation,unknown,ALL,*) // @Param filter[action] query string false "Action filter; single/comma-separated values or ALL/*" Enums(list,get,create,update,delete,export,custom,ALL,*) // @Param action query string false "Alias for filter[action]" Enums(list,get,create,update,delete,export,custom,ALL,*) // @Param page[number] query int false "Page number (default 1)" // @Param page[size] query int false "Page size (default 20, max 200)" // @Param sort query string false "Sort order" Enums(created_at,-created_at,module,-module,action,-action,layer,-layer,status_code,-status_code,success,-success) // @Success 200 {object} jsonapi.Document // @Failure 400 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/audit-logs/get-all [get] func (h *AuditHandler) List(c *fiber.Ctx) error { filter := c.Query("filter[search]") if filter == "" { filter = c.Query("filter[action]") } if filter == "" { filter = c.Query("filter[layer]") } methodFilter := c.Query("filter[method]") if methodFilter == "" { methodFilter = c.Query("method") } methods := parseAuditMethodFilter(methodFilter) moduleFilter := c.Query("filter[module]") if moduleFilter == "" { moduleFilter = c.Query("module") } modules := parseAuditTokenFilter(moduleFilter) actionFilter := c.Query("filter[action]") if actionFilter == "" { actionFilter = c.Query("action") } actions := parseAuditTokenFilter(actionFilter) pageNumber := parseIntDefault(c.Query("page[number]"), 1) pageSize := parseIntDefault(c.Query("page[size]"), 20) if pageSize > 200 { pageSize = 200 } if pageNumber < 1 { pageNumber = 1 } offset := (pageNumber - 1) * pageSize _ = offset sort := c.Query("sort") if errObj := validateAuditListQuery(methods, modules, actions, sort); errObj != nil { return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj}) } logs, total, err := h.svc.List(c.UserContext(), filter, methods, modules, actions, sort, 0, 0) if err != nil { return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } data := make([]dto.AuditLogResource, 0, len(logs)) for i := range logs { data = append(data, auditLogResource(&logs[i])) } meta := map[string]any{ "total": total, } return response.WriteWithMeta(c, fiber.StatusOK, data, meta) } func parseAuditMethodFilter(raw string) []string { v := strings.TrimSpace(raw) if v == "" { return nil } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, part := range parts { token := strings.TrimSpace(part) if token == "" { continue } out = append(out, token) } return out } func auditLogResource(v *audit.AuditLog) dto.AuditLogResource { id, _ := uuidv7.BytesToString(v.ID) actorID := "" if len(v.ActorUserID) == 16 { actorID, _ = uuidv7.BytesToString(v.ActorUserID) } var metadata any if len(v.Metadata) > 0 { _ = json.Unmarshal(v.Metadata, &metadata) } module := strings.TrimSpace(v.Module) if module == "" { module = inferAuditModuleFromPath(strings.TrimSpace(v.Path)) } return dto.AuditLogResource{ Type: "audit_log", ID: id, Attributes: dto.AuditLogAttributes{ RequestID: strings.TrimSpace(v.RequestID), ActorUserID: actorID, Layer: strings.TrimSpace(v.Layer), Module: module, Action: strings.TrimSpace(v.Action), Method: strings.TrimSpace(v.Method), Path: strings.TrimSpace(v.Path), StatusCode: v.StatusCode, Success: v.Success, IP: strings.TrimSpace(v.IP), UserAgent: strings.TrimSpace(v.UserAgent), Message: strings.TrimSpace(v.Message), Metadata: metadata, CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339), }, } } func inferAuditModuleFromPath(path string) string { parts := strings.Split(strings.Trim(strings.ToLower(strings.TrimSpace(path)), "/"), "/") if len(parts) < 3 || parts[0] != "api" || parts[1] != "v1" { return sharedconst.AuditModuleUnknown } switch parts[2] { case "air-rescuer-checklist": return sharedconst.AuditModuleAirRescuerChecklist case "audit-logs": return sharedconst.AuditModuleAuditLog case "auth": return sharedconst.AuditModuleAuth case "bases": return sharedconst.AuditModuleBase case "branding", "public": return sharedconst.AuditModuleBranding case "contacts": return sharedconst.AuditModuleContact case "duty-roster": return sharedconst.AuditModuleDutyRoster case "dul": return sharedconst.AuditModuleDUL case "facilities": return sharedconst.AuditModuleFacility case "federal-state": return sharedconst.AuditModuleFederalState case "file-manager": return sharedconst.AuditModuleFileManager case "flight-data": return sharedconst.AuditModuleFlightData case "flights": return sharedconst.AuditModuleFlight case "forces-present": return sharedconst.AuditModuleForcesPresent case "health-insurance-companies": return sharedconst.AuditModuleHealthInsuranceCompany case "helicopter-files": return sharedconst.AuditModuleHelicopterFile case "helicopters": return sharedconst.AuditModuleHelicopter case "hems-operation", "hems-operation-category", "hems-operational-data": return sharedconst.AuditModuleUnknown case "hospital": return sharedconst.AuditModuleHospital case "icao": return sharedconst.AuditModuleICAO case "insurance-patient-data": return sharedconst.AuditModuleInsurancePatientData case "land": return sharedconst.AuditModuleLand case "master-settings": return sharedconst.AuditModuleMasterSetting case "medicine": return sharedconst.AuditModuleMedicine case "mission": return sharedconst.AuditModuleMission case "opc": return sharedconst.AuditModuleOPC case "patient-data": return sharedconst.AuditModulePatientData case "reserve-acs": return sharedconst.AuditModuleReserveAC case "roles": return sharedconst.AuditModuleRole case "users": return sharedconst.AuditModuleUser case "vocation": return sharedconst.AuditModuleVocation default: return sharedconst.AuditModuleUnknown } } func parseAuditTokenFilter(raw string) []string { v := strings.TrimSpace(raw) if v == "" { return nil } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, part := range parts { token := strings.TrimSpace(part) if token == "" { continue } out = append(out, token) } return out } func validateAuditListQuery(methods, modules, actions []string, sort string) *jsonapi.ErrorObject { for _, method := range methods { v := strings.ToUpper(strings.TrimSpace(method)) if v == "" || v == "ALL" || v == "*" { continue } if !slices.Contains([]string{"POST", "PUT", "PATCH", "DELETE", "UPDATE"}, v) { return &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("invalid method filter: %s", method), Source: &jsonapi.ErrorSource{Pointer: "/query/method"}, } } } validModules := []string{ sharedconst.AuditModuleAirRescuerChecklist, sharedconst.AuditModuleAuditLog, sharedconst.AuditModuleAuth, sharedconst.AuditModuleBase, sharedconst.AuditModuleBaseRegular, sharedconst.AuditModuleBaseHEMS, sharedconst.AuditModuleBranding, sharedconst.AuditModuleContact, sharedconst.AuditModuleDutyRoster, sharedconst.AuditModuleDUL, sharedconst.AuditModuleFacility, sharedconst.AuditModuleFederalState, sharedconst.AuditModuleFileManager, sharedconst.AuditModuleFlightData, sharedconst.AuditModuleFlight, sharedconst.AuditModuleForcesPresent, sharedconst.AuditModuleHealthInsuranceCompany, sharedconst.AuditModuleHelicopterFile, sharedconst.AuditModuleHelicopter, sharedconst.AuditModuleHospital, sharedconst.AuditModuleICAO, sharedconst.AuditModuleInsurancePatientData, sharedconst.AuditModuleLand, sharedconst.AuditModuleMasterSetting, sharedconst.AuditModuleMedicine, sharedconst.AuditModuleMission, sharedconst.AuditModuleOPC, sharedconst.AuditModulePatientData, sharedconst.AuditModuleReserveAC, sharedconst.AuditModuleRole, sharedconst.AuditModuleUser, sharedconst.AuditModuleVocation, sharedconst.AuditModuleUnknown, } for _, module := range modules { v := strings.ToLower(strings.TrimSpace(module)) if v == "" || v == "all" || v == "*" { continue } if !slices.Contains(validModules, v) { return &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("invalid module filter: %s", module), Source: &jsonapi.ErrorSource{Pointer: "/query/module"}, } } } validActions := []string{ sharedconst.AuditActionList, sharedconst.AuditActionGet, sharedconst.AuditActionCreate, sharedconst.AuditActionUpdate, sharedconst.AuditActionDelete, sharedconst.AuditActionExport, sharedconst.AuditActionCustom, } for _, action := range actions { v := strings.ToLower(strings.TrimSpace(action)) if v == "" || v == "all" || v == "*" { continue } if !slices.Contains(validActions, v) { return &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("invalid action filter: %s", action), Source: &jsonapi.ErrorSource{Pointer: "/query/action"}, } } } if strings.TrimSpace(sort) != "" { allowed := map[string]struct{}{ "created_at": {}, "-created_at": {}, "module": {}, "-module": {}, "action": {}, "-action": {}, "layer": {}, "-layer": {}, "status_code": {}, "-status_code": {}, "success": {}, "-success": {}, } if _, ok := allowed[strings.TrimSpace(sort)]; !ok { return &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("invalid sort: %s", sort), Source: &jsonapi.ErrorSource{Pointer: "/query/sort"}, } } } return nil }