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.
This commit is contained in:
Leon.Schmidt
2026-08-23 18:23:09 +02:00
committed by GitHub
parent 435320e8fd
commit 312ae8a3a3
11 changed files with 361 additions and 50 deletions
@@ -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)',
)
})
})
+6 -6
View File
@@ -286,19 +286,19 @@ describe('phone inventory contracts', () => {
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"') 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 config = readResourceFile('config/config.lua')
const phoneClient = readResourceFile('source/client/main.lua') const phoneClient = readResourceFile('source/client/main.lua')
const phoneServer = readResourceFile('source/server/phone.lua') const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"') 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('refresh_phone_key_mapping = function()')
expect(phoneClient).toContain( expect(phoneClient).toMatch(
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)', /RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
)
expect(phoneClient).toContain(
'if active_key_mapping_command == command_name then',
) )
expect(phoneClient).not.toContain('sky_phone_toggle_config_')
expect(phoneClient).not.toContain('key_mapping_revision')
expect(phoneClient).toContain( expect(phoneClient).toContain(
'request_phone_open("sky_phone:device:open-request")', 'request_phone_open("sky_phone:device:open-request")',
) )
+11
View File
@@ -43,6 +43,17 @@ describe('SkyNavbar', () => {
expect(html).toContain('Account') 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 () => { it('exposes the large-title header without changing heading semantics', async () => {
const html = await renderToString( const html = await renderToString(
createSSRApp(SkyNavbar, { createSSRApp(SkyNavbar, {
+1
View File
@@ -168,6 +168,7 @@
.sky-navbar__title { .sky-navbar__title {
min-width: 0; min-width: 0;
max-width: 100%;
margin: 0; margin: 0;
padding: 0 var(--sky-space-1); padding: 0 var(--sky-space-1);
overflow: hidden; overflow: hidden;
@@ -135,6 +135,49 @@ describe('admin configurator fixture', () => {
).toBe('map') ).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<string, Record<string, unknown>>
| 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', () => { it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap( const fields = loadConfiguratorSections().flatMap(
(section) => section.fields, (section) => section.fields,
@@ -183,7 +226,7 @@ describe('admin configurator fixture', () => {
template: { kind: 'value', valueType: 'string' }, template: { kind: 'value', valueType: 'string' },
}) })
expect( expect(
root('Companies.Definitions')?.fields?.police.fields?.Services, root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
).toMatchObject({ ).toMatchObject({
items: [], items: [],
kind: 'list', kind: 'list',
+7 -15
View File
@@ -4178,7 +4178,7 @@ const companyCategories = [
let companyCallAvailable = false let companyCallAvailable = false
const companyProfiles = [ const companyProfiles = [
{ {
acceptsRequests: false, acceptsRequests: true,
announcement: { announcement: {
body: 'Community traffic unit active around Legion Square.', body: 'Community traffic unit active around Legion Square.',
expiresAt: isoTime(6 * 60 * 60 * 1000), expiresAt: isoTime(6 * 60 * 60 * 1000),
@@ -4202,28 +4202,20 @@ const companyProfiles = [
label: 'Mission Row Police Station', label: 'Mission Row Police Station',
}, },
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180', logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
name: 'Los Santos Police', name: 'Los Santos Police Department',
phoneNumber: '911', phoneNumber: '911',
revision: 3, revision: 3,
services: [ services: [
{ {
acceptsRequests: false, acceptsRequests: true,
active: true, active: true,
description: 'Immediate police response through the service line.', description: 'Request non-emergency police assistance.',
id: 'emergency-response', id: 'police-assistance',
priceText: null, priceText: null,
title: 'Emergency Response', title: 'Police Assistance',
},
{
acceptsRequests: false,
active: true,
description: 'General information and non-emergency assistance.',
id: 'public-assistance',
priceText: null,
title: 'Public Assistance',
}, },
], ],
serviceSummary: 'Emergency response and public assistance', serviceSummary: 'Non-emergency police assistance',
verified: true, verified: true,
}, },
{ {
+10 -2
View File
@@ -1181,7 +1181,7 @@ if IsDuplicityVersion() then
LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180",
Description = "Public safety, emergency response, and police services.", Description = "Public safety, emergency response, and police services.",
DefaultAvailability = "closed", DefaultAvailability = "closed",
AcceptsRequests = false, AcceptsRequests = true,
District = "Mission Row", District = "Mission Row",
LocationLabel = "Mission Row Police Station", LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station", Address = "Mission Row Police Station",
@@ -1203,7 +1203,15 @@ if IsDuplicityVersion() then
Services = 3, Services = 3,
Announcement = 3, Announcement = 3,
}, },
Services = {}, Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
}, },
ambulance = { ambulance = {
Job = "ambulance", Job = "ambulance",
+16 -20
View File
@@ -13,9 +13,7 @@ local suggested_admin_command = nil
local suggested_test_data_command = nil local suggested_test_data_command = nil
local active_development_command = nil local active_development_command = nil
local registered_development_commands = {} local registered_development_commands = {}
local active_key_mapping_command = nil local phone_key_mapping_registered = false
local active_key_mapping_key = nil
local key_mapping_revision = 0
local refresh_development_command local refresh_development_command
local refresh_phone_key_mapping local refresh_phone_key_mapping
local refresh_test_data_command_suggestion local refresh_test_data_command_suggestion
@@ -312,32 +310,30 @@ RegisterCommand("sky_phone_live_activity_open", function()
end end
end, false) 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() refresh_phone_key_mapping = function()
local key_name = Config.Phone.Keybind local key_name = Config.Phone.Keybind
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then 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.") error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end end
if key_name == active_key_mapping_key then if phone_key_mapping_registered or not key_name then
return return
end end
active_key_mapping_key = key_name -- FiveM persists player rebindings by command name, so this identifier must remain stable.
active_key_mapping_command = nil phone_key_mapping_registered = true
if not key_name then RegisterKeyMapping(
return "sky_phone_toggle",
end locale.Controls.OpenPhone,
"keyboard",
key_mapping_revision = key_mapping_revision + 1 key_name
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)
end end
refresh_phone_key_mapping() refresh_phone_key_mapping()
+45 -4
View File
@@ -279,7 +279,6 @@ local function validate_configuration()
or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability] or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability]
or not valid_text(definition.Icon, 64, false) or not valid_text(definition.Icon, 64, false)
or not logo_url or not logo_url:match("^https://[^%s]+$") or not logo_url or not logo_url:match("^https://[^%s]+$")
or (definition.Emergency and definition.AcceptsRequests)
then then
error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)) error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id))
end end
@@ -466,6 +465,48 @@ local function seed_companies()
end 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 function tombstone_removed_companies()
local profiles = Bridge.Database.Query([[ local profiles = Bridge.Database.Query([[
SELECT DISTINCT profile.`company_id` SELECT DISTINCT profile.`company_id`
@@ -826,7 +867,7 @@ local function company_payload(company_id, include_inactive_services)
availability = availability, availability = availability,
availabilityUpdatedAt = iso_time(row.availability_updated_at_unix) availabilityUpdatedAt = iso_time(row.availability_updated_at_unix)
or iso_time(row.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, phoneNumber = line and line.Number or nil,
canCall = line and line.CanCall == true or false, canCall = line and line.CanCall == true or false,
canMessage = line and line.CanMessage == true or false, canMessage = line and line.CanMessage == true or false,
@@ -912,6 +953,7 @@ local function refresh_runtime_configuration()
service_lines_by_number = {} service_lines_by_number = {}
validate_configuration() validate_configuration()
seed_companies() seed_companies()
migrate_requestable_emergency_companies()
tombstone_removed_companies() tombstone_removed_companies()
end end
@@ -1839,7 +1881,7 @@ Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source,
end end
local company_id = data.companyId local company_id = data.companyId
local definition = type(company_id) == "string" and definitions[company_id] or nil 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" } return { success = false, error = "company_not_found" }
end end
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false) 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) 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 if not revision or not description or not district or not location_label or not address
or type(data.acceptsRequests) ~= "boolean" or type(data.acceptsRequests) ~= "boolean"
or (member.definition.Emergency and data.acceptsRequests)
then then
return { success = false, error = "invalid_profile" } return { success = false, error = "invalid_profile" }
end end
@@ -1075,6 +1075,69 @@ local function apply_stored_row(row)
updated_by_name = row.updated_by_name updated_by_name = row.updated_by_name
end 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 = {} default_config = {}
for key, value in pairs(ConfigDefaults) do for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
@@ -1095,6 +1158,7 @@ Bridge.Database.Query(([[
apply_stored_row(read_stored_row()) apply_stored_row(read_stored_row())
apply_runtime_configuration() apply_runtime_configuration()
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
function SkyPhoneConfigurator.GetAdminData() function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data() local data = build_admin_data()
+10 -2
View File
@@ -1166,7 +1166,7 @@ if IsDuplicityVersion() then
LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180", LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180",
Description = "Public safety, emergency response, and police services.", Description = "Public safety, emergency response, and police services.",
DefaultAvailability = "closed", DefaultAvailability = "closed",
AcceptsRequests = false, AcceptsRequests = true,
District = "Mission Row", District = "Mission Row",
LocationLabel = "Mission Row Police Station", LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station", Address = "Mission Row Police Station",
@@ -1188,7 +1188,15 @@ if IsDuplicityVersion() then
Services = 3, Services = 3,
Announcement = 3, Announcement = 3,
}, },
Services = {}, Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
}, },
ambulance = { ambulance = {
Job = "ambulance", Job = "ambulance",