init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
package sharedconst
const (
AuditModuleUnknown = "unknown"
AuditModuleAirRescuerChecklist = "air_rescuer_checklist"
AuditModuleAuditLog = "audit_log"
AuditModuleAuth = "auth"
AuditModuleBase = "base"
AuditModuleBaseRegular = "base_regular"
AuditModuleBaseHEMS = "base_hems"
AuditModuleBranding = "branding"
AuditModuleContact = "contact"
AuditModuleDutyRoster = "duty_roster"
AuditModuleDUL = "dul"
AuditModuleFacility = "facility"
AuditModuleFederalState = "federal_state"
AuditModuleFileManager = "file_manager"
AuditModuleFlightData = "flight_data"
AuditModuleFlight = "flight"
AuditModuleForcesPresent = "forces_present"
AuditModuleHealthInsuranceCompany = "health_insurance_company"
AuditModuleHelicopterFile = "helicopter_file"
AuditModuleHelicopter = "helicopter"
AuditModuleHospital = "hospital"
AuditModuleICAO = "icao"
AuditModuleInsurancePatientData = "insurance_patient_data"
AuditModuleLand = "land"
AuditModuleMasterSetting = "master_setting"
AuditModuleMedicine = "medicine"
AuditModuleMission = "mission"
AuditModuleOPC = "opc"
AuditModulePatientData = "patient_data"
AuditModuleReserveAC = "reserve_ac"
AuditModuleRole = "role"
AuditModuleUser = "user"
AuditModuleVocation = "vocation"
)
const (
AuditActionList = "list"
AuditActionGet = "get"
AuditActionCreate = "create"
AuditActionUpdate = "update"
AuditActionDelete = "delete"
AuditActionExport = "export"
AuditActionImport = "import"
AuditActionCustom = "custom"
)

View File

@@ -0,0 +1,5 @@
package sharedconst
const (
BrandingEmailLogoPublicPath = "/api/v1/public/branding/email-logo"
)

View File

@@ -0,0 +1,14 @@
package sharedconst
const (
FlightDataResourceType = "flight_data"
FlightDataLocationTypeICAO = "icao"
FlightDataLocationTypeHospital = "hospital"
FlightDataFacilityCategoryHesloRopeLabel = "heslo-rope-label"
FlightDataFacilityCategoryHesloRopeLength = "heslo-rope-length"
FlightDataFacilityCategoryHesloHook = "heslo-hook"
FlightDataFacilityCategoryHecRopeLabel = "hec-rope-label"
FlightDataFacilityCategoryHecRopeLength = "hec-rope-length"
)

View File

@@ -0,0 +1,15 @@
package sharedconst
const (
MissionDefaultPageSize = 20
MissionMaxPageSize = 100
)
const (
MissionResourceType = "mission"
MissionTypeCAT = "CAT"
MissionTypeSPO = "SPO"
MissionTypeNCO = "NCO"
MissionTypeHEMS = "HEMS"
)

View File

@@ -0,0 +1,344 @@
package sharedconst
import "strings"
const (
// Opaque transaction IDs for PIN-protected HTTP actions.
PinActionUserCreate = "2a59e8b4-5a72-4be3-b8fc-4f49941181c3"
PinActionUserUpdate = "428fdf48-3f99-46ea-bc47-8fb2e312be08"
PinActionUserDelete = "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"
PinActionRoleCreate = "7fb1d693-8692-4e8f-be86-fd0e29e96bc3"
PinActionRoleUpdate = "18ca5bbf-ce9d-4e24-8ace-793de56c1de0"
PinActionRoleDelete = "b87fca76-783d-45e7-9022-b2d9c95f6da7"
PinActionRolePermissionAssign = "48e523f8-d86f-4b24-b0e3-e213e4d33db4"
PinActionRolePermissionRemove = "f2bf59f8-0783-40fc-94ce-a79e40f84c44"
PinActionRolePermissionUpdate = "de8f3c0c-8f84-4c13-a146-5ca833bc66f7"
PinActionHelicopterCreate = "11c8f526-2bf5-4507-bf6b-5ea65e4f2b8a"
PinActionHelicopterReportGenerate = "f84fc149-c1a8-4b55-8d68-7f1130a91c62"
PinActionHelicopterUpdate = "7436fdf4-0f71-4a5d-a21e-a22cbf7f45fa"
PinActionHelicopterDelete = "3b3f6c0c-7e9d-4d7e-b2f2-250d325a9008"
PinActionHelicopterAOGChange = "9b2a8f32-b7f1-4cc8-9f75-2f7f4e5d8f1c"
PinActionFlightInspectionCreate = "6a4f31e2-f6f2-4fd0-8e0f-c56613fb7996"
PinActionFlightInspectionUpdate = "fe936c6f-6634-4c8f-88c0-86f46c22f6e2"
PinActionAfterFlightInspectionRead = "7a4f1d7a-3f8e-4a8b-9f8c-5e7b3a9d8a31"
PinActionAfterFlightInspectionCreate = "1d7f1e5c-0c1f-4d5d-9c59-2d7b7f9d3c2f"
PinActionAfterFlightInspectionUpdate = "b4a7e8b2-1fb4-4c9f-9e6b-7f53c0b4f1a8"
PinActionAfterFlightInspectionDelete = "d9c0b6f3-5f3b-4f9d-8a5b-1b0c2d7a9e4f"
PinActionFlightCreate = "03482710-17bb-4478-b379-fafd92a6f2f1"
PinActionFlightUpdate = "f5fe23d8-79af-4f53-82f8-ef5436f54d73"
PinActionFlightDelete = "846f2a97-b4c0-4a89-bacf-14393968ce4d"
PinActionReserveACCreate = "70fccbfe-1908-44fd-97eb-8ec7cb26f441"
PinActionReserveACUpdate = "514ec6f8-57ef-43a2-ad8c-48be77ef7f7e"
PinActionReserveACDelete = "01954c5e-e321-4556-aef1-ed9724f64409"
PinActionFacilityCreate = "4a4e2041-258c-4f12-89af-8473f8b666d6"
PinActionFacilityUpdate = "2a7530e9-b365-4229-b2cc-a7059dbb6530"
PinActionFacilityDelete = "6ca1f6ca-b89f-4be2-9e5d-bebcd4f7140f"
PinActionBaseCreate = "195f2e65-8159-42a9-a198-34fa8e47a9b4"
PinActionBaseUpdate = "f57ef758-53e6-4065-b1ed-58afbfd1bb89"
PinActionBaseDelete = "2e6c907f-7e29-4617-9fd5-5298c4cb5f16"
PinActionMedicineCreate = "8477bc66-28b8-4c21-aa15-3e06d8944aef"
PinActionMedicineUpdate = "832ddfcb-ef58-4c00-b863-d5e11fec3e3d"
PinActionMedicineDelete = "c5ec1cb5-f403-48de-868a-576e4d1d84e1"
PinActionVocationCreate = "e07b5460-5e74-4df7-a81a-0be67a62cc58"
PinActionVocationUpdate = "f3629c15-8a20-4c61-ba04-81bdff021ae6"
PinActionVocationDelete = "dbb1f792-f15c-4a19-9e5d-07c42c2a79df"
PinActionOpcCreate = "97285960-79b9-4beb-97c6-68d0a1113a1d"
PinActionOpcUpdate = "f5c3018b-fcec-470e-88ed-99d58f6d1d45"
PinActionOpcDelete = "f8d692cf-8196-4eab-b33d-fa073729f5a6"
PinActionForcesPresentCreate = "fb4026a1-5b41-4a39-bd3e-9f659de7422f"
PinActionForcesPresentUpdate = "9e3edd96-7a70-406f-bfa0-5990999f8563"
PinActionForcesPresentDelete = "8abebbd0-a265-44d2-8c24-a9787fa168bf"
PinActionNoIcaoCodeCreate = "6de5d0e9-f157-4f8f-b86f-470f0f2f4f59"
PinActionNoIcaoCodeUpdate = "f116da2f-1229-4f12-97ec-ecbff1936fa7"
PinActionNoIcaoCodeDelete = "f315f998-6bc2-4bdd-8aab-2a61184bf51d"
PinActionHospitalCreate = PinActionNoIcaoCodeCreate
PinActionHospitalUpdate = PinActionNoIcaoCodeUpdate
PinActionHospitalDelete = PinActionNoIcaoCodeDelete
PinActionHealthInsuranceCompaniesCreate = "7d6494e4-44f3-4a49-a370-8cf0f430af26"
PinActionHealthInsuranceCompaniesUpdate = "88f1e75f-bfc0-48d7-bbe2-10f02f92c514"
PinActionHealthInsuranceCompaniesDelete = "5ce3f937-1f8c-4140-9f50-9f0a0d9b930f"
PinActionFederalStateCreate = "17720d29-8afa-4932-93a3-40d44fbf8563"
PinActionFederalStateUpdate = "43f5db24-ac9f-44cd-82da-2d596d93c736"
PinActionFederalStateDelete = "b37f7fb2-fdf4-4f31-9de3-a62f0b47eb3b"
PinActionICAOCreate = "f7d407cf-0fb7-4b6b-a530-a22f378f0f2d"
PinActionICAOUpdate = "96f05643-b5ca-4d36-9459-f31d55c5c350"
PinActionICAODelete = "89f3de85-4bca-4fce-8c8f-b79156257e95"
PinActionCountryCreate = "056984e1-e316-428e-a9a1-c4ed6fac2c5a"
PinActionCountryUpdate = "436fcde1-50d3-4bfc-9d90-a082c94732a1"
PinActionCountryDelete = "b72ecaf6-fa38-4bd9-a272-5b00307e5e34"
PinActionLandCreate = PinActionCountryCreate
PinActionLandUpdate = PinActionCountryUpdate
PinActionLandDelete = PinActionCountryDelete
PinActionMasterSettingsCreate = "24f9e3f1-4de7-4fd4-bdf6-5e6ac7d3a8a5"
PinActionMasterSettingsUpdate = "8ff7fa7f-8d27-4e7a-81dc-b2d15a6cce9d"
PinActionMasterSettingsDelete = "c0c9cbe8-f281-44e9-8414-47a5fcc8d6d3"
PinActionCrewCreate = "c6911bc0-9677-4a6d-96e5-8debcde079cb"
PinActionCrewUpdate = "f89fc2cb-11f0-46c8-b5db-71f9be889734"
PinActionCrewDelete = "49e6782e-7d38-48bd-b949-8414827483fb"
PinActionFileManagerFolderCreate = "0a5f5463-0f9c-4f75-8da4-5aef28e0fc42"
PinActionFileManagerFolderUpdate = "b1fb11f7-a8b4-4d80-b3aa-f54a0f4ec9c0"
PinActionFileManagerFolderDelete = "6b84e2d6-2b61-44e3-a4df-7ad4568baf5d"
PinActionFileManagerFolderRestore = "08f5d1f2-99a6-4e2e-aaf9-24103fb34b5f"
PinActionFileManagerFolderPurge = "645c13eb-0ef4-4f3f-a31f-81f7f08a7718"
PinActionFileManagerFileCreate = "f8f4e17c-9b2f-4f1a-a86a-c8c4f2a9de41"
PinActionFileManagerFileUpload = "8ef739b4-a56a-4fb2-b563-0bc8f1e9d305"
PinActionFileManagerFileUpdate = "4f6405aa-b889-4b9a-8872-d662ef53fcb8"
PinActionFileManagerFileDelete = "29db3d11-1064-4940-bf97-e9c8d8cab2b1"
PinActionFileManagerFileRestore = "198edb44-8443-4b66-ae5f-1587c3130cc0"
PinActionFileManagerFilePurge = "4d2ad12d-aafd-460b-818d-e4bd8f4bf947"
PinActionFileManagerAttachmentCreate = "55f6de50-49ea-4e03-b16f-cfe1bcb4f0a2"
PinActionFileManagerAttachmentDelete = "39ef5f25-a86d-4f06-a5fc-f85ba6df8a45"
PinActionHemsDutyRosterCreate = "a9a8d9d7-8804-4f2b-bd17-a990488f0fc1"
PinActionHemsDutyRosterUpdate = "c1c9f6d6-205f-4488-b68a-29f810123b7b"
PinActionHemsDutyRosterDelete = "3f8c8de7-10eb-4e92-9415-cfca2b50cc72"
PinActionHemsDutyRosterDeleteCrew = "b2f1d1f1-a8b5-43ea-8fea-e4f3225e9d31"
PinActionBaseDutyRosterCreate = "b1f0743f-4af8-43f9-acf1-f3ad3df842fb"
PinActionBaseDutyRosterUpdate = "cb7f6494-7035-4288-a736-c21ca2aaad6e"
PinActionBaseDutyRosterDelete = "399be2cb-b7ea-4e4a-a95e-a45a78475c4f"
PinActionBaseDutyRosterDeleteCrew = "f7f57b0a-6e4a-4a59-b8cc-5b0c7fdb3f0f"
PinActionAirRescuerChecklistCreate = "0da46094-7af2-4897-84f2-d4f6409a23cd"
PinActionAirRescuerChecklistUpdate = "2a8df0df-6c30-4338-a7eb-a288f8f43cb8"
PinActionAirRescuerChecklistDelete = "74c4dd5f-4040-4b4f-87c6-f7ca6ad2ffde"
PinActionFlightPrepCheckCreate = "c2d8014f-86f7-4e59-acaa-7126d8fa1b2f"
PinActionFlightPrepCheckUpdate = "acbe9954-e9b2-447f-bf53-f10da99f1ff8"
PinActionInsurancePatientDataCreate = "5b8f8f70-c3f5-4c3a-8e3f-fb5bb886f12f"
PinActionInsurancePatientDataUpdate = "ea5da1cf-735f-4bcf-8b6d-d50b9f0f2e85"
PinActionInsurancePatientDataDelete = "f5e36c38-10cf-4df2-8eb0-e546f57ecce1"
PinActionPatientDataCreate = "a8f4dd13-f255-4d22-8fcf-3ddedca95f79"
PinActionPatientDataUpdate = "0171a3fe-33ee-450f-9f64-bfe2ce6ecf72"
PinActionPatientDataDelete = "a311702f-778a-4737-9fa5-cb67bf0e43cb"
PinActionOperationCreate = "cd6d274d-8459-4fd8-b840-f3f9c06a53d2"
PinActionOperationUpdate = "5340a403-7eb1-4f57-966a-d5da679048f4"
PinActionOperationDelete = "b5d6ec7c-994b-40f5-90f5-f38bf0878e6e"
PinActionOperationCategoryCreate = "e1cb42db-f6d1-42e5-8d5f-cf711ab4bff2"
PinActionOperationCategoryUpdate = "ed5e8d89-0674-4378-b260-d560f815d8cb"
PinActionOperationCategoryDelete = "3f7b7ce4-f358-4898-baea-19b8f15235d1"
PinActionHemsOperationCreate = "5fef650e-0477-4200-af6c-3034f2cd96a6"
PinActionHemsOperationUpdate = "2c20f88a-5866-4972-a908-c9766156f332"
PinActionHemsOperationDelete = "62de5f5f-e737-4f58-8d8f-b3fce2d618d7"
PinActionAirRescuerChecklistItemCreate = "654f0050-c4ad-4dd3-aa52-4a93e9737397"
PinActionAirRescuerChecklistItemUpdate = "73ce2aaf-b7a5-4c05-af6f-536775df6e5d"
PinActionAirRescuerChecklistItemDelete = "9f2053e6-4be8-4d77-b6f5-89ad6d04a2a4"
PinActionAuditLogRead = "c847e7fc-5e44-48f0-a7d9-1e8d15543d74"
PinActionTOTPResetSetup = "0b5409b5-94f3-4aab-9de2-44b0ad5858f1"
PinActionWebAuthnResetSetup = "1f7d457e-0a19-4a36-8d9f-2c66fd2369ab"
PinActionSessionDelete = "3d7d1d5f-ec48-4eca-a7ea-37f4f7927d06"
PinActionContactCreate = "6eea32d5-3c73-4a63-a929-ace48542bd5e"
PinActionContactUpdate = "aa2cfd85-f87d-400f-918c-01d90d965516"
PinActionContactDelete = "1d6d5f43-c174-488f-aab5-8f71be570156"
PinActionContactStatusChange = "31f8bb88-a95f-43be-8fd8-129e1355d6a5"
PinActionContactRoleChange = "28d6b6fa-4de8-4e70-b65d-59bb54ca9387"
PinActionContactPilotCategoryChange = "a90d6543-4196-4a94-b2f5-c1db89dc0f79"
PinActionContactLicenseEdit = "dc4cc081-68e5-4304-8b39-40ef7d995f48"
PinActionContactChiefFlagsEdit = "ebc77226-7d84-4b4f-9f08-27f004770aee"
PinActionContactResponsibleComplaints = "4f4c8f3d-73ea-4eec-8d08-c3be6e2ffad6"
PinActionContactBulkUpdate = "d9dc5187-c67e-4f58-9dd3-2664cbc9f9fd"
PinActionContactIrreversible = "78f90214-8bcf-4274-adfb-6d207c5f5af6"
PinActionFmReportComplete = "1a75135b-430a-4592-83e1-6d27814185af"
PinActionFmReportDelete = "9b4e6d8f-0a2c-4c5e-a7f3-1b0d5e7c9a2f"
PinActionEASAReleaseSign = "e4a1c2d8-6b3f-4f7a-9c21-8d5e0b7a4f36"
)
func PinActionForPermissionKey(permissionKey string) string {
switch strings.TrimSpace(strings.ToLower(permissionKey)) {
case "user.create":
return PinActionUserCreate
case "user.update":
return PinActionUserUpdate
case "user.delete":
return PinActionUserDelete
case "role.create":
return PinActionRoleCreate
case "role.update":
return PinActionRoleUpdate
case "role.delete":
return PinActionRoleDelete
case "role.permission.assign":
return PinActionRolePermissionAssign
case "role.permission.remove":
return PinActionRolePermissionRemove
case "role.permission.update":
return PinActionRolePermissionUpdate
case "audit.read":
return PinActionAuditLogRead
case "helicopter.create":
return PinActionHelicopterCreate
case "helicopter.update":
return PinActionHelicopterUpdate
case "helicopter.delete":
return PinActionHelicopterDelete
case "helicopter.report.generate":
return PinActionHelicopterReportGenerate
case "flight_inspection.create":
return PinActionFlightInspectionCreate
case "flight_inspection.update":
return PinActionFlightInspectionUpdate
case "after_flight.read":
return PinActionAfterFlightInspectionRead
case "after_flight.create":
return PinActionAfterFlightInspectionCreate
case "after_flight.update":
return PinActionAfterFlightInspectionUpdate
case "after_flight.delete":
return PinActionAfterFlightInspectionDelete
case "flight.create":
return PinActionFlightCreate
case "flight.update":
return PinActionFlightUpdate
case "flight.complete":
return PinActionFmReportComplete
case "easa_release.sign":
return PinActionEASAReleaseSign
case "flight.delete":
return PinActionFlightDelete
case "reserve_ac.create":
return PinActionReserveACCreate
case "reserve_ac.update":
return PinActionReserveACUpdate
case "reserve_ac.delete":
return PinActionReserveACDelete
case "facility.create":
return PinActionFacilityCreate
case "facility.update":
return PinActionFacilityUpdate
case "facility.delete":
return PinActionFacilityDelete
case "base.create":
return PinActionBaseCreate
case "base.update":
return PinActionBaseUpdate
case "base.delete":
return PinActionBaseDelete
case "hems_base.create":
return PinActionBaseCreate
case "hems_base.update":
return PinActionBaseUpdate
case "hems_base.delete":
return PinActionBaseDelete
case "medicine.create":
return PinActionMedicineCreate
case "medicine.update":
return PinActionMedicineUpdate
case "medicine.delete":
return PinActionMedicineDelete
case "vocation.create":
return PinActionVocationCreate
case "vocation.update":
return PinActionVocationUpdate
case "vocation.delete":
return PinActionVocationDelete
case "opc.create":
return PinActionOpcCreate
case "opc.update":
return PinActionOpcUpdate
case "opc.delete":
return PinActionOpcDelete
case "forces_present.create":
return PinActionForcesPresentCreate
case "forces_present.update":
return PinActionForcesPresentUpdate
case "forces_present.delete":
return PinActionForcesPresentDelete
case "hospital.create", "no_icao_code.create":
return PinActionNoIcaoCodeCreate
case "hospital.update", "no_icao_code.update":
return PinActionNoIcaoCodeUpdate
case "hospital.delete", "no_icao_code.delete":
return PinActionNoIcaoCodeDelete
case "health_insurance_companies.create":
return PinActionHealthInsuranceCompaniesCreate
case "health_insurance_companies.update":
return PinActionHealthInsuranceCompaniesUpdate
case "health_insurance_companies.delete":
return PinActionHealthInsuranceCompaniesDelete
case "federal_state.create":
return PinActionFederalStateCreate
case "federal_state.update":
return PinActionFederalStateUpdate
case "federal_state.delete":
return PinActionFederalStateDelete
case "icao.create":
return PinActionICAOCreate
case "icao.update":
return PinActionICAOUpdate
case "icao.delete":
return PinActionICAODelete
case "land.create", "country.create":
return PinActionCountryCreate
case "land.update", "country.update":
return PinActionCountryUpdate
case "land.delete", "country.delete":
return PinActionCountryDelete
case "master_settings.create":
return PinActionMasterSettingsCreate
case "master_settings.update":
return PinActionMasterSettingsUpdate
case "master_settings.delete":
return PinActionMasterSettingsDelete
case "flight_prep_check.create":
return PinActionFlightPrepCheckCreate
case "flight_prep_check.update":
return PinActionFlightPrepCheckUpdate
case "insurance_patient_data.create":
return PinActionInsurancePatientDataCreate
case "insurance_patient_data.update":
return PinActionInsurancePatientDataUpdate
case "insurance_patient_data.delete":
return PinActionInsurancePatientDataDelete
case "patient_data.create":
return PinActionPatientDataCreate
case "patient_data.update":
return PinActionPatientDataUpdate
case "patient_data.delete":
return PinActionPatientDataDelete
case "operation.create":
return PinActionOperationCreate
case "operation.update":
return PinActionOperationUpdate
case "operation.delete":
return PinActionOperationDelete
case "operation_category.create":
return PinActionOperationCategoryCreate
case "operation_category.update":
return PinActionOperationCategoryUpdate
case "operation_category.delete":
return PinActionOperationCategoryDelete
case "hems_operation.create":
return PinActionHemsOperationCreate
case "hems_operation.update":
return PinActionHemsOperationUpdate
case "hems_operation.delete":
return PinActionHemsOperationDelete
case "hems_duty_roster.create":
return PinActionHemsDutyRosterCreate
case "hems_duty_roster.update":
return PinActionHemsDutyRosterUpdate
case "hems_duty_roster.delete":
return PinActionHemsDutyRosterDelete
case "air_rescuer_checklist.create":
return PinActionAirRescuerChecklistCreate
case "air_rescuer_checklist.update":
return PinActionAirRescuerChecklistUpdate
case "air_rescuer_checklist.delete":
return PinActionAirRescuerChecklistDelete
case "file_manager.create":
return PinActionFileManagerFileCreate
case "file_manager.update":
return PinActionFileManagerFileUpdate
case "file_manager.delete":
return PinActionFileManagerFileDelete
case "file_manager.folder.create":
return PinActionFileManagerFolderCreate
case "file_manager.folder.update":
return PinActionFileManagerFolderUpdate
case "file_manager.folder.delete":
return PinActionFileManagerFolderDelete
case "file_manager.file.create":
return PinActionFileManagerFileCreate
case "file_manager.file.update":
return PinActionFileManagerFileUpdate
case "file_manager.file.delete":
return PinActionFileManagerFileDelete
}
return ""
}

