From 0ab93f76d20aa5ecc37aa9edcba4e3a5e1001f0d Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Fri, 16 Oct 2026 11:08:06 +0100 Subject: [PATCH 1/6] feat(avatar-templates): add answer time range per template Two per-template columns, answer_min_seconds (default 90) and answer_max_seconds (default 150), bounded 10..600 with min < max (validated on create/update, import, and a DB CHECK). Exposed on the admin template resources, copied by duplicate, carried by export/import (older files fall back to the defaults) and delivered in the candidate session response next to interview_max_seconds. --- .../DuplicateAvatarTemplate.php | 4 +- .../BuildInterviewSessionResponse.php | 3 + .../Api/PlatformAvatarTemplateController.php | 2 + .../Controllers/AvatarTemplateController.php | 2 + .../AvatarTemplatePortabilityController.php | 14 +- .../Candidate/InterviewController.php | 2 +- .../ValidatesAvatarTemplateWrites.php | 32 +++ app/Http/Resources/AvatarTemplateResource.php | 9 +- .../PlatformAvatarTemplateResource.php | 4 +- app/Models/AvatarTemplate.php | 8 + .../AvatarTemplates/TemplateDocument.php | 8 +- app/Support/Interview/SessionLiveClock.php | 16 ++ ..._answer_time_range_to_avatar_templates.php | 44 ++++ openapi.json | 76 +++++- .../AvatarTemplates/AnswerTimeRangeTest.php | 221 ++++++++++++++++++ .../Interview/ConversationPlanCreateTest.php | 2 +- tests/Helpers/PublicApi/ExposureCatalogue.php | 2 + 17 files changed, 437 insertions(+), 12 deletions(-) create mode 100644 database/migrations/2026_10_16_100000_add_answer_time_range_to_avatar_templates.php create mode 100644 tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php diff --git a/app/Actions/AvatarTemplates/DuplicateAvatarTemplate.php b/app/Actions/AvatarTemplates/DuplicateAvatarTemplate.php index b0e135ac..0d0ce7f5 100644 --- a/app/Actions/AvatarTemplates/DuplicateAvatarTemplate.php +++ b/app/Actions/AvatarTemplates/DuplicateAvatarTemplate.php @@ -18,7 +18,7 @@ * to write. The same `saving` guards a normal create runs (the LLM binding * invariants) therefore run on every copy. * - * What is copied: name, description, provider, config, persona and the + * What is copied: name, description, provider, config, persona, the answer time range and the * model/credential binding (credentials are platform rows). What is NOT: * `is_active` (a copy is inactive), and everything that belongs to the SOURCE's * provider-side resources (`heygen_llm_configuration_id`, `llm_sync_status`, @@ -59,6 +59,8 @@ public function run(AvatarTemplate $source, array $targetOrganizationIds, ?strin 'provider' => $source->provider, 'config' => $source->config, 'persona' => $source->persona, + 'answer_min_seconds' => $source->answer_min_seconds, + 'answer_max_seconds' => $source->answer_max_seconds, 'is_active' => false, 'llm_model_id' => $source->llm_model_id, 'llm_credential_id' => $source->llm_credential_id, diff --git a/app/Actions/Interview/BuildInterviewSessionResponse.php b/app/Actions/Interview/BuildInterviewSessionResponse.php index c02f307f..6cc6266a 100644 --- a/app/Actions/Interview/BuildInterviewSessionResponse.php +++ b/app/Actions/Interview/BuildInterviewSessionResponse.php @@ -110,6 +110,9 @@ public function handle( // The candidate app counts down against it; the server remains the enforcer. $body['interview_max_seconds'] = $this->clock->resolveMaxSeconds($session); + // The range the answer bar calls a good answer (guidance only), from the same template. + ['min' => $body['answer_min_seconds'], 'max' => $body['answer_max_seconds']] = $this->clock->resolveAnswerRange($session); + // Only a fresh single-session create names its conversation (design A6/N11); with the gate // closed the key is absent, not null, so the body is byte-identical to the old one. if ($conversationId !== null) { diff --git a/app/Http/Controllers/Api/PlatformAvatarTemplateController.php b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php index 942ff1e1..bcd907f0 100644 --- a/app/Http/Controllers/Api/PlatformAvatarTemplateController.php +++ b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php @@ -152,6 +152,7 @@ public function store(Request $request): JsonResponse $validated = $request->validate($this->templateStoreRules()); $validated = array_merge($validated, $this->personaWrite($request, null)); + $this->assertAnswerRangeOrdered($validated); $this->assertConfigValid($validated['provider'], $validated['config']); app(BindHeygenTemplateVoice::class)->run($validated['provider'], $validated['config']); @@ -198,6 +199,7 @@ public function update(Request $request, int $id): PlatformAvatarTemplateResourc $validated = $request->validate($this->templateUpdateRules()); $validated = array_merge($validated, $this->personaWrite($request, $template->persona)); + $this->assertAnswerRangeOrdered($validated, $template->answer_min_seconds, $template->answer_max_seconds); // The provider is immutable: the config's knobs belong to it, so a // template that changed provider would validate as empty and quietly diff --git a/app/Http/Controllers/AvatarTemplateController.php b/app/Http/Controllers/AvatarTemplateController.php index b48d2226..605efc19 100644 --- a/app/Http/Controllers/AvatarTemplateController.php +++ b/app/Http/Controllers/AvatarTemplateController.php @@ -228,6 +228,7 @@ public function store(Request $request): JsonResponse $validated = $request->validate($this->templateStoreRules()); $validated = array_merge($validated, $this->personaWrite($request, null)); + $this->assertAnswerRangeOrdered($validated); $this->assertConfigValid($validated['provider'], $validated['config']); // A superadmin's external voice is bound here, before the row is written, @@ -263,6 +264,7 @@ public function update(Request $request, int $id): AvatarTemplateResource $validated = $request->validate($this->templateUpdateRules()); $validated = array_merge($validated, $this->personaWrite($request, $template->persona)); + $this->assertAnswerRangeOrdered($validated, $template->answer_min_seconds, $template->answer_max_seconds); // The provider is immutable. Changing it would leave every knob in the // config belonging to the other one — avatarId where faceId is diff --git a/app/Http/Controllers/AvatarTemplatePortabilityController.php b/app/Http/Controllers/AvatarTemplatePortabilityController.php index 73595f98..3539c84b 100644 --- a/app/Http/Controllers/AvatarTemplatePortabilityController.php +++ b/app/Http/Controllers/AvatarTemplatePortabilityController.php @@ -113,7 +113,7 @@ public function import(Request $request): JsonResponse } /** - * @param array{name: string, provider: string, config: array, persona: array|null, description: string|null} $record + * @param array{name: string, provider: string, config: array, persona: array|null, answer_min_seconds: mixed, answer_max_seconds: mixed, description: string|null} $record */ private function reject(array $record): ?string { @@ -125,6 +125,14 @@ private function reject(array $record): ?string return "Unknown provider '{$record['provider']}'."; } + // The SAME bounds the form applies; a file without the range (an older export) keeps the defaults. + $min = $record['answer_min_seconds'] ?? 90; + $max = $record['answer_max_seconds'] ?? 150; + + if (! is_int($min) || ! is_int($max) || $min < 10 || $max > 600 || $min >= $max) { + return 'answer_min_seconds / answer_max_seconds: integers from 10 to 600 seconds, the minimum below the maximum.'; + } + // The SAME rule the form applies to the interviewer's name: a file must never install a // name the form would refuse. The message never repeats a matched vendor word. if (isset($record['persona']['display_name'])) { @@ -170,7 +178,7 @@ private function reject(array $record): ?string } /** - * @param array{name: string, provider: string, config: array, persona: array|null, description: string|null, llm: array{model_key: string, credential_name: string}|null} $record + * @param array{name: string, provider: string, config: array, persona: array|null, answer_min_seconds: mixed, answer_max_seconds: mixed, description: string|null, llm: array{model_key: string, credential_name: string}|null} $record * @return array */ private function create(array $record): array @@ -195,6 +203,8 @@ private function create(array $record): array 'provider' => $record['provider'], 'config' => $record['config'], 'persona' => $record['persona'], + 'answer_min_seconds' => $record['answer_min_seconds'] ?? 90, + 'answer_max_seconds' => $record['answer_max_seconds'] ?? 150, // Imports arrive inactive (D8). Activation stays a deliberate act: // a file must never change which avatar an organization's live // interviews are running on. diff --git a/app/Http/Controllers/Candidate/InterviewController.php b/app/Http/Controllers/Candidate/InterviewController.php index 98dbea08..57497c4c 100644 --- a/app/Http/Controllers/Candidate/InterviewController.php +++ b/app/Http/Controllers/Candidate/InterviewController.php @@ -118,7 +118,7 @@ public function __construct( * interviewed, `429 provider_busy` means the provider is busy and the call can be retried, and `502` * means the provider failed. * - * @scramble-return array{session_id: int, provider: string, provider_token: string|null, conversation_url: string|null, audio_only: bool, question_context: array{competency_code: string, question_index: int, end_phrase: string, final_phrase: string, prompt_version: string|null, competency_ordinal: int|null, total_competencies: int|null}, interview_max_seconds: int, conversation_id?: string, conversation_ttl_seconds?: int, continuation?: array{conversation_id: string, competency_code: string}} + * @scramble-return array{session_id: int, provider: string, provider_token: string|null, conversation_url: string|null, audio_only: bool, question_context: array{competency_code: string, question_index: int, end_phrase: string, final_phrase: string, prompt_version: string|null, competency_ordinal: int|null, total_competencies: int|null}, interview_max_seconds: int, answer_min_seconds: int, answer_max_seconds: int, conversation_id?: string, conversation_ttl_seconds?: int, continuation?: array{conversation_id: string, competency_code: string}} * * @throws \Throwable * diff --git a/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php b/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php index 6274df4f..cac737f4 100644 --- a/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php +++ b/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php @@ -29,6 +29,36 @@ */ trait ValidatesAvatarTemplateWrites { + /** + * The seconds a good answer lasts (answer-time-range). The bounds live here and in the DB CHECK; + * `min < max` needs both values, so it is `assertAnswerRangeOrdered()`'s job, not a rule's. + * + * @var array> + */ + private const ANSWER_RANGE_RULES = [ + 'answer_min_seconds' => ['sometimes', 'integer', 'min:10', 'max:600'], + 'answer_max_seconds' => ['sometimes', 'integer', 'min:10', 'max:600'], + ]; + + /** + * Refuses a range whose minimum is not below its maximum, comparing a field the request omits + * against the stored value (the defaults on create). A one-field PATCH could otherwise + * invert a range the other field's stored value defines. + * + * @param array $validated + */ + private function assertAnswerRangeOrdered(array $validated, int $storedMin = 90, int $storedMax = 150): void + { + $min = $validated['answer_min_seconds'] ?? $storedMin; + $max = $validated['answer_max_seconds'] ?? $storedMax; + + if ($min >= $max) { + throw ValidationException::withMessages([ + 'answer_max_seconds' => 'The maximum answer time must be greater than the minimum.', + ]); + } + } + /** * @return array> */ @@ -46,6 +76,7 @@ private function templateStoreRules(): array // Bind a language model to the template. `llm_model_id` and `llm_credential_id` are both set or both null. 'llm_model_id' => ['sometimes', 'nullable', 'integer'], 'llm_credential_id' => ['sometimes', 'nullable', 'integer'], + ...self::ANSWER_RANGE_RULES, ...$this->personaRules(), ]; } @@ -67,6 +98,7 @@ private function templateUpdateRules(): array // Bind a language model to the template. `llm_model_id` and `llm_credential_id` are both set or both null; sending both as null clears the binding. 'llm_model_id' => ['sometimes', 'nullable', 'integer'], 'llm_credential_id' => ['sometimes', 'nullable', 'integer'], + ...self::ANSWER_RANGE_RULES, ...$this->personaRules(), ]; } diff --git a/app/Http/Resources/AvatarTemplateResource.php b/app/Http/Resources/AvatarTemplateResource.php index 87e54b8a..e0a397e2 100644 --- a/app/Http/Resources/AvatarTemplateResource.php +++ b/app/Http/Resources/AvatarTemplateResource.php @@ -63,9 +63,12 @@ class AvatarTemplateResource extends JsonResource * `persona.display_name` is the interviewer's name the candidate reads on the live screen (null when * unset or when a stored value fails the write rules). Admin-only: classified in `ExposureCatalogue`. * - * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}} + * `answer_min_seconds` / `answer_max_seconds` are the range, in seconds, the candidate's answer bar + * calls a good answer (guidance only). Admin-only: classified in `ExposureCatalogue`. * - * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}} + * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, answer_min_seconds: int, answer_max_seconds: int} + * + * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, answer_min_seconds: int, answer_max_seconds: int} */ public function toArray(Request $request): array { @@ -96,6 +99,8 @@ public function toArray(Request $request): array ], // Only `display_name`, never the raw JSON: keys an import brought in stay off this surface. 'persona' => ['display_name' => $template->personaDisplayName()], + 'answer_min_seconds' => $template->answer_min_seconds, + 'answer_max_seconds' => $template->answer_max_seconds, ]; } diff --git a/app/Http/Resources/PlatformAvatarTemplateResource.php b/app/Http/Resources/PlatformAvatarTemplateResource.php index be1be641..8312d477 100644 --- a/app/Http/Resources/PlatformAvatarTemplateResource.php +++ b/app/Http/Resources/PlatformAvatarTemplateResource.php @@ -30,9 +30,9 @@ public function __construct(AvatarTemplate $resource, private readonly array $us } /** - * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, usage: array{organization_count: int, project_count: int}} + * @return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, answer_min_seconds: int, answer_max_seconds: int, usage: array{organization_count: int, project_count: int}} * - * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, usage: array{organization_count: int, project_count: int}} + * @scramble-return array{id: int, name: string, description: string|null, provider: string, scope: 'organization'|'platform', config: array, is_active: bool, created_at: string|null, updated_at: string|null, llm_model_id: int|null, llm_credential_id: int|null, llm_sync_status: string|null, llm_synced_at: string|null, llm: array{estimated_cost_usd_per_interview: array{minutes: int, turns: int, usd: float}|null}, pal_sync: array{status: string|null, code: string|null, synced_at: string|null}, persona: array{display_name: string|null}, answer_min_seconds: int, answer_max_seconds: int, usage: array{organization_count: int, project_count: int}} */ public function toArray(Request $request): array { diff --git a/app/Models/AvatarTemplate.php b/app/Models/AvatarTemplate.php index eeffa765..0cf04d0e 100644 --- a/app/Models/AvatarTemplate.php +++ b/app/Models/AvatarTemplate.php @@ -50,6 +50,8 @@ * @property array $config * @property array|null $persona * @property bool $is_active + * @property int $answer_min_seconds + * @property int $answer_max_seconds * @property int|null $llm_model_id * @property int|null $llm_credential_id * @property string|null $heygen_llm_configuration_id @@ -79,6 +81,8 @@ class AvatarTemplate extends TenantModel implements AdmitsPlatformRows protected $attributes = [ 'is_active' => false, 'config' => '{}', + 'answer_min_seconds' => 90, + 'answer_max_seconds' => 150, ]; /** @var list */ @@ -88,6 +92,8 @@ class AvatarTemplate extends TenantModel implements AdmitsPlatformRows 'provider', 'config', 'persona', + 'answer_min_seconds', + 'answer_max_seconds', 'is_active', // Both-or-neither, enforced by a DB CHECK (I1) and by booted()'s I2/ // I3/I4 guards below (pluggable-conversation-llm PR P3a, design D4). @@ -105,6 +111,8 @@ protected function casts(): array 'config' => 'array', 'persona' => 'array', 'is_active' => 'boolean', + 'answer_min_seconds' => 'integer', + 'answer_max_seconds' => 'integer', 'llm_model_id' => 'integer', 'llm_credential_id' => 'integer', 'llm_synced_at' => 'datetime', diff --git a/app/Support/AvatarTemplates/TemplateDocument.php b/app/Support/AvatarTemplates/TemplateDocument.php index 4b926923..5df99533 100644 --- a/app/Support/AvatarTemplates/TemplateDocument.php +++ b/app/Support/AvatarTemplates/TemplateDocument.php @@ -46,6 +46,8 @@ public static function export(Collection $templates): array 'config' => $t->config, // Persona is optional: a template may be pure provider config. 'persona' => $t->persona ?? null, + 'answer_min_seconds' => $t->answer_min_seconds, + 'answer_max_seconds' => $t->answer_max_seconds, // The language model binding, by model key and credential name; `null` when the template has none. 'llm' => $t->llm_model_id === null ? null : [ 'model_key' => $t->llmModel?->key, @@ -65,7 +67,7 @@ public static function export(Collection $templates): array * lose a block. * * @param array $entry - * @return list, persona: array|null, llm: array{model_key: string, credential_name: string}|null}> + * @return list, persona: array|null, answer_min_seconds: mixed, answer_max_seconds: mixed, llm: array{model_key: string, credential_name: string}|null}> */ public static function flatten(array $entry): array { @@ -81,6 +83,8 @@ public static function flatten(array $entry): array 'provider' => is_string($entry['provider']) ? $entry['provider'] : '', 'config' => is_array($entry['config'] ?? null) ? $entry['config'] : [], 'persona' => $persona, + 'answer_min_seconds' => $entry['answer_min_seconds'] ?? null, + 'answer_max_seconds' => $entry['answer_max_seconds'] ?? null, 'llm' => self::flattenLlm($entry['llm'] ?? null), ]]; } @@ -103,6 +107,8 @@ public static function flatten(array $entry): array 'provider' => $provider, 'config' => $config, 'persona' => $persona, + 'answer_min_seconds' => null, + 'answer_max_seconds' => null, 'llm' => null, ]; } diff --git a/app/Support/Interview/SessionLiveClock.php b/app/Support/Interview/SessionLiveClock.php index f74dcf3f..388104ea 100644 --- a/app/Support/Interview/SessionLiveClock.php +++ b/app/Support/Interview/SessionLiveClock.php @@ -119,6 +119,22 @@ public function close(InterviewSession $session, string $reason): void $open->save(); } + /** + * The answer-length range the active template sets, in seconds; the platform default when no + * template resolves. Guidance for the candidate's bar: nothing here ends or shortens an answer. + * + * @return array{min: int, max: int} + */ + public function resolveAnswerRange(InterviewSession $session): array + { + $template = $this->templates->resolve($session->provider, $session->project_id); + + return [ + 'min' => $template->answer_min_seconds ?? 90, + 'max' => $template->answer_max_seconds ?? 150, + ]; + } + /** * The provider's real ceiling for this session, in seconds (D4a). * diff --git a/database/migrations/2026_10_16_100000_add_answer_time_range_to_avatar_templates.php b/database/migrations/2026_10_16_100000_add_answer_time_range_to_avatar_templates.php new file mode 100644 index 00000000..2c784291 --- /dev/null +++ b/database/migrations/2026_10_16_100000_add_answer_time_range_to_avatar_templates.php @@ -0,0 +1,44 @@ +unsignedSmallInteger('answer_min_seconds')->default(90); + $table->unsignedSmallInteger('answer_max_seconds')->default(150); + }); + + DB::statement( + 'ALTER TABLE avatar_templates ADD CONSTRAINT avatar_templates_answer_range + CHECK (answer_min_seconds >= 10 AND answer_max_seconds <= 600 AND answer_min_seconds < answer_max_seconds)' + ); + } + + public function down(): void + { + DB::statement('ALTER TABLE avatar_templates DROP CONSTRAINT IF EXISTS avatar_templates_answer_range'); + + Schema::table('avatar_templates', function (Blueprint $table): void { + $table->dropColumn(['answer_min_seconds', 'answer_max_seconds']); + }); + } +}; diff --git a/openapi.json b/openapi.json index 1debb286..f158b486 100644 --- a/openapi.json +++ b/openapi.json @@ -1749,6 +1749,16 @@ "null" ] }, + "answer_min_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, + "answer_max_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, "persona": { "type": "object", "properties": { @@ -1895,6 +1905,16 @@ "null" ] }, + "answer_min_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, + "answer_max_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, "persona": { "type": "object", "properties": { @@ -2167,6 +2187,12 @@ "description": "Persona is optional: a template may be pure provider config.", "items": {} }, + "answer_min_seconds": { + "type": "integer" + }, + "answer_max_seconds": { + "type": "integer" + }, "llm": { "type": [ "object", @@ -2193,6 +2219,8 @@ "provider", "config", "persona", + "answer_min_seconds", + "answer_max_seconds", "llm" ] } @@ -6718,6 +6746,12 @@ "interview_max_seconds": { "type": "integer" }, + "answer_min_seconds": { + "type": "integer" + }, + "answer_max_seconds": { + "type": "integer" + }, "conversation_id": { "type": "string" }, @@ -6747,7 +6781,9 @@ "conversation_url", "audio_only", "question_context", - "interview_max_seconds" + "interview_max_seconds", + "answer_min_seconds", + "answer_max_seconds" ] } } @@ -9016,6 +9052,16 @@ "null" ] }, + "answer_min_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, + "answer_max_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, "persona": { "type": "object", "properties": { @@ -9165,6 +9211,16 @@ "null" ] }, + "answer_min_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, + "answer_max_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 600 + }, "persona": { "type": "object", "properties": { @@ -14825,6 +14881,12 @@ "required": [ "display_name" ] + }, + "answer_min_seconds": { + "type": "integer" + }, + "answer_max_seconds": { + "type": "integer" } }, "required": [ @@ -14843,7 +14905,9 @@ "llm_synced_at", "llm", "pal_sync", - "persona" + "persona", + "answer_min_seconds", + "answer_max_seconds" ], "title": "AvatarTemplateResource" }, @@ -16643,6 +16707,12 @@ "display_name" ] }, + "answer_min_seconds": { + "type": "integer" + }, + "answer_max_seconds": { + "type": "integer" + }, "usage": { "type": "object", "properties": { @@ -16676,6 +16746,8 @@ "llm", "pal_sync", "persona", + "answer_min_seconds", + "answer_max_seconds", "usage" ], "title": "PlatformAvatarTemplateResource" diff --git a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php new file mode 100644 index 00000000..87f32f61 --- /dev/null +++ b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php @@ -0,0 +1,221 @@ +create(); + + return $surface === 'organization' + ? ['/api/avatar-templates', TemplateActors::token('acting', $org), $org] + : ['/api/admin/avatar-templates', TemplateActors::token('bare', $org), $org]; +} + +/** + * @param array $extra + * @return array + */ +function atrPayload(array $extra = []): array +{ + return array_merge([ + 'name' => 'Range '.uniqid(), + 'provider' => 'heygen', + 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1'], + ], $extra); +} + +/** + * @param array $columns + */ +function atrSeed(string $surface, Organization $org, array $columns = []): AvatarTemplate +{ + if ($surface === 'platform') { + return PlatformTemplates::insertGlobal($columns); + } + + return TenantContextScope::runFor($org->id, fn (): AvatarTemplate => AvatarTemplate::create(array_merge([ + 'name' => 'Seed '.uniqid(), + 'provider' => 'heygen', + 'config' => ['avatarId' => 'av', 'voiceId' => 'vo'], + ], $columns))); +} + +test('a template defaults to a 90 to 150 second range, platform rows included', function (string $surface): void { + [$url, $token, $org] = atrSurface($surface); + $template = atrSeed($surface, $org); + + expect($template->fresh()->answer_min_seconds)->toBe(90) + ->and($template->fresh()->answer_max_seconds)->toBe(150); + + $this->withToken($token)->getJson("{$url}/{$template->id}")->assertOk() + ->assertJsonPath('data.answer_min_seconds', 90) + ->assertJsonPath('data.answer_max_seconds', 150); +})->with('atrSurfaces'); + +test('a create without the range stores the defaults and a create with it stores the values', function (string $surface): void { + [$url, $token] = atrSurface($surface); + + $this->withToken($token)->postJson($url, atrPayload())->assertCreated() + ->assertJsonPath('data.answer_min_seconds', 90) + ->assertJsonPath('data.answer_max_seconds', 150); + + $this->withToken($token)->postJson($url, atrPayload(['answer_min_seconds' => 60, 'answer_max_seconds' => 120]))->assertCreated() + ->assertJsonPath('data.answer_min_seconds', 60) + ->assertJsonPath('data.answer_max_seconds', 120); +})->with('atrSurfaces'); + +test('the range is refused when out of bounds, not an integer or not min below max', function (string $surface, array $range, string $field): void { + [$url, $token] = atrSurface($surface); + + $this->withToken($token)->postJson($url, atrPayload($range)) + ->assertUnprocessable()->assertJsonValidationErrors([$field]); +})->with('atrSurfaces')->with([ + 'min under 10' => [['answer_min_seconds' => 9], 'answer_min_seconds'], + 'max over 600' => [['answer_max_seconds' => 601], 'answer_max_seconds'], + 'not an integer' => [['answer_min_seconds' => 'soon'], 'answer_min_seconds'], + 'min equal to max' => [['answer_min_seconds' => 100, 'answer_max_seconds' => 100], 'answer_max_seconds'], + 'min above max' => [['answer_min_seconds' => 200, 'answer_max_seconds' => 100], 'answer_max_seconds'], + 'min above the default max' => [['answer_min_seconds' => 150], 'answer_max_seconds'], + 'max under the default min' => [['answer_max_seconds' => 90], 'answer_max_seconds'], +]); + +test('an update compares a single field against the stored one', function (string $surface): void { + [$url, $token, $org] = atrSurface($surface); + $template = atrSeed($surface, $org); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['answer_min_seconds' => 200]) + ->assertUnprocessable()->assertJsonValidationErrors(['answer_max_seconds']); + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['answer_max_seconds' => 60]) + ->assertUnprocessable()->assertJsonValidationErrors(['answer_max_seconds']); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['answer_min_seconds' => 45])->assertOk() + ->assertJsonPath('data.answer_min_seconds', 45) + ->assertJsonPath('data.answer_max_seconds', 150); + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['answer_min_seconds' => 200, 'answer_max_seconds' => 300])->assertOk() + ->assertJsonPath('data.answer_min_seconds', 200) + ->assertJsonPath('data.answer_max_seconds', 300); +})->with('atrSurfaces'); + +test('the database refuses a range the application let through', function (): void { + $org = Organization::factory()->create(); + + expect(fn () => atrSeed('platform', $org, ['answer_min_seconds' => 200, 'answer_max_seconds' => 100])) + ->toThrow(QueryException::class); +}); + +test('duplicating a template copies the range', function (): void { + $source = Organization::factory()->create(); + $target = Organization::factory()->create(); + $original = atrSeed('organization', $source, ['answer_min_seconds' => 30, 'answer_max_seconds' => 75]); + + $this->withToken(TemplateActors::token('bare', $source)) + ->postJson("/api/avatar-templates/{$original->id}/duplicate", ['target_organization_ids' => [$target->id]]) + ->assertCreated(); + + $copy = TenantContextScope::runFor($target->id, fn () => AvatarTemplate::query()->firstOrFail()); + expect($copy->answer_min_seconds)->toBe(30)->and($copy->answer_max_seconds)->toBe(75); +}); + +test('export carries the range and import restores it', function (): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + atrSeed('organization', $org, ['answer_min_seconds' => 30, 'answer_max_seconds' => 75]); + + $document = $this->withToken($token)->getJson('/api/avatar-templates/export')->assertOk()->json(); + expect($document['templates'][0])->toMatchArray(['answer_min_seconds' => 30, 'answer_max_seconds' => 75]); + + $document['templates'][0]['name'] = 'Round trip'; + $this->withToken($token)->postJson('/api/avatar-templates/import', $document)->assertCreated(); + + $copy = AvatarTemplate::withoutGlobalScopes()->where('name', 'Round trip')->firstOrFail(); + expect($copy->answer_min_seconds)->toBe(30)->and($copy->answer_max_seconds)->toBe(75); +}); + +test('an older file without the range imports with the defaults and a bad range is refused', function (): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + $entry = ['name' => 'Old', 'provider' => 'heygen', 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1']]; + + $this->withToken($token)->postJson('/api/avatar-templates/import', ['schema' => 'beai.avatar-template/1', 'templates' => [$entry]])->assertCreated(); + $old = AvatarTemplate::withoutGlobalScopes()->where('name', 'Old')->firstOrFail(); + expect($old->answer_min_seconds)->toBe(90)->and($old->answer_max_seconds)->toBe(150); + + $bad = array_merge($entry, ['name' => 'Bad', 'answer_min_seconds' => 500, 'answer_max_seconds' => 20]); + $this->withToken($token)->postJson('/api/avatar-templates/import', ['schema' => 'beai.avatar-template/1', 'templates' => [$bad]])->assertUnprocessable(); + expect(AvatarTemplate::withoutGlobalScopes()->where('name', 'Bad')->exists())->toBeFalse(); +}); + +/** + * @param array $columns + */ +function atrSession(Organization $org, ?array $columns): InterviewSession +{ + // `null` pins a template of ANOTHER provider, so nothing resolves for the tavus session + // (a project always pins some template; the resolver ignores one of the wrong provider). + $template = PlatformTemplates::insertActiveGlobal(array_merge([ + 'provider' => $columns === null ? 'heygen' : 'tavus', + 'config' => ['avatarId' => 'av', 'voiceId' => 'vo'], + ], $columns ?? [])); + + return TenantContextScope::runFor($org->id, function () use ($org, $template): InterviewSession { + $fv = FrameworkVersion::factory()->create(['organization_id' => $org->id]); + $project = Project::factory()->create([ + 'organization_id' => $org->id, + 'framework_version_id' => $fv->id, + 'avatar_template_id' => $template->id, + ]); + $participant = Participant::factory()->create(['organization_id' => $org->id, 'project_id' => $project->id]); + + return InterviewSession::factory()->create([ + 'organization_id' => $org->id, + 'participant_id' => $participant->id, + 'project_id' => $project->id, + 'framework_version_id' => $fv->id, + 'provider' => 'tavus', + ]); + }); +} + +test('the session response carries the template range, or 90 and 150 without a template', function (?array $columns, int $min, int $max): void { + $org = Organization::factory()->create(); + $session = atrSession($org, $columns); + + $body = TenantContextScope::runFor($org->id, fn () => app(BuildInterviewSessionResponse::class)->handle( + $session, + new ProviderToken(provider: 'tavus', conversation_url: 'https://example.test/room'), + 'it', + ))->getData(true); + + expect($body)->toHaveKey('answer_min_seconds', $min)->toHaveKey('answer_max_seconds', $max); +})->with([ + 'custom template' => [['answer_min_seconds' => 60, 'answer_max_seconds' => 120], 60, 120], + 'default template' => [[], 90, 150], + 'no template' => [null, 90, 150], +]); diff --git a/tests/Feature/Interview/ConversationPlanCreateTest.php b/tests/Feature/Interview/ConversationPlanCreateTest.php index 6ad07298..f1a13197 100644 --- a/tests/Feature/Interview/ConversationPlanCreateTest.php +++ b/tests/Feature/Interview/ConversationPlanCreateTest.php @@ -137,7 +137,7 @@ function cpcRow(Organization $org, Participant $participant): InterviewSession $response = cpcStart($participant)->assertStatus(201); $body = cpcCreateBody(); - expect(array_keys($response->json()))->toBe(['session_id', 'provider', 'audio_only', 'provider_token', 'conversation_url', 'question_context', 'interview_max_seconds']) + expect(array_keys($response->json()))->toBe(['session_id', 'provider', 'audio_only', 'provider_token', 'conversation_url', 'question_context', 'interview_max_seconds', 'answer_min_seconds', 'answer_max_seconds']) ->and(cpcRow($org, $participant)->conversation_plan)->toBeNull() ->and($body['properties'] ?? [])->not->toHaveKey('participant_left_timeout') ->and($body['conversational_context'])->not->toContain('=== TOPIC CODE:'); diff --git a/tests/Helpers/PublicApi/ExposureCatalogue.php b/tests/Helpers/PublicApi/ExposureCatalogue.php index 2e417d41..c9199a24 100644 --- a/tests/Helpers/PublicApi/ExposureCatalogue.php +++ b/tests/Helpers/PublicApi/ExposureCatalogue.php @@ -82,6 +82,8 @@ public static function exclusions(): array 'avatar_template.pal_sync.code', 'avatar_template.pal_sync.synced_at', 'avatar_template.persona.display_name', + 'avatar_template.answer_min_seconds', + 'avatar_template.answer_max_seconds', 'can.update', 'can.delete', 'competencies[].id', From 8e9c5461ace69aa512f82127f8fd843e881992c5 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Fri, 16 Oct 2026 11:39:22 +0100 Subject: [PATCH 2/6] test(migration): insert the template with the columns that exist on the old schema The baseline revision test rolls the schema back, so creating the template through the model also named the answer range columns added later. --- .../Migration/BaselineRevisionMigrationTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Feature/Migration/BaselineRevisionMigrationTest.php b/tests/Feature/Migration/BaselineRevisionMigrationTest.php index 2652adba..cc6a2eab 100644 --- a/tests/Feature/Migration/BaselineRevisionMigrationTest.php +++ b/tests/Feature/Migration/BaselineRevisionMigrationTest.php @@ -91,9 +91,22 @@ 'label' => 'PR1 pre-revision snapshot', ]); + // The schema is older than the code here, so the template is inserted with the columns that + // exist at this point: an `AvatarTemplate::create()` would also name every column added later. + $templateId = DB::table('avatar_templates')->insertGetId([ + 'organization_id' => $org->id, + 'name' => 'PR1 pre-revision template', + 'provider' => 'heygen', + 'config' => '{}', + 'is_active' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $project = Project::factory()->create([ 'framework_version_id' => $fv->id, 'role_code' => 'PR1TEST', + 'avatar_template_id' => $templateId, ]); $participant = Participant::factory()->create([ 'project_id' => $project->id, From b94e7da32a7ae7ad3e5132a5055532e6397c62b0 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Fri, 16 Oct 2026 12:14:14 +0100 Subject: [PATCH 3/6] fix(avatar-templates): answer a concurrent answer-range violation with a 422 Translate the avatar_templates_answer_range CHECK violation into the same 422 on answer_max_seconds the ordering helper raises, on both update paths, and cover the import type, bounds and defaulting branches. --- .../Api/PlatformAvatarTemplateController.php | 4 +- .../Controllers/AvatarTemplateController.php | 2 +- .../ValidatesAvatarTemplateWrites.php | 28 +++++++++ .../AvatarTemplates/AnswerTimeRangeTest.php | 60 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/PlatformAvatarTemplateController.php b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php index bcd907f0..9aeafba8 100644 --- a/app/Http/Controllers/Api/PlatformAvatarTemplateController.php +++ b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php @@ -228,7 +228,7 @@ public function update(Request $request, int $id): PlatformAvatarTemplateResourc // records field NAMES, never config values. $usage = $this->usage->for([$template->id])[$template->id]; - $this->answeringPlatformNameRace(fn () => $this->context->run($actor, fn () => DB::transaction(function () use ($template, $validated, $actor, $before, $originals, $beforeBinding, $usage): void { + $this->answeringAnswerRangeRace(fn () => $this->answeringPlatformNameRace(fn () => $this->context->run($actor, fn () => DB::transaction(function () use ($template, $validated, $actor, $before, $originals, $beforeBinding, $usage): void { $template->update($validated); $changed = array_values(array_filter( @@ -260,7 +260,7 @@ public function update(Request $request, int $id): PlatformAvatarTemplateResourc $unbound ? null : $this->bindingNames($template->refresh()), ); } - }))); + })))); return $this->present($template)->additional($this->recordSync($template)); } diff --git a/app/Http/Controllers/AvatarTemplateController.php b/app/Http/Controllers/AvatarTemplateController.php index 605efc19..6a8e5371 100644 --- a/app/Http/Controllers/AvatarTemplateController.php +++ b/app/Http/Controllers/AvatarTemplateController.php @@ -294,7 +294,7 @@ public function update(Request $request, int $id): AvatarTemplateResource $beforeBindingNames = $bindingRequested ? $this->bindingAuditNames($template) : null; $before = ['name' => $template->name]; - $template->update($validated); + $this->answeringAnswerRangeRace(fn (): bool => DB::transaction(fn (): bool => $template->update($validated))); app(AuditRecorder::class)->record( 'avatar_template.updated', diff --git a/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php b/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php index cac737f4..70d0dd9e 100644 --- a/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php +++ b/app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php @@ -13,6 +13,7 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\QueryException; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -253,6 +254,33 @@ private function answeringPlatformNameRace(Closure $write): mixed } } + /** + * `assertAnswerRangeOrdered()` reads the stored range before the write, so two concurrent + * one-field PATCHes can each pass it and the second then meets the other's value. The CHECK + * `avatar_templates_answer_range` is the real guard and refuses the loser; that is the same + * problem the helper reports, so it is answered the same way (a 422, not a 500). Only THAT + * constraint is translated. + * + * @template T + * + * @param Closure(): T $write + * @return T + */ + private function answeringAnswerRangeRace(Closure $write): mixed + { + try { + return $write(); + } catch (QueryException $e) { + if ($e->getCode() !== '23514' || ! str_contains($e->getMessage(), 'avatar_templates_answer_range')) { + throw $e; + } + + throw ValidationException::withMessages([ + 'answer_max_seconds' => 'The maximum answer time must be greater than the minimum.', + ]); + } + } + private function nameTaken(): ValidationException { return ValidationException::withMessages([ diff --git a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php index 87f32f61..4bab4673 100644 --- a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php +++ b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php @@ -19,6 +19,8 @@ use App\Support\Tenancy\TenantContextScope; use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Tests\Helpers\AvatarTemplates\PlatformTemplates; use Tests\Helpers\AvatarTemplates\TemplateActors; @@ -130,6 +132,20 @@ function atrSeed(string $surface, Organization $org, array $columns = []): Avata ->toThrow(QueryException::class); }); +test('a concurrent write that slips past the ordering check is a 422, not a 500', function (string $surface): void { + [$url, $token, $org] = atrSurface($surface); + $template = atrSeed($surface, $org); + + // The other writer lands between our validation (stored max 150) and our write: the + // validated `min 140` now meets a stored max of 100, which only the DB CHECK can refuse. + Event::listen('eloquent.updating: '.AvatarTemplate::class, function () use ($template): void { + DB::table('avatar_templates')->where('id', $template->id)->update(['answer_max_seconds' => 100]); + }); + + $this->withToken($token)->patchJson("{$url}/{$template->id}", ['answer_min_seconds' => 140]) + ->assertUnprocessable()->assertJsonValidationErrors(['answer_max_seconds']); +})->with('atrSurfaces'); + test('duplicating a template copies the range', function (): void { $source = Organization::factory()->create(); $target = Organization::factory()->create(); @@ -172,6 +188,50 @@ function atrSeed(string $surface, Organization $org, array $columns = []): Avata expect(AvatarTemplate::withoutGlobalScopes()->where('name', 'Bad')->exists())->toBeFalse(); }); +test('an import refuses a range of the wrong type or bounds and defaults the field it omits', function (array $range, bool $accepted, int $min, int $max): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + $entry = array_merge(['name' => 'Imp', 'provider' => 'heygen', 'config' => ['avatarId' => 'av_1', 'voiceId' => 'vo_1']], $range); + + $response = $this->withToken($token)->postJson('/api/avatar-templates/import', ['schema' => 'beai.avatar-template/1', 'templates' => [$entry]]); + + if (! $accepted) { + $response->assertUnprocessable(); + expect(AvatarTemplate::withoutGlobalScopes()->where('name', 'Imp')->exists())->toBeFalse(); + + return; + } + + $response->assertCreated(); + $row = AvatarTemplate::withoutGlobalScopes()->where('name', 'Imp')->firstOrFail(); + expect($row->answer_min_seconds)->toBe($min)->and($row->answer_max_seconds)->toBe($max); +})->with([ + 'string min' => [['answer_min_seconds' => '90'], false, 0, 0], + 'string max' => [['answer_max_seconds' => '150'], false, 0, 0], + 'fractional max' => [['answer_max_seconds' => 150.5], false, 0, 0], + 'min under 10' => [['answer_min_seconds' => 5], false, 0, 0], + 'max over 600' => [['answer_max_seconds' => 601], false, 0, 0], + 'min above the default max' => [['answer_min_seconds' => 200], false, 0, 0], + 'max under the default min' => [['answer_max_seconds' => 60], false, 0, 0], + 'only min, max defaults' => [['answer_min_seconds' => 30], true, 30, 150], + 'only max, min defaults' => [['answer_max_seconds' => 300], true, 90, 300], + 'bounds are inclusive' => [['answer_min_seconds' => 10, 'answer_max_seconds' => 600], true, 10, 600], +]); + +test('an import refuses a whole-number float, which PHP would otherwise encode as an int', function (): void { + $org = Organization::factory()->create(); + $token = TemplateActors::token('acting', $org); + + // json_encode() writes 90.0 as `90`, so the body is written by hand to reach the decoder as a float. + $body = '{"schema":"beai.avatar-template/1","templates":[{"name":"Imp","provider":"heygen",' + .'"config":{"avatarId":"av_1","voiceId":"vo_1"},"answer_min_seconds":90.0}]}'; + + $this->withServerVariables($this->transformHeadersToServerVars(['Authorization' => "Bearer {$token}", 'Accept' => 'application/json'])) + ->call('POST', '/api/avatar-templates/import', [], [], [], ['CONTENT_TYPE' => 'application/json'], $body) + ->assertUnprocessable(); + expect(AvatarTemplate::withoutGlobalScopes()->where('name', 'Imp')->exists())->toBeFalse(); +}); + /** * @param array $columns */ From abd8e31333d4e67f4adfa223ef8d7f5aba4c1819 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Fri, 16 Oct 2026 11:26:21 +0100 Subject: [PATCH 4/6] feat(organizations): add a background colour to the branding Nullable #rrggbb organizations.background_color with a CHECK, writable by admins and superadmins, exposed to the candidate in branding.background_color and classified admin-only for T-EXPOSE-001. openapi.json regenerated. --- .../Api/AdminOrganizationController.php | 2 +- .../Api/OrganizationController.php | 3 +- .../UpdateManagedOrganizationRequest.php | 5 +- .../Requests/UpdateOrganizationRequest.php | 3 ++ .../Resources/Admin/OrganizationResource.php | 5 +- .../ParticipantEnrolmentResource.php | 4 +- app/Http/Resources/ParticipantResource.php | 9 ++-- app/Models/Organization.php | 2 + ..._add_background_color_to_organizations.php | 42 +++++++++++++++ openapi.json | 39 +++++++++++++- .../OrganizationSettings/BrandingTest.php | 54 +++++++++++++++++-- .../Superadmin/OrganizationManagementTest.php | 14 ++++- tests/Helpers/PublicApi/ExposureCatalogue.php | 1 + 13 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2026_10_16_110000_add_background_color_to_organizations.php diff --git a/app/Http/Controllers/Api/AdminOrganizationController.php b/app/Http/Controllers/Api/AdminOrganizationController.php index ebe6a729..d0fc8ae1 100644 --- a/app/Http/Controllers/Api/AdminOrganizationController.php +++ b/app/Http/Controllers/Api/AdminOrganizationController.php @@ -70,7 +70,7 @@ public function update(UpdateManagedOrganizationRequest $request, int $id): Json $actor = $request->user(); $organization = Organization::findOrFail($id); - $organization->fill($request->safe()->only(['name', 'primary_color', 'currency_code'])); + $organization->fill($request->safe()->only(['name', 'primary_color', 'background_color', 'currency_code'])); $changes = $organization->getDirty(); $before = array_intersect_key($organization->getOriginal(), $changes); $organization->save(); diff --git a/app/Http/Controllers/Api/OrganizationController.php b/app/Http/Controllers/Api/OrganizationController.php index 3db25472..8070a7de 100644 --- a/app/Http/Controllers/Api/OrganizationController.php +++ b/app/Http/Controllers/Api/OrganizationController.php @@ -61,7 +61,7 @@ public function show(): JsonResponse /** * Update the organization. * - * Accepts `name`, the default webhook settings, `primary_color` and + * Accepts `name`, the default webhook settings, `primary_color`, `background_color` and * `currency_code` (active catalogue codes only). Any other field in the body * is ignored. */ @@ -85,6 +85,7 @@ public function update(UpdateOrganizationRequest $request): JsonResponse // stored. Accepting it here would let a client point the logo at an // arbitrary path on the disk. 'primary_color', + 'background_color', 'currency_code', ])); diff --git a/app/Http/Requests/UpdateManagedOrganizationRequest.php b/app/Http/Requests/UpdateManagedOrganizationRequest.php index ba3a10ae..34f8bfbd 100644 --- a/app/Http/Requests/UpdateManagedOrganizationRequest.php +++ b/app/Http/Requests/UpdateManagedOrganizationRequest.php @@ -11,7 +11,7 @@ /** * Validates PATCH /api/admin/organizations/{id} (superadmin only). * - * Only `name`, `primary_color` and `currency_code` are writable here. `slug` is a tenancy + * Only `name`, `primary_color`, `background_color` and `currency_code` are writable here. `slug` is a tenancy * identifier and is never editable; the webhook defaults and the logo keep * their own tenant-facing endpoints. */ @@ -32,6 +32,7 @@ public function rules(): array return [ 'name' => ['sometimes', 'required', 'string', 'max:255'], 'primary_color' => ['sometimes', 'nullable', 'string', 'regex:/\A#[0-9a-fA-F]{6}\z/'], + 'background_color' => ['sometimes', 'nullable', 'string', 'regex:/\A#[0-9a-fA-F]{6}\z/'], 'currency_code' => ['sometimes', 'required', 'string', 'regex:/\A[A-Z]{3}\z/', Rule::exists('currencies', 'code')->where('is_active', true)], ]; } @@ -47,6 +48,8 @@ public function messages(): array 'name.max' => 'name_too_long', 'primary_color.string' => 'primary_color_invalid', 'primary_color.regex' => 'primary_color_invalid', + 'background_color.string' => 'background_color_invalid', + 'background_color.regex' => 'background_color_invalid', 'currency_code.required' => 'currency_unavailable', 'currency_code.string' => 'currency_unavailable', 'currency_code.regex' => 'currency_unavailable', diff --git a/app/Http/Requests/UpdateOrganizationRequest.php b/app/Http/Requests/UpdateOrganizationRequest.php index fe94dd28..aec3b903 100644 --- a/app/Http/Requests/UpdateOrganizationRequest.php +++ b/app/Http/Requests/UpdateOrganizationRequest.php @@ -81,6 +81,7 @@ public function rules(): array * choosing a colour would be a one-way door. */ 'primary_color' => ['sometimes', 'nullable', 'string', 'regex:/\A#[0-9a-fA-F]{6}\z/'], + 'background_color' => ['sometimes', 'nullable', 'string', 'regex:/\A#[0-9a-fA-F]{6}\z/'], 'currency_code' => ['sometimes', 'required', 'string', 'regex:/\A[A-Z]{3}\z/', Rule::exists('currencies', 'code')->where('is_active', true)], ]; } @@ -117,6 +118,8 @@ public function messages(): array 'default_webhook_events.*.in' => 'webhook_event_unknown', 'primary_color.string' => 'primary_color_invalid', 'primary_color.regex' => 'primary_color_invalid', + 'background_color.string' => 'background_color_invalid', + 'background_color.regex' => 'background_color_invalid', // One slug for every currency refusal (inactive, unknown, malformed): the // response never says which codes exist but are switched off. 'currency_code.required' => 'currency_unavailable', diff --git a/app/Http/Resources/Admin/OrganizationResource.php b/app/Http/Resources/Admin/OrganizationResource.php index 2ed4db7b..33c35362 100644 --- a/app/Http/Resources/Admin/OrganizationResource.php +++ b/app/Http/Resources/Admin/OrganizationResource.php @@ -30,9 +30,9 @@ class OrganizationResource extends JsonResource * return bigint as string, and the contract must be true regardless of * that PDO detail. * - * @return array{id: int, name: string, slug: string, primary_color: string|null, currency_code: string, logo_url: string|null, default_webhook_url: string|null, default_webhook_events: list|null, has_default_webhook_secret: bool, created_at: string|null, updated_at: string|null} + * @return array{id: int, name: string, slug: string, primary_color: string|null, background_color: string|null, currency_code: string, logo_url: string|null, default_webhook_url: string|null, default_webhook_events: list|null, has_default_webhook_secret: bool, created_at: string|null, updated_at: string|null} * - * @scramble-return array{id: int, name: string, slug: string, primary_color: string|null, currency_code: string, logo_url: string|null, default_webhook_url: string|null, default_webhook_events: list|null, has_default_webhook_secret: bool, created_at: string|null, updated_at: string|null} + * @scramble-return array{id: int, name: string, slug: string, primary_color: string|null, background_color: string|null, currency_code: string, logo_url: string|null, default_webhook_url: string|null, default_webhook_events: list|null, has_default_webhook_secret: bool, created_at: string|null, updated_at: string|null} */ public function toArray(Request $request): array { @@ -49,6 +49,7 @@ public function toArray(Request $request): array // distinction to decide between the organization's colour and the // product's own. 'primary_color' => $organization->primary_color, + 'background_color' => $organization->background_color, // A resolved URL, not the stored path. The path is disk-relative // and the disk differs per environment, so resolving it here is the // one place that knows which disk is configured — the same reason diff --git a/app/Http/Resources/ParticipantEnrolmentResource.php b/app/Http/Resources/ParticipantEnrolmentResource.php index 2c77c022..427dbd38 100644 --- a/app/Http/Resources/ParticipantEnrolmentResource.php +++ b/app/Http/Resources/ParticipantEnrolmentResource.php @@ -40,9 +40,9 @@ final class ParticipantEnrolmentResource extends JsonResource /** * The `ParticipantResource` shape plus `external_id` and `source`. * - * @return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null, external_id: int|null, source: string|null} + * @return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, background_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null, external_id: int|null, source: string|null} * - * @scramble-return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null, external_id: int|null, source: string|null} + * @scramble-return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, background_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null, external_id: int|null, source: string|null} */ public function toArray(Request $request): array { diff --git a/app/Http/Resources/ParticipantResource.php b/app/Http/Resources/ParticipantResource.php index 0eee512b..f64b41e5 100644 --- a/app/Http/Resources/ParticipantResource.php +++ b/app/Http/Resources/ParticipantResource.php @@ -43,9 +43,9 @@ class ParticipantResource extends JsonResource * `project.assessment_type` union (closed by the FormRequest + model * immutability guard, `Project.php:147-150`) and `project.id` `(int)`. * - * @return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null} + * @return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, background_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null} * - * @scramble-return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null} + * @scramble-return array{id: int, candidate_ref: string, display_name: string, role_code: string|null, language: string|null, status: 'in_attesa'|'in_corso'|'in_valutazione'|'completato'|'errore', started_at: string|null, completed_at: string|null, created_at: string|null, scheduled_at: string|null, scheduling_status: 'pending'|'notice_sent'|'started'|'cancelled'|null, branding: array{name: string|null, primary_color: string|null, background_color: string|null, logo_url: string|null}, project: array{id: int, role_code: string|null, language: string, assessment_type: 'standard'|'potential', exit_redirect_url: string|null, error_redirect_url: string|null}|null} */ public function toArray(Request $request): array { @@ -80,8 +80,8 @@ public function toArray(Request $request): array * bootstrap the app already makes, rather than needing a second * endpoint and a second round trip before anything can render. * - * Exactly two keys. A candidate is an outsider holding a - * short-lived token; the organization's name, webhook + * Exactly four keys (name, primary_color, background_color, logo_url). A candidate is an outsider holding a + * short-lived token; the webhook * configuration and every other setting are none of their * business, and the test asserts this key set exactly so a field * added to `OrganizationResource` later cannot leak here. @@ -106,6 +106,7 @@ public function toArray(Request $request): array // staying out. 'name' => $participant->organization?->name, 'primary_color' => $participant->organization?->primary_color, + 'background_color' => $participant->organization?->background_color, // Absolute, for the same reason as OrganizationResource: the // candidate frontend is a separate origin from this API, so a // rooted `/storage/...` path resolved against the wrong host. diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 608dcc95..14c92939 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -24,6 +24,7 @@ * @property string|null $default_webhook_secret * @property string|null $logo_path * @property string|null $primary_color + * @property string|null $background_color * @property string $currency_code Display currency, a real FK to `currencies.code`; defaults to `USD`. * @property int|null $public_api_rate_limit_live * @property int|null $public_api_rate_limit_test @@ -61,6 +62,7 @@ class Organization extends Model implements PubliclyIdentifiable // so a stored URL would bake one environment's host into the row. 'logo_path', 'primary_color', + 'background_color', 'currency_code', // Public API (`/v1`) rate-limit overrides (public-api step 3, SPEC.md // §3.2) — both null-by-default; see the migration's own docblock for diff --git a/database/migrations/2026_10_16_110000_add_background_color_to_organizations.php b/database/migrations/2026_10_16_110000_add_background_color_to_organizations.php new file mode 100644 index 00000000..62297e52 --- /dev/null +++ b/database/migrations/2026_10_16_110000_add_background_color_to_organizations.php @@ -0,0 +1,42 @@ +string('background_color', 7)->nullable()->after('primary_color'); + }); + + DB::statement( + "ALTER TABLE organizations ADD CONSTRAINT organizations_background_color_hex + CHECK (background_color IS NULL OR background_color ~* '^#[0-9a-f]{6}$')" + ); + } + + public function down(): void + { + DB::statement( + 'ALTER TABLE organizations DROP CONSTRAINT IF EXISTS organizations_background_color_hex' + ); + + Schema::table('organizations', function (Blueprint $table): void { + $table->dropColumn('background_color'); + }); + } +}; diff --git a/openapi.json b/openapi.json index f158b486..a972aef1 100644 --- a/openapi.json +++ b/openapi.json @@ -7342,7 +7342,7 @@ }, "patch": { "operationId": "organization.update", - "description": "Accepts `name`, the default webhook settings, `primary_color` and\n`currency_code` (active catalogue codes only). Any other field in the body\nis ignored.", + "description": "Accepts `name`, the default webhook settings, `primary_color`, `background_color` and\n`currency_code` (active catalogue codes only). Any other field in the body\nis ignored.", "summary": "Update the organization", "tags": [ "Organization" @@ -14664,6 +14664,12 @@ "null" ] }, + "background_color": { + "type": [ + "string", + "null" + ] + }, "logo_url": { "type": [ "string", @@ -14674,6 +14680,7 @@ "required": [ "name", "primary_color", + "background_color", "logo_url" ] }, @@ -15923,6 +15930,12 @@ "null" ] }, + "background_color": { + "type": [ + "string", + "null" + ] + }, "currency_code": { "type": "string" }, @@ -15968,6 +15981,7 @@ "name", "slug", "primary_color", + "background_color", "currency_code", "logo_url", "default_webhook_url", @@ -16358,6 +16372,12 @@ "null" ] }, + "background_color": { + "type": [ + "string", + "null" + ] + }, "logo_url": { "type": [ "string", @@ -16368,6 +16388,7 @@ "required": [ "name", "primary_color", + "background_color", "logo_url" ] }, @@ -18820,7 +18841,7 @@ }, "UpdateManagedOrganizationRequest": { "type": "object", - "description": "Validates PATCH /api/admin/organizations/{id} (superadmin only).\n\nOnly `name`, `primary_color` and `currency_code` are writable here. `slug` is a tenancy\nidentifier and is never editable; the webhook defaults and the logo keep\ntheir own tenant-facing endpoints.", + "description": "Validates PATCH /api/admin/organizations/{id} (superadmin only).\n\nOnly `name`, `primary_color`, `background_color` and `currency_code` are writable here. `slug` is a tenancy\nidentifier and is never editable; the webhook defaults and the logo keep\ntheir own tenant-facing endpoints.", "properties": { "name": { "type": "string", @@ -18833,6 +18854,13 @@ ], "pattern": "^#[0-9a-fA-F]{6}$" }, + "background_color": { + "type": [ + "string", + "null" + ], + "pattern": "^#[0-9a-fA-F]{6}$" + }, "currency_code": { "type": "string", "pattern": "^[A-Z]{3}$" @@ -18885,6 +18913,13 @@ "description": "The primary colour is CSS, and is validated as CSS rather than as\na string that happens to look like one. It is interpolated into a custom property in both Nuxt apps, so a\nvalue that is not a colour becomes a stylesheet that silently\ndoes not apply, and one carrying `;` or `}` appends rules of its\nown to every page an operator's candidates load.\n\n`\\z`, NOT `$`, and that difference is the whole security of this\nrule. In PCRE `$` also matches immediately BEFORE a final\nnewline, so `/^#[0-9a-f]{6}$/` accepts `\"#123456\\n\"` \u2014 and the\nnewline is exactly what lets a payload open a second declaration\nonce it is interpolated into a stylesheet. `\\z` matches only at\nthe true end of the subject. Caught by the injection test, which\nfailed against the `$` version of this rule.\n\nThree-digit shorthand is deliberately refused so the apps never\nhave to expand it \u2014 one canonical form, and an operator pasting\n`#abc` is told so rather than getting a colour that quietly\ndiffers from the one they copied.\n\n`nullable` is the route back to the product palette; without it,\nchoosing a colour would be a one-way door.", "pattern": "^#[0-9a-fA-F]{6}$" }, + "background_color": { + "type": [ + "string", + "null" + ], + "pattern": "^#[0-9a-fA-F]{6}$" + }, "currency_code": { "type": "string", "pattern": "^[A-Z]{3}$" diff --git a/tests/Feature/OrganizationSettings/BrandingTest.php b/tests/Feature/OrganizationSettings/BrandingTest.php index c25221f9..9c37734f 100644 --- a/tests/Feature/OrganizationSettings/BrandingTest.php +++ b/tests/Feature/OrganizationSettings/BrandingTest.php @@ -68,7 +68,7 @@ function brandingUser(Organization $org, string $role): array // The KEY must be present even when null — a UI cannot distinguish "not // configured" from "this build of the API does not support branding" if the // field is simply missing. - $response->assertJsonStructure(['data' => ['primary_color', 'logo_url']]); + $response->assertJsonStructure(['data' => ['primary_color', 'background_color', 'logo_url']]); }); // ─── Writing ────────────────────────────────────────────────────────────────── @@ -324,6 +324,52 @@ function brandingUser(Organization $org, string $role): array expect($check?->def)->toContain('[0-9a-f]{6}'); }); +// ─── Background colour ──────────────────────────────────────────────────────── + +test('an admin can set and clear the background colour', function (): void { + $org = Organization::factory()->create(); + ['token' => $token] = brandingUser($org, 'admin'); + + $this->withToken($token)->patchJson('/api/organization', ['background_color' => '#F1F5F9']) + ->assertOk()->assertJsonPath('data.background_color', '#F1F5F9'); + expect($org->fresh()->background_color)->toBe('#F1F5F9'); + + $this->withToken($token)->patchJson('/api/organization', ['background_color' => null])->assertOk(); + expect($org->fresh()->background_color)->toBeNull(); +}); + +test('a non-admin cannot change the background colour', function (): void { + $org = Organization::factory()->create(); + ['token' => $token] = brandingUser($org, 'operator'); + + $this->withToken($token)->patchJson('/api/organization', ['background_color' => '#123456'])->assertForbidden(); + expect($org->fresh()->background_color)->toBeNull(); +}); + +test('a background colour that is not hex is refused with a code', function (): void { + $org = Organization::factory()->create(); + ['token' => $token] = brandingUser($org, 'admin'); + + foreach (['red', '#abc', '#12345g', 'red;}'] as $bad) { + $this->withToken($token) + ->patchJson('/api/organization', ['background_color' => $bad]) + ->assertUnprocessable() + ->assertJsonPath('errors.background_color.0', 'background_color_invalid'); + } + + expect($org->fresh()->background_color)->toBeNull(); +}); + +test('the DATABASE refuses a malformed background colour', function (): void { + $check = DB::selectOne( + "select pg_get_constraintdef(oid) as def + from pg_constraint + where conname = 'organizations_background_color_hex'" + ); + + expect($check?->def)->toContain('[0-9a-f]{6}'); +}); + // ─── Logo upload ────────────────────────────────────────────────────────────── function brandingImage(string $bytes, string $name): UploadedFile @@ -550,7 +596,7 @@ function brandingCandidateToken(Participant $participant): string // can paint. Storage::fake(); - $org = Organization::factory()->create(['primary_color' => '#7C3AED']); + $org = Organization::factory()->create(['primary_color' => '#7C3AED', 'background_color' => '#0F172A']); ['token' => $adminToken] = brandingUser($org, 'admin'); $this->withToken($adminToken)->post('/api/organization/logo', [ @@ -565,6 +611,7 @@ function brandingCandidateToken(Participant $participant): string $response->assertOk(); $response->assertJsonPath('data.branding.primary_color', '#7C3AED'); + $response->assertJsonPath('data.branding.background_color', '#0F172A'); expect($response->json('data.branding.logo_url'))->not->toBeNull(); }); @@ -596,7 +643,7 @@ function brandingCandidateToken(Participant $participant): string $response->assertOk(); expect(array_keys($response->json('data.branding'))) - ->toEqualCanonicalizing(['primary_color', 'logo_url', 'name']); + ->toEqualCanonicalizing(['primary_color', 'background_color', 'logo_url', 'name']); expect(json_encode($response->json()))->not->toContain('secret.example.test'); }); @@ -614,6 +661,7 @@ function brandingCandidateToken(Participant $participant): string $response->assertOk(); $response->assertJsonPath('data.branding.primary_color', null); + $response->assertJsonPath('data.branding.background_color', null); $response->assertJsonPath('data.branding.logo_url', null); }); diff --git a/tests/Feature/Superadmin/OrganizationManagementTest.php b/tests/Feature/Superadmin/OrganizationManagementTest.php index f8b7b293..2dad5522 100644 --- a/tests/Feature/Superadmin/OrganizationManagementTest.php +++ b/tests/Feature/Superadmin/OrganizationManagementTest.php @@ -75,8 +75,10 @@ $this->withToken($token)->patchJson("/api/admin/organizations/{$acme->id}", [ 'name' => 'Acme Renamed', 'primary_color' => '#112233', + 'background_color' => '#F1F5F9', 'slug' => 'must-be-ignored', - ])->assertOk()->assertJsonPath('data.name', 'Acme Renamed')->assertJsonPath('data.primary_color', '#112233'); + ])->assertOk()->assertJsonPath('data.name', 'Acme Renamed')->assertJsonPath('data.primary_color', '#112233') + ->assertJsonPath('data.background_color', '#F1F5F9'); $fresh = $acme->fresh(); expect($fresh->slug)->toBe($acme->slug) @@ -94,6 +96,16 @@ expect($response->json('errors.primary_color.0'))->toBe('primary_color_invalid'); }); +test('an invalid background colour is refused on update', function (): void { + $acme = Organization::factory()->create(); + ['token' => $token] = saSuperadmin(); + + $response = $this->withToken($token)->patchJson("/api/admin/organizations/{$acme->id}", ['background_color' => 'red;}']); + + $response->assertUnprocessable(); + expect($response->json('errors.background_color.0'))->toBe('background_color_invalid'); +}); + test('an unknown organization is a 404', function (): void { ['token' => $token] = saSuperadmin(); diff --git a/tests/Helpers/PublicApi/ExposureCatalogue.php b/tests/Helpers/PublicApi/ExposureCatalogue.php index c9199a24..c0873de1 100644 --- a/tests/Helpers/PublicApi/ExposureCatalogue.php +++ b/tests/Helpers/PublicApi/ExposureCatalogue.php @@ -39,6 +39,7 @@ public static function exclusions(): array 'Organization' => [ 'slug', 'primary_color', + 'background_color', 'currency_code', 'logo_url', 'default_webhook_url', From 942423ff9ce0fc91698e0766cd2e0b83ad32b69a Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Fri, 16 Oct 2026 12:40:00 +0100 Subject: [PATCH 5/6] fix(avatar-templates): refuse an explicit null range on import and pin the start values --- .../AvatarTemplatePortabilityController.php | 11 ++++++----- app/Support/AvatarTemplates/TemplateDocument.php | 10 ++++++---- .../Feature/AvatarTemplates/AnswerTimeRangeTest.php | 2 ++ .../Feature/Interview/ConversationPlanCreateTest.php | 12 ++++++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/AvatarTemplatePortabilityController.php b/app/Http/Controllers/AvatarTemplatePortabilityController.php index 3539c84b..afb484da 100644 --- a/app/Http/Controllers/AvatarTemplatePortabilityController.php +++ b/app/Http/Controllers/AvatarTemplatePortabilityController.php @@ -125,9 +125,10 @@ private function reject(array $record): ?string return "Unknown provider '{$record['provider']}'."; } - // The SAME bounds the form applies; a file without the range (an older export) keeps the defaults. - $min = $record['answer_min_seconds'] ?? 90; - $max = $record['answer_max_seconds'] ?? 150; + // The SAME bounds the form applies. A file without the range (an older export) was given the + // defaults by TemplateDocument::flatten(); an explicit null reaches here and is refused. + $min = $record['answer_min_seconds']; + $max = $record['answer_max_seconds']; if (! is_int($min) || ! is_int($max) || $min < 10 || $max > 600 || $min >= $max) { return 'answer_min_seconds / answer_max_seconds: integers from 10 to 600 seconds, the minimum below the maximum.'; @@ -203,8 +204,8 @@ private function create(array $record): array 'provider' => $record['provider'], 'config' => $record['config'], 'persona' => $record['persona'], - 'answer_min_seconds' => $record['answer_min_seconds'] ?? 90, - 'answer_max_seconds' => $record['answer_max_seconds'] ?? 150, + 'answer_min_seconds' => $record['answer_min_seconds'], + 'answer_max_seconds' => $record['answer_max_seconds'], // Imports arrive inactive (D8). Activation stays a deliberate act: // a file must never change which avatar an organization's live // interviews are running on. diff --git a/app/Support/AvatarTemplates/TemplateDocument.php b/app/Support/AvatarTemplates/TemplateDocument.php index 5df99533..332113b5 100644 --- a/app/Support/AvatarTemplates/TemplateDocument.php +++ b/app/Support/AvatarTemplates/TemplateDocument.php @@ -83,8 +83,10 @@ public static function flatten(array $entry): array 'provider' => is_string($entry['provider']) ? $entry['provider'] : '', 'config' => is_array($entry['config'] ?? null) ? $entry['config'] : [], 'persona' => $persona, - 'answer_min_seconds' => $entry['answer_min_seconds'] ?? null, - 'answer_max_seconds' => $entry['answer_max_seconds'] ?? null, + // An OMITTED key (an older export) takes the default; an explicit null is kept so the + // controller refuses it, as the form's non-nullable integer rule does. + 'answer_min_seconds' => array_key_exists('answer_min_seconds', $entry) ? $entry['answer_min_seconds'] : 90, + 'answer_max_seconds' => array_key_exists('answer_max_seconds', $entry) ? $entry['answer_max_seconds'] : 150, 'llm' => self::flattenLlm($entry['llm'] ?? null), ]]; } @@ -107,8 +109,8 @@ public static function flatten(array $entry): array 'provider' => $provider, 'config' => $config, 'persona' => $persona, - 'answer_min_seconds' => null, - 'answer_max_seconds' => null, + 'answer_min_seconds' => 90, + 'answer_max_seconds' => 150, 'llm' => null, ]; } diff --git a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php index 4bab4673..84ac0ff1 100644 --- a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php +++ b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php @@ -213,6 +213,8 @@ function atrSeed(string $surface, Organization $org, array $columns = []): Avata 'max over 600' => [['answer_max_seconds' => 601], false, 0, 0], 'min above the default max' => [['answer_min_seconds' => 200], false, 0, 0], 'max under the default min' => [['answer_max_seconds' => 60], false, 0, 0], + 'explicit null min' => [['answer_min_seconds' => null], false, 0, 0], + 'explicit null max' => [['answer_max_seconds' => null], false, 0, 0], 'only min, max defaults' => [['answer_min_seconds' => 30], true, 30, 150], 'only max, min defaults' => [['answer_max_seconds' => 300], true, 90, 300], 'bounds are inclusive' => [['answer_min_seconds' => 10, 'answer_max_seconds' => 600], true, 10, 600], diff --git a/tests/Feature/Interview/ConversationPlanCreateTest.php b/tests/Feature/Interview/ConversationPlanCreateTest.php index f1a13197..4004b916 100644 --- a/tests/Feature/Interview/ConversationPlanCreateTest.php +++ b/tests/Feature/Interview/ConversationPlanCreateTest.php @@ -138,11 +138,23 @@ function cpcRow(Organization $org, Participant $participant): InterviewSession $body = cpcCreateBody(); expect(array_keys($response->json()))->toBe(['session_id', 'provider', 'audio_only', 'provider_token', 'conversation_url', 'question_context', 'interview_max_seconds', 'answer_min_seconds', 'answer_max_seconds']) + ->and($response->json('answer_min_seconds'))->toBe(90) + ->and($response->json('answer_max_seconds'))->toBe(150) ->and(cpcRow($org, $participant)->conversation_plan)->toBeNull() ->and($body['properties'] ?? [])->not->toHaveKey('participant_left_timeout') ->and($body['conversational_context'])->not->toContain('=== TOPIC CODE:'); }); +test('a template with a custom answer range puts those values on the start response', function (): void { + cpcFake(); + [$org, $project, $participant] = cpcFixture(3); + AvatarTemplate::query()->whereKey($project->avatar_template_id)->update(['answer_min_seconds' => 60, 'answer_max_seconds' => 120]); + + cpcStart($participant)->assertStatus(201) + ->assertJsonPath('answer_min_seconds', 60) + ->assertJsonPath('answer_max_seconds', 120); +}); + test('a single remaining competency writes no plan and returns no conversation_id', function (): void { config(['interview.tavus.single_session' => true]); cpcFake(); From 3ed524a4c1775aa0f18143e576b9457a33a368a2 Mon Sep 17 00:00:00 2001 From: Micio Dev Date: Fri, 16 Oct 2026 12:52:34 +0100 Subject: [PATCH 6/6] fix(avatar-templates): refuse an import record that lacks the answer range keys --- .../AvatarTemplatePortabilityController.php | 5 +++-- .../Feature/AvatarTemplates/AnswerTimeRangeTest.php | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/AvatarTemplatePortabilityController.php b/app/Http/Controllers/AvatarTemplatePortabilityController.php index afb484da..23117175 100644 --- a/app/Http/Controllers/AvatarTemplatePortabilityController.php +++ b/app/Http/Controllers/AvatarTemplatePortabilityController.php @@ -127,8 +127,9 @@ private function reject(array $record): ?string // The SAME bounds the form applies. A file without the range (an older export) was given the // defaults by TemplateDocument::flatten(); an explicit null reaches here and is refused. - $min = $record['answer_min_seconds']; - $max = $record['answer_max_seconds']; + // A record from another producer may lack a key altogether: that is the same refusal. + $min = $record['answer_min_seconds'] ?? null; + $max = $record['answer_max_seconds'] ?? null; if (! is_int($min) || ! is_int($max) || $min < 10 || $max > 600 || $min >= $max) { return 'answer_min_seconds / answer_max_seconds: integers from 10 to 600 seconds, the minimum below the maximum.'; diff --git a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php index 84ac0ff1..c010436c 100644 --- a/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php +++ b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php @@ -9,6 +9,7 @@ */ use App\Actions\Interview\BuildInterviewSessionResponse; +use App\Http\Controllers\AvatarTemplatePortabilityController; use App\Models\AvatarTemplate; use App\Models\FrameworkVersion; use App\Models\InterviewSession; @@ -281,3 +282,14 @@ function atrSession(Organization $org, ?array $columns): InterviewSession 'default template' => [[], 90, 150], 'no template' => [null, 90, 150], ]); + +test('an import record lacking a range key is a validation message, not an undefined-key 500', function (string $missing): void { + $record = ['name' => 'T', 'provider' => 'heygen', 'config' => [], 'persona' => null, 'description' => null, + 'answer_min_seconds' => 90, 'answer_max_seconds' => 150]; + unset($record[$missing]); + + $reject = new ReflectionMethod(AvatarTemplatePortabilityController::class, 'reject'); + + expect($reject->invoke(app(AvatarTemplatePortabilityController::class), $record)) + ->toContain('answer_min_seconds / answer_max_seconds'); +})->with(['answer_min_seconds', 'answer_max_seconds']);