From a12e43b624395a6b39ced0c23f513ec38aa33f68 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:20:37 +0200 Subject: [PATCH] FIX - allow configurator company creation (#33) The generic mutable-table blank template persisted incomplete company definitions before runtime domain validation. Seed valid company defaults, validate before SQL writes, and repair legacy blank entries. --- ...ompaniesEmergencyRequests.contract.test.ts | 12 +- ...mpanyConfiguratorCreation.contract.test.ts | 109 +++++++++++ .../src/components/AdminConfigValueEditor.vue | 61 ++---- .../components/AdminPanel.contract.test.ts | 7 +- frontend/src/types/admin.ts | 1 + .../utils/adminConfiguratorDefaults.test.ts | 114 +++++++++++ .../src/utils/adminConfiguratorDefaults.ts | 142 ++++++++++++++ frontend/testserver/configurator-fixture.cjs | 61 ++++++ .../testserver/configurator-fixture.test.ts | 21 ++- sky_phone/config/locales/de.lua | 2 +- sky_phone/config/locales/en.lua | 2 +- sky_phone/config/locales/es.lua | 2 +- sky_phone/source/server/companies.lua | 178 ++++++++++-------- .../source/server/phone_configurator.lua | 164 ++++++++++++++++ 14 files changed, 744 insertions(+), 132 deletions(-) create mode 100644 frontend/src/companyConfiguratorCreation.contract.test.ts create mode 100644 frontend/src/utils/adminConfiguratorDefaults.test.ts create mode 100644 frontend/src/utils/adminConfiguratorDefaults.ts diff --git a/frontend/src/companiesEmergencyRequests.contract.test.ts b/frontend/src/companiesEmergencyRequests.contract.test.ts index ce6235d..391bdaf 100644 --- a/frontend/src/companiesEmergencyRequests.contract.test.ts +++ b/frontend/src/companiesEmergencyRequests.contract.test.ts @@ -41,7 +41,7 @@ describe('Companies emergency request contract', () => { const mockPolice = sourceBlock( testServer, 'const companyProfiles = [', - " {\n acceptsRequests: false,\n announcement: null,", + ' {\n acceptsRequests: false,\n announcement: null,', ) expect(police).toContain('Emergency = true') @@ -56,7 +56,7 @@ describe('Companies emergency request contract', () => { it('authorizes configured emergency companies through the normal request gates', () => { const validation = sourceBlock( companiesServer, - 'local function validate_configuration()', + 'local function validate_configuration(configuration)', 'local function seed_companies()', ) const payload = sourceBlock( @@ -103,9 +103,7 @@ describe('Companies emergency request contract', () => { '\n\nrefresh_runtime_configuration()', ) - expect(migration).toContain( - 'sky-phone:companies:requestable-emergency:v1', - ) + expect(migration).toContain('sky-phone:companies:requestable-emergency:v1') expect(migration).toContain( 'if definition.Emergency and definition.AcceptsRequests then', ) @@ -133,7 +131,9 @@ describe('Companies emergency request contract', () => { expect(migration).toContain( 'police.AcceptsRequests = defaults.AcceptsRequests', ) - expect(migration).toContain('police.Services = copy_value(defaults.Services)') + 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`') diff --git a/frontend/src/companyConfiguratorCreation.contract.test.ts b/frontend/src/companyConfiguratorCreation.contract.test.ts new file mode 100644 index 0000000..c6fe769 --- /dev/null +++ b/frontend/src/companyConfiguratorCreation.contract.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +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 englishLocale = readFileSync( + new URL('../../sky_phone/config/locales/en.lua', import.meta.url), + 'utf8', +) +const germanLocale = readFileSync( + new URL('../../sky_phone/config/locales/de.lua', import.meta.url), + 'utf8', +) +const spanishLocale = readFileSync( + new URL('../../sky_phone/config/locales/es.lua', import.meta.url), + 'utf8', +) + +describe('company configurator creation contract', () => { + it('publishes complete defaults for newly added company definitions', () => { + expect(configuratorServer).toContain( + 'local function company_definition_entry_default(company_id, configuration)', + ) + expect(configuratorServer).toContain( + 'local function next_available_company_service_number(configuration)', + ) + expect(configuratorServer).toContain( + 'entryDefault = company_definition_entry_default()', + ) + expect(configuratorServer).toContain('Routing = "round_robin"') + expect(configuratorServer).toContain('RequestsEnabled = true') + }) + + it('validates the candidate runtime config before encoding or writing SQL', () => { + const save = configuratorServer.slice( + configuratorServer.indexOf('function SkyPhoneConfigurator.Save('), + ) + const validation = save.indexOf( + 'SkyPhoneCompanies.ValidateConfiguration(candidate_config)', + ) + const encoding = save.indexOf( + 'local config_encoded = encode_payload(next_config, "config")', + ) + const update = save.indexOf('UPDATE `%s`') + + expect(validation).toBeGreaterThanOrEqual(0) + expect(validation).toBeLessThan(encoding) + expect(validation).toBeLessThan(update) + expect(save).toContain( + 'return { success = false, error = "invalid_company_configuration" }', + ) + }) + + it('validates into isolated registries before replacing the live company state', () => { + expect(companiesServer).toContain( + 'function SkyPhoneCompanies.ValidateConfiguration(configuration)', + ) + expect(companiesServer).toContain( + 'local validated, validation_error = validate_configuration(configuration)', + ) + expect(companiesServer).toContain('definitions = validated.definitions') + expect(companiesServer).toContain( + 'definition_ids = validated.definition_ids', + ) + }) + + it('localizes rejected company configurations in every shipped locale', () => { + for (const locale of [englishLocale, germanLocale, spanishLocale]) { + expect(locale).toContain('invalid_company_configuration =') + } + }) + + it('repairs company rows created by the legacy blank schema before Companies starts', () => { + const migration = configuratorServer.slice( + configuratorServer.indexOf( + 'local function migrate_blank_company_definitions()', + ), + configuratorServer.indexOf( + 'local function migrate_police_request_defaults()', + ), + ) + const blankRegistration = configuratorServer.indexOf( + 'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)', + ) + const policeRegistration = configuratorServer.indexOf( + 'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)', + ) + + expect(migration).toContain( + 'sky-phone:configurator:company-definition-defaults:v1', + ) + expect(migration).toContain('company_definition_entry_default(') + expect(migration).toContain('Bridge.Database.Transaction(statements)') + expect(migration).toContain('SET `config_payload` = ?') + expect(migration).toContain('`revision` = `revision` + 1') + expect(blankRegistration).toBeGreaterThanOrEqual(0) + expect(blankRegistration).toBeLessThan(policeRegistration) + }) +}) diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue index ecf43d7..5af001a 100644 --- a/frontend/src/components/AdminConfigValueEditor.vue +++ b/frontend/src/components/AdminConfigValueEditor.vue @@ -10,6 +10,10 @@ import { computed, ref } from 'vue' import { vConfigInputWidth } from '@/directives/configInputWidth' import type { AdminConfiguratorStructure } from '@/types/admin' +import { + blankFromConfiguratorStructure, + createMutableTableEntry, +} from '@/utils/adminConfiguratorDefaults' import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription' export type AdminConfigEditorLabels = { @@ -342,40 +346,6 @@ function mapValuePath(entry: SerializedMapEntry): string { return props.path ? `${props.path}.${entry.key}` : String(entry.key) } -function blankFromStructure(structure: AdminConfiguratorStructure): unknown { - if (structure.kind === 'optionalString') return '' - if (structure.kind === 'value') return blankValue(structure.valueType) - if (structure.kind === 'vector') { - const axes = ['x', 'y', 'z', 'w'].slice( - 0, - Number(structure.vectorType.slice(-1)), - ) - return Object.fromEntries([ - ['__skyType', structure.vectorType], - ...axes.map((axis) => [axis, 0]), - ]) - } - if (structure.kind === 'list') { - return structure.items.map(blankFromStructure) - } - if (structure.kind === 'map') { - return { - __skyType: 'map', - entries: structure.entries.map((entry) => ({ - key: entry.key, - keyType: entry.keyType, - value: blankFromStructure(entry.structure), - })), - } - } - return Object.fromEntries( - Object.entries(structure.fields).map(([key, field]) => [ - key, - blankFromStructure(field), - ]), - ) -} - function updateScalar(event: Event): void { const target = event.target if (!(target instanceof HTMLInputElement)) return @@ -413,7 +383,7 @@ function addListRow(): void { const value = rows.length ? blankLike(rows[0]) : listTemplate.value - ? blankFromStructure(listTemplate.value) + ? blankFromConfiguratorStructure(listTemplate.value) : blankValue(newArrayKind.value) rows.push(value) emit('update:modelValue', rows) @@ -476,12 +446,21 @@ function addTableField(): void { const template = tableStructure.value?.mutableKeys ? tableStructure.value.template : undefined - const value = template - ? blankFromStructure(template) - : blankCollectionValue( - newObjectKind.value, - Object.values(tableValue.value).filter((_, index) => index < 50), + const structuredValue = tableStructure.value?.mutableKeys + ? createMutableTableEntry( + tableStructure.value, + props.path, + key, + tableValue.value, ) + : undefined + const value = + structuredValue !== undefined + ? structuredValue + : blankCollectionValue( + newObjectKind.value, + Object.values(tableValue.value).filter((_, index) => index < 50), + ) emit('update:modelValue', { ...tableValue.value, [key]: value, @@ -556,7 +535,7 @@ function addMapEntry(): void { if (!canAddMapEntry.value || parsedNewMapKey.value === null) return const key = parsedNewMapKey.value const value = mapTemplate.value - ? blankFromStructure(mapTemplate.value) + ? blankFromConfiguratorStructure(mapTemplate.value) : blankCollectionValue( newMapValueKind.value, mapEntries.value.slice(0, 50).map((entry) => entry.value), diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index 9a8e7ee..4d3bbcf 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -196,9 +196,7 @@ describe('standalone admin panel contracts', () => { expect(configuratorServer).toContain('Apps = true,') expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)') expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()') - expect(phoneServer).toContain( - 'disabledApps = SkyPhone.GetDisabledApps()', - ) + expect(phoneServer).toContain('disabledApps = SkyPhone.GetDisabledApps()') expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then') expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()') expect(store).toContain('disabledApps: [] as string[]') @@ -499,7 +497,8 @@ describe('standalone admin panel contracts', () => { expect(configuratorValueEditor).toContain('function tableFieldStructure(') expect(configuratorValueEditor).toContain('function isFixedTableField(') expect(configuratorValueEditor).toContain('function blankCollectionValue(') - expect(configuratorValueEditor).toContain('function blankFromStructure(') + expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure') + expect(configuratorValueEditor).toContain('createMutableTableEntry(') expect(source).toContain("'is-structured': field.type === 'json'") expect(configuratorValueEditor).toContain('function isStructuredValue(') expect(configuratorValueEditor).toContain( diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 4c24c1b..a163e86 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -138,6 +138,7 @@ export type AdminConfiguratorStructure = template?: AdminConfiguratorStructure } | { + entryDefault?: unknown fields: Record kind: 'table' mutableKeys?: boolean diff --git a/frontend/src/utils/adminConfiguratorDefaults.test.ts b/frontend/src/utils/adminConfiguratorDefaults.test.ts new file mode 100644 index 0000000..fc3e2c9 --- /dev/null +++ b/frontend/src/utils/adminConfiguratorDefaults.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import type { AdminConfiguratorStructure } from '@/types/admin' +import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults' + +describe('admin configurator defaults', () => { + it('creates a usable company draft from its key and the server defaults', () => { + const entryDefault = { + AcceptsRequests: true, + Category: 'public_services', + Job: '', + LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180', + Name: '', + ServiceLine: { + Number: '500', + Routing: 'round_robin', + }, + Services: [ + { + Description: '', + Id: '', + Price: '', + RequestsEnabled: true, + Title: '', + }, + ], + } + const structure: AdminConfiguratorStructure = { + entryDefault, + fields: {}, + kind: 'table', + mutableKeys: true, + template: { + fields: { + Job: { kind: 'value', valueType: 'string' }, + Name: { kind: 'value', valueType: 'string' }, + }, + kind: 'table', + }, + } + + expect( + createMutableTableEntry( + structure, + 'Companies.Definitions', + 'pizza_palace', + ), + ).toEqual({ + AcceptsRequests: true, + Category: 'public_services', + Job: 'pizza_palace', + LogoUrl: 'https://picsum.photos/seed/companies-pizza_palace-logo/180/180', + Name: 'Pizza Palace', + ServiceLine: { + Number: '500', + Routing: 'round_robin', + }, + Services: [ + { + Description: '', + Id: 'pizza_palace', + Price: '', + RequestsEnabled: true, + Title: 'Pizza Palace', + }, + ], + }) + expect(entryDefault).toMatchObject({ + Job: '', + Name: '', + Services: [{ Id: '', Title: '' }], + }) + }) + + it('allocates distinct service numbers for multiple unsaved companies', () => { + const structure: AdminConfiguratorStructure = { + entryDefault: { + Job: '', + LogoUrl: '', + Name: '', + ServiceLine: { Number: '500' }, + }, + fields: {}, + kind: 'table', + mutableKeys: true, + } + const existing = { + pizzeria: { ServiceLine: { Number: '500' } }, + restaurant: { ServiceLine: { Number: '501' } }, + } + + expect( + createMutableTableEntry( + structure, + 'Companies.Definitions', + 'bakery', + existing, + ), + ).toMatchObject({ ServiceLine: { Number: '502' } }) + }) + + it('keeps generic mutable tables on their schema-derived blank value', () => { + const structure: AdminConfiguratorStructure = { + fields: {}, + kind: 'table', + mutableKeys: true, + template: { kind: 'value', valueType: 'boolean' }, + } + + expect(createMutableTableEntry(structure, 'FeatureFlags', 'example')).toBe( + false, + ) + }) +}) diff --git a/frontend/src/utils/adminConfiguratorDefaults.ts b/frontend/src/utils/adminConfiguratorDefaults.ts new file mode 100644 index 0000000..cfb5100 --- /dev/null +++ b/frontend/src/utils/adminConfiguratorDefaults.ts @@ -0,0 +1,142 @@ +import type { AdminConfiguratorStructure } from '@/types/admin' + +function cloneConfiguratorValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(cloneConfiguratorValue) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + cloneConfiguratorValue(child), + ]), + ) + } + return value +} + +function blankScalar(valueType: 'boolean' | 'number' | 'string'): unknown { + if (valueType === 'boolean') return false + if (valueType === 'number') return 0 + return '' +} + +export function blankFromConfiguratorStructure( + structure: AdminConfiguratorStructure, +): unknown { + if (structure.kind === 'optionalString') return '' + if (structure.kind === 'value') return blankScalar(structure.valueType) + if (structure.kind === 'vector') { + const axes = ['x', 'y', 'z', 'w'].slice( + 0, + Number(structure.vectorType.slice(-1)), + ) + return Object.fromEntries([ + ['__skyType', structure.vectorType], + ...axes.map((axis) => [axis, 0]), + ]) + } + if (structure.kind === 'list') { + return structure.items.map(blankFromConfiguratorStructure) + } + if (structure.kind === 'map') { + return { + __skyType: 'map', + entries: structure.entries.map((entry) => ({ + key: entry.key, + keyType: entry.keyType, + value: blankFromConfiguratorStructure(entry.structure), + })), + } + } + return Object.fromEntries( + Object.entries(structure.fields).map(([key, field]) => [ + key, + blankFromConfiguratorStructure(field), + ]), + ) +} + +export function createMutableTableEntry( + structure: Extract, + path: string, + key: string, + entries: Record = {}, +): unknown { + const value = + structure.entryDefault !== undefined + ? cloneConfiguratorValue(structure.entryDefault) + : structure.template + ? blankFromConfiguratorStructure(structure.template) + : undefined + + if ( + path !== 'Companies.Definitions' || + value === null || + typeof value !== 'object' || + Array.isArray(value) + ) { + return value + } + + const company = value as Record + company.Job = key + company.Name = key + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) + company.LogoUrl = `https://picsum.photos/seed/companies-${key}-logo/180/180` + if (Array.isArray(company.Services)) { + if (company.Services.length === 0) { + company.Services.push({ + Description: '', + Id: key, + Price: '', + RequestsEnabled: true, + Title: company.Name, + }) + } else { + const service = company.Services[0] + if ( + service !== null && + typeof service === 'object' && + !Array.isArray(service) && + !(service as Record).Id + ) { + const mutableService = service as Record + mutableService.Id = key + mutableService.Title = company.Name + } + } + } + const serviceLine = company.ServiceLine + if ( + serviceLine !== null && + typeof serviceLine === 'object' && + !Array.isArray(serviceLine) + ) { + const mutableServiceLine = serviceLine as Record + const number = String(mutableServiceLine.Number ?? '') + const numeric = Number(number) + if (/^\d+$/.test(number) && Number.isSafeInteger(numeric) && numeric > 0) { + const used = new Set( + Object.values(entries).map((entry) => { + if (entry === null || typeof entry !== 'object') return '' + const line = (entry as Record).ServiceLine + if (line === null || typeof line !== 'object') return '' + return String((line as Record).Number ?? '').replace( + /\D/g, + '', + ) + }), + ) + const maximum = 10 ** number.length - 1 + for (let offset = 0; offset < maximum; offset += 1) { + const candidate = ((numeric - 1 + offset) % maximum) + 1 + const formatted = String(candidate).padStart(number.length, '0') + if (!used.has(formatted)) { + mutableServiceLine.Number = formatted + break + } + } + } + } + return company +} diff --git a/frontend/testserver/configurator-fixture.cjs b/frontend/testserver/configurator-fixture.cjs index 7ccc80e..6d02ee5 100644 --- a/frontend/testserver/configurator-fixture.cjs +++ b/frontend/testserver/configurator-fixture.cjs @@ -476,6 +476,66 @@ function emptyStructure(scope, path) { return undefined } +function companyDefinitionEntryDefault(definitions) { + const firstDefinition = definitions[Object.keys(definitions).sort()[0]] + const usedNumbers = new Set( + Object.values(definitions).map((definition) => + String(definition.ServiceLine.Number).replace(/\D/g, ''), + ), + ) + let serviceNumber = '' + for (let offset = 0; offset < 999; offset += 1) { + const candidate = String(((499 + offset) % 999) + 1).padStart(3, '0') + if (!usedNumbers.has(candidate)) { + serviceNumber = candidate + break + } + } + return { + AcceptsRequests: true, + Address: '', + Category: firstDefinition.Category, + DefaultAvailability: 'closed', + Description: '', + District: '', + Emergency: false, + Icon: 'building', + Job: '', + Location: { __skyType: 'vector3', x: 0, y: 0, z: 0 }, + LocationLabel: '', + LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180', + Name: '', + Permissions: { + Announcement: 0, + Assign: 0, + Availability: 0, + Hours: 0, + Profile: 0, + Services: 0, + WorkQueue: 0, + }, + Public: true, + ServiceLine: { + AutoContact: true, + CanCall: true, + CanMessage: false, + MinimumGrade: 0, + Number: serviceNumber, + Routing: 'round_robin', + }, + Services: [ + { + Description: '', + Id: '', + Price: '', + RequestsEnabled: true, + Title: '', + }, + ], + Verified: false, + } +} + function buildStructure(value, scope, path) { if (scope === 'config' && path === 'Phone.Keybind') { return { kind: 'optionalString' } @@ -495,6 +555,7 @@ function buildStructure(value, scope, path) { ]), ) return { + entryDefault: companyDefinitionEntryDefault(value), fields, kind: 'table', mutableKeys: true, diff --git a/frontend/testserver/configurator-fixture.test.ts b/frontend/testserver/configurator-fixture.test.ts index ba5a547..e5deea8 100644 --- a/frontend/testserver/configurator-fixture.test.ts +++ b/frontend/testserver/configurator-fixture.test.ts @@ -12,6 +12,7 @@ type ConfiguratorField = { } type ConfiguratorStructure = { + entryDefault?: unknown entries?: Array<{ structure: ConfiguratorStructure }> fields?: Record items?: ConfiguratorStructure[] @@ -122,6 +123,22 @@ describe('admin configurator fixture', () => { expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString') expect(companyJobs?.label).toBe('Jobs') expect(companyJobs?.structure).toMatchObject({ + entryDefault: { + AcceptsRequests: true, + Category: 'public_services', + Job: '', + ServiceLine: { + Number: '500', + Routing: 'round_robin', + }, + Services: [ + { + Id: '', + RequestsEnabled: true, + Title: '', + }, + ], + }, fields: { ambulance: { kind: 'table' }, police: { kind: 'table' }, @@ -140,9 +157,7 @@ describe('admin configurator fixture', () => { .flatMap((section) => section.fields) .find((field) => field.path === 'Companies.Definitions') const police = ( - companyJobs?.value as - | Record> - | undefined + companyJobs?.value as Record> | undefined )?.police expect(police).toMatchObject({ diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 537e600..d050636 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -222,7 +222,7 @@ Locales["de"] = { }, audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt", save_configuration = "Konfiguration gespeichert" } }, editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App- oder Konfigurationsänderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" }, - errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." }, + errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", invalid_company_configuration = "Die Firmenkonfiguration ist unvollständig oder enthält einen doppelten Job, eine doppelte Servicenummer oder Service-ID.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." }, }, Apps = { health = { diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index b8a6b5e..62dec8e 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -222,7 +222,7 @@ Locales["en"] = { }, audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset", save_configuration = "Configuration saved" } }, editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app or configuration changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" }, - errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." }, + errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", invalid_company_configuration = "The company configuration is incomplete or contains a duplicate job, service number, or service ID.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." }, }, Apps = { health = { diff --git a/sky_phone/config/locales/es.lua b/sky_phone/config/locales/es.lua index 4ed8cd3..d290cf7 100644 --- a/sky_phone/config/locales/es.lua +++ b/sky_phone/config/locales/es.lua @@ -222,7 +222,7 @@ Locales["es"] = { }, audit = { eyebrow = "Responsabilidad", title = "Trayectoria de auditoría", body = "Las revelaciones sensibles y los cambios remotos de la aplicación se registran aquí.", empty = "No hay acciones de administración todavía", emptyBody = "Las acciones protegidas aparecerán aquí después de que se realicen.", by = "{actor} · Identificación del objetivo {target}", actions = { grant_app = "Aplicación instalada", revoke_app = "Aplicación eliminada", reveal_account_password = "Se reveló la contraseña", view_messages = "Los mensajes vistos", view_calls = "Las llamadas vistas", reset_passcode = "Reset de código de acceso", change_number = "Cambió el número de teléfono", factory_reset = "Reinicio de fábrica de teléfono", save_configuration = "Configuración guardada" } }, editor = { brand = "SKY PHONE", workspace = "ADMINISTRACIÓN", players = "Directorio de jugadores", audit = "Registro de auditoría", selectPlayer = "Selecciona un jugador para inspeccionar la identidad, los dispositivos, las credenciales y el acceso a la aplicación.", save = "Guardar cambios", saveHint = "Aplicar los cambios pendientes", saved = "Cambios guardados.", unsaved = "Cambios no guardados", close = "Cerrar el panel de administración", refresh = "Actualizar los datos en vivo", online = "VIVIENDO", profile = "PROFILES", financial = "FINANCIERO", device = "DISPOSITIVO", security = "LA SEGURIDAD", noAutoSave = "Salvamiento manual", noAutoSaveBody = "Los cambios permanecen locales hasta que se presione el cheque verde.", discardTitle = "¿Deshacerse de los cambios no salvos?", discardBody = "No se han guardado los cambios de configuración de su aplicación en etapas.", keepEditing = "Sigue editando", discard = "Rechazar los cambios", saveFailed = "Algunos cambios no se pudieron salvar.", noSelection = "Ningún jugador seleccionado" }, - errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." }, + errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", invalid_company_configuration = "La configuración de la empresa está incompleta o contiene un trabajo, número de servicio o ID de servicio duplicado.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." }, }, Apps = { health = { diff --git a/sky_phone/source/server/companies.lua b/sky_phone/source/server/companies.lua index c73200a..0a03a2e 100644 --- a/sky_phone/source/server/companies.lua +++ b/sky_phone/source/server/companies.lua @@ -156,12 +156,18 @@ local function permission_grade(definition, permission) return grade and math.max(0, math.floor(grade)) or nil end -local function validate_configuration() - if type(Config.Companies) ~= "table" or type(Config.Companies.Definitions) ~= "table" then - error("[sky_phone] Config.Companies.Definitions must be configured.") +local function validate_configuration(configuration) + local company_config = configuration.Companies + local validated_definitions = {} + local validated_definition_ids = {} + local validated_definitions_by_job = {} + local validated_service_lines_by_number = {} + + if type(company_config) ~= "table" or type(company_config.Definitions) ~= "table" then + return nil, "[sky_phone] Config.Companies.Definitions must be configured." end - if type(Config.Companies.Enabled) ~= "boolean" then - error("[sky_phone] Config.Companies.Enabled must be a boolean.") + if type(company_config.Enabled) ~= "boolean" then + return nil, "[sky_phone] Config.Companies.Enabled must be a boolean." end for _, field in ipairs({ { "PageSize", 1000 }, @@ -184,26 +190,26 @@ local function validate_configuration() { "AnnouncementMaximumSeconds", 31536000 }, { "RetentionDays", 36500 }, }) do - if not valid_integer(Config.Companies[field[1]], 1, field[2]) then - error(("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1])) + if not valid_integer(company_config[field[1]], 1, field[2]) then + return nil, ("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1]) end end - if Config.Companies.PageSize > Config.Companies.MaximumPageSize then - error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.") + if company_config.PageSize > company_config.MaximumPageSize then + return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize." end - if type(Config.Companies.RateLimits) ~= "table" then - error("[sky_phone] Config.Companies.RateLimits must be configured.") + if type(company_config.RateLimits) ~= "table" then + return nil, "[sky_phone] Config.Companies.RateLimits must be configured." end for _, name in ipairs({ "Read", "Search", "CreateRequest", "Message", "RequestAction", "Profile", "CallAvailability" }) do - if not valid_integer(Config.Companies.RateLimits[name], 1, 100000) then - error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name)) + if not valid_integer(company_config.RateLimits[name], 1, 100000) then + return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name) end end - if type(Config.Companies.CallRouting) ~= "table" - or not valid_integer(Config.Companies.CallRouting.MaxAttempts, 1, 20) - or not valid_integer(Config.Companies.CallRouting.RingSeconds, 1, 120) + if type(company_config.CallRouting) ~= "table" + or not valid_integer(company_config.CallRouting.MaxAttempts, 1, 20) + or not valid_integer(company_config.CallRouting.RingSeconds, 1, 120) then - error("[sky_phone] Config.Companies.CallRouting is invalid.") + return nil, "[sky_phone] Config.Companies.CallRouting is invalid." end local configured_statuses = { new = true, @@ -213,48 +219,48 @@ local function validate_configuration() completed = true, cancelled = true, } - if type(Config.Companies.Statuses) ~= "table" then - error("[sky_phone] Config.Companies.Statuses must be configured.") + if type(company_config.Statuses) ~= "table" then + return nil, "[sky_phone] Config.Companies.Statuses must be configured." end for status in pairs(configured_statuses) do - if Config.Companies.Statuses[status] ~= true then - error(("[sky_phone] Companies status '%s' must be enabled."):format(status)) + if company_config.Statuses[status] ~= true then + return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status) end end - for status, enabled in pairs(Config.Companies.Statuses) do + for status, enabled in pairs(company_config.Statuses) do if not configured_statuses[status] or enabled ~= true then - error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))) + return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status)) end end - if type(Config.Companies.AvailabilityStatuses) ~= "table" then - error("[sky_phone] Config.Companies.AvailabilityStatuses must be configured.") + if type(company_config.AvailabilityStatuses) ~= "table" then + return nil, "[sky_phone] Config.Companies.AvailabilityStatuses must be configured." end local configured_availability = { available = true, busy = true, closed = true } for _, status in ipairs({ "available", "busy", "closed" }) do - if Config.Companies.AvailabilityStatuses[status] ~= true then - error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status)) + if company_config.AvailabilityStatuses[status] ~= true then + return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status) end end - for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do + for status, enabled in pairs(company_config.AvailabilityStatuses) do if not configured_availability[status] or enabled ~= true then - error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))) + return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status)) end end - if not valid_array(Config.Companies.Categories, 100) then - error("[sky_phone] Config.Companies.Categories must be a bounded array.") + if not valid_array(company_config.Categories, 100) then + return nil, "[sky_phone] Config.Companies.Categories must be a bounded array." end local category_ids = {} local configured_service_ids = {} - for _, category_id in ipairs(Config.Companies.Categories) do + for _, category_id in ipairs(company_config.Categories) do if type(category_id) ~= "string" or #category_id > 64 or not category_id:match("^[a-z0-9_-]+$") or category_ids[category_id] then - error("[sky_phone] Companies contains an invalid category ID.") + return nil, "[sky_phone] Companies contains an invalid category ID." end category_ids[category_id] = true end - for company_id, definition in pairs(Config.Companies.Definitions) do + for company_id, definition in pairs(company_config.Definitions) do if type(company_id) ~= "string" or #company_id > 64 or not company_id:match("^[a-z0-9_-]+$") or type(definition) ~= "table" or type(definition.Job) ~= "string" or #definition.Job > 64 @@ -262,25 +268,25 @@ local function validate_configuration() or not valid_text(definition.Name, 120, false) or not category_ids[definition.Category] then - error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))) + return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id)) end - local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true) - local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true) + local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true) + local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true) local location_label = valid_text( definition.LocationLabel or definition.Address or "", - Config.Companies.DistrictMaxLength, + company_config.DistrictMaxLength, true ) - local address = valid_text(definition.Address or "", Config.Companies.AddressMaxLength, true) + local address = valid_text(definition.Address or "", company_config.AddressMaxLength, true) local logo_url = valid_text(definition.LogoUrl, 2048, false) if not description or not district or not location_label or not address or type(definition.Public) ~= "boolean" or type(definition.Emergency) ~= "boolean" or type(definition.Verified) ~= "boolean" or type(definition.AcceptsRequests) ~= "boolean" - or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability] + or not company_config.AvailabilityStatuses[definition.DefaultAvailability] or not valid_text(definition.Icon, 64, false) or not logo_url or not logo_url:match("^https://[^%s]+$") then - error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)) + return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id) end definition.Name = trim(definition.Name) definition.Description = description @@ -291,7 +297,7 @@ local function validate_configuration() if definition.Location ~= nil then local location_type = type(definition.Location) if location_type ~= "table" and location_type ~= "vector3" then - error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)) + return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id) end local x = tonumber(definition.Location.x) local y = tonumber(definition.Location.y) @@ -299,46 +305,51 @@ local function validate_configuration() if not x or not y or not z or x ~= x or y ~= y or z ~= z or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000 then - error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)) + return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id) end end - if definitions_by_job[definition.Job] then - error(("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job)) + if validated_definitions_by_job[definition.Job] then + return nil, ("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job) end local line = definition.ServiceLine if type(line) ~= "table" then - error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id)) + return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id) end - local number = SkyPhoneSimNumber.NormalizeService(line.Number, Config.Sim.NumberLength) + local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength) if not number then - error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id)) + return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id) end - if service_lines_by_number[number] then - error(("[sky_phone] Service number '%s' is assigned more than once."):format(number)) + if validated_service_lines_by_number[number] then + return nil, ("[sky_phone] Service number '%s' is assigned more than once."):format(number) end if type(line.AutoContact) ~= "boolean" or type(line.CanCall) ~= "boolean" or type(line.CanMessage) ~= "boolean" or not valid_integer(line.MinimumGrade, 0, 10000) then - error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)) + return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id) end if line.AutoContact and not definition.Public then - error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)) + return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id) end if line.Routing ~= "round_robin" then - error(("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(company_id, tostring(line.Routing))) + return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format( + company_id, + tostring(line.Routing) + ) end if line.CanMessage then - error(("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(company_id)) + return nil, ("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format( + company_id + ) end line.Number = number - definitions[company_id] = definition - definition_ids[#definition_ids + 1] = company_id - definitions_by_job[definition.Job] = company_id - service_lines_by_number[number] = company_id + validated_definitions[company_id] = definition + validated_definition_ids[#validated_definition_ids + 1] = company_id + validated_definitions_by_job[definition.Job] = company_id + validated_service_lines_by_number[number] = company_id for _, permission in ipairs({ "WorkQueue", "Availability", "Assign", "Profile", "Hours", "Services", "Announcement" }) do if not definition.Permissions or not valid_integer(definition.Permissions[permission], 0, 10000) then - error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)) + return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission) end end local default_services = definition.Services @@ -346,40 +357,54 @@ local function validate_configuration() default_services = {} definition.Services = default_services end - if not valid_array(default_services, Config.Companies.MaximumServices) then - error(("[sky_phone] Company '%s' has an invalid default service list."):format(company_id)) + if not valid_array(default_services, company_config.MaximumServices) then + return nil, ("[sky_phone] Company '%s' has an invalid default service list."):format(company_id) end for _, service in ipairs(default_services) do if type(service) ~= "table" then - error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id)) + return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id) end - local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false) + local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false) local service_description = valid_text( service.Description or "", - Config.Companies.ServiceDescriptionMaxLength, + company_config.ServiceDescriptionMaxLength, true ) - local price = valid_text(service.Price or "", Config.Companies.ServicePriceMaxLength, true) + local price = valid_text(service.Price or "", company_config.ServicePriceMaxLength, true) if not valid_service_id(service.Id) or not title or not service_description or not price or type(service.RequestsEnabled) ~= "boolean" then - error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id)) + return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id) end service.Title = title service.Description = service_description service.Price = price if configured_service_ids[service.Id] then - error(("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)) + return nil, ("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id) end configured_service_ids[service.Id] = true end end - table.sort(definition_ids, function(left, right) - local left_name = definitions[left].Name:lower() - local right_name = definitions[right].Name:lower() + table.sort(validated_definition_ids, function(left, right) + local left_name = validated_definitions[left].Name:lower() + local right_name = validated_definitions[right].Name:lower() return left_name == right_name and left < right or left_name < right_name end) + return { + definition_ids = validated_definition_ids, + definitions = validated_definitions, + definitions_by_job = validated_definitions_by_job, + service_lines_by_number = validated_service_lines_by_number, + } +end + +function SkyPhoneCompanies.ValidateConfiguration(configuration) + local validated, validation_error = validate_configuration(configuration) + if not validated then + return false, validation_error + end + return true end local function seed_companies() @@ -947,11 +972,14 @@ local function cleanup_retained_data() end local function refresh_runtime_configuration() - definitions = {} - definition_ids = {} - definitions_by_job = {} - service_lines_by_number = {} - validate_configuration() + local validated, validation_error = validate_configuration(Config) + if not validated then + error(validation_error) + end + definitions = validated.definitions + definition_ids = validated.definition_ids + definitions_by_job = validated.definitions_by_job + service_lines_by_number = validated.service_lines_by_number seed_companies() migrate_requestable_emergency_companies() tombstone_removed_companies() diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index 40f97db..b04baed 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -563,6 +563,75 @@ local function empty_structure(scope, path) return nil end +local function next_available_company_service_number(configuration) + configuration = configuration or stored_config + local maximum_length = configuration.Sim.NumberLength + local service_length = math.max(1, math.min(3, maximum_length - 1)) + local first_candidate = service_length == 1 and 5 or 5 * (10 ^ (service_length - 1)) + local last_candidate = (10 ^ service_length) - 1 + local used = {} + for _, definition in pairs(configuration.Companies.Definitions) do + used[tostring(definition.ServiceLine.Number):gsub("%D", "")] = true + end + for offset = 0, last_candidate - 1 do + local candidate = ((first_candidate - 1 + offset) % last_candidate) + 1 + local number = ("%0" .. tostring(service_length) .. "d"):format(candidate) + if not used[number] then + return number + end + end + return "" +end + +local function company_definition_entry_default(company_id, configuration) + configuration = configuration or stored_config + local name = company_id and humanize(company_id) or "" + local logo_seed = company_id or "new" + return { + AcceptsRequests = true, + Address = "", + Category = configuration.Companies.Categories[1] or default_config.Companies.Categories[1], + DefaultAvailability = "closed", + Description = "", + District = "", + Emergency = false, + Icon = "building", + Job = company_id or "", + Location = { __skyType = "vector3", x = 0.0, y = 0.0, z = 0.0 }, + LocationLabel = "", + LogoUrl = ("https://picsum.photos/seed/companies-%s-logo/180/180"):format(logo_seed), + Name = name, + Permissions = { + Announcement = 0, + Assign = 0, + Availability = 0, + Hours = 0, + Profile = 0, + Services = 0, + WorkQueue = 0, + }, + Public = true, + ServiceLine = { + AutoContact = true, + CanCall = true, + CanMessage = false, + MinimumGrade = 0, + Number = next_available_company_service_number(configuration), + Routing = "round_robin", + }, + Services = { + { + Description = "", + Id = company_id or "", + Price = "", + RequestsEnabled = true, + Title = name, + }, + }, + Verified = false, + } +end + local function build_structure(value, scope, path) local value_type = type(value) if scope == "config" and path == "Phone.Keybind" then @@ -581,6 +650,7 @@ local function build_structure(value, scope, path) fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key)) end return { + entryDefault = company_definition_entry_default(), fields = fields, kind = "table", mutableKeys = true, @@ -1075,6 +1145,87 @@ local function apply_stored_row(row) updated_by_name = row.updated_by_name end +local function migrate_blank_company_definitions() + local migration_name = "sky-phone:configurator:company-definition-defaults: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 next_config = merge_values(default_config, config_payload, "", FIXED_CONFIG_PATHS) + local migrated_companies = {} + for company_id, definition in pairs(next_config.Companies.Definitions) do + local line = type(definition) == "table" and definition.ServiceLine or nil + if type(company_id) == "string" + and company_id:match("^[a-z0-9_-]+$") + and type(definition) == "table" + and (definition.Job == "" or definition.Job == company_id) + and definition.Name == "" + and definition.Category == "" + and definition.Icon == "" + and definition.LogoUrl == "" + and definition.DefaultAvailability == "" + and type(line) == "table" + and line.Number == "" + and line.Routing == "" + and type(definition.Services) == "table" + and next(definition.Services) == nil + then + next_config.Companies.Definitions[company_id] = company_definition_entry_default( + company_id, + next_config + ) + migrated_companies[#migrated_companies + 1] = company_id + end + end + table.sort(migrated_companies) + + local statements = {} + if #migrated_companies > 0 then + statements[#statements + 1] = { + query = ([[ + UPDATE `%s` + SET `config_payload` = ?, `revision` = `revision` + 1 + WHERE `id` = ? + ]]):format(TABLE_NAME), + params = { encode_payload(next_config, "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({ companies = migrated_companies }), + }, + } + if not Bridge.Database.Transaction(statements) then + error("[sky_phone] Could not migrate blank Phone Configurator company definitions.") + end + if #migrated_companies == 0 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 blank Phone Configurator company definitions: %s", + table.concat(migrated_companies, ", "), + { always = true } + ) +end + local function migrate_police_request_defaults() local migration_name = "sky-phone:configurator:police-requests:v1" local completed = Bridge.Database.Query( @@ -1158,6 +1309,7 @@ Bridge.Database.Query(([[ apply_stored_row(read_stored_row()) apply_runtime_configuration() +Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions) Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults) function SkyPhoneConfigurator.GetAdminData() @@ -1208,6 +1360,18 @@ function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier, end end + local candidate_config = deserialize_value(next_config) + local companies_valid, validation_error = SkyPhoneCompanies.ValidateConfiguration(candidate_config) + if not companies_valid then + Bridge.Debug( + "warn", + "[sky_phone] Rejected invalid Phone Configurator Companies configuration: %s", + validation_error, + { always = true } + ) + return { success = false, error = "invalid_company_configuration" } + end + local config_encoded = encode_payload(next_config, "config") local media_encoded = encode_payload(next_media, "media") local result = Bridge.Database.Query(([[