View File

@@ -0,0 +1,57 @@
package appctx
import (
"context"
"strings"
"wucher/internal/shared/pkg/uuidv7"
)
type contextKey string
const userIDKey contextKey = "appctx-user-id"
func WithUserID(ctx context.Context, userID []byte) context.Context {
if len(userID) == 0 {
return ctx
}
copyID := make([]byte, len(userID))
copy(copyID, userID)
return context.WithValue(ctx, userIDKey, copyID)
}
func GetUserID(ctx context.Context) []byte {
if ctx == nil {
return nil
}
raw := ctx.Value(userIDKey)
if raw == nil {
if getter, ok := any(ctx).(interface{ UserValue(any) any }); ok {
raw = getter.UserValue("user_id")
}
}
switch v := raw.(type) {
case []byte:
if len(v) == 0 {
return nil
}
out := make([]byte, len(v))
copy(out, v)
return out
case string:
s := strings.TrimSpace(v)
if s == "" {
return nil
}
id, err := uuidv7.ParseString(s)
if err != nil {
return nil
}
return id
default:
return nil
}
}

View File

@@ -0,0 +1,46 @@
package appctx
import (
"context"
"testing"
"wucher/internal/shared/pkg/uuidv7"
)
func TestWithUserIDAndGetUserID(t *testing.T) {
id, err := uuidv7.ParseString("00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatal(err)
}
ctx := WithUserID(context.Background(), id)
got := GetUserID(ctx)
if len(got) != len(id) {
t.Fatalf("expected %d bytes, got %d", len(id), len(got))
}
if string(got) != string(id) {
t.Fatalf("expected %x, got %x", id, got)
}
got[0] ^= 0xff
if string(GetUserID(ctx)) == string(got) {
t.Fatal("GetUserID returned a reference to internal data")
}
}
func TestGetUserIDFromLegacyContext(t *testing.T) {
ctx := &legacyContext{Context: context.Background(), userID: []byte("legacy")}
got := GetUserID(ctx)
if string(got) != "legacy" {
t.Fatalf("expected legacy user id, got %s", got)
}
}
type legacyContext struct {
context.Context
userID []byte
}
func (c *legacyContext) UserValue(_ any) any {
return c.userID
}

View File

@@ -0,0 +1,57 @@
package apperrors
import (
"errors"
"fmt"
"strings"
)
type DeleteConflictError struct {
RelatedModules []string
}
func (e *DeleteConflictError) Error() string {
modules := cleanModules(e.RelatedModules)
if len(modules) == 0 {
return "cannot delete this data because it is currently used by another module. please remove the relation first"
}
return fmt.Sprintf(
"cannot delete this data because it is currently used by %s. please remove the relation first",
strings.Join(modules, ", "),
)
}
func IsDeleteConflict(err error) bool {
var target *DeleteConflictError
return errors.As(err, &target)
}
func AsDeleteConflict(err error) (*DeleteConflictError, bool) {
var target *DeleteConflictError
if !errors.As(err, &target) {
return nil, false
}
return target, true
}
func NewDeleteConflictError(relatedModules ...string) error {
return &DeleteConflictError{RelatedModules: cleanModules(relatedModules)}
}
func cleanModules(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]struct{}{}
for _, module := range in {
trimmed := strings.TrimSpace(module)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
return out
}

View File

@@ -0,0 +1,36 @@
package apperrorsx
import "wucher/internal/transport/http/jsonapi"
// AppError is the canonical backend error contract used by translators and writers.
type AppError struct {
Status int
Code string
ErrorCode string
Title string
Message string
Detail any
Source *jsonapi.ErrorSource
Cause error
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
if e.Message != "" {
return e.Message
}
if e.Cause != nil {
return e.Cause.Error()
}
return e.Code
}
func (e *AppError) WithCause(err error) *AppError {
if e == nil {
return nil
}
e.Cause = err
return e
}

View File

