From 312ae8a3a3fcb8f2ec1481db748a95ea601f2bdf Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:23:09 +0200 Subject: [PATCH] FIX - repair company actions and key rebindings (#32) * FIX - repair company detail actions Constrain compact navbar titles to their grid column so long company names cannot overlap the back action. Allow configured public emergency companies to accept normal service requests, and migrate existing police profile and Phone Configurator defaults exactly once. * FIX - preserve phone key rebindings Use the stable sky_phone_toggle command as the RegisterKeyMapping identifier. The previous revisioned command names detached FiveM's persisted keyboard settings from the active handler whenever the mapping identity changed. --- ...ompaniesEmergencyRequests.contract.test.ts | 147 ++++++++++++++++++ frontend/src/phoneInventory.contract.test.ts | 12 +- frontend/src/ui/SkyNavbar.test.ts | 11 ++ frontend/src/ui/foundation.css | 1 + .../testserver/configurator-fixture.test.ts | 45 +++++- frontend/testserver/index.cjs | 22 +-- sky_phone/config/config.lua | 12 +- sky_phone/source/client/main.lua | 36 ++--- sky_phone/source/server/companies.lua | 49 +++++- .../source/server/phone_configurator.lua | 64 ++++++++ sky_phone/source/shared/config_default.lua | 12 +- 11 files changed, 361 insertions(+), 50 deletions(-) create mode 100644 frontend/src/companiesEmergencyRequests.contract.test.ts diff --git a/frontend/src/companiesEmergencyRequests.contract.test.ts b/frontend/src/companiesEmergencyRequests.contract.test.ts new file mode 100644 index 0000000..ce6235d --- /dev/null +++ b/frontend/src/companiesEmergencyRequests.contract.test.ts @@ -0,0 +1,147 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const config = readFileSync( + new URL('../../sky_phone/config/config.lua', import.meta.url), + 'utf8', +).replace(/\r\n/g, '\n') +const companiesServer = readFileSync( + new URL('../../sky_phone/source/server/companies.lua', import.meta.url), + 'utf8', +).replace(/\r\n/g, '\n') +const configuratorServer = readFileSync( + new URL( + '../../sky_phone/source/server/phone_configurator.lua', + import.meta.url, + ), + 'utf8', +).replace(/\r\n/g, '\n') +const testServer = readFileSync( + new URL('../testserver/index.cjs', import.meta.url), + 'utf8', +).replace(/\r\n/g, '\n') + +function sourceBlock(source: string, startMarker: string, endMarker: string) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('Companies emergency request contract', () => { + it('ships non-emergency police assistance as a requestable service', () => { + const police = sourceBlock( + config, + ' police = {', + ' ambulance = {', + ) + const mockPolice = sourceBlock( + testServer, + 'const companyProfiles = [', + " {\n acceptsRequests: false,\n announcement: null,", + ) + + expect(police).toContain('Emergency = true') + expect(police).toContain('AcceptsRequests = true') + expect(police).toContain('Id = "police-assistance"') + expect(police).toContain('RequestsEnabled = true') + expect(mockPolice).toContain('acceptsRequests: true') + expect(mockPolice).toContain("name: 'Los Santos Police Department'") + expect(mockPolice).toContain("id: 'police-assistance'") + }) + + it('authorizes configured emergency companies through the normal request gates', () => { + const validation = sourceBlock( + companiesServer, + 'local function validate_configuration()', + 'local function seed_companies()', + ) + const payload = sourceBlock( + companiesServer, + 'local function company_payload(', + 'local function public_company(', + ) + const createRequest = sourceBlock( + companiesServer, + 'Bridge.Callbacks.Register("sky_phone:companies:create-request"', + 'Bridge.Callbacks.Register("sky_phone:companies:cancel-request"', + ) + const updateProfile = sourceBlock( + companiesServer, + 'Bridge.Callbacks.Register("sky_phone:companies:update-profile"', + 'Bridge.Callbacks.Register("sky_phone:companies:update-hours"', + ) + + expect(validation).not.toContain( + 'definition.Emergency and definition.AcceptsRequests', + ) + expect(payload).toContain( + 'acceptsRequests = tonumber(row.accepts_requests) == 1,', + ) + expect(payload).not.toContain('not definition.Emergency') + expect(createRequest).toContain( + 'if not definition or not definition.Public then', + ) + expect(createRequest).not.toContain('definition.Emergency') + expect(createRequest).toContain('SELECT `accepts_requests`') + expect(createRequest).toContain('AND `requests_enabled` = 1') + expect(updateProfile).not.toContain('member.definition.Emergency') + }) + + it('migrates existing requestable emergency profiles exactly once', () => { + const migration = sourceBlock( + companiesServer, + 'local function migrate_requestable_emergency_companies()', + 'local function tombstone_removed_companies()', + ) + const refresh = sourceBlock( + companiesServer, + 'local function refresh_runtime_configuration()', + '\n\nrefresh_runtime_configuration()', + ) + + expect(migration).toContain( + 'sky-phone:companies:requestable-emergency:v1', + ) + expect(migration).toContain( + 'if definition.Emergency and definition.AcceptsRequests then', + ) + expect(migration).toContain('SET `accepts_requests` = 1') + expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`') + expect(migration).toContain('Bridge.Database.Transaction(statements)') + expect(refresh.indexOf('seed_companies()')).toBeLessThan( + refresh.indexOf('migrate_requestable_emergency_companies()'), + ) + expect( + refresh.indexOf('migrate_requestable_emergency_companies()'), + ).toBeLessThan(refresh.indexOf('tombstone_removed_companies()')) + }) + + it('migrates the existing Phone Configurator police defaults', () => { + const migration = sourceBlock( + configuratorServer, + 'local function migrate_police_request_defaults()', + '\n\ndefault_config = {}', + ) + + expect(migration).toContain('sky-phone:configurator:police-requests:v1') + expect(migration).toContain('police.AcceptsRequests == false') + expect(migration).toContain('next(police.Services) == nil') + expect(migration).toContain( + 'police.AcceptsRequests = defaults.AcceptsRequests', + ) + expect(migration).toContain('police.Services = copy_value(defaults.Services)') + expect(migration).toContain('SET `config_payload` = ?') + expect(migration).toContain('`revision` = `revision` + 1') + expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`') + expect(migration).toContain('Bridge.Database.Transaction(statements)') + expect(migration).toContain('apply_stored_row(read_stored_row())') + expect(migration).toContain('apply_runtime_configuration()') + expect(configuratorServer).toContain( + 'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)', + ) + }) +}) diff --git a/frontend/src/phoneInventory.contract.test.ts b/frontend/src/phoneInventory.contract.test.ts index add39d8..e24e6f9 100644 --- a/frontend/src/phoneInventory.contract.test.ts +++ b/frontend/src/phoneInventory.contract.test.ts @@ -286,19 +286,19 @@ describe('phone inventory contracts', () => { expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"') }) - it('opens from a configurable F1 mapping without client-provided device identity', () => { + it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => { const config = readResourceFile('config/config.lua') const phoneClient = readResourceFile('source/client/main.lua') const phoneServer = readResourceFile('source/server/phone.lua') expect(config).toContain('Keybind = "F1"') + expect(phoneClient).toContain('local phone_key_mapping_registered = false') expect(phoneClient).toContain('refresh_phone_key_mapping = function()') - expect(phoneClient).toContain( - 'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)', - ) - expect(phoneClient).toContain( - 'if active_key_mapping_command == command_name then', + expect(phoneClient).toMatch( + /RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/, ) + expect(phoneClient).not.toContain('sky_phone_toggle_config_') + expect(phoneClient).not.toContain('key_mapping_revision') expect(phoneClient).toContain( 'request_phone_open("sky_phone:device:open-request")', ) diff --git a/frontend/src/ui/SkyNavbar.test.ts b/frontend/src/ui/SkyNavbar.test.ts index 29e6c54..8464728 100644 --- a/frontend/src/ui/SkyNavbar.test.ts +++ b/frontend/src/ui/SkyNavbar.test.ts @@ -43,6 +43,17 @@ describe('SkyNavbar', () => { expect(html).toContain('Account') }) + it('constrains long compact titles to the center column', () => { + const titleRule = foundationStyles.match( + /\.sky-navbar__title\s*\{([^}]*)\}/s, + )?.[1] + + expect(titleRule).toContain('max-width: 100%') + expect(titleRule).toContain('overflow: hidden') + expect(titleRule).toContain('text-overflow: ellipsis') + expect(titleRule).toContain('white-space: nowrap') + }) + it('exposes the large-title header without changing heading semantics', async () => { const html = await renderToString( createSSRApp(SkyNavbar, { diff --git a/frontend/src/ui/foundation.css b/frontend/src/ui/foundation.css index e2fc793..a4cfd3c 100644 --- a/frontend/src/ui/foundation.css +++ b/frontend/src/ui/foundation.css @@ -168,6 +168,7 @@ .sky-navbar__title { min-width: 0; + max-width: 100%; margin: 0; padding: 0 var(--sky-space-1); overflow: hidden; diff --git a/frontend/testserver/configurator-fixture.test.ts b/frontend/testserver/configurator-fixture.test.ts index 0bca2ce..ba5a547 100644 --- a/frontend/testserver/configurator-fixture.test.ts +++ b/frontend/testserver/configurator-fixture.test.ts @@ -135,6 +135,49 @@ describe('admin configurator fixture', () => { ).toBe('map') }) + it('exposes the requestable police assistance defaults', () => { + const companyJobs = loadConfiguratorSections() + .flatMap((section) => section.fields) + .find((field) => field.path === 'Companies.Definitions') + const police = ( + companyJobs?.value as + | Record> + | undefined + )?.police + + expect(police).toMatchObject({ + AcceptsRequests: true, + Emergency: true, + Services: [ + { + Id: 'police-assistance', + RequestsEnabled: true, + }, + ], + }) + expect( + companyJobs?.structure?.fields?.police.fields?.Services, + ).toMatchObject({ + items: [ + { + fields: { + Id: { kind: 'value', valueType: 'string' }, + RequestsEnabled: { kind: 'value', valueType: 'boolean' }, + }, + kind: 'table', + }, + ], + kind: 'list', + template: { + fields: { + Id: { kind: 'value', valueType: 'string' }, + RequestsEnabled: { kind: 'value', valueType: 'boolean' }, + }, + kind: 'table', + }, + }) + }) + it('publishes fixed schemas for every empty configurable collection', () => { const fields = loadConfiguratorSections().flatMap( (section) => section.fields, @@ -183,7 +226,7 @@ describe('admin configurator fixture', () => { template: { kind: 'value', valueType: 'string' }, }) expect( - root('Companies.Definitions')?.fields?.police.fields?.Services, + root('Companies.Definitions')?.fields?.ambulance.fields?.Services, ).toMatchObject({ items: [], kind: 'list', diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 1941763..f0ca325 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -4178,7 +4178,7 @@ const companyCategories = [ let companyCallAvailable = false const companyProfiles = [ { - acceptsRequests: false, + acceptsRequests: true, announcement: { body: 'Community traffic unit active around Legion Square.', expiresAt: isoTime(6 * 60 * 60 * 1000), @@ -4202,28 +4202,20 @@ const companyProfiles = [ label: 'Mission Row Police Station', }, logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180', - name: 'Los Santos Police', + name: 'Los Santos Police Department', phoneNumber: '911', revision: 3, services: [ { - acceptsRequests: false, + acceptsRequests: true, active: true, - description: 'Immediate police response through the service line.', - id: 'emergency-response', + description: 'Request non-emergency police assistance.', + id: 'police-assistance', priceText: null, - title: 'Emergency Response', - }, - { - acceptsRequests: false, - active: true, - description: 'General information and non-emergency assistance.', - id: 'public-assistance', - priceText: null, - title: 'Public Assistance', + title: 'Police Assistance', }, ], - serviceSummary: 'Emergency response and public assistance', + serviceSummary: 'Non-emergency police assistance', verified: true, }, { diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 1e50ed5..e639f79 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -1181,7 +1181,7 @@ if IsDuplicityVersion() then LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", Description = "Public safety, emergency response, and police services.", DefaultAvailability = "closed", - AcceptsRequests = false, + AcceptsRequests = true, District = "Mission Row", LocationLabel = "Mission Row Police Station", Address = "Mission Row Police Station", @@ -1203,7 +1203,15 @@ if IsDuplicityVersion() then Services = 3, Announcement = 3, }, - Services = {}, + Services = { + { + Id = "police-assistance", + Title = "Police assistance", + Description = "Request non-emergency police assistance.", + Price = "", + RequestsEnabled = true, + }, + }, }, ambulance = { Job = "ambulance", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index ca496c6..becaaa4 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -13,9 +13,7 @@ local suggested_admin_command = nil local suggested_test_data_command = nil local active_development_command = nil local registered_development_commands = {} -local active_key_mapping_command = nil -local active_key_mapping_key = nil -local key_mapping_revision = 0 +local phone_key_mapping_registered = false local refresh_development_command local refresh_phone_key_mapping local refresh_test_data_command_suggestion @@ -312,32 +310,30 @@ RegisterCommand("sky_phone_live_activity_open", function() end end, false) -RegisterCommand("sky_phone_toggle", run_phone_toggle, false) +RegisterCommand("sky_phone_toggle", function() + if not Config.Phone.Keybind then + return + end + run_phone_toggle() +end, false) refresh_phone_key_mapping = function() local key_name = Config.Phone.Keybind if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.") end - if key_name == active_key_mapping_key then + if phone_key_mapping_registered or not key_name then return end - active_key_mapping_key = key_name - active_key_mapping_command = nil - if not key_name then - return - end - - key_mapping_revision = key_mapping_revision + 1 - local command_name = "sky_phone_toggle_config_" .. key_mapping_revision - active_key_mapping_command = command_name - RegisterCommand(command_name, function() - if active_key_mapping_command == command_name then - run_phone_toggle() - end - end, false) - RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name) + -- FiveM persists player rebindings by command name, so this identifier must remain stable. + phone_key_mapping_registered = true + RegisterKeyMapping( + "sky_phone_toggle", + locale.Controls.OpenPhone, + "keyboard", + key_name + ) end refresh_phone_key_mapping() diff --git a/sky_phone/source/server/companies.lua b/sky_phone/source/server/companies.lua index 44cb187..c73200a 100644 --- a/sky_phone/source/server/companies.lua +++ b/sky_phone/source/server/companies.lua @@ -279,7 +279,6 @@ local function validate_configuration() or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability] or not valid_text(definition.Icon, 64, false) or not logo_url or not logo_url:match("^https://[^%s]+$") - or (definition.Emergency and definition.AcceptsRequests) then error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)) end @@ -466,6 +465,48 @@ local function seed_companies() end +local function migrate_requestable_emergency_companies() + local migration_name = "sky-phone:companies:requestable-emergency:v1" + local completed = Bridge.Database.Query( + "SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1", + { migration_name } + ) + if completed[1] then + return + end + + local statements = {} + local migrated_companies = {} + for _, company_id in ipairs(definition_ids) do + local definition = definitions[company_id] + if definition.Emergency and definition.AcceptsRequests then + statements[#statements + 1] = { + query = [[ + UPDATE `sky_phone_company_profiles` + SET `accepts_requests` = 1, `revision` = `revision` + 1 + WHERE `company_id` = ? AND `accepts_requests` = 0 + ]], + params = { company_id }, + } + migrated_companies[#migrated_companies + 1] = company_id + end + end + statements[#statements + 1] = { + query = [[ + INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`) + VALUES (?, ?, ?) + ]], + params = { + migration_name, + "sky-phone", + json.encode({ companies = migrated_companies }), + }, + } + if not Bridge.Database.Transaction(statements) then + error("[sky_phone] Could not migrate requestable emergency company profiles.") + end +end + local function tombstone_removed_companies() local profiles = Bridge.Database.Query([[ SELECT DISTINCT profile.`company_id` @@ -826,7 +867,7 @@ local function company_payload(company_id, include_inactive_services) availability = availability, availabilityUpdatedAt = iso_time(row.availability_updated_at_unix) or iso_time(row.updated_at_unix), - acceptsRequests = tonumber(row.accepts_requests) == 1 and not definition.Emergency, + acceptsRequests = tonumber(row.accepts_requests) == 1, phoneNumber = line and line.Number or nil, canCall = line and line.CanCall == true or false, canMessage = line and line.CanMessage == true or false, @@ -912,6 +953,7 @@ local function refresh_runtime_configuration() service_lines_by_number = {} validate_configuration() seed_companies() + migrate_requestable_emergency_companies() tombstone_removed_companies() end @@ -1839,7 +1881,7 @@ Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, end local company_id = data.companyId local definition = type(company_id) == "string" and definitions[company_id] or nil - if not definition or not definition.Public or definition.Emergency then + if not definition or not definition.Public then return { success = false, error = "company_not_found" } end local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false) @@ -2636,7 +2678,6 @@ Bridge.Callbacks.Register("sky_phone:companies:update-profile", function(source, local address = valid_text(data.address, Config.Companies.AddressMaxLength, true) if not revision or not description or not district or not location_label or not address or type(data.acceptsRequests) ~= "boolean" - or (member.definition.Emergency and data.acceptsRequests) then return { success = false, error = "invalid_profile" } end diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index a824cdc..40f97db 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -1075,6 +1075,69 @@ local function apply_stored_row(row) updated_by_name = row.updated_by_name end +local function migrate_police_request_defaults() + local migration_name = "sky-phone:configurator:police-requests:v1" + local completed = Bridge.Database.Query( + "SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1", + { migration_name } + ) + if completed[1] then + return + end + + local row = read_stored_row() + local config_payload = decode_payload(row.config_payload, "config") + local police = config_payload.Companies + and config_payload.Companies.Definitions + and config_payload.Companies.Definitions.police + local migrated = type(police) == "table" + and police.Emergency == true + and police.AcceptsRequests == false + and type(police.Services) == "table" + and next(police.Services) == nil + local statements = {} + if migrated then + local defaults = default_config.Companies.Definitions.police + police.AcceptsRequests = defaults.AcceptsRequests + police.Services = copy_value(defaults.Services) + statements[#statements + 1] = { + query = ([[ + UPDATE `%s` + SET `config_payload` = ?, `revision` = `revision` + 1 + WHERE `id` = ? + ]]):format(TABLE_NAME), + params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID }, + } + end + statements[#statements + 1] = { + query = [[ + INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`) + VALUES (?, ?, ?) + ]], + params = { + migration_name, + "sky-phone", + json.encode({ migrated = migrated }), + }, + } + if not Bridge.Database.Transaction(statements) then + error("[sky_phone] Could not migrate Phone Configurator police request defaults.") + end + if not migrated then + return + end + + apply_stored_row(read_stored_row()) + apply_runtime_configuration() + TriggerEvent("sky_phone:configurator:serverUpdated", revision) + SkyPhoneConfigurator.Broadcast(-1) + Bridge.Debug( + "info", + "[sky_phone] Migrated Phone Configurator police request defaults.", + { always = true } + ) +end + default_config = {} for key, value in pairs(ConfigDefaults) do if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then @@ -1095,6 +1158,7 @@ Bridge.Database.Query(([[ apply_stored_row(read_stored_row()) apply_runtime_configuration() +Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults) function SkyPhoneConfigurator.GetAdminData() local data = build_admin_data() diff --git a/sky_phone/source/shared/config_default.lua b/sky_phone/source/shared/config_default.lua index f484772..b25afe4 100644 --- a/sky_phone/source/shared/config_default.lua +++ b/sky_phone/source/shared/config_default.lua @@ -1166,7 +1166,7 @@ if IsDuplicityVersion() then LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", Description = "Public safety, emergency response, and police services.", DefaultAvailability = "closed", - AcceptsRequests = false, + AcceptsRequests = true, District = "Mission Row", LocationLabel = "Mission Row Police Station", Address = "Mission Row Police Station", @@ -1188,7 +1188,15 @@ if IsDuplicityVersion() then Services = 3, Announcement = 3, }, - Services = {}, + Services = { + { + Id = "police-assistance", + Title = "Police assistance", + Description = "Request non-emergency police assistance.", + Price = "", + RequestsEnabled = true, + }, + }, }, ambulance = { Job = "ambulance",