Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/Actions/AvatarTemplates/DuplicateAvatarTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions app/Actions/Interview/BuildInterviewSessionResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/AdminOrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Controllers/Api/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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',
]));

Expand Down
6 changes: 4 additions & 2 deletions app/Http/Controllers/Api/PlatformAvatarTemplateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
}
Expand Down
4 changes: 3 additions & 1 deletion app/Http/Controllers/AvatarTemplateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
16 changes: 14 additions & 2 deletions app/Http/Controllers/AvatarTemplatePortabilityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function import(Request $request): JsonResponse
}

/**
* @param array{name: string, provider: string, config: array<string, mixed>, persona: array<string, mixed>|null, description: string|null} $record
* @param array{name: string, provider: string, config: array<string, mixed>, persona: array<string, mixed>|null, answer_min_seconds: mixed, answer_max_seconds: mixed, description: string|null} $record
*/
private function reject(array $record): ?string
{
Expand All @@ -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'])) {
Expand Down Expand Up @@ -170,7 +180,7 @@ private function reject(array $record): ?string
}

/**
* @param array{name: string, provider: string, config: array<string, mixed>, persona: array<string, mixed>|null, description: string|null, llm: array{model_key: string, credential_name: string}|null} $record
* @param array{name: string, provider: string, config: array<string, mixed>, persona: array<string, mixed>|null, answer_min_seconds: mixed, answer_max_seconds: mixed, description: string|null, llm: array{model_key: string, credential_name: string}|null} $record
* @return array<string, mixed>
*/
private function create(array $record): array
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Candidate/InterviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
60 changes: 60 additions & 0 deletions app/Http/Controllers/Concerns/ValidatesAvatarTemplateWrites.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, list<string>>
*/
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<string, mixed> $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<string, list<string|ValidationRule|Closure>>
*/
Expand All @@ -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(),
];
}
Expand All @@ -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(),
];
}
Expand Down Expand Up @@ -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([
Expand Down
5 changes: 4 additions & 1 deletion app/Http/Requests/UpdateManagedOrganizationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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)],
];
}
Expand All @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Requests/UpdateOrganizationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
];
}
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions app/Http/Resources/Admin/OrganizationResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|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<string>|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<string>|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<string>|null, has_default_webhook_secret: bool, created_at: string|null, updated_at: string|null}
*/
public function toArray(Request $request): array
{
Expand All @@ -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
Expand Down
Loading
Loading