@@ -0,0 +1,183 @@
package apperrorsx
import (
"fmt"
"net/http"
)
const (
ModuleGeneral = "00"
ModuleAuth = "01"
ModulePIN = "02"
ModuleHelicopter = "03"
ModuleHelicopterUsage = "35"
ModuleUser = "04"
ModuleMasterData = "05"
ModuleDutyRoster = "06"
ModuleFlight = "07"
ModuleContact = "08"
ModuleMissionOps = "09"
ModuleFacility = "10"
ModuleHospital = "11"
ModuleFederalState = "12"
ModuleNationality = "13"
ModuleICAO = "14"
ModuleLand = "15"
ModuleBase = "16"
ModuleHealthInsuranceCompanies = "17"
ModuleVocation = "18"
ModuleMedicine = "19"
ModuleMasterSettings = "20"
ModuleFileManager = "21"
ModuleOPCData = "22"
ModulePatientData = "23"
ModuleInsurancePatientData = "24"
ModuleRole = "25"
ModuleFlightData = "26"
ModuleAirRescuerChecklist = "27"
ModuleBranding = "28"
ModuleDUL = "29"
ModuleTakeover = "30"
ModuleFleetStatus = "31"
ModuleComplaint = "32"
ModuleEASARelease = "33"
ModuleMCF = "34"
ModuleHelicopterUsageSync = "35"
ModuleFMReport = "36"
ModuleAfterFlightInspection = "37"
ModuleActionSignoff = "38"
)
// ContextKeyHTTPErrorDetail stores the original, non-sanitized error detail for
// request logging. HTTP response writers should sanitize the public payload but
// keep this local available for access logs.
const ContextKeyHTTPErrorDetail = "http_error_detail"
// ContextKeyHTTPErrorInternalDetail stores an internal-only, verbose error
// summary for request logging. Unlike ContextKeyHTTPErrorDetail, this is not
// meant for public response sanitizers.
const ContextKeyHTTPErrorInternalDetail = "http_error_internal_detail"
type Def struct {
Status int
Code string
ErrorCode string
Title string
Message string
}
func Build(status int, moduleCode, caseCode, code, message string) Def {
return BuildWithTitle(
status,
moduleCode,
caseCode,
code,
titleForStatus(status),
message,
)
}
func BuildWithTitle(status int, moduleCode, caseCode, code, title, message string) Def {
return Def{
Status: status,
Code: code,
ErrorCode: fmt.Sprintf("%03d%s%s", status, moduleCode, caseCode),
Title: title,
Message: message,
}
}
func titleForStatus(status int) string {
switch status {
case http.StatusAccepted:
return "Accepted"
case http.StatusBadRequest:
return "Bad Request"
case http.StatusUnauthorized:
return "Unauthorized"
case http.StatusLocked:
return "Locked"
case http.StatusForbidden:
return "Forbidden"
case http.StatusNotFound:
return "Not found"
case http.StatusConflict:
return "Conflict"
case http.StatusUnprocessableEntity:
return "Validation error"
case http.StatusTooManyRequests:
return "Too many requests"
default:
return "Internal server error"
}
}
func New(def Def) *AppError {
return &AppError{
Status: def.Status,
Code: def.Code,
ErrorCode: def.ErrorCode,
Title: def.Title,
Message: def.Message,
}
}
func DefGeneral(status int, caseCode, code, message string) Def {
return Build(status, ModuleGeneral, caseCode, code, message)
}
func DefGeneralWithTitle(status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, ModuleGeneral, caseCode, code, title, message)
}
func DefAuth(status int, caseCode, code, message string) Def {
return Build(status, ModuleAuth, caseCode, code, message)
}
func DefPIN(status int, caseCode, code, message string) Def {
return Build(status, ModulePIN, caseCode, code, message)
}
func DefPINWithTitle(status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, ModulePIN, caseCode, code, title, message)
}
func DefHelicopter(status int, caseCode, code, message string) Def {
return Build(status, ModuleHelicopter, caseCode, code, message)
}
func DefHelicopterWithTitle(status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, ModuleHelicopter, caseCode, code, title, message)
}
func DefHelicopterUsage(status int, caseCode, code, message string) Def {
return Build(status, ModuleHelicopterUsage, caseCode, code, message)
}
func DefHelicopterUsageWithTitle(status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, ModuleHelicopterUsage, caseCode, code, title, message)
}
func DefHelicopterUsageSync(status int, caseCode, code, message string) Def {
return Build(status, ModuleHelicopterUsageSync, caseCode, code, message)
}
func DefHelicopterUsageSyncWithTitle(status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, ModuleHelicopterUsageSync, caseCode, code, title, message)
}
func DefFMReport(status int, caseCode, code, message string) Def {
return Build(status, ModuleFMReport, caseCode, code, message)
}
func DefFMReportWithTitle(status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, ModuleFMReport, caseCode, code, title, message)
}
func DefByModule(moduleCode string, status int, caseCode, code, message string) Def {
return Build(status, moduleCode, caseCode, code, message)
}
func DefByModuleWithTitle(moduleCode string, status int, caseCode, code, title, message string) Def {
return BuildWithTitle(status, moduleCode, caseCode, code, title, message)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
package apperrorsx
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/jsonapi"
)
var (
registryByCode map[string]Def
registryByErrorCode map[string]Def
)
func init() {
registryByCode = make(map[string]Def, len(Registry))
registryByErrorCode = make(map[string]Def, len(Registry))
for _, def := range Registry {
registryByCode[def.Code] = def
registryByErrorCode[def.ErrorCode] = def
}
}
func EnrichErrorObjects(status int, errs []jsonapi.ErrorObject) []jsonapi.ErrorObject {
out := make([]jsonapi.ErrorObject, 0, len(errs))
for i := range errs {
obj := errs[i]
obj.Status = strconv.Itoa(status)
def := inferDef(status, obj)
if obj.Code == "" && def.Code != "" {
obj.Code = def.Code
}
if obj.ErrorCode == "" && def.ErrorCode != "" {
obj.ErrorCode = def.ErrorCode
}
if obj.Title == "" && def.Title != "" {
obj.Title = def.Title
}
out = append(out, obj)
}
return out
}
func inferDef(status int, obj jsonapi.ErrorObject) Def {
if obj.Code != "" {
if def, ok := registryByCode[obj.Code]; ok {
def.Status = status
def.ErrorCode = Build(status, moduleFromErrorCode(def.ErrorCode), caseFromErrorCode(def.ErrorCode), def.Code, def.Message).ErrorCode
return def
}
}
if obj.ErrorCode != "" {
if def, ok := registryByErrorCode[obj.ErrorCode]; ok {
return def
}
}
switch status {
case fiber.StatusBadRequest:
return ErrInvalidRequest
case fiber.StatusUnauthorized:
return ErrUserUnauthorized
case fiber.StatusForbidden:
return ErrForbiddenAccess
case fiber.StatusNotFound:
return ErrDataNotFound
case fiber.StatusConflict:
return ErrBusinessRuleFailed
case fiber.StatusUnprocessableEntity:
return ErrValidation
case fiber.StatusTooManyRequests:
return ErrPINAttemptLimitReached
default:
msg := strings.ToLower(strings.TrimSpace(obj.Detail))
if strings.Contains(msg, "not found") {
return ErrDataNotFound
}
if strings.Contains(msg, "unauthorized") {
return ErrUserUnauthorized
}
if strings.Contains(msg, "forbidden") {
return ErrForbiddenAccess
}
if strings.Contains(msg, "validation") || strings.Contains(msg, "invalid") {
return ErrValidation
}
return ErrInternal
}
}
func moduleFromErrorCode(errorCode string) string {
if len(errorCode) >= 5 {
return errorCode[3:5]
}
return ModuleGeneral
}
func caseFromErrorCode(errorCode string) string {
if len(errorCode) >= 7 {
return errorCode[5:7]
}
return CaseGeneralInternalError
}

View File

@@ -0,0 +1,141 @@
package apperrorsx
import (
"errors"
mysqldriver "github.com/go-sql-driver/mysql"
)
const (
mysqlErrAccessDenied uint16 = 1045
mysqlErrDuplicateEntry uint16 = 1062
mysqlErrCommandDenied uint16 = 1142
mysqlErrLockWaitTimeout uint16 = 1205
mysqlErrDeadlockFound uint16 = 1213
mysqlErrSpecificAccessDenied uint16 = 1227
mysqlErrColumnCannotBeNull uint16 = 1048
mysqlErrTruncatedWrongValue uint16 = 1292
mysqlErrIncorrectValue uint16 = 1366
mysqlErrServerGone uint16 = 2006
mysqlErrFkParentStillReferenced uint16 = 1451
mysqlErrFkChildNotFound uint16 = 1452
mysqlErrCheckConstraintViolated uint16 = 3819
mysqlErrConstraintFailed uint16 = 4025
)
func mapMySQLError(module string, err error) *AppError {
var mysqlErr *mysqldriver.MySQLError
if !errors.As(err, &mysqlErr) {
return nil
}
switch module {
case ModuleHelicopter:
return mapMySQLErrorHelicopter(mysqlErr, err)
case ModuleHospital:
return mapMySQLErrorHospital(mysqlErr, err)
case ModuleFleetStatus:
return mapMySQLErrorFleetStatus(mysqlErr, err)
case ModuleComplaint:
return mapMySQLErrorComplaint(mysqlErr, err)
default:
return mapMySQLErrorGeneral(mysqlErr, err)
}
}
func mapMySQLErrorFleetStatus(mysqlErr *mysqldriver.MySQLError, cause error) *AppError {
switch mysqlErr.Number {
case mysqlErrDuplicateEntry:
return New(ErrFleetStatusDuplicate).WithCause(cause)
case mysqlErrFkParentStillReferenced:
return New(ErrFleetStatusRelationDelete).WithCause(cause)
case mysqlErrFkChildNotFound:
// fleet_status.helicopter_id references a non-existent helicopter.
return New(ErrFleetStatusHelicopterInvalid).WithCause(cause)
case mysqlErrColumnCannotBeNull,
mysqlErrTruncatedWrongValue,
mysqlErrIncorrectValue,
mysqlErrCheckConstraintViolated,
mysqlErrConstraintFailed:
return New(ErrFleetStatusInvalidPayload).WithCause(cause)
default:
return nil
}
}
func mapMySQLErrorGeneral(mysqlErr *mysqldriver.MySQLError, cause error) *AppError {
switch mysqlErr.Number {
case mysqlErrAccessDenied:
return New(ErrUserUnauthorized).WithCause(cause)
case mysqlErrCommandDenied, mysqlErrSpecificAccessDenied:
return New(ErrForbiddenAccess).WithCause(cause)
case mysqlErrLockWaitTimeout, mysqlErrDeadlockFound:
return New(ErrBusinessRuleFailed).WithCause(cause)
case mysqlErrServerGone:
return New(ErrInternal).WithCause(cause)
case mysqlErrDuplicateEntry, mysqlErrFkParentStillReferenced:
return New(ErrBusinessRuleFailed).WithCause(cause)
case mysqlErrColumnCannotBeNull,
mysqlErrTruncatedWrongValue,
mysqlErrIncorrectValue,
mysqlErrFkChildNotFound,
mysqlErrCheckConstraintViolated,
mysqlErrConstraintFailed:
return New(ErrValidation).WithCause(cause)
default:
return nil
}
}
func mapMySQLErrorHelicopter(mysqlErr *mysqldriver.MySQLError, cause error) *AppError {
switch mysqlErr.Number {
case mysqlErrDuplicateEntry:
return New(ErrHelicopterDuplicate).WithCause(cause)
case mysqlErrFkParentStillReferenced:
return New(ErrHelicopterRelationDelete).WithCause(cause)
case mysqlErrColumnCannotBeNull,
mysqlErrTruncatedWrongValue,
mysqlErrIncorrectValue,
mysqlErrFkChildNotFound,
mysqlErrCheckConstraintViolated,
mysqlErrConstraintFailed:
return New(ErrHelicopterInvalidPayload).WithCause(cause)
default:
return nil
}
}
func mapMySQLErrorHospital(mysqlErr *mysqldriver.MySQLError, cause error) *AppError {
switch mysqlErr.Number {
case mysqlErrFkParentStillReferenced:
return New(ErrHospitalRelationDelete).WithCause(cause)
case mysqlErrColumnCannotBeNull,
mysqlErrTruncatedWrongValue,
mysqlErrIncorrectValue,
mysqlErrFkChildNotFound,
mysqlErrCheckConstraintViolated,
mysqlErrConstraintFailed:
return New(ErrHospitalInvalidPayload).WithCause(cause)
default:
return nil
}
}
func mapMySQLErrorComplaint(mysqlErr *mysqldriver.MySQLError, cause error) *AppError {
switch mysqlErr.Number {
case mysqlErrDuplicateEntry:
return New(ErrComplaintInvalidPayload).WithCause(cause)
case mysqlErrFkParentStillReferenced:
return New(ErrComplaintRelationDelete).WithCause(cause)
case mysqlErrFkChildNotFound:
return New(ErrComplaintInvalidHelicopterID).WithCause(cause)
case mysqlErrColumnCannotBeNull,
mysqlErrTruncatedWrongValue,
mysqlErrIncorrectValue,
mysqlErrCheckConstraintViolated,
mysqlErrConstraintFailed:
return New(ErrComplaintInvalidPayload).WithCause(cause)
default:
return nil
}
}

View File

@@ -0,0 +1,33 @@
package apperrorsx
import "testing"
func TestRegistry_UniqueCodeAndErrorCode(t *testing.T) {
seenCode := map[string]struct{}{}
seenErrorCode := map[string]struct{}{}
seenModuleCase := map[string]struct{}{}
for _, d := range Registry {
if d.Code == "" {
t.Fatalf("empty code in registry: %#v", d)
}
if d.ErrorCode == "" {
t.Fatalf("empty error_code in registry: %#v", d)
}
if _, ok := seenCode[d.Code]; ok {
t.Fatalf("duplicate code: %s", d.Code)
}
if _, ok := seenErrorCode[d.ErrorCode]; ok {
t.Fatalf("duplicate error_code: %s", d.ErrorCode)
}
if len(d.ErrorCode) != 7 {
t.Fatalf("invalid error_code length for %s: %s", d.Code, d.ErrorCode)
}
moduleCase := d.ErrorCode[3:]
if _, ok := seenModuleCase[moduleCase]; ok {
t.Fatalf("duplicate module+case detected: %s (error_code=%s)", moduleCase, d.ErrorCode)
}
seenCode[d.Code] = struct{}{}
seenErrorCode[d.ErrorCode] = struct{}{}
seenModuleCase[moduleCase] = struct{}{}
}
}

View File

