init push
This commit is contained in:
48
internal/shared/const/audit.go
Normal file
48
internal/shared/const/audit.go
Normal 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"
|
||||
)
|
||||
5
internal/shared/const/branding.go
Normal file
5
internal/shared/const/branding.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package sharedconst
|
||||
|
||||
const (
|
||||
BrandingEmailLogoPublicPath = "/api/v1/public/branding/email-logo"
|
||||
)
|
||||
14
internal/shared/const/flight_data.go
Normal file
14
internal/shared/const/flight_data.go
Normal 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"
|
||||
)
|
||||
15
internal/shared/const/mission.go
Normal file
15
internal/shared/const/mission.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package sharedconst
|
||||
|
||||
const (
|
||||
MissionDefaultPageSize = 20
|
||||
MissionMaxPageSize = 100
|
||||
)
|
||||
|
||||
const (
|
||||
MissionResourceType = "mission"
|
||||
|
||||
MissionTypeCAT = "CAT"
|
||||
MissionTypeSPO = "SPO"
|
||||
MissionTypeNCO = "NCO"
|
||||
MissionTypeHEMS = "HEMS"
|
||||
)
|
||||
344
internal/shared/const/pin_actions.go
Normal file
344
internal/shared/const/pin_actions.go
Normal 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 ""
|
||||
}
|
||||
57
internal/shared/pkg/appctx/appctx.go
Normal file
57
internal/shared/pkg/appctx/appctx.go
Normal 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
|
||||
}
|
||||
}
|
||||
46
internal/shared/pkg/appctx/appctx_test.go
Normal file
46
internal/shared/pkg/appctx/appctx_test.go
Normal 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
|
||||
}
|
||||
57
internal/shared/pkg/apperrors/delete_conflict.go
Normal file
57
internal/shared/pkg/apperrors/delete_conflict.go
Normal 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
|
||||
}
|
||||
36
internal/shared/pkg/apperrorsx/apperror.go
Normal file
36
internal/shared/pkg/apperrorsx/apperror.go
Normal 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
|
||||
}
|
||||
183
internal/shared/pkg/apperrorsx/codes.go
Normal file
183
internal/shared/pkg/apperrorsx/codes.go
Normal 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)
|
||||
}
|
||||
2601
internal/shared/pkg/apperrorsx/codes_defs.go
Normal file
2601
internal/shared/pkg/apperrorsx/codes_defs.go
Normal file
File diff suppressed because it is too large
Load Diff
106
internal/shared/pkg/apperrorsx/enrich.go
Normal file
106
internal/shared/pkg/apperrorsx/enrich.go
Normal 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
|
||||
}
|
||||
141
internal/shared/pkg/apperrorsx/mysql_map.go
Normal file
141
internal/shared/pkg/apperrorsx/mysql_map.go
Normal 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
|
||||
}
|
||||
}
|
||||
33
internal/shared/pkg/apperrorsx/registry_test.go
Normal file
33
internal/shared/pkg/apperrorsx/registry_test.go
Normal 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{}{}
|
||||
}
|
||||
}
|
||||
68
internal/shared/pkg/apperrorsx/response.go
Normal file
68
internal/shared/pkg/apperrorsx/response.go
Normal 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"
|
||||
}
|
||||
}
|
||||
54
internal/shared/pkg/apperrorsx/response_test.go
Normal file
54
internal/shared/pkg/apperrorsx/response_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
221
internal/shared/pkg/apperrorsx/translator.go
Normal file
221
internal/shared/pkg/apperrorsx/translator.go
Normal 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
|
||||
}
|
||||
40
internal/shared/pkg/apperrorsx/translator_test.go
Normal file
40
internal/shared/pkg/apperrorsx/translator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
251
internal/shared/pkg/attachmentresolver/resolver.go
Normal file
251
internal/shared/pkg/attachmentresolver/resolver.go
Normal 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
|
||||
}
|
||||
}
|
||||
41
internal/shared/pkg/logger/logger.go
Normal file
41
internal/shared/pkg/logger/logger.go
Normal 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
|
||||
}
|
||||
}
|
||||
74
internal/shared/pkg/metricparse/metricparse.go
Normal file
74
internal/shared/pkg/metricparse/metricparse.go
Normal 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' }
|
||||
43
internal/shared/pkg/metricparse/metricparse_test.go
Normal file
43
internal/shared/pkg/metricparse/metricparse_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
384
internal/shared/pkg/metrics/prometheus.go
Normal file
384
internal/shared/pkg/metrics/prometheus.go
Normal 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"
|
||||
}
|
||||
32
internal/shared/pkg/missioncode/mission_code.go
Normal file
32
internal/shared/pkg/missioncode/mission_code.go
Normal 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)
|
||||
}
|
||||
82
internal/shared/pkg/nextdue/parser.go
Normal file
82
internal/shared/pkg/nextdue/parser.go
Normal 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)
|
||||
}
|
||||
77
internal/shared/pkg/nextdue/parser_test.go
Normal file
77
internal/shared/pkg/nextdue/parser_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
33
internal/shared/pkg/reserveacutil/status.go
Normal file
33
internal/shared/pkg/reserveacutil/status.go
Normal 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"
|
||||
}
|
||||
32
internal/shared/pkg/sortkey/sortkey.go
Normal file
32
internal/shared/pkg/sortkey/sortkey.go
Normal 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
|
||||
}
|
||||
34
internal/shared/pkg/sortkey/sortkey_test.go
Normal file
34
internal/shared/pkg/sortkey/sortkey_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
108
internal/shared/pkg/suncalc/suncalc.go
Normal file
108
internal/shared/pkg/suncalc/suncalc.go
Normal 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
|
||||
}
|
||||
35
internal/shared/pkg/timezone/timezone.go
Normal file
35
internal/shared/pkg/timezone/timezone.go
Normal 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
|
||||
}
|
||||
29
internal/shared/pkg/txctx/txctx.go
Normal file
29
internal/shared/pkg/txctx/txctx.go
Normal 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)
|
||||
}
|
||||
346
internal/shared/pkg/userctx/user_name_cache.go
Normal file
346
internal/shared/pkg/userctx/user_name_cache.go
Normal 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()
|
||||
}
|
||||
}
|
||||
244
internal/shared/pkg/util/shift_clock.go
Normal file
244
internal/shared/pkg/util/shift_clock.go
Normal 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))
|
||||
}
|
||||
}
|
||||
70
internal/shared/pkg/util/shift_clock_test.go
Normal file
70
internal/shared/pkg/util/shift_clock_test.go
Normal 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
|
||||
}
|
||||
201
internal/shared/pkg/uuidv7/uuidv7.go
Normal file
201
internal/shared/pkg/uuidv7/uuidv7.go
Normal 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
|
||||
}
|
||||
38
internal/shared/pkg/uuidv7/uuidv7_test.go
Normal file
38
internal/shared/pkg/uuidv7/uuidv7_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user