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/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/Controllers/Api/PlatformAvatarTemplateController.php b/app/Http/Controllers/Api/PlatformAvatarTemplateController.php index 942ff1e1..9aeafba8 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 @@ -226,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( @@ -258,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 b48d2226..6a8e5371 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 @@ -292,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/AvatarTemplatePortabilityController.php b/app/Http/Controllers/AvatarTemplatePortabilityController.php index 73595f98..23117175 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,16 @@ 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) was given the + // defaults by TemplateDocument::flatten(); an explicit null reaches here and is refused. + // 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.'; + } + // 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 +180,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 +205,8 @@ private function create(array $record): array 'provider' => $record['provider'], 'config' => $record['config'], 'persona' => $record['persona'], + '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/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..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; @@ -29,6 +30,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 +77,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 +99,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(), ]; } @@ -221,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/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/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/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/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/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/app/Support/AvatarTemplates/TemplateDocument.php b/app/Support/AvatarTemplates/TemplateDocument.php index 4b926923..332113b5 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,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, + // 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), ]]; } @@ -103,6 +109,8 @@ public static function flatten(array $entry): array 'provider' => $provider, 'config' => $config, 'persona' => $persona, + 'answer_min_seconds' => 90, + 'answer_max_seconds' => 150, '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/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 1debb286..a972aef1 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" ] } } @@ -7306,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" @@ -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": { @@ -14608,6 +14664,12 @@ "null" ] }, + "background_color": { + "type": [ + "string", + "null" + ] + }, "logo_url": { "type": [ "string", @@ -14618,6 +14680,7 @@ "required": [ "name", "primary_color", + "background_color", "logo_url" ] }, @@ -14825,6 +14888,12 @@ "required": [ "display_name" ] + }, + "answer_min_seconds": { + "type": "integer" + }, + "answer_max_seconds": { + "type": "integer" } }, "required": [ @@ -14843,7 +14912,9 @@ "llm_synced_at", "llm", "pal_sync", - "persona" + "persona", + "answer_min_seconds", + "answer_max_seconds" ], "title": "AvatarTemplateResource" }, @@ -15859,6 +15930,12 @@ "null" ] }, + "background_color": { + "type": [ + "string", + "null" + ] + }, "currency_code": { "type": "string" }, @@ -15904,6 +15981,7 @@ "name", "slug", "primary_color", + "background_color", "currency_code", "logo_url", "default_webhook_url", @@ -16294,6 +16372,12 @@ "null" ] }, + "background_color": { + "type": [ + "string", + "null" + ] + }, "logo_url": { "type": [ "string", @@ -16304,6 +16388,7 @@ "required": [ "name", "primary_color", + "background_color", "logo_url" ] }, @@ -16643,6 +16728,12 @@ "display_name" ] }, + "answer_min_seconds": { + "type": "integer" + }, + "answer_max_seconds": { + "type": "integer" + }, "usage": { "type": "object", "properties": { @@ -16676,6 +16767,8 @@ "llm", "pal_sync", "persona", + "answer_min_seconds", + "answer_max_seconds", "usage" ], "title": "PlatformAvatarTemplateResource" @@ -18748,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", @@ -18761,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}$" @@ -18813,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/AvatarTemplates/AnswerTimeRangeTest.php b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php new file mode 100644 index 00000000..c010436c --- /dev/null +++ b/tests/Feature/AvatarTemplates/AnswerTimeRangeTest.php @@ -0,0 +1,295 @@ +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('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(); + $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(); +}); + +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], + '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], +]); + +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 + */ +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], +]); + +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']); diff --git a/tests/Feature/Interview/ConversationPlanCreateTest.php b/tests/Feature/Interview/ConversationPlanCreateTest.php index 6ad07298..4004b916 100644 --- a/tests/Feature/Interview/ConversationPlanCreateTest.php +++ b/tests/Feature/Interview/ConversationPlanCreateTest.php @@ -137,12 +137,24 @@ 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($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(); 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, 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 2e417d41..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', @@ -82,6 +83,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',