@@ -0,0 +1,68 @@
package apperrorsx
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/jsonapi"
)
func Write(c *fiber.Ctx, e *AppError) error {
if e == nil {
e = New(ErrInternal)
}
if e.Status == 0 {
e.Status = fiber.StatusInternalServerError
}
if e.Title == "" {
e.Title = defaultTitle(e.Status)
}
if detail := publicLogDetail(e.Message, e.Cause, e.Title); detail != "" {
c.Locals(ContextKeyHTTPErrorDetail, detail)
}
obj := jsonapi.ErrorObject{
Status: strconv.Itoa(e.Status),
Code: e.Code,
ErrorCode: e.ErrorCode,
Title: e.Title,
Detail: PublicDetail(e.Status),
Source: e.Source,
}
return jsonapi.WriteErrors(c, e.Status, []jsonapi.ErrorObject{obj})
}
func publicLogDetail(message string, cause error, fallback string) string {
if cause != nil {
if trimmed := strings.TrimSpace(cause.Error()); trimmed != "" {
return trimmed
}
}
if trimmed := strings.TrimSpace(message); trimmed != "" {
return trimmed
}
return strings.TrimSpace(fallback)
}
func defaultTitle(status int) string {
switch status {
case fiber.StatusBadRequest:
return "Bad Request"
case fiber.StatusUnauthorized:
return "Unauthorized"
case fiber.StatusForbidden:
return "Forbidden"
case fiber.StatusNotFound:
return "Not found"
case fiber.StatusConflict:
return "Conflict"
case fiber.StatusUnprocessableEntity:
return "Validation error"
case fiber.StatusTooManyRequests:
return "Too many requests"
default:
return "Internal server error"
}
}

View File

@@ -0,0 +1,54 @@
package apperrorsx
import (
"bytes"
"io"
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestPublicDetail(t *testing.T) {
tests := []struct {
status int
want string
}{
{http.StatusBadRequest, "invalid request"},
{http.StatusUnauthorized, "unauthorized"},
{http.StatusForbidden, "forbidden"},
{http.StatusNotFound, "not found"},
{http.StatusConflict, "conflict"},
{http.StatusUnprocessableEntity, "validation failed"},
{http.StatusTooManyRequests, "too many requests"},
{http.StatusInternalServerError, "an internal error occurred"},
}
for _, tc := range tests {
if got := PublicDetail(tc.status); got != tc.want {
t.Fatalf("status %d: expected %q, got %q", tc.status, tc.want, got)
}
}
}
func TestWriteSanitizesDetail(t *testing.T) {
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
err := New(ErrInternal)
err.Message = "raw database message"
return Write(c, err)
})
req, _ := http.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
body, _ := io.ReadAll(resp.Body)
if bytes.Contains(body, []byte("raw database message")) {
t.Fatalf("expected sanitized detail, got %s", string(body))
}
if !bytes.Contains(body, []byte(`"detail":"an internal error occurred"`)) {
t.Fatalf("expected generic detail, got %s", string(body))
}
}

View File

@@ -0,0 +1,221 @@
package apperrorsx
import (
"context"
"errors"
"gorm.io/gorm"
afterflightinspection "wucher/internal/domain/after_flight_inspection"
"wucher/internal/service"
"wucher/internal/shared/pkg/apperrors"
)
// Translate is the single entrypoint for mapping domain/service errors to AppError.
func Translate(module string, err error) *AppError {
if err == nil {
return nil
}
switch module {
case ModuleAuth, ModulePIN:
if mapped := mapAuthPIN(err); mapped != nil {
return mapped
}
case ModuleHelicopter:
if mapped := mapHelicopter(err); mapped != nil {
return mapped
}
case ModuleHospital:
if mapped := mapHospital(err); mapped != nil {
return mapped
}
case ModuleTakeover:
if mapped := mapTakeover(err); mapped != nil {
return mapped
}
case ModuleFleetStatus:
if mapped := mapFleetStatus(err); mapped != nil {
return mapped
}
case ModuleComplaint:
if mapped := mapComplaint(err); mapped != nil {
return mapped
}
case ModuleAfterFlightInspection:
if mapped := mapAfterFlightInspection(err); mapped != nil {
return mapped
}
}
return mapGeneral(err)
}
func mapAfterFlightInspection(err error) *AppError {
switch {
case errors.Is(err, afterflightinspection.ErrInvalidFlightInspectionID):
return New(ErrAfterFlightInvalidID).WithCause(err)
case errors.Is(err, afterflightinspection.ErrFlightInspectionNotFound):
return New(ErrAfterFlightFlightInspectionNotFound).WithCause(err)
case errors.Is(err, afterflightinspection.ErrCapabilitiesUnavailable):
return New(ErrAfterFlightCapabilitiesUnavailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrNR1Required):
return New(ErrAfterFlightNR1Required).WithCause(err)
case errors.Is(err, afterflightinspection.ErrNR1NotAvailable):
return New(ErrAfterFlightNR1NotAvailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrNR2Required):
return New(ErrAfterFlightNR2Required).WithCause(err)
case errors.Is(err, afterflightinspection.ErrNR2NotAvailable):
return New(ErrAfterFlightNR2NotAvailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrLHRequired):
return New(ErrAfterFlightLHRequired).WithCause(err)
case errors.Is(err, afterflightinspection.ErrLHNotAvailable):
return New(ErrAfterFlightLHNotAvailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrRHRequired):
return New(ErrAfterFlightRHRequired).WithCause(err)
case errors.Is(err, afterflightinspection.ErrRHNotAvailable):
return New(ErrAfterFlightRHNotAvailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrMGBRequired):
return New(ErrAfterFlightMGBRequired).WithCause(err)
case errors.Is(err, afterflightinspection.ErrMGBNotAvailable):
return New(ErrAfterFlightMGBNotAvailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrIGBRequired):
return New(ErrAfterFlightIGBRequired).WithCause(err)
case errors.Is(err, afterflightinspection.ErrIGBNotAvailable):
return New(ErrAfterFlightIGBNotAvailable).WithCause(err)
case errors.Is(err, afterflightinspection.ErrTGBRequired):
return New(ErrAfterFlightTGBRequired).WithCause(err)
case errors.Is(err, afterflightinspection.ErrTGBNotAvailable):
return New(ErrAfterFlightTGBNotAvailable).WithCause(err)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return New(ErrAfterFlightInspectionNotFound).WithCause(err)
}
if mapped := mapMySQLError(ModuleAfterFlightInspection, err); mapped != nil {
return mapped
}
return nil
}
func mapFleetStatus(err error) *AppError {
if errors.Is(err, gorm.ErrRecordNotFound) {
return New(ErrFleetStatusNotFound).WithCause(err)
}
if _, ok := apperrors.AsDeleteConflict(err); ok {
return New(ErrFleetStatusRelationDelete).WithCause(err)
}
if mapped := mapMySQLError(ModuleFleetStatus, err); mapped != nil {
return mapped
}
return nil
}
func mapComplaint(err error) *AppError {
if errors.Is(err, gorm.ErrRecordNotFound) {
return New(ErrComplaintNotFound).WithCause(err)
}
if mapped := mapMySQLError(ModuleComplaint, err); mapped != nil {
return mapped
}
return nil
}
func mapGeneral(err error) *AppError {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return New(ErrDataNotFound).WithCause(err)
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return New(ErrInvalidRequest).WithCause(err)
}
if mapped := mapMySQLError(ModuleGeneral, err); mapped != nil {
return mapped
}
switch {
case errors.Is(err, service.ErrInvalidReauthMethod):
return New(ErrValidation).WithCause(err)
default:
return New(ErrInternal).WithCause(err)
}
}
func mapAuthPIN(err error) *AppError {
switch {
case errors.Is(err, service.ErrInvalidToken):
return New(ErrTokenInvalid).WithCause(err)
case errors.Is(err, service.ErrInvalidCredentials):
return New(ErrUserUnauthorized).WithCause(err)
case errors.Is(err, service.ErrSecurityPINNotSet):
return New(ErrPINNotSet).WithCause(err)
case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired):
return New(ErrPINBlocked).WithCause(err)
case errors.Is(err, service.ErrSecurityPINAttemptLimit):
return New(ErrPINBlocked).WithCause(err)
case errors.Is(err, service.ErrInvalidSecurityPIN):
return New(ErrPINIncorrect).WithCause(err)
case errors.Is(err, service.ErrInvalidPINAction):
return New(ErrPINVerificationRequired202).WithCause(err)
case errors.Is(err, service.ErrInvalidReauthMethod):
return New(ErrValidation).WithCause(err)
default:
return nil
}
}
func mapHelicopter(err error) *AppError {
if errors.Is(err, gorm.ErrRecordNotFound) {
return New(ErrHelicopterNotFound).WithCause(err)
}
if _, ok := apperrors.AsDeleteConflict(err); ok {
return New(ErrHelicopterRelationDelete).WithCause(err)
}
if mapped := mapMySQLError(ModuleHelicopter, err); mapped != nil {
return mapped
}
switch {
case errors.Is(err, service.ErrInvalidReauthMethod):
return New(ErrHelicopterInvalidPayload).WithCause(err)
default:
return nil
}
}
func mapHospital(err error) *AppError {
if errors.Is(err, gorm.ErrRecordNotFound) {
return New(ErrHospitalNotFound).WithCause(err)
}
if _, ok := apperrors.AsDeleteConflict(err); ok {
return New(ErrHospitalRelationDelete).WithCause(err)
}
if mapped := mapMySQLError(ModuleHospital, err); mapped != nil {
return mapped
}
return nil
}
func mapTakeover(err error) *AppError {
if errors.Is(err, gorm.ErrRecordNotFound) {
return New(ErrTakeoverNotFound).WithCause(err)
}
if mapped := mapMySQLError(ModuleTakeover, err); mapped != nil {
return mapped
}
return nil
}

View File

@@ -0,0 +1,40 @@
package apperrorsx
import (
"errors"
"testing"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"wucher/internal/service"
)
func TestTranslate_AuthPIN(t *testing.T) {
got := Translate(ModulePIN, service.ErrInvalidSecurityPIN)
if got == nil || got.Code != CodePINIncorrect {
t.Fatalf("expected %s, got %#v", CodePINIncorrect, got)
}
got = Translate(ModuleAuth, service.ErrInvalidToken)
if got == nil || got.Code != CodeTokenInvalid {
t.Fatalf("expected %s, got %#v", CodeTokenInvalid, got)
}
}
func TestTranslate_HelicopterAndFallback(t *testing.T) {
got := Translate(ModuleHelicopter, gorm.ErrRecordNotFound)
if got == nil || got.Code != CodeHelicopterNotFound {
t.Fatalf("expected %s, got %#v", CodeHelicopterNotFound, got)
}
got = Translate(ModuleHelicopter, &mysqldriver.MySQLError{Number: 1062, Message: "duplicate"})
if got == nil || got.Code != CodeHelicopterDuplicate {
t.Fatalf("expected %s, got %#v", CodeHelicopterDuplicate, got)
}
got = Translate("99", errors.New("unknown issue"))
if got == nil || got.Code != CodeInternalServerError {
t.Fatalf("expected %s, got %#v", CodeInternalServerError, got)
}
}

View File

@@ -0,0 +1,251 @@
package attachmentresolver
import (
"context"
"errors"
"fmt"
"strings"
"time"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/shared/pkg/uuidv7"
)
type ErrorCode string
const (
ErrorInvalidAttachmentUUID ErrorCode = "invalid_attachment_uuid"
ErrorInvalidFileUUID ErrorCode = "invalid_file_uuid"
ErrorAttachmentNotFound ErrorCode = "attachment_not_found"
ErrorRequireValue ErrorCode = "require_value"
ErrorFileManagerUnavailable ErrorCode = "file_manager_unavailable"
ErrorFileNotReadyForAttachment ErrorCode = "file_not_ready_for_attachment"
ErrorCannotResolveFromFile ErrorCode = "cannot_resolve_from_file"
)
const (
notReadyRetryAttempts = 2
notReadyRetryDelay = 150 * time.Millisecond
)
type ResolveError struct {
Code ErrorCode
AttachmentField string
FileField string
Cause error
}
func (e *ResolveError) Error() string {
if e == nil {
return ""
}
switch e.Code {
case ErrorInvalidAttachmentUUID:
return fmt.Sprintf("%s is invalid UUID", fallbackField(e.AttachmentField, "attachment_id"))
case ErrorInvalidFileUUID:
return fmt.Sprintf("%s is invalid UUID", fallbackField(e.FileField, "file_id"))
case ErrorAttachmentNotFound:
return fmt.Sprintf("%s not found", fallbackField(e.AttachmentField, "attachment_id"))
case ErrorRequireValue:
return fmt.Sprintf(
"%s or %s is required",
fallbackField(e.AttachmentField, "attachment_id"),
fallbackField(e.FileField, "file_id"),
)
case ErrorFileManagerUnavailable:
return "file manager service is not configured"
case ErrorFileNotReadyForAttachment:
return "file is not ready for attachment"
case ErrorCannotResolveFromFile:
return fmt.Sprintf("cannot resolve attachment from %s", fallbackField(e.FileField, "file_id"))
default:
if e.Cause != nil {
return e.Cause.Error()
}
return "attachment resolve failed"
}
}
func (e *ResolveError) Unwrap() error { return e.Cause }
type Params struct {
FileManager filemanager.Service
RawAttachmentID *string
RawFileID *string
AttachmentField string
FileField string
RequireValue bool
RefType string
RefID string
Category *string
IsPrimary bool
ActorID []byte
ValidateAttachmentExists bool
AttachmentExists func(ctx context.Context, attachmentID []byte) (bool, error)
}
func Resolve(ctx context.Context, p Params) ([]byte, error) {
if p.RawAttachmentID != nil && strings.TrimSpace(*p.RawAttachmentID) != "" {
attachmentID, parseErr := uuidv7.ParseString(strings.TrimSpace(*p.RawAttachmentID))
if parseErr != nil {
return nil, &ResolveError{
Code: ErrorInvalidAttachmentUUID,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
Cause: parseErr,
}
}
if p.ValidateAttachmentExists {
exists, existsErr := resolveAttachmentExists(ctx, p, attachmentID)
if existsErr != nil {
return nil, existsErr
}
if !exists {
return nil, &ResolveError{
Code: ErrorAttachmentNotFound,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
}
}
}
return attachmentID, nil
}
if p.RawFileID != nil && strings.TrimSpace(*p.RawFileID) != "" {
if p.FileManager == nil {
return nil, &ResolveError{
Code: ErrorFileManagerUnavailable,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
}
}
fileID, parseErr := uuidv7.ParseString(strings.TrimSpace(*p.RawFileID))
if parseErr != nil {
return nil, &ResolveError{
Code: ErrorInvalidFileUUID,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
Cause: parseErr,
}
}
created, createErr := p.FileManager.CreateAttachment(ctx, filemanager.CreateAttachmentParams{
FileID: fileID,
RefType: p.RefType,
RefID: p.RefID,
Category: p.Category,
SortOrder: 0,
IsPrimary: p.IsPrimary,
ActorID: p.ActorID,
})
if createErr == nil && created != nil && len(created.ID) == 16 {
return created.ID, nil
}
if createErr != nil &&
!errors.Is(createErr, filemanager.ErrAttachmentAlreadyExists) &&
!errors.Is(createErr, filemanager.ErrFileNotReadyForAttachment) {
return nil, createErr
}
if failed, failureErr := fileIsFailed(ctx, p.FileManager, fileID); failureErr != nil {
return nil, failureErr
} else if failed {
return nil, &ResolveError{
Code: ErrorCannotResolveFromFile,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
}
}
if errors.Is(createErr, filemanager.ErrFileNotReadyForAttachment) {
return nil, &ResolveError{
Code: ErrorFileNotReadyForAttachment,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
}
}
rows, _, listErr := p.FileManager.ListAttachmentsByReference(ctx, filemanager.ListAttachmentsParams{
RefType: p.RefType,
RefID: p.RefID,
Limit: 100,
Offset: 0,
})
if listErr != nil {
return nil, listErr
}
for i := range rows {
if string(rows[i].FileID) == string(fileID) {
return rows[i].ID, nil
}
}
return nil, &ResolveError{
Code: ErrorCannotResolveFromFile,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
}
}
if p.RequireValue {
return nil, &ResolveError{
Code: ErrorRequireValue,
AttachmentField: p.AttachmentField,
FileField: p.FileField,
}
}
return nil, nil
}
func resolveAttachmentExists(ctx context.Context, p Params, attachmentID []byte) (bool, error) {
if p.AttachmentExists != nil {
return p.AttachmentExists(ctx, attachmentID)
}
if p.FileManager != nil {
row, err := p.FileManager.GetAttachmentByID(ctx, attachmentID)
if err != nil {
return false, err
}
return row != nil, nil
}
return true, nil
}
func fileIsFailed(ctx context.Context, fm filemanager.Service, fileID []byte) (bool, error) {
if fm == nil || len(fileID) == 0 {
return false, nil
}
file, err := fm.GetFileByID(ctx, fileID)
if err != nil {
return false, err
}
if file == nil {
return false, nil
}
return filemanager.NormalizeFileStatus(file.Status) == filemanager.FileStatusFailed, nil
}
func fallbackField(v string, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return strings.TrimSpace(v)
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

View File

@@ -0,0 +1,41 @@
package logger
import (
"io"
"log/slog"
"os"
"strings"
"wucher/internal/config"
)
func New(cfg config.LoggingConfig) *slog.Logger {
level := parseLevel(cfg.Level)
options := &slog.HandlerOptions{Level: level}
var handler slog.Handler
switch strings.ToLower(strings.TrimSpace(cfg.Format)) {
case "text":
handler = slog.NewTextHandler(os.Stdout, options)
default:
handler = slog.NewJSONHandler(os.Stdout, options)
}
return slog.New(handler)
}
func NewDiscard() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}
func parseLevel(raw string) slog.Leveler {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,74 @@
// Package metricparse extracts a numeric value from the free-text engine/metric strings
// used across FM reports (e.g. "GPC/N1 63.2"). The same parser MUST be used both when
// aggregating totals and when deriving a single report's "today" contribution, otherwise
// prev = total - today would not reconcile.
package metricparse
import (
"regexp"
"strconv"
"strings"
"unicode"
)
// numberToken matches a signed decimal number (integer or fractional).
var numberToken = regexp.MustCompile(`[-+]?\d*\.?\d+`)
// Parse returns ParseString(*v), or 0 when v is nil.
func Parse(v *string) float64 {
if v == nil {
return 0
}
return ParseString(*v)
}
// ParseString pulls the numeric VALUE out of a metric label string:
//
// "GPC/N1 63.2" -> 63.2 "PTC/N2 61.8" -> 61.8 "63.2" -> 63.2
// "63,2" -> 63.2 " -5 " -> -5 "N1" -> 0 (label only)
//
// It ignores digits fused to a label letter (the "1" in "N1", the "2" in "N2") by skipping
// any number immediately preceded by a letter, and prefers a token that carries a decimal
// point when several stand-alone numbers are present.
func ParseString(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
// Normalize a decimal comma ("63,2") to a dot when it is the sole separator.
if !strings.Contains(s, ".") {
if i := strings.IndexByte(s, ','); i > 0 && i+1 < len(s) &&
isASCIIDigit(s[i-1]) && isASCIIDigit(s[i+1]) {
s = s[:i] + "." + s[i+1:]
}
}
locs := numberToken.FindAllStringIndex(s, -1)
candidates := make([]string, 0, len(locs))
for _, loc := range locs {
// A value is never glued to a preceding letter; that only happens for labels
// like N1/N2. Real values start the string or follow a space/slash/colon/etc.
if loc[0] > 0 && unicode.IsLetter(rune(s[loc[0]-1])) {
continue
}
candidates = append(candidates, s[loc[0]:loc[1]])
}
if len(candidates) == 0 {
return 0
}
chosen := candidates[0]
for _, c := range candidates {
if strings.Contains(c, ".") {
chosen = c
break
}
}
n, err := strconv.ParseFloat(chosen, 64)
if err != nil {
return 0
}
return n
}
func isASCIIDigit(b byte) bool { return b >= '0' && b <= '9' }

View File

@@ -0,0 +1,43 @@
package metricparse
import "testing"
func TestParseString(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"GPC/N1 63.2", 63.2}, // the "1" in N1 must NOT win
{"PTC/N2 61.8", 61.8}, // the "2" in N2 must NOT win
{"GPC/NG/N1 105.3", 105.3},
{"63.2", 63.2},
{" 63.2 ", 63.2},
{"63", 63},
{"63,2", 63.2}, // decimal comma
{"GPC/N1: 63.2 %", 63.2}, // colon + trailing unit
{"GPC/N1 63.2 / N2 61.5", 63.2}, // first standalone decimal
{"-5", -5},
{"+7.5", 7.5},
{".5", 0.5},
{"N1", 0}, // label only, no value
{"N2", 0}, // label only
{"", 0},
{" ", 0},
{"no numbers here", 0},
}
for _, c := range cases {
if got := ParseString(c.in); got != c.want {
t.Errorf("ParseString(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestParseNilAndPtr(t *testing.T) {
if got := Parse(nil); got != 0 {
t.Fatalf("Parse(nil) = %v, want 0", got)
}
s := "GPC/N1 63.2"
if got := Parse(&s); got != 63.2 {
t.Fatalf("Parse(&%q) = %v, want 63.2", s, got)
}
}

View File

@@ -0,0 +1,384 @@
package metrics
import (
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
smithyhttp "github.com/aws/smithy-go/transport/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
registerOnce sync.Once
awsRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_aws_requests_total",
Help: "Total number of AWS requests by component, service, operation, and result.",
}, []string{"component", "service", "operation", "result"})
awsRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "app_aws_request_duration_seconds",
Help: "AWS request duration in seconds by component, service, and operation.",
Buckets: prometheus.DefBuckets,
}, []string{"component", "service", "operation"})
awsErrorsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_aws_errors_total",
Help: "Total number of AWS errors by class.",
}, []string{"component", "service", "operation", "error_class"})
awsTransferredBytesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_aws_transferred_bytes_total",
Help: "Total AWS transferred bytes by component, service, operation, and direction.",
}, []string{"component", "service", "operation", "direction"})
queueMessagesReceivedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_queue_messages_received_total",
Help: "Total number of queue messages received.",
}, []string{"component", "queue_name"})
queueMessagesDeletedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_queue_messages_deleted_total",
Help: "Total number of queue messages deleted.",
}, []string{"component", "queue_name"})
queueVisibilityChangesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_queue_visibility_changes_total",
Help: "Total number of queue visibility timeout changes.",
}, []string{"component", "queue_name", "result"})
queueSendTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_queue_send_total",
Help: "Total number of queue send attempts.",
}, []string{"component", "queue_name", "result"})
queueRetriesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_queue_retries_total",
Help: "Total number of queue retry/requeue attempts.",
}, []string{"component", "queue_name", "result"})
queueMessageBytesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_queue_message_bytes_total",
Help: "Total queue message bytes observed on send and receive.",
}, []string{"component", "queue_name", "direction"})
queueDepth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "app_queue_depth",
Help: "Approximate queue depth from SQS attributes.",
}, []string{"component", "queue_name"})
workerJobsProcessedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_worker_jobs_processed_total",
Help: "Total number of worker jobs processed.",
}, []string{"component", "job_type", "result"})
workerJobDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "app_worker_job_duration_seconds",
Help: "Worker job processing duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"component", "job_type"})
emailSendTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_email_send_total",
Help: "Total number of email send attempts.",
}, []string{"component", "provider", "result"})
emailSendDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "app_email_send_duration_seconds",
Help: "Email send duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"component", "provider"})
fileUploadTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_file_upload_total",
Help: "Total number of file upload operations.",
}, []string{"component", "result"})
fileDeleteTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_file_delete_total",
Help: "Total number of file delete operations.",
}, []string{"component", "result"})
fileHeadTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_file_head_total",
Help: "Total number of file head operations.",
}, []string{"component", "result"})
filePresignDownloadTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_file_presign_download_total",
Help: "Total number of file presign download operations.",
}, []string{"component", "result"})
brandingLogoFetchTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_branding_logo_fetch_total",
Help: "Total number of branding logo fetch operations.",
}, []string{"component", "source", "result"})
brandingLogoFetchDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "app_branding_logo_fetch_duration_seconds",
Help: "Branding logo fetch duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"component", "source"})
brandingRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "app_branding_requests_total",
Help: "Total number of branding endpoint requests by endpoint, source, status, and result.",
}, []string{"component", "endpoint", "source", "status_code", "result"})
brandingRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "app_branding_request_duration_seconds",
Help: "Branding endpoint request duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"component", "endpoint"})
)
func Register() {
registerOnce.Do(func() {
collectors := []prometheus.Collector{
awsRequestsTotal,
awsRequestDurationSeconds,
awsErrorsTotal,
awsTransferredBytesTotal,
queueMessagesReceivedTotal,
queueMessagesDeletedTotal,
queueVisibilityChangesTotal,
queueSendTotal,
queueRetriesTotal,
queueMessageBytesTotal,
queueDepth,
workerJobsProcessedTotal,
workerJobDurationSeconds,
emailSendTotal,
emailSendDurationSeconds,
fileUploadTotal,
fileDeleteTotal,
fileHeadTotal,
filePresignDownloadTotal,
brandingLogoFetchTotal,
brandingLogoFetchDurationSeconds,
brandingRequestsTotal,
brandingRequestDurationSeconds,
}
for _, collector := range collectors {
registerCollector(collector)
}
})
}
func PromHTTPHandler() http.Handler {
Register()
return promhttp.Handler()
}
func ObserveAWSRequest(component, service, operation string, err error, started time.Time) {
Register()
component = normalizeLabel(component, "worker")
service = normalizeLabel(service, "unknown")
operation = normalizeLabel(operation, "unknown")
result := resultLabel(err)
awsRequestsTotal.WithLabelValues(component, service, operation, result).Inc()
awsRequestDurationSeconds.WithLabelValues(component, service, operation).Observe(time.Since(started).Seconds())
if err != nil {
awsErrorsTotal.WithLabelValues(component, service, operation, errorClass(err)).Inc()
}
}
func ObserveAWSTransferredBytes(component, service, operation, direction string, bytes int64) {
Register()
if bytes <= 0 {
return
}
component = normalizeLabel(component, "worker")
service = normalizeLabel(service, "unknown")
operation = normalizeLabel(operation, "unknown")
direction = normalizeLabel(direction, "unknown")
awsTransferredBytesTotal.WithLabelValues(component, service, operation, direction).Add(float64(bytes))
}
func ObserveQueueReceive(component, queueName string, messageCount int, messageBytes int64, err error, started time.Time) {
ObserveAWSRequest(component, "sqs", "ReceiveMessage", err, started)
if err != nil || messageCount <= 0 {
return
}
component = normalizeLabel(component, "worker")
queueName = normalizeLabel(queueName, "unknown")
queueMessagesReceivedTotal.WithLabelValues(component, queueName).Add(float64(messageCount))
if messageBytes > 0 {
queueMessageBytesTotal.WithLabelValues(component, queueName, "receive").Add(float64(messageBytes))
}
}
func ObserveQueueDeleteBatch(component, queueName string, deletedCount int, err error, started time.Time) {
ObserveAWSRequest(component, "sqs", "DeleteMessageBatch", err, started)
if deletedCount <= 0 {
return
}
component = normalizeLabel(component, "worker")
queueName = normalizeLabel(queueName, "unknown")
queueMessagesDeletedTotal.WithLabelValues(component, queueName).Add(float64(deletedCount))
}
func ObserveQueueVisibilityChange(component, queueName string, err error, started time.Time) {
ObserveAWSRequest(component, "sqs", "ChangeMessageVisibility", err, started)
component = normalizeLabel(component, "worker")
queueName = normalizeLabel(queueName, "unknown")
result := resultLabel(err)
queueVisibilityChangesTotal.WithLabelValues(component, queueName, result).Inc()
queueRetriesTotal.WithLabelValues(component, queueName, result).Inc()
}
func ObserveQueueSend(component, queueName string, messageBytes int64, err error, started time.Time) {
ObserveAWSRequest(component, "sqs", "SendMessage", err, started)
component = normalizeLabel(component, "worker")
queueName = normalizeLabel(queueName, "unknown")
result := resultLabel(err)
queueSendTotal.WithLabelValues(component, queueName, result).Inc()
if err == nil && messageBytes > 0 {
queueMessageBytesTotal.WithLabelValues(component, queueName, "send").Add(float64(messageBytes))
}
}
func ObserveQueueDepth(component, queueName string, depth int64, err error, started time.Time) {
ObserveAWSRequest(component, "sqs", "GetQueueAttributes", err, started)
if err != nil {
return
}
component = normalizeLabel(component, "worker")
queueName = normalizeLabel(queueName, "unknown")
queueDepth.WithLabelValues(component, queueName).Set(float64(depth))
}
func InitializeQueueSeries(component, queueName string) {
Register()
component = normalizeLabel(component, "worker")
queueName = normalizeLabel(queueName, "unknown")
queueMessagesReceivedTotal.WithLabelValues(component, queueName).Add(0)
queueMessagesDeletedTotal.WithLabelValues(component, queueName).Add(0)
queueVisibilityChangesTotal.WithLabelValues(component, queueName, "success").Add(0)
queueVisibilityChangesTotal.WithLabelValues(component, queueName, "error").Add(0)
queueSendTotal.WithLabelValues(component, queueName, "success").Add(0)
queueSendTotal.WithLabelValues(component, queueName, "error").Add(0)
queueRetriesTotal.WithLabelValues(component, queueName, "success").Add(0)
queueRetriesTotal.WithLabelValues(component, queueName, "error").Add(0)
queueMessageBytesTotal.WithLabelValues(component, queueName, "send").Add(0)
queueMessageBytesTotal.WithLabelValues(component, queueName, "receive").Add(0)
queueDepth.WithLabelValues(component, queueName).Set(0)
}
func ObserveWorkerJob(component, jobType string, err error, started time.Time) {
Register()
component = normalizeLabel(component, "worker")
jobType = normalizeLabel(jobType, "unknown")
workerJobsProcessedTotal.WithLabelValues(component, jobType, resultLabel(err)).Inc()
workerJobDurationSeconds.WithLabelValues(component, jobType).Observe(time.Since(started).Seconds())
}
func ObserveEmailSend(component, provider string, err error, started time.Time) {
Register()
component = normalizeLabel(component, "worker")
provider = normalizeLabel(provider, "unknown")
emailSendTotal.WithLabelValues(component, provider, resultLabel(err)).Inc()
emailSendDurationSeconds.WithLabelValues(component, provider).Observe(time.Since(started).Seconds())
}
func ObserveFileUpload(component string, err error) {
Register()
fileUploadTotal.WithLabelValues(normalizeLabel(component, "api"), resultLabel(err)).Inc()
}
func ObserveFileDelete(component string, err error) {
Register()
fileDeleteTotal.WithLabelValues(normalizeLabel(component, "api"), resultLabel(err)).Inc()
}
func ObserveFileHead(component string, err error) {
Register()
fileHeadTotal.WithLabelValues(normalizeLabel(component, "api"), resultLabel(err)).Inc()
}
func ObserveFilePresignDownload(component string, err error) {
Register()
filePresignDownloadTotal.WithLabelValues(normalizeLabel(component, "api"), resultLabel(err)).Inc()
}
func ObserveBrandingLogoFetch(component, source string, err error, started time.Time) {
Register()
component = normalizeLabel(component, "api")
source = normalizeLabel(source, "unknown")
brandingLogoFetchTotal.WithLabelValues(component, source, resultLabel(err)).Inc()
brandingLogoFetchDurationSeconds.WithLabelValues(component, source).Observe(time.Since(started).Seconds())
}
func ObserveBrandingRequest(component, endpoint, source string, statusCode int, err error, started time.Time) {
Register()
component = normalizeLabel(component, "api")
endpoint = normalizeLabel(endpoint, "unknown")
source = normalizeLabel(source, "unknown")
status := "0"
if statusCode > 0 {
status = normalizeLabel(fmt.Sprintf("%d", statusCode), "0")
}
brandingRequestsTotal.WithLabelValues(component, endpoint, source, status, resultLabel(err)).Inc()
brandingRequestDurationSeconds.WithLabelValues(component, endpoint).Observe(time.Since(started).Seconds())
}
func QueueNameFromURL(queueURL string) string {
raw := strings.TrimSpace(queueURL)
if raw == "" {
return "unknown"
}
parsed, err := url.Parse(raw)
if err != nil {
return "unknown"
}
name := strings.TrimSpace(path.Base(parsed.Path))
if name == "" || name == "." || name == "/" {
return "unknown"
}
return name
}
func registerCollector(collector prometheus.Collector) {
if err := prometheus.Register(collector); err != nil {
var alreadyRegistered prometheus.AlreadyRegisteredError
if errors.As(err, &alreadyRegistered) {
return
}
panic(err)
}
}
func errorClass(err error) string {
statusCode := statusCodeFromError(err)
if statusCode >= 400 && statusCode < 500 {
return "4xx"
}
if statusCode >= 500 && statusCode < 600 {
return "5xx"
}
return "other"
}
func statusCodeFromError(err error) int {
if err == nil {
return 0
}
type statusCoder interface {
HTTPStatusCode() int
}
var sc statusCoder
if errors.As(err, &sc) {
return sc.HTTPStatusCode()
}
var respErr *smithyhttp.ResponseError
if errors.As(err, &respErr) {
return respErr.HTTPStatusCode()
}
return 0
}
func normalizeLabel(value, fallback string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return fallback
}
return value
}
func resultLabel(err error) string {
if err != nil {
return "error"
}
return "success"
}

View File

@@ -0,0 +1,32 @@
package missioncode
import (
"fmt"
"strconv"
"strings"
"time"
)
const (
prefixStatic = "M-"
suffixWidth = 3
)
// BuildPrefix returns the daily mission code prefix, e.g. M-042426-
func BuildPrefix(t time.Time) string {
return prefixStatic + t.Format("010206") + "-"
}
// NextFromLatest returns next code in sequence using latest code for a day.
// If latest is empty/invalid, sequence starts from 001.
func NextFromLatest(prefix string, latest string) string {
next := 1
latest = strings.TrimSpace(latest)
if strings.HasPrefix(latest, prefix) {
suffix := strings.TrimPrefix(latest, prefix)
if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
next = n + 1
}
}
return fmt.Sprintf("%s%0*d", prefix, suffixWidth, next)
}

View File

@@ -0,0 +1,82 @@
package nextdue
import (
"fmt"
"strconv"
"strings"
"time"
)
type ValueKind string
const (
ValueKindUnknown ValueKind = ""
ValueKindHours ValueKind = "hours"
ValueKindDate ValueKind = "date"
)
type Value struct {
Kind ValueKind
Raw string
Hours *float64
Date *time.Time
}
type ParseError struct {
Field string
Raw string
Msg string
}
func (e *ParseError) Error() string {
if e == nil {
return ""
}
if strings.TrimSpace(e.Field) == "" {
return e.Msg
}
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
type Parser struct {
DateLayout string
}
func NewParser() Parser { return Parser{DateLayout: "2006-01-02"} }
func (p Parser) Parse(field, raw string) (Value, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Value{Kind: ValueKindUnknown, Raw: ""}, nil
}
if d, err := time.Parse(p.dateLayout(), raw); err == nil {
dd := d.UTC()
return Value{Kind: ValueKindDate, Raw: raw, Date: &dd}, nil
}
if h, err := strconv.ParseFloat(raw, 64); err == nil {
return Value{Kind: ValueKindHours, Raw: raw, Hours: &h}, nil
}
return Value{}, &ParseError{Field: field, Raw: raw, Msg: "must be either an ISO date (YYYY-MM-DD) or a numeric hour value"}
}
func IsUnset(v Value) bool {
switch v.Kind {
case ValueKindHours:
return v.Hours == nil || *v.Hours <= 0
case ValueKindDate:
return v.Date == nil || v.Date.IsZero()
default:
return true
}
}
func (p Parser) dateLayout() string {
if strings.TrimSpace(p.DateLayout) == "" {
return "2006-01-02"
}
return p.DateLayout
}
func NormalizeDate(t time.Time) time.Time {
return time.Date(t.UTC().Year(), t.UTC().Month(), t.UTC().Day(), 0, 0, 0, 0, time.UTC)
}

View File

@@ -0,0 +1,77 @@
package nextdue
import "testing"
func TestParseErrorError(t *testing.T) {
var nilErr *ParseError
if nilErr.Error() != "" {
t.Fatal("nil ParseError should produce empty string")
}
if (&ParseError{Msg: "boom"}).Error() != "boom" {
t.Fatal("fieldless error should be just the message")
}
if (&ParseError{Field: "due", Msg: "boom"}).Error() != "due: boom" {
t.Fatal("field error should be 'field: msg'")
}
}
func TestParseDefaultDateLayout(t *testing.T) {
p := Parser{} // empty DateLayout -> falls back to default
v, err := p.Parse("due", "2026-06-17")
if err != nil || v.Kind != ValueKindDate {
t.Fatalf("default layout should parse date, got kind=%q err=%v", v.Kind, err)
}
}
func TestParse(t *testing.T) {
p := NewParser()
cases := []struct {
raw string
kind ValueKind
ok bool
}{
{"", ValueKindUnknown, true},
{"1200", ValueKindHours, true},
{"1200.5", ValueKindHours, true},
{"2026-06-17", ValueKindDate, true},
{"1200 FH", ValueKindUnknown, false},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
v, err := p.Parse("due", tc.raw)
if tc.ok && err != nil {
t.Fatalf("Parse(%q) err = %v", tc.raw, err)
}
if !tc.ok && err == nil {
t.Fatalf("Parse(%q) expected error", tc.raw)
}
if tc.ok && v.Kind != tc.kind {
t.Fatalf("Parse(%q) kind = %q, want %q", tc.raw, v.Kind, tc.kind)
}
})
}
}
func TestIsUnset(t *testing.T) {
p := NewParser()
cases := []struct {
raw string
unset bool
}{
{"", true},
{"0", true},
{"100", false},
{"2026-06-17", false},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
v, err := p.Parse("due", tc.raw)
if err != nil {
t.Fatalf("Parse(%q) err = %v", tc.raw, err)
}
if IsUnset(v) != tc.unset {
t.Fatalf("IsUnset(%q) = %v, want %v", tc.raw, IsUnset(v), tc.unset)
}
})
}
}

View File

@@ -0,0 +1,33 @@
package reserveacutil
import "strings"
const (
StatusPending = "pending"
StatusApproved = "approved"
StatusOnProgress = "onprogress"
)
func NormalizeStatus(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case StatusApproved:
return StatusApproved
case StatusOnProgress:
return StatusOnProgress
default:
return StatusPending
}
}
func IsValidStatus(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case StatusPending, StatusApproved, StatusOnProgress:
return true
default:
return false
}
}
func AllowedStatusText() string {
return "pending, approved, onprogress"
}

View File

@@ -0,0 +1,32 @@
package sortkey
import "fmt"
// ActivePositiveSortClauses builds ORDER BY clauses where non-negative sort keys
// are treated as prioritized values. NULL/negative are grouped as empty values.
func ActivePositiveSortClauses(tableAlias, activeColumn, sortKeyColumn, fallbackColumn string, descending bool) []string {
active := qualify(tableAlias, activeColumn)
sortKey := qualify(tableAlias, sortKeyColumn)
fallback := qualify(tableAlias, fallbackColumn)
valid := fmt.Sprintf("%s = 1 AND %s IS NOT NULL AND %s >= 0", active, sortKey, sortKey)
direction := "ASC"
if descending {
direction = "DESC"
}
return []string{
fmt.Sprintf("%s DESC", active),
fmt.Sprintf("CASE WHEN %s THEN 0 ELSE 1 END ASC", valid),
fmt.Sprintf("CASE WHEN %s THEN %s END %s", valid, sortKey, direction),
fmt.Sprintf("%s ASC", fallback),
}
}
func qualify(tableAlias, column string) string {
if tableAlias == "" {
return column
}
return tableAlias + "." + column
}

View File

@@ -0,0 +1,34 @@
package sortkey
import (
"reflect"
"testing"
)
func TestActivePositiveSortClauses(t *testing.T) {
t.Run("ascending without alias", func(t *testing.T) {
got := ActivePositiveSortClauses("", "is_active", "sortkey", "designation", false)
want := []string{
"is_active DESC",
"CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC",
"CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END ASC",
"designation ASC",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected clauses: got=%v want=%v", got, want)
}
})
t.Run("descending with alias", func(t *testing.T) {
got := ActivePositiveSortClauses("helicopters", "is_active", "sortkey", "designation", true)
want := []string{
"helicopters.is_active DESC",
"CASE WHEN helicopters.is_active = 1 AND helicopters.sortkey IS NOT NULL AND helicopters.sortkey >= 0 THEN 0 ELSE 1 END ASC",
"CASE WHEN helicopters.is_active = 1 AND helicopters.sortkey IS NOT NULL AND helicopters.sortkey >= 0 THEN helicopters.sortkey END DESC",
"helicopters.designation ASC",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected clauses: got=%v want=%v", got, want)
}
})
}

View File

@@ -0,0 +1,108 @@
package suncalc
import (
"errors"
"math"
"time"
)
const (
dayMs = 1000 * 60 * 60 * 24
j1970 = 2440588
j2000 = 2451545
rad = math.Pi / 180
e = rad * 23.4397
)
var ErrNoCivilTwilight = errors.New("civil twilight is not available for the selected date and location")
type CivilTwilight struct {
Dawn time.Time
Dusk time.Time
}
func GetCivilTwilight(date time.Time, lat, lng float64) (CivilTwilight, error) {
if math.IsNaN(lat) || math.IsNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180 {
return CivilTwilight{}, errors.New("invalid coordinates")
}
d := toDays(date)
lw := -lng * rad
phi := lat * rad
n := julianCycle(d, lw)
ds := approxTransit(0, lw, n)
M := solarMeanAnomaly(ds)
L := eclipticLongitude(M)
dec := declination(L, 0)
noonJ := solarTransitJ(ds, M, L)
duskJ, ok := getSetJ(-6*rad, lw, phi, dec, n, M, L)
if !ok {
return CivilTwilight{}, ErrNoCivilTwilight
}
dawnJ := 2*noonJ - duskJ
return CivilTwilight{
Dawn: fromJulian(dawnJ),
Dusk: fromJulian(duskJ),
}, nil
}
func toJulian(date time.Time) float64 {
return float64(date.UTC().Unix())/86400 - 0.5 + j1970
}
func fromJulian(j float64) time.Time {
return time.Unix(int64((j+0.5-float64(j1970))*86400), 0).UTC()
}
func toDays(date time.Time) float64 {
return toJulian(date) - j2000
}
func rightAscension(l, b float64) float64 {
return math.Atan2(math.Sin(l)*math.Cos(e)-math.Tan(b)*math.Sin(e), math.Cos(l))
}
func declination(l, b float64) float64 {
return math.Asin(math.Sin(b)*math.Cos(e) + math.Cos(b)*math.Sin(e)*math.Sin(l))
}
func solarMeanAnomaly(d float64) float64 {
return rad * (357.5291 + 0.98560028*d)
}
func eclipticLongitude(M float64) float64 {
C := rad * (1.9148*math.Sin(M) + 0.02*math.Sin(2*M) + 0.0003*math.Sin(3*M))
P := rad * 102.9372
return M + C + P + math.Pi
}
func julianCycle(d, lw float64) float64 {
return math.Round(d - 0.0009 - lw/(2*math.Pi))
}
func approxTransit(Ht, lw, n float64) float64 {
return 0.0009 + (Ht+lw)/(2*math.Pi) + n
}
func solarTransitJ(ds, M, L float64) float64 {
return j2000 + ds + 0.0053*math.Sin(M) - 0.0069*math.Sin(2*L)
}
func hourAngle(h, phi, d float64) float64 {
cosH := (math.Sin(h) - math.Sin(phi)*math.Sin(d)) / (math.Cos(phi) * math.Cos(d))
if cosH < -1 || cosH > 1 {
return math.NaN()
}
return math.Acos(cosH)
}
func getSetJ(h, lw, phi, dec, n, M, L float64) (float64, bool) {
w := hourAngle(h, phi, dec)
if math.IsNaN(w) {
return 0, false
}
a := approxTransit(w, lw, n)
return solarTransitJ(a, M, L), true
}

View File

@@ -0,0 +1,35 @@
package timezone
import (
"errors"
"strings"
"time"
)
const Default = "UTC"
var (
ErrEmpty = errors.New("timezone is required")
ErrInvalid = errors.New("timezone must be a valid IANA timezone")
)
func Normalize(name string) (string, error) {
tz := strings.TrimSpace(name)
if tz == "" {
return "", ErrEmpty
}
loc, err := time.LoadLocation(tz)
if err != nil {
return "", ErrInvalid
}
return loc.String(), nil
}
func WithDefault(name string) string {
tz := strings.TrimSpace(name)
if tz == "" {
return Default
}
return tz
}

View File

@@ -0,0 +1,29 @@
// Package txctx propagates a GORM transaction through a context.Context so that
// repositories transparently join a caller's transaction. A service starts a
// transaction, stashes the *gorm.DB tx with With, and calls its existing
// repository methods unchanged; those repositories resolve the handle with DB.
package txctx
import (
"context"
"gorm.io/gorm"
)
type key struct{}
// With returns a context carrying the given transaction handle.
func With(ctx context.Context, tx *gorm.DB) context.Context {
return context.WithValue(ctx, key{}, tx)
}
// DB returns the transaction bound to ctx when present, otherwise the fallback
// db scoped to ctx. Repositories should use this instead of their raw db handle
// so a single statement runs standalone while orchestrated writes join the
// caller's transaction.
func DB(ctx context.Context, fallback *gorm.DB) *gorm.DB {
if tx, ok := ctx.Value(key{}).(*gorm.DB); ok && tx != nil {
return tx
}
return fallback.WithContext(ctx)
}

View File

@@ -0,0 +1,346 @@
package userctx
import (
"context"
"strings"
"sync"
"time"
"wucher/internal/domain/user"
)
// UserNameGetter is the interface used by handlers to resolve user IDs to names.
type UserNameGetter interface {
GetLastName(ctx context.Context, userID []byte) (string, error)
}
// UserFullNameGetter is an optional extension for getters that can resolve
// full names (first + last). Code should prefer this when available.
type UserFullNameGetter interface {
GetFullName(ctx context.Context, userID []byte) (string, error)
}
// UserShortNameGetter is an optional extension for getters that can resolve a
// user's short name (profile 3-letter code, e.g. "GAN"). Code should prefer
// this when available, falling back to initials from the display name.
type UserShortNameGetter interface {
GetShortName(ctx context.Context, userID []byte) (string, error)
}
// shortNameRepo is the optional repository capability used by UserNameCache to
// look up a profile short name. Only the concrete MySQL repo implements it;
// keeping it out of user.Repository avoids churning every mock.
type shortNameRepo interface {
GetShortNameByID(ctx context.Context, id []byte) (string, error)
}
// initialsFromFullName derives an uppercase initials short name from a full
// name: "System Admin" → "SA", "Gantner" → "G". Empty when there is no name.
func initialsFromFullName(fullName string) string {
parts := strings.Fields(strings.TrimSpace(fullName))
if len(parts) == 0 {
return ""
}
if len(parts) == 1 {
r := []rune(parts[0])
if len(r) == 0 {
return ""
}
return strings.ToUpper(string(r[0]))
}
first := []rune(parts[0])
last := []rune(parts[len(parts)-1])
if len(first) == 0 || len(last) == 0 {
return ""
}
return strings.ToUpper(string(first[0]) + string(last[0]))
}
// GlobalUserNameGetter is set once at application startup via WireUserNameGetter.
// It is used by uuidStringOrEmpty helpers across packages.
var GlobalUserNameGetter UserNameGetter
// WireUserNameGetter configures the global user name getter.
// Call this once during application startup (e.g. in main.go).
func WireUserNameGetter(getter UserNameGetter) {
GlobalUserNameGetter = getter
}
// contextKey for storing the getter in context.
type contextKey string
const getterKey contextKey = "userctx-getter"
// WithGetter stores a UserNameGetter in the context.
func WithGetter(ctx context.Context, getter UserNameGetter) context.Context {
return context.WithValue(ctx, getterKey, getter)
}
// GetGetter retrieves the UserNameGetter from context.
func GetGetter(ctx context.Context) UserNameGetter {
if ctx == nil {
return nil
}
if getter, ok := any(ctx).(interface{ UserValue(any) any }); ok {
raw := getter.UserValue(string(getterKey))
if raw != nil {
return raw.(UserNameGetter)
}
}
v := ctx.Value(getterKey)
if v == nil {
return nil
}
g, _ := v.(UserNameGetter)
return g
}
// GetDisplayName resolves a user ID to a display name.
// Priority:
// 1) full name from context getter (if supported)
// 2) last name from context getter
// 3) full name from global getter (if supported)
// 4) last name from global getter
func GetDisplayName(ctx context.Context, userID []byte) string {
if len(userID) == 0 {
return ""
}
type userNameGetter interface {
GetLastName(context.Context, []byte) (string, error)
}
type userFullNameGetter interface {
GetFullName(context.Context, []byte) (string, error)
}
// Backward compatibility: some call sites pass a context-like object that
// implements getter methods directly.
if fullGetter, ok := any(ctx).(userFullNameGetter); ok {
if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" {
return name
}
}
if getter, ok := any(ctx).(userNameGetter); ok {
if name, err := getter.GetLastName(ctx, userID); err == nil && name != "" {
return name
}
}
if getter := GetGetter(ctx); getter != nil {
if fullGetter, ok := getter.(UserFullNameGetter); ok {
if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" {
return name
}
}
if name, err := getter.GetLastName(ctx, userID); err == nil && name != "" {
return name
}
}
if GlobalUserNameGetter != nil {
if fullGetter, ok := GlobalUserNameGetter.(UserFullNameGetter); ok {
if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" {
return name
}
}
if name, err := GlobalUserNameGetter.GetLastName(ctx, userID); err == nil && name != "" {
return name
}
}
return ""
}
// GetShortName resolves a user ID to a short name (profile 3-letter code, e.g.
// "GAN"), falling back to initials derived from the user's display name.
// Priority mirrors GetDisplayName: context getter, then global getter, then a
// display-name-initials fallback so it always returns something short.
func GetShortName(ctx context.Context, userID []byte) string {
if len(userID) == 0 {
return ""
}
type shortGetter interface {
GetShortName(context.Context, []byte) (string, error)
}
// Backward compatibility: a context-like object implementing the getter.
if g, ok := any(ctx).(shortGetter); ok {
if s, err := g.GetShortName(ctx, userID); err == nil && s != "" {
return s
}
}
if getter := GetGetter(ctx); getter != nil {
if sg, ok := getter.(UserShortNameGetter); ok {
if s, err := sg.GetShortName(ctx, userID); err == nil && s != "" {
return s
}
}
}
if GlobalUserNameGetter != nil {
if sg, ok := GlobalUserNameGetter.(UserShortNameGetter); ok {
if s, err := sg.GetShortName(ctx, userID); err == nil && s != "" {
return s
}
}
}
// Universal fallback: initials from whatever display name we can resolve.
return initialsFromFullName(GetDisplayName(ctx, userID))
}
// GetLastName resolves a user ID to their last name using the getter from context.
// Returns an empty string if no getter is available or if userID is nil/empty.
func GetLastName(ctx context.Context, userID []byte) string {
if len(userID) == 0 {
return ""
}
getter := GetGetter(ctx)
if getter == nil {
return ""
}
name, err := getter.GetLastName(ctx, userID)
if err != nil {
return ""
}
return name
}
// cacheEntry holds a cached last name with expiry time.
type cacheEntry struct {
lastName string
expires time.Time
}
// UserNameCache is an in-memory TTL cache backed by a user.Repository.
type UserNameCache struct {
repo user.Repository
cache map[string]cacheEntry
shortCache map[string]cacheEntry
mu sync.RWMutex
ttl time.Duration
}
// NewUserNameCache creates a UserNameCache with the given TTL (default 5 minutes).
func NewUserNameCache(repo user.Repository, ttl time.Duration) *UserNameCache {
if ttl <= 0 {
ttl = 5 * time.Minute
}
c := &UserNameCache{
repo: repo,
cache: make(map[string]cacheEntry),
shortCache: make(map[string]cacheEntry),
ttl: ttl,
}
go c.cleanupLoop()
return c
}
// GetShortName resolves a user ID to their profile short name (3-letter code),
// falling back to initials derived from their full name. Cached with the same
// TTL as names. (The cacheEntry.lastName field stores the short name here.)
func (c *UserNameCache) GetShortName(ctx context.Context, userID []byte) (string, error) {
if len(userID) == 0 {
return "", nil
}
key := string(userID)
c.mu.RLock()
entry, ok := c.shortCache[key]
c.mu.RUnlock()
if ok && time.Now().Before(entry.expires) {
return entry.lastName, nil
}
short := ""
if sr, ok := c.repo.(shortNameRepo); ok {
s, err := sr.GetShortNameByID(ctx, userID)
if err != nil {
return "", err
}
short = strings.TrimSpace(s)
}
if short == "" {
// Fallback: initials derived from the full name.
if full, err := c.GetFullName(ctx, userID); err == nil {
short = initialsFromFullName(full)
}
}
c.mu.Lock()
c.shortCache[key] = cacheEntry{lastName: short, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
return short, nil
}
func (c *UserNameCache) GetLastName(ctx context.Context, userID []byte) (string, error) {
if len(userID) == 0 {
return "", nil
}
key := string(userID)
c.mu.RLock()
entry, ok := c.cache[key]
c.mu.RUnlock()
if ok && time.Now().Before(entry.expires) {
return entry.lastName, nil
}
lastName, err := c.repo.GetLastNameByID(ctx, userID)
if err != nil {
return "", err
}
if lastName == "" {
lastName = "(Unknown)"
}
c.mu.Lock()
c.cache[key] = cacheEntry{lastName: lastName, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
return lastName, nil
}
func (c *UserNameCache) GetFullName(ctx context.Context, userID []byte) (string, error) {
if len(userID) == 0 {
return "", nil
}
u, err := c.repo.GetByID(ctx, userID)
if err != nil {
return "", err
}
if u == nil {
return "", nil
}
first := ""
last := ""
if u.FirstName != "" {
first = u.FirstName
}
if u.LastName != "" {
last = u.LastName
}
if first == "" {
return last, nil
}
if last == "" {
return first, nil
}
return first + " " + last, nil
}
func (c *UserNameCache) cleanupLoop() {
ticker := time.NewTicker(c.ttl * 2)
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, v := range c.cache {
if now.After(v.expires) {
delete(c.cache, k)
}
}
for k, v := range c.shortCache {
if now.After(v.expires) {
delete(c.shortCache, k)
}
}
c.mu.Unlock()
}
}

View File

@@ -0,0 +1,244 @@
package util
import (
"errors"
"fmt"
"math"
"strings"
"time"
basedomain "wucher/internal/domain/base"
"wucher/internal/shared/pkg/suncalc"
)
var (
ErrShiftScheduleMissing = errors.New("base shift schedule not found")
ErrShiftTwilightMissing = errors.New("base twilight time not available")
ErrShiftCoordinatesInvalid = errors.New("base coordinates invalid")
)
func ResolveShiftWindow(b *basedomain.Base, date time.Time) (string, string, error) {
if b == nil || date.IsZero() {
return "", "", ErrShiftScheduleMissing
}
shift := PickShiftForDate(b, date)
loc := LocationFromUTCOffset(b.UTC)
scheduleDay := calendarDate(date)
scheduleDate := time.Date(scheduleDay.Year(), scheduleDay.Month(), scheduleDay.Day(), 12, 0, 0, 0, loc)
if shift == nil {
if shiftWindowNeedsCoordinates(b.DefaultStartTimeType, b.DefaultEndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) {
return "", "", ErrShiftCoordinatesInvalid
}
return resolveBaseDefaultShiftWindow(b, scheduleDate, loc)
}
if shiftWindowNeedsCoordinates(shift.StartTimeType, shift.EndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) {
return "", "", ErrShiftCoordinatesInvalid
}
start, err := resolveShiftClock(scheduleDate, loc, b, shift.StartTimeType, shift.ShiftStart, true)
if err != nil {
return "", "", err
}
end, err := resolveShiftClock(scheduleDate, loc, b, shift.EndTimeType, shift.ShiftEnd, false)
if err != nil {
return "", "", err
}
return start, end, nil
}
func shiftWindowNeedsCoordinates(startTimeType, endTimeType string) bool {
return NormalizeShiftTimeType(startTimeType) != basedomain.ShiftTimeTypeFixed ||
NormalizeShiftTimeType(endTimeType) != basedomain.ShiftTimeTypeFixed
}
func resolveBaseDefaultShiftWindow(b *basedomain.Base, scheduleDate time.Time, loc *time.Location) (string, string, error) {
start, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultStartTimeType, b.DefaultShiftStart, true)
if err != nil {
return "", "", err
}
end, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultEndTimeType, b.DefaultShiftEnd, false)
if err != nil {
return "", "", err
}
if start == "" || end == "" {
return "", "", ErrShiftScheduleMissing
}
return start, end, nil
}
func PickShiftForDate(b *basedomain.Base, date time.Time) *basedomain.BaseOperationalShiftTime {
if b == nil {
return nil
}
day := calendarDate(date)
for i := range b.OperationalShiftTimes {
row := &b.OperationalShiftTimes[i]
if row.DateStart == nil || row.DateEnd == nil {
continue
}
start := calendarDate(*row.DateStart)
end := calendarDate(*row.DateEnd)
if (day.Equal(start) || day.After(start)) && (day.Equal(end) || day.Before(end.Add(24*time.Hour))) {
return row
}
}
return nil
}
func calendarDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func DefaultShiftWindow(b *basedomain.Base) (string, string, error) {
if b == nil {
return "", "", ErrShiftScheduleMissing
}
start := NormalizeClockValue(b.DefaultShiftStart)
end := NormalizeClockValue(b.DefaultShiftEnd)
if start == "" || end == "" {
return "", "", ErrShiftScheduleMissing
}
return start, end, nil
}
func resolveShiftClock(date time.Time, loc *time.Location, b *basedomain.Base, timeType, fixedValue string, morning bool) (string, error) {
switch NormalizeShiftTimeType(timeType) {
case basedomain.ShiftTimeTypeFixed:
clock := NormalizeClockValue(fixedValue)
if clock == "" {
return "", ErrShiftScheduleMissing
}
return clock, nil
case basedomain.ShiftTimeTypeBMCT:
return resolveSunCalcClock(date, loc, b, true)
case basedomain.ShiftTimeTypeECET:
return resolveSunCalcClock(date, loc, b, false)
default:
return "", fmt.Errorf("invalid shift time type %q", timeType)
}
}
func resolveSunCalcClock(date time.Time, loc *time.Location, b *basedomain.Base, morning bool) (string, error) {
twilight, err := suncalc.GetCivilTwilight(date, b.Latitude, b.Longitude)
if err != nil {
return "", ErrShiftTwilightMissing
}
var resolved time.Time
if morning {
resolved = twilight.Dawn
} else {
resolved = twilight.Dusk
}
if resolved.IsZero() {
return "", ErrShiftTwilightMissing
}
if loc == nil {
loc = time.UTC
}
return resolved.In(loc).Format("15:04"), nil
}
func NormalizeClockValue(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if len(trimmed) == 5 {
trimmed += ":00"
}
layouts := []string{
"15:04:05",
"15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
time.RFC3339,
time.RFC3339Nano,
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, trimmed)
if err == nil {
return parsed.UTC().Format("15:04")
}
}
return ""
}
func LocationFromUTCOffset(raw string) *time.Location {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || trimmed == "0" {
return time.UTC
}
if loc, err := time.LoadLocation(trimmed); err == nil {
return loc
}
if offset, ok := parseUTCOffset(trimmed); ok {
return time.FixedZone("UTC", offset)
}
return time.UTC
}
func parseUTCOffset(raw string) (int, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0, false
}
sign := 1
switch {
case strings.HasPrefix(trimmed, "+"):
trimmed = strings.TrimPrefix(trimmed, "+")
case strings.HasPrefix(trimmed, "-"):
sign = -1
trimmed = strings.TrimPrefix(trimmed, "-")
}
trimmed = strings.TrimPrefix(strings.ToUpper(trimmed), "UTC")
trimmed = strings.TrimSpace(trimmed)
switch {
case strings.HasPrefix(trimmed, "+"):
trimmed = strings.TrimPrefix(trimmed, "+")
case strings.HasPrefix(trimmed, "-"):
sign = -1
trimmed = strings.TrimPrefix(trimmed, "-")
}
if trimmed == "" {
return 0, false
}
parts := strings.Split(trimmed, ":")
hours := 0
minutes := 0
switch len(parts) {
case 1:
if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil {
return 0, false
}
case 2:
if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil {
return 0, false
}
if _, err := fmt.Sscanf(parts[1], "%d", &minutes); err != nil {
return 0, false
}
default:
return 0, false
}
total := ((hours * 3600) + (minutes * 60)) * sign
return total, true
}
func ValidCoordinates(lat, lon float64) bool {
return !math.IsNaN(lat) && !math.IsNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
}
func NormalizeShiftTimeType(raw string) string {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "", basedomain.ShiftTimeTypeFixed:
return basedomain.ShiftTimeTypeFixed
case basedomain.ShiftTimeTypeBMCT:
return basedomain.ShiftTimeTypeBMCT
case basedomain.ShiftTimeTypeECET:
return basedomain.ShiftTimeTypeECET
default:
return strings.ToUpper(strings.TrimSpace(raw))
}
}

View File

@@ -0,0 +1,70 @@
package util
import (
"testing"
"time"
basedomain "wucher/internal/domain/base"
)
func TestResolveShiftWindowUsesOperationalShiftWithinDateRange(t *testing.T) {
base := &basedomain.Base{
DefaultStartTimeType: basedomain.ShiftTimeTypeFixed,
DefaultEndTimeType: basedomain.ShiftTimeTypeFixed,
DefaultShiftStart: "09:00",
DefaultShiftEnd: "21:00",
OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{
{
DateStart: timePtr(time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)),
DateEnd: timePtr(time.Date(2026, time.June, 30, 0, 0, 0, 0, time.UTC)),
StartTimeType: basedomain.ShiftTimeTypeFixed,
EndTimeType: basedomain.ShiftTimeTypeFixed,
ShiftStart: "10:00",
ShiftEnd: "22:00",
},
},
}
start, end, err := ResolveShiftWindow(base, time.Date(2026, time.June, 15, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("ResolveShiftWindow returned error: %v", err)
}
if start != "10:00" || end != "22:00" {
t.Fatalf("expected operational shift 10:00-22:00, got %s-%s", start, end)
}
start, end, err = ResolveShiftWindow(base, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("ResolveShiftWindow returned error: %v", err)
}
if start != "09:00" || end != "21:00" {
t.Fatalf("expected default shift 09:00-21:00, got %s-%s", start, end)
}
}
func TestPickShiftForDatePreservesCalendarDateFromLocalTimezone(t *testing.T) {
jakarta := time.FixedZone("UTC+7", 7*60*60)
shift := basedomain.BaseOperationalShiftTime{
DateStart: timePtr(time.Date(2026, time.June, 1, 0, 0, 0, 0, jakarta)),
DateEnd: timePtr(time.Date(2026, time.June, 30, 0, 0, 0, 0, jakarta)),
StartTimeType: basedomain.ShiftTimeTypeFixed,
EndTimeType: basedomain.ShiftTimeTypeFixed,
ShiftStart: "10:00",
ShiftEnd: "22:00",
}
base := &basedomain.Base{
OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{shift},
}
picked := PickShiftForDate(base, time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC))
if picked == nil {
t.Fatalf("expected operational shift to match calendar date from local timezone")
}
if picked.ShiftStart != "10:00" || picked.ShiftEnd != "22:00" {
t.Fatalf("unexpected picked shift %s-%s", picked.ShiftStart, picked.ShiftEnd)
}
}
func timePtr(v time.Time) *time.Time {
return &v
}

View File

@@ -0,0 +1,201 @@
package uuidv7
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/url"
"strings"
"time"
)
// New returns a UUIDv7 (RFC 9562) as a 16-byte array.
func New() ([16]byte, error) {
var out [16]byte
var r [10]byte
if _, err := rand.Read(r[:]); err != nil {
return out, err
}
// 48-bit Unix epoch milliseconds
ms := uint64(time.Now().UnixNano() / int64(time.Millisecond))
out[0] = byte(ms >> 40)
out[1] = byte(ms >> 32)
out[2] = byte(ms >> 24)
out[3] = byte(ms >> 16)
out[4] = byte(ms >> 8)
out[5] = byte(ms)
// Version (0111) in high 4 bits of byte 6
out[6] = (r[0] & 0x0f) | 0x70
out[7] = r[1]
// Variant (10) in high 2 bits of byte 8
out[8] = (r[2] & 0x3f) | 0x80
out[9] = r[3]
out[10] = r[4]
out[11] = r[5]
out[12] = r[6]
out[13] = r[7]
out[14] = r[8]
out[15] = r[9]
return out, nil
}
// MustNew panics if UUID generation fails.
func MustNew() [16]byte {
u, err := New()
if err != nil {
panic(err)
}
return u
}
// Bytes returns a new UUIDv7 as []byte for BINARY(16) columns.
func Bytes() ([]byte, error) {
u, err := New()
if err != nil {
return nil, err
}
b := make([]byte, 16)
copy(b, u[:])
return b, nil
}
// MustBytes panics if UUID generation fails.
func MustBytes() []byte {
u := MustNew()
b := make([]byte, 16)
copy(b, u[:])
return b
}
// String formats a UUID as a canonical string (8-4-4-4-12).
func String(u [16]byte) (string, error) {
if len(u) != 16 {
return "", errors.New("invalid uuid length")
}
dst := make([]byte, 36)
hex.Encode(dst[0:8], u[0:4])
dst[8] = '-'
hex.Encode(dst[9:13], u[4:6])
dst[13] = '-'
hex.Encode(dst[14:18], u[6:8])
dst[18] = '-'
hex.Encode(dst[19:23], u[8:10])
dst[23] = '-'
hex.Encode(dst[24:36], u[10:16])
return string(dst), nil
}
// BytesToString formats a UUID stored in []byte as a canonical string.
func BytesToString(b []byte) (string, error) {
if len(b) != 16 {
return "", errors.New("invalid uuid length")
}
var u [16]byte
copy(u[:], b)
return String(u)
}
// ParseString parses a canonical UUID string into []byte (16 bytes).
func ParseString(s string) ([]byte, error) {
if len(s) != 36 {
return nil, errors.New("invalid uuid string length")
}
buf := make([]byte, 16)
_, err := hex.Decode(buf[0:4], []byte(s[0:8]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[4:6], []byte(s[9:13]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[6:8], []byte(s[14:18]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[8:10], []byte(s[19:23]))
if err != nil {
return nil, err
}
_, err = hex.Decode(buf[10:16], []byte(s[24:36]))
if err != nil {
return nil, err
}
return buf, nil
}
// ParsePathLikeString parses a UUID from a path-like value. This is useful for
// URL params that may arrive escaped, wrapped in a full URL, or with a trailing slash.
func ParsePathLikeString(raw string) ([]byte, error) {
for _, candidate := range candidateUUIDStrings(raw) {
if parsed, err := ParseString(candidate); err == nil {
return parsed, nil
}
}
return nil, errors.New("invalid uuid string")
}
func candidateUUIDStrings(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
candidates := make([]string, 0, 6)
seen := make(map[string]struct{}, 6)
push := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
candidates = append(candidates, value)
}
push(raw)
if decoded, err := url.PathUnescape(raw); err == nil && decoded != raw {
push(decoded)
}
if decoded, err := url.QueryUnescape(raw); err == nil && decoded != raw {
push(decoded)
}
for i := 0; i < len(candidates); i++ {
value := candidates[i]
value = strings.TrimSuffix(value, "/")
push(value)
if idx := strings.IndexAny(value, "?#"); idx >= 0 {
push(value[:idx])
}
if idx := strings.LastIndex(value, "/"); idx >= 0 && idx+1 < len(value) {
push(value[idx+1:])
}
if idx := strings.LastIndex(value, "="); idx >= 0 && idx+1 < len(value) {
push(value[idx+1:])
}
for _, part := range strings.Split(value, "/") {
push(part)
}
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
switch r {
case '/', '?', '#', '&', '=':
return true
default:
return false
}
}) {
push(part)
}
}
return candidates
}

View File

@@ -0,0 +1,38 @@
package uuidv7
import "testing"
func TestParsePathLikeString(t *testing.T) {
t.Parallel()
want := "019f1b8b-040d-7669-800c-d2ae52a53646"
cases := []string{
want,
want + "/",
"https://api-wfm-dev.mybit.co.id/wopi/files/" + want,
"https://api-wfm-dev.mybit.co.id/wopi/files/" + want + "/contents",
"https%3A%2F%2Fapi-wfm-dev.mybit.co.id%2Fwopi%2Ffiles%2F" + want,
"https%3A%2F%2Fapi-wfm-dev.mybit.co.id%2Fwopi%2Ffiles%2F" + want + "%2Fcontents",
"WOPISrc=https%3A%2F%2Fapi-wfm-dev.mybit.co.id%2Fwopi%2Ffiles%2F" + want,
"/wopi/files/" + want + "/contents",
}
for _, tc := range cases {
tc := tc
t.Run(tc, func(t *testing.T) {
t.Parallel()
parsed, err := ParsePathLikeString(tc)
if err != nil {
t.Fatalf("ParsePathLikeString(%q) error = %v", tc, err)
}
got, err := BytesToString(parsed)
if err != nil {
t.Fatalf("BytesToString error = %v", err)
}
if got != want {
t.Fatalf("ParsePathLikeString(%q) = %q, want %q", tc, got, want)
}
})
}
}