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",