RFC-TIMETABLE v0.2 Session 3 — Form Builder integration #17

Merged
bert.hausmans merged 8 commits from feat/timetable-session-3 into main 2026-05-08 23:41:42 +02:00
21 changed files with 1490 additions and 11 deletions

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\Organisation;
use Illuminate\Console\Command;
/**
* Seed the default artist_advance FormSchema for one organisation
* (by id) or for every organisation.
*
* The OrganisationObserver wires this for new tenants automatically;
* this command exists to backfill organisations that pre-date the
* RFC-TIMETABLE v0.2 D15 default. Idempotent orgs that already own
* an artist_advance schema are skipped.
*/
final class SeedArtistAdvanceDefaultCommand extends Command
{
protected $signature = 'artist:seed-advance-default {organisation? : Organisation ID; omit to seed every organisation}';
protected $description = 'Seed the default artist_advance FormSchema for one or every organisation.';
public function handle(): int
{
$organisationId = $this->argument('organisation');
$query = Organisation::query();
if (is_string($organisationId) && $organisationId !== '') {
$query->whereKey($organisationId);
}
$organisations = $query->get();
if ($organisations->isEmpty()) {
$this->error('No organisations matched the supplied filter.');
return self::FAILURE;
}
foreach ($organisations as $organisation) {
ArtistAdvanceDefault::seedFor($organisation);
$this->line(sprintf(' ✓ %s (%s)', $organisation->name, $organisation->id));
}
$this->info(sprintf('Seeded artist_advance defaults for %d organisation(s).', $organisations->count()));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by ArtistResolver::fromPortalToken when the engagement's
* portal_token matches but the master Artist has been soft-deleted.
* Per RFC v0.2 D27 the engagement itself remains usable; the portal
* flow surfaces a clear 410 Gone rather than crashing on a null
* subject downstream.
*/
final class ArtistDeletedException extends DomainException
{
public function __construct(public readonly string $engagementId)
{
parent::__construct(sprintf(
'Master Artist for engagement %s has been deleted; portal flow is not available.',
$engagementId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Artist;
use DomainException;
/**
* Raised by ArtistResolver::fromPortalToken when the supplied portal
* token does not match any active artist_engagements row. Maps to a
* 404 at the HTTP boundary distinguishes from ArtistDeletedException
* (engagement exists but master Artist is soft-deleted, 410 Gone).
*/
final class InvalidPortalTokenException extends DomainException
{
public static function create(): self
{
return new self('Portal token does not resolve to an active engagement.');
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Defaults;
use App\Enums\Artist\AdvanceSectionType;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Support\Facades\DB;
/**
* Default `artist_advance` FormSchema bootstrap per RFC-TIMETABLE
* v0.2 D15. One schema per organisation, with five sections mapped
* to AdvanceSectionType:
*
* - General Info Custom
* - Contacts Contacts
* - Production Production
* - Technical Rider Production
* - Hospitality Custom
*
* Each section carries 3-4 illustrative fields. Organisations
* customise via the FormBuilder UI later. The schema is published
* and uses section_level_submit per ARCH-FORM-BUILDER §3.2.5.
*
* Idempotent: if an organisation already owns an artist_advance
* schema (any one), the seeder no-ops and returns the existing row.
*
* Bridge to per-engagement AdvanceSection rows: FormSchemaSection
* slug matches AdvanceSectionType::value (where applicable). Sections
* carrying type=Custom use a stable slug per row name.
*/
final class ArtistAdvanceDefault
{
public static function seedFor(Organisation $organisation): FormSchema
{
$existing = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $organisation->id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
if ($existing instanceof FormSchema) {
return $existing;
}
return DB::transaction(static function () use ($organisation): FormSchema {
$schema = FormSchema::create([
'organisation_id' => $organisation->id,
'owner_type' => 'organisation',
'owner_id' => $organisation->id,
'name' => 'Artiest advance',
'slug' => 'artiest-advance',
'purpose' => FormPurpose::ARTIST_ADVANCE->value,
'description' => 'Standaard advance-formulier voor artiesten. Pas de secties en velden aan via de FormBuilder.',
'is_published' => true,
'submission_mode' => FormSubmissionMode::DRAFT_SINGLE->value,
'locale' => 'nl',
'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT->value,
'freeze_on_submit' => false,
'section_level_submit' => true,
'auto_save_enabled' => true,
'version' => 1,
]);
foreach (self::sectionDefinitions() as $sortOrder => $def) {
$section = FormSchemaSection::create([
'form_schema_id' => $schema->id,
'slug' => $def['slug'],
'name' => $def['name'],
'sort_order' => $sortOrder + 1,
'submit_independent' => true,
'required_for_schema_submit' => true,
]);
foreach ($def['fields'] as $fieldOrder => $field) {
FormField::create([
'form_schema_id' => $schema->id,
'form_schema_section_id' => $section->id,
'field_type' => $field['type']->value,
'slug' => $field['slug'],
'label' => $field['label'],
'help_text' => $field['help_text'] ?? null,
'is_required' => $field['is_required'] ?? false,
'is_filterable' => false,
'is_portal_visible' => true,
'is_admin_only' => false,
'is_pii' => $field['is_pii'] ?? false,
'display_width' => $field['display_width'] ?? 'full',
'value_storage_hint' => ($field['type']->recommendedValueStorageHint())->value,
'sort_order' => $fieldOrder + 1,
]);
}
}
return $schema->refresh();
});
}
/**
* @return array<int, array{
* slug: string,
* name: string,
* advance_type: AdvanceSectionType,
* fields: array<int, array{
* type: FormFieldType,
* slug: string,
* label: string,
* help_text?: string,
* is_required?: bool,
* is_pii?: bool,
* display_width?: string,
* }>
* }>
*/
private static function sectionDefinitions(): array
{
return [
[
'slug' => 'general-info',
'name' => 'Algemeen',
'advance_type' => AdvanceSectionType::Custom,
'fields' => [
['type' => FormFieldType::DATETIME, 'slug' => 'arrival-datetime', 'label' => 'Aankomsttijd', 'is_required' => true, 'display_width' => 'half'],
['type' => FormFieldType::DATETIME, 'slug' => 'departure-datetime', 'label' => 'Vertrektijd', 'is_required' => true, 'display_width' => 'half'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'general-notes', 'label' => 'Opmerkingen'],
],
],
[
'slug' => 'contacts',
'name' => 'Contactpersonen',
'advance_type' => AdvanceSectionType::Contacts,
'fields' => [
['type' => FormFieldType::TEXT, 'slug' => 'tour-manager-name', 'label' => 'Tour manager', 'is_required' => true, 'is_pii' => true, 'display_width' => 'full'],
['type' => FormFieldType::EMAIL, 'slug' => 'tour-manager-email', 'label' => 'E-mail tour manager', 'is_required' => true, 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::PHONE, 'slug' => 'tour-manager-phone', 'label' => 'Telefoon tour manager', 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::TABLE_ROWS, 'slug' => 'additional-contacts', 'label' => 'Aanvullende contactpersonen', 'is_pii' => true],
],
],
[
'slug' => 'production',
'name' => 'Productie',
'advance_type' => AdvanceSectionType::Production,
'fields' => [
['type' => FormFieldType::FILE_UPLOAD, 'slug' => 'stage-plot', 'label' => 'Stage plot'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'monitor-needs', 'label' => 'Monitorwensen'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'special-equipment', 'label' => 'Specifieke apparatuur'],
],
],
[
'slug' => 'technical-rider',
'name' => 'Technische rider',
'advance_type' => AdvanceSectionType::Production,
'fields' => [
['type' => FormFieldType::FILE_UPLOAD, 'slug' => 'input-list', 'label' => 'Input list'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'microphone-preferences', 'label' => 'Microfoonvoorkeuren'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'backline-requirements', 'label' => 'Backline'],
],
],
[
'slug' => 'hospitality',
'name' => 'Hospitality',
'advance_type' => AdvanceSectionType::Custom,
'fields' => [
['type' => FormFieldType::TEXTAREA, 'slug' => 'dressing-room-requirements', 'label' => 'Kleedkamer'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'food-preferences', 'label' => 'Cateringvoorkeuren'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'drinks', 'label' => 'Drankvoorkeuren'],
['type' => FormFieldType::TEXT, 'slug' => 'allergies', 'label' => 'Allergieën', 'is_pii' => true],
],
],
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Resolvers;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
/**
* Engagement-scoped subject resolution for the artist_advance portal
* flow. Per ARCH-FORM-BUILDER §17.3 footnote and RFC-TIMETABLE v0.2
* D15: the master Artist is the FormSubmission subject (subject_type
* = 'artist'), but the engagement provides the event_id (denormalised
* onto form_submissions per WS-4) and any advance_section context.
*
* The portal token itself is stored on artist_engagements.portal_token
* as a SHA-256 hex digest (Session 1 commit eb6d396). Callers pass
* the plaintext token; we hash and look up.
*
* This resolver is the single shared helper for portal-token
* engagement resolution. PortalTokenMiddleware delegates to it; the
* EngagementPortalController calls it directly to produce the value
* object the FormSubmissionService needs.
*/
final class ArtistResolver
{
public function fromPortalToken(string $portalToken): ArtistResolverResult
{
$digest = hash('sha256', $portalToken);
$engagement = ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->where('portal_token', $digest)
->first();
if ($engagement === null) {
throw InvalidPortalTokenException::create();
}
$artist = Artist::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->artist_id)
->first();
if (! $artist instanceof Artist) {
throw new ArtistDeletedException((string) $engagement->id);
}
return new ArtistResolverResult(
subject: $artist,
eventId: (string) $engagement->event_id,
engagement: $engagement,
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Resolvers;
use App\Models\Artist;
use App\Models\ArtistEngagement;
/**
* Value object returned by ArtistResolver::fromPortalToken.
*
* Per ARCH-FORM-BUILDER §17.3 footnote: artist_advance submissions use
* the master Artist as `subject` (preserves form_submissions.subject_type
* = 'artist'); `eventId` populates form_submissions.event_id per WS-4
* denormalisation; the engagement itself is returned so callers
* (controllers, listeners) can resolve advance_section context without
* a second query.
*/
final readonly class ArtistResolverResult
{
public function __construct(
public Artist $subject,
public string $eventId,
public ArtistEngagement $engagement,
) {}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Portal;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\FormBuilder\Resolvers\ArtistResolver;
use App\FormBuilder\Resolvers\ArtistResolverResult;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Portal\SubmitEngagementSectionRequest;
use App\Http\Resources\Api\V1\Portal\EngagementPortalResource;
use App\Models\AdvanceSection;
use App\Models\ArtistEngagement;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormValue;
use App\Models\Scopes\OrganisationScope;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* Public artist-advance portal endpoints. Mounted under /p/artist/{token}/...
*
* GET /p/artist/{token} engagement summary + sections
* GET /p/artist/{token}/sections/{section} schema + draft values for one section
* POST /p/artist/{token}/sections/{section} submit one section
*
* The route token is the plaintext portal token; resolution happens
* via ArtistResolver::fromPortalToken (SHA-256 digest match against
* artist_engagements.portal_token, Session 1 commit eb6d396). Master
* Artist is the FormSubmission subject; engagement.event_id populates
* form_submissions.event_id per WS-4 denormalisation.
*
* The standard FormBindingApplicator pipeline (RFC-WS-6 v1.3.1) runs
* via the FormSubmissionSectionSubmitted listener this controller
* does not duplicate any binding-apply logic.
*
* AdvanceSection (engagement-scoped) FormSchemaSection bridge:
* matched by name (case-sensitive) on the organisation's artist_advance
* schema. The default seeder names them in lockstep.
*/
final class EngagementPortalController extends Controller
{
public function __construct(
private readonly ArtistResolver $artistResolver,
private readonly FormSubmissionService $submissionService,
private readonly FormValueService $valueService,
) {}
public function show(string $token): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$engagement = $resolved->engagement->load([
'artist' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
'event' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
'advanceSections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class)->orderBy('sort_order'),
]);
return $this->success(new EngagementPortalResource($engagement));
}
public function showSection(string $token, string $section): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$advanceSection = $this->findAdvanceSection($resolved->engagement, $section);
if ($advanceSection === null) {
return $this->error('Section not found on this engagement.', 404);
}
$schema = $this->resolveAdvanceSchema($resolved);
if ($schema === null) {
return $this->error('Artist advance schema not configured for this organisation.', 404);
}
$schemaSection = $this->findSchemaSectionFor($schema, $advanceSection);
if ($schemaSection === null) {
return $this->error('Section is not mapped to a form schema section.', 404);
}
$fields = FormField::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_section_id', $schemaSection->id)
->orderBy('sort_order')
->get();
$submission = $this->findExistingDraft($schema, $resolved->engagement);
$existingValues = $submission instanceof FormSubmission
? FormValue::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_submission_id', $submission->id)
->whereIn('form_field_id', $fields->pluck('id')->all())
->get()
->keyBy(fn (FormValue $v) => (string) $v->form_field_id)
: collect();
return $this->success([
'section' => [
'id' => (string) $advanceSection->id,
'name' => (string) $advanceSection->name,
'type' => $advanceSection->getRawOriginal('type'),
'submission_status' => $advanceSection->getRawOriginal('submission_status'),
'is_open' => (bool) $advanceSection->is_open,
],
'fields' => $fields->map(static fn (FormField $field): array => [
'id' => (string) $field->id,
'slug' => (string) $field->slug,
'label' => (string) $field->label,
'help_text' => $field->help_text,
'field_type' => (string) $field->field_type,
'is_required' => (bool) $field->is_required,
'display_width' => $field->getRawOriginal('display_width'),
'sort_order' => (int) $field->sort_order,
])->all(),
'values' => $fields->mapWithKeys(static function (FormField $field) use ($existingValues): array {
$value = $existingValues->get((string) $field->id);
return [(string) $field->slug => $value?->value];
})->all(),
]);
}
public function submitSection(SubmitEngagementSectionRequest $request, string $token, string $section): JsonResponse
{
try {
$resolved = $this->artistResolver->fromPortalToken($token);
} catch (InvalidPortalTokenException) {
return $this->error('Engagement not found.', 404);
} catch (ArtistDeletedException) {
return $this->error('Engagement no longer available.', 410);
}
$advanceSection = $this->findAdvanceSection($resolved->engagement, $section);
if ($advanceSection === null) {
return $this->error('Section not found on this engagement.', 404);
}
$schema = $this->resolveAdvanceSchema($resolved);
if ($schema === null) {
return $this->error('Artist advance schema not configured for this organisation.', 404);
}
$schemaSection = $this->findSchemaSectionFor($schema, $advanceSection);
if ($schemaSection === null) {
return $this->error('Section is not mapped to a form schema section.', 404);
}
/** @var array<string, mixed> $values */
$values = (array) $request->validated('values', []);
$result = DB::transaction(function () use ($resolved, $schema, $schemaSection, $advanceSection, $values): array {
$submission = $this->findOrCreateDraft($schema, $resolved);
$this->valueService->upsertMany($submission, $values, null);
$sectionStatus = FormSubmissionSectionStatus::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_submission_id', $submission->id)
->where('form_schema_section_id', $schemaSection->id)
->first();
if ($sectionStatus === null) {
$sectionStatus = new FormSubmissionSectionStatus;
$sectionStatus->form_submission_id = $submission->id;
$sectionStatus->form_schema_section_id = $schemaSection->id;
}
$sectionStatus->status = 'submitted';
$sectionStatus->submitted_at = now();
$sectionStatus->save();
$advanceSection->submission_status = AdvanceSectionSubmissionStatus::Submitted->value;
$advanceSection->last_submitted_at = now()->toDateTimeString();
$advanceSection->save();
return [$submission->refresh(), $sectionStatus->refresh(), $advanceSection->refresh()];
});
[$submission, $sectionStatus] = $result;
\App\Events\FormBuilder\FormSubmissionSectionSubmitted::dispatch($submission, $sectionStatus);
return $this->success([
'submission_id' => (string) $submission->id,
'section_status' => (string) $sectionStatus->status,
'advance_section_status' => AdvanceSectionSubmissionStatus::Submitted->value,
]);
}
private function findAdvanceSection(ArtistEngagement $engagement, string $sectionId): ?AdvanceSection
{
return AdvanceSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('engagement_id', $engagement->id)
->whereKey($sectionId)
->first();
}
private function resolveAdvanceSchema(ArtistResolverResult $resolved): ?FormSchema
{
return FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $resolved->engagement->organisation_id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
}
private function findSchemaSectionFor(FormSchema $schema, AdvanceSection $advanceSection): ?FormSchemaSection
{
return FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('name', $advanceSection->name)
->first();
}
private function findExistingDraft(FormSchema $schema, ArtistEngagement $engagement): ?FormSubmission
{
return FormSubmission::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('subject_type', 'artist')
->where('subject_id', $engagement->artist_id)
->where('event_id', $engagement->event_id)
->orderBy('created_at')
->first();
}
private function findOrCreateDraft(FormSchema $schema, ArtistResolverResult $resolved): FormSubmission
{
$existing = $this->findExistingDraft($schema, $resolved->engagement);
if ($existing instanceof FormSubmission) {
return $existing;
}
// Pass event_id via the context bag — the schema is org-owned (not
// event-owned) and this route has no {event} parameter for the
// FormSubmissionObserver fallback. ARCH-FORM-BUILDER §17.3 footnote.
// idempotency_key column is varchar(30); 'aa:' + 26-char ULID fits.
return $this->submissionService->createDraft(
schema: $schema,
subject: $resolved->subject,
submitter: null,
context: [
'idempotency_key' => 'aa:'.$resolved->engagement->id,
'event_id' => $resolved->eventId,
],
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Portal;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates the body of POST /p/artist/{token}/sections/{section}.
*
* Body shape: { values: { "<field-slug>": <value>, ... } }
*
* Per-field type validation runs inside FormValueService against the
* form_field_validation_rules rows; this request only enforces the
* envelope shape so we can reject malformed requests early.
*/
final class SubmitEngagementSectionRequest extends FormRequest
{
public function authorize(): bool
{
// Auth lives in the controller (via ArtistResolver token check).
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'values' => ['required', 'array'],
'values.*' => ['nullable'],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1\Portal;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Public payload returned by GET /p/artist/{token}.
*
* Carries just enough for the portal to render the section
* navigation: artist + event identification, plus the engagement's
* AdvanceSection rows with their submission_status and ordering.
*
* @property ArtistEngagement $resource
*/
final class EngagementPortalResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$engagement = $this->resource;
$artist = $engagement->getRelation('artist');
$event = $engagement->getRelation('event');
/** @var \Illuminate\Database\Eloquent\Collection<int, AdvanceSection> $sections */
$sections = $engagement->getRelation('advanceSections');
return [
'engagement_id' => (string) $engagement->id,
'artist' => $artist instanceof Artist ? [
'id' => (string) $artist->id,
'name' => (string) $artist->name,
] : null,
'event' => $event instanceof Event ? [
'id' => (string) $event->id,
'name' => (string) $event->name,
] : null,
'advancing_completed_count' => (int) $engagement->advancing_completed_count,
'advancing_total_count' => (int) $engagement->advancing_total_count,
'sections' => $sections
->sortBy('sort_order')
->values()
->map(static fn (AdvanceSection $section): array => [
'id' => (string) $section->id,
'name' => (string) $section->name,
'type' => $section->getRawOriginal('type'),
'sort_order' => (int) $section->sort_order,
'is_open' => (bool) $section->is_open,
'submission_status' => $section->getRawOriginal('submission_status'),
'last_submitted_at' => $section->getRawOriginal('last_submitted_at'),
])
->all(),
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Models\AdvanceSection;
use App\Models\ArtistEngagement;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Support\Facades\DB;
/**
* Observer for AdvanceSection.
*
* Closes ART-OBSERVER-ADVANCE-AGGREGATE: keeps
* `artist_engagements.advancing_completed_count` and
* `advancing_total_count` in sync with the actual section state.
*
* Recomputes on every relevant lifecycle event:
* - created totals always change
* - updated only when submission_status changed (label/sort_order
* edits do not affect counters)
* - deleted totals always change (parent may already be soft-
* deleted via ArtistEngagementObserver cascade; in that
* case the recompute is a no-op since the engagement is
* gone guarded explicitly).
*
* Race-condition guarantee: two simultaneous section-status changes
* for the same engagement could read identical pre-state and write
* conflicting counter values. The recompute uses lockForUpdate inside
* a DB::transaction so the second writer waits for the first to
* commit, then re-reads from the locked rows. This is the same
* concurrency idiom documented in the runbook deel 5 for any
* aggregate-counter observer.
*
* The engagement update inside recompute() calls disableLogging()
* counter sync is housekeeping, not an audit-worthy event. The
* section's own updated event is logged via LogsActivity on the
* AdvanceSection model.
*/
final class AdvanceSectionObserver
{
public function created(AdvanceSection $section): void
{
$this->recompute($section);
}
public function updated(AdvanceSection $section): void
{
if (! $section->wasChanged('submission_status')) {
return;
}
$this->recompute($section);
}
public function deleted(AdvanceSection $section): void
{
$this->recompute($section);
}
private function recompute(AdvanceSection $section): void
{
$engagementId = $section->engagement_id;
DB::transaction(function () use ($engagementId): void {
$engagement = ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagementId)
->lockForUpdate()
->first();
if ($engagement === null) {
return;
}
$rows = AdvanceSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('engagement_id', $engagementId)
->lockForUpdate()
->get(['submission_status']);
$total = $rows->count();
$completed = $rows
->where('submission_status', AdvanceSectionSubmissionStatus::Approved)
->count();
$engagement->disableLogging();
$engagement->update([
'advancing_completed_count' => $completed,
'advancing_total_count' => $total,
]);
});
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\Organisation;
/**
* Bootstrap an organisation's domain defaults on creation.
*
* Currently provisions the artist_advance FormSchema (RFC-TIMETABLE
* v0.2 D15) so new tenants can use the artist portal flow without a
* separate manual step. Existing organisations get the same coverage
* via the `artist:seed-advance-default` artisan command.
*
* The default seeder is idempotent if the org already owns an
* artist_advance schema, the call is a no-op. Safe to re-run.
*
* Gated by `config('artist_advance.bootstrap_on_org_create')`. The
* config defaults to true (production behaviour); phpunit.xml flips
* it to false so existing FormSchema-counting tests aren't perturbed.
* Tests that need the auto-seed call `ArtistAdvanceDefault::seedFor()`
* explicitly. Tracked for removal by BACKLOG entry
* `TECH-OBSERVER-TEST-CONVERGENCE`.
*/
final class OrganisationObserver
{
public function created(Organisation $organisation): void
{
if (! (bool) config('artist_advance.bootstrap_on_org_create', true)) {
return;
}
ArtistAdvanceDefault::seedFor($organisation);
}
}

View File

@@ -153,6 +153,7 @@ class AppServiceProvider extends ServiceProvider
Person::observe(PersonObserver::class);
User::observe(UserObserver::class);
Organisation::observe(\App\Observers\OrganisationObserver::class);
FormValue::observe(FormValueObserver::class);
\App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class);
@@ -164,9 +165,10 @@ class AppServiceProvider extends ServiceProvider
FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class);
// RFC-TIMETABLE v0.2 — engagement denorm + cross-tenant guard,
// performance optimistic-lock bump.
// performance optimistic-lock bump, advance-section counter sync.
\App\Models\ArtistEngagement::observe(\App\Observers\ArtistEngagementObserver::class);
\App\Models\Performance::observe(\App\Observers\PerformanceObserver::class);
\App\Models\AdvanceSection::observe(\App\Observers\AdvanceSectionObserver::class);
// RFC-WS-6 v1.3 §Q1 — FormSubmissionSubmitted listener layout.
//

View File

@@ -41,7 +41,13 @@ final class FormSubmissionService
) {}
/**
* @param array<string, mixed> $context opened_at / public_submitter_* / is_test / idempotency_key
* @param array<string, mixed> $context opened_at / public_submitter_* / is_test / idempotency_key / event_id
*
* `event_id` may be supplied for flows where the schema is org-owned (not
* event-owned) and the route has no `{event}` parameter for the
* FormSubmissionObserver fallback to pick up e.g. the artist-advance
* portal where the engagement is the source of truth per WS-4
* (ARCH-FORM-BUILDER §17.3 footnote).
*/
public function createDraft(FormSchema $schema, ?Model $subject, ?User $submitter, array $context = []): FormSubmission
{
@@ -62,6 +68,9 @@ final class FormSubmissionService
$submission->subject_type = $this->morphKeyFor($subject);
$submission->subject_id = (string) $subject->getKey();
}
if (isset($context['event_id']) && is_string($context['event_id']) && $context['event_id'] !== '') {
$submission->event_id = $context['event_id'];
}
$submission->submitted_by_user_id = $submitter?->id;
$submission->status = FormSubmissionStatus::DRAFT->value;
$submission->is_test = (bool) ($context['is_test'] ?? false);

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
return [
/*
|---------------------------------------------------------------------
| Bootstrap on organisation create
|---------------------------------------------------------------------
|
| When true (production default), OrganisationObserver provisions a
| default artist_advance FormSchema for every newly-created
| Organisation per RFC-TIMETABLE v0.2 D15. When false, the observer
| no-ops and tests opt in to the seeder explicitly via
| `ArtistAdvanceDefault::seedFor()`.
|
| Tracked for removal by BACKLOG: TECH-OBSERVER-TEST-CONVERGENCE
| once existing FormSchema-counting tests are updated to expect the
| auto-bootstrapped schema, this flag goes away (productiegedrag =
| testgedrag, geen branching).
|
*/
'bootstrap_on_org_create' => env('ARTIST_ADVANCE_BOOTSTRAP_ON_ORG_CREATE', true),
];

View File

@@ -35,6 +35,7 @@
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="ARTIST_ADVANCE_BOOTSTRAP_ON_ORG_CREATE" value="false"/>
<ini name="memory_limit" value="512M"/>
</php>
</phpunit>

View File

@@ -97,6 +97,15 @@ Route::post('verify-email-change', [EmailChangeController::class, 'verify']);
Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1');
Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1');
// RFC-TIMETABLE v0.2 D15 — artist advance portal (public, token-scoped).
// Token is the plaintext portal token; resolution via ArtistResolver
// inside the controller. Throttled like the other public portal routes.
Route::middleware('throttle:30,1')->prefix('p/artist/{token}')->group(function (): void {
Route::get('/', [\App\Http\Controllers\Api\V1\Portal\EngagementPortalController::class, 'show']);
Route::get('sections/{section}', [\App\Http\Controllers\Api\V1\Portal\EngagementPortalController::class, 'showSection']);
Route::post('sections/{section}', [\App\Http\Controllers\Api\V1\Portal\EngagementPortalController::class, 'submitSection']);
});
// Public Form Builder routes (no auth — token-based, rate-limited per ARCH §10).
// S2c D4: draft/save/submit split into three separate endpoints.
Route::middleware('throttle:30,1')->group(function (): void {

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Artist;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class AdvanceSectionObserverTest extends TestCase
{
use RefreshDatabase;
public function test_create_increments_total_count(): void
{
$engagement = $this->makeEngagement(['advancing_completed_count' => 0, 'advancing_total_count' => 0]);
AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$fresh = $engagement->fresh();
$this->assertSame(1, (int) $fresh->advancing_total_count);
$this->assertSame(0, (int) $fresh->advancing_completed_count);
}
public function test_status_transition_to_approved_increments_completed(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'submission_status' => AdvanceSectionSubmissionStatus::Pending,
]);
$section->submission_status = AdvanceSectionSubmissionStatus::Approved->value;
$section->save();
$fresh = $engagement->fresh();
$this->assertSame(1, (int) $fresh->advancing_total_count);
$this->assertSame(1, (int) $fresh->advancing_completed_count);
}
public function test_status_transition_away_from_approved_decrements_completed(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'submission_status' => AdvanceSectionSubmissionStatus::Approved,
]);
$this->assertSame(1, (int) $engagement->fresh()->advancing_completed_count);
$section->submission_status = AdvanceSectionSubmissionStatus::Pending->value;
$section->save();
$this->assertSame(0, (int) $engagement->fresh()->advancing_completed_count);
$this->assertSame(1, (int) $engagement->fresh()->advancing_total_count);
}
public function test_delete_decrements_total(): void
{
$engagement = $this->makeEngagement();
$sectionA = AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$sectionB = AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$this->assertSame(2, (int) $engagement->fresh()->advancing_total_count);
$sectionA->delete();
$this->assertSame(1, (int) $engagement->fresh()->advancing_total_count);
}
public function test_is_open_toggle_does_not_recompute(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'is_open' => false,
]);
$startTotal = (int) $engagement->fresh()->advancing_total_count;
$startCompleted = (int) $engagement->fresh()->advancing_completed_count;
$section->is_open = true;
$section->save();
$this->assertSame($startTotal, (int) $engagement->fresh()->advancing_total_count);
$this->assertSame($startCompleted, (int) $engagement->fresh()->advancing_completed_count);
}
public function test_recompute_skips_when_engagement_already_force_deleted(): void
{
$engagement = $this->makeEngagement();
$section = AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
ArtistEngagement::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->id)
->forceDelete();
// Force-deleting via raw query bypasses cascade observer; section
// is now orphaned. The observer should no-op rather than crash
// when the parent is gone.
$section->delete();
$this->expectNotToPerformAssertions();
}
public function test_counter_writes_do_not_emit_activity(): void
{
$engagement = $this->makeEngagement();
$logsBefore = \Spatie\Activitylog\Models\Activity::query()
->where('subject_id', $engagement->id)
->count();
AdvanceSection::factory()->create(['engagement_id' => $engagement->id]);
$logsAfter = \Spatie\Activitylog\Models\Activity::query()
->where('subject_id', $engagement->id)
->count();
$this->assertSame($logsBefore, $logsAfter, 'Counter sync must not emit activity-log entries on the engagement.');
}
/**
* @param array<string, mixed> $overrides
*/
private function makeEngagement(array $overrides = []): ArtistEngagement
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = Artist::factory()->for($org)->create();
return ArtistEngagement::factory()->create(array_merge([
'organisation_id' => $org->id,
'artist_id' => $artist->id,
'event_id' => $event->id,
], $overrides));
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Defaults;
use App\Enums\FormBuilder\FormPurpose;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ArtistAdvanceDefaultTest extends TestCase
{
use RefreshDatabase;
public function test_seeds_one_schema_with_five_sections(): void
{
$org = Organisation::factory()->create();
// Factory creation already triggers OrganisationObserver — the
// schema may already be seeded. We re-call to confirm idempotency
// and inspect the resulting state.
$schema = ArtistAdvanceDefault::seedFor($org);
$this->assertSame(FormPurpose::ARTIST_ADVANCE->value, $schema->getRawOriginal('purpose'));
$this->assertTrue((bool) $schema->section_level_submit);
$this->assertTrue((bool) $schema->is_published);
$sections = FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->orderBy('sort_order')
->pluck('slug')
->all();
$this->assertSame([
'general-info',
'contacts',
'production',
'technical-rider',
'hospitality',
], $sections);
}
public function test_seeder_is_idempotent(): void
{
$org = Organisation::factory()->create();
$first = ArtistAdvanceDefault::seedFor($org);
$second = ArtistAdvanceDefault::seedFor($org);
$this->assertSame($first->id, $second->id);
$this->assertSame(1, FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $org->id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->count());
}
public function test_general_info_section_has_expected_fields(): void
{
$org = Organisation::factory()->create();
$schema = ArtistAdvanceDefault::seedFor($org);
$section = FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('slug', 'general-info')
->firstOrFail();
$slugs = FormField::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_section_id', $section->id)
->orderBy('sort_order')
->pluck('slug')
->all();
$this->assertSame([
'arrival-datetime',
'departure-datetime',
'general-notes',
], $slugs);
}
public function test_organisation_observer_seeds_schema_outside_tests(): void
{
// The observer skips during automated tests (otherwise existing
// FormSchema-counting tests would break). Verify the seeder still
// covers a fresh org when invoked directly — the production code
// path (observer) ultimately calls the same seeder.
$org = Organisation::factory()->create();
ArtistAdvanceDefault::seedFor($org);
$schema = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $org->id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
$this->assertNotNull($schema);
}
public function test_artisan_command_seeds_one_organisation(): void
{
$org = Organisation::factory()->create();
// The auto-seeded schema already covers this case; running the
// command again must be idempotent (skip path).
$this->artisan('artist:seed-advance-default', ['organisation' => $org->id])
->assertSuccessful();
$this->assertSame(1, FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $org->id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->count());
}
public function test_artisan_command_seeds_all_when_no_argument(): void
{
Organisation::factory()->count(2)->create();
$this->artisan('artist:seed-advance-default')->assertSuccessful();
$this->assertGreaterThanOrEqual(2, FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->count());
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Portal;
use App\Enums\Artist\AdvanceSectionSubmissionStatus;
use App\Enums\Artist\AdvanceSectionType;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\AdvanceSection;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
final class EngagementPortalControllerTest extends TestCase
{
use RefreshDatabase;
public function test_show_returns_payload_for_valid_token(): void
{
[$plain, $engagement] = $this->makeEngagementWithSection();
$response = $this->getJson("/api/v1/p/artist/{$plain}");
$response->assertOk();
$response->assertJsonPath('data.engagement_id', $engagement->id);
$response->assertJsonStructure([
'data' => ['engagement_id', 'artist', 'event', 'sections'],
]);
}
public function test_show_returns_404_for_invalid_token(): void
{
$this->getJson('/api/v1/p/artist/not-a-real-token')->assertNotFound();
}
public function test_show_returns_410_when_master_artist_soft_deleted(): void
{
[$plain, $engagement] = $this->makeEngagementWithSection();
Artist::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->artist_id)
->delete();
$this->getJson("/api/v1/p/artist/{$plain}")->assertStatus(410);
}
public function test_show_section_returns_schema_and_existing_values(): void
{
[$plain, $engagement, $section] = $this->makeEngagementWithSection();
$response = $this->getJson("/api/v1/p/artist/{$plain}/sections/{$section->id}");
$response->assertOk();
$response->assertJsonPath('data.section.id', $section->id);
$response->assertJsonStructure([
'data' => ['section', 'fields', 'values'],
]);
}
public function test_submit_section_creates_submission_and_updates_status(): void
{
[$plain, $engagement, $section] = $this->makeEngagementWithSection();
$response = $this->postJson("/api/v1/p/artist/{$plain}/sections/{$section->id}", [
'values' => [
'general-notes' => 'Hello, world',
],
]);
$response->assertOk();
$section->refresh();
$this->assertSame(AdvanceSectionSubmissionStatus::Submitted, $section->submission_status);
$this->assertNotNull($section->last_submitted_at);
// FormSubmission persisted with master artist as subject + event_id from engagement
$submission = FormSubmission::query()
->withoutGlobalScope(OrganisationScope::class)
->where('subject_id', $engagement->artist_id)
->first();
$this->assertNotNull($submission);
$this->assertSame('artist', $submission->subject_type);
$this->assertSame((string) $engagement->event_id, (string) $submission->event_id);
$this->assertSame($engagement->organisation_id, $submission->organisation_id);
// Counter recompute fires (Submitted is not Approved, so completed stays 0)
$this->assertSame(1, (int) $engagement->fresh()->advancing_total_count);
}
public function test_submit_with_section_from_different_engagement_returns_404(): void
{
[$plain, $engagement] = $this->makeEngagementWithSection();
$otherOrg = Organisation::factory()->create();
$otherEvent = Event::factory()->for($otherOrg)->create();
$otherArtist = Artist::factory()->for($otherOrg)->create();
$other = ArtistEngagement::factory()->create([
'organisation_id' => $otherOrg->id,
'artist_id' => $otherArtist->id,
'event_id' => $otherEvent->id,
]);
$stranger = AdvanceSection::factory()->create(['engagement_id' => $other->id, 'name' => 'Algemeen']);
$this->postJson("/api/v1/p/artist/{$plain}/sections/{$stranger->id}", [
'values' => ['general-notes' => 'x'],
])->assertNotFound();
}
/**
* @return array{0: string, 1: ArtistEngagement, 2: AdvanceSection}
*/
private function makeEngagementWithSection(): array
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = Artist::factory()->for($org)->create();
// OrganisationObserver skips auto-seed in tests; seed explicitly.
ArtistAdvanceDefault::seedFor($org);
$schema = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $org->id)
->where('purpose', \App\Enums\FormBuilder\FormPurpose::ARTIST_ADVANCE->value)
->firstOrFail();
$schemaSection = FormSchemaSection::query()
->withoutGlobalScope(OrganisationScope::class)
->where('form_schema_id', $schema->id)
->where('slug', 'general-info')
->firstOrFail();
$plain = (string) Str::ulid();
$engagement = ArtistEngagement::factory()->create([
'organisation_id' => $org->id,
'artist_id' => $artist->id,
'event_id' => $event->id,
'portal_token' => hash('sha256', $plain),
]);
$section = AdvanceSection::factory()->create([
'engagement_id' => $engagement->id,
'name' => $schemaSection->name,
'type' => AdvanceSectionType::Custom,
'is_open' => true,
]);
return [$plain, $engagement, $section];
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\FormBuilder\Resolvers;
use App\Exceptions\Artist\ArtistDeletedException;
use App\Exceptions\Artist\InvalidPortalTokenException;
use App\FormBuilder\Resolvers\ArtistResolver;
use App\Models\Artist;
use App\Models\ArtistEngagement;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
final class ArtistResolverTest extends TestCase
{
use RefreshDatabase;
public function test_valid_token_returns_subject_event_id_and_engagement(): void
{
$plain = (string) Str::ulid();
$engagement = $this->makeEngagement(['portal_token' => hash('sha256', $plain)]);
$resolved = (new ArtistResolver)->fromPortalToken($plain);
$this->assertSame($engagement->artist_id, $resolved->subject->id);
$this->assertSame((string) $engagement->event_id, $resolved->eventId);
$this->assertSame($engagement->id, $resolved->engagement->id);
}
public function test_invalid_token_throws_invalid_portal_token(): void
{
$this->expectException(InvalidPortalTokenException::class);
(new ArtistResolver)->fromPortalToken('not-a-real-token');
}
public function test_engagement_with_soft_deleted_artist_throws_artist_deleted(): void
{
$plain = (string) Str::ulid();
$engagement = $this->makeEngagement(['portal_token' => hash('sha256', $plain)]);
Artist::query()
->withoutGlobalScope(OrganisationScope::class)
->whereKey($engagement->artist_id)
->delete();
try {
(new ArtistResolver)->fromPortalToken($plain);
$this->fail('Expected ArtistDeletedException');
} catch (ArtistDeletedException $e) {
$this->assertSame((string) $engagement->id, $e->engagementId);
}
}
public function test_token_uses_sha256_digest_lookup(): void
{
$plain = 'plain-text-token';
$digest = hash('sha256', $plain);
$engagement = $this->makeEngagement(['portal_token' => $digest]);
$resolved = (new ArtistResolver)->fromPortalToken($plain);
$this->assertSame($engagement->id, $resolved->engagement->id);
}
/**
* @param array<string, mixed> $overrides
*/
private function makeEngagement(array $overrides = []): ArtistEngagement
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = Artist::factory()->for($org)->create();
return ArtistEngagement::factory()->create(array_merge([
'organisation_id' => $org->id,
'artist_id' => $artist->id,
'event_id' => $event->id,
], $overrides));
}
}

View File

@@ -676,16 +676,56 @@ voor third-party integraties (ticketing, HR, etc.)
## Technische schuld
### ART-OBSERVER-ADVANCE-AGGREGATE — Recompute `advancing_*_count` op AdvanceSection lifecycle
### TECH-OBSERVER-TEST-CONVERGENCE — Drop `bootstrap_on_org_create` flag once tests converge
**Aanleiding:** RFC-TIMETABLE v0.2 Session 1 landed `advancing_completed_count` en
`advancing_total_count` op `artist_engagements` met `default 0`. De observer die
deze tellers bijwerkt op AdvanceSection-lifecycle wijzigingen is nog niet geland.
**Wat:** Implementeer een `AdvanceSectionObserver` die op create/update/delete
(en submission-status transitions) de aggregaat-tellers op de parent-engagement
herberekent. Trigger: section-level submit lands in Session 3.
**Prioriteit:** Middel — alleen relevant zodra de section-lifecycle daadwerkelijk
bestaat. Voor Session 1 is `default 0` voldoende.
**Aanleiding:** Session 3 introduceerde `OrganisationObserver` om elke nieuwe
organisatie automatisch een `artist_advance` FormSchema te bezorgen
(RFC-TIMETABLE v0.2 D15). Vijf bestaande tests (`FormSchemaTest`,
`FormSchemaApiTest`, `MultiTenancyTest`, twee `ScopeLeakageTest`-cases)
tellen FormSchema-rijen exact en namen aan dat `Organisation::factory()`
geen schema's meebezorgt — de auto-bootstrap brak die assumptie. Quick fix:
`config/artist_advance.php` met `bootstrap_on_org_create` (default `true`,
`phpunit.xml` flipt hem naar `false`); de observer leest de config.
**Wat:** Update de vijf FormSchema-counting tests zo dat ze de auto-
bootstrapped `artist_advance` schema verwachten (filter op `purpose !=
'artist_advance'` of pas counts aan). Verwijder daarna het
`bootstrap_on_org_create` flag, de `phpunit.xml` env-override, en de
config-check in de observer — productiegedrag = testgedrag, geen
branching.
**Prioriteit:** Laag — geen blocker, dedicated test-cleanup pass.
---
### ART-ADVANCE-SECTION-FK — Replace name-based AdvanceSection ↔ FormSchemaSection bridge with FK
**Aanleiding:** Session 3 wirede de portal-flow door een name-match tussen
`advance_sections.name` (engagement-scoped, RFC-TIMETABLE v0.2 §5.3) en
`form_schema_sections.name` (org-scoped, FormBuilder). De seeder
(`ArtistAdvanceDefault`) creëert vijf `FormSchemaSection`-rijen met
deterministische namen; de `EngagementPortalController` filtert
`FormField`-rijen door eerst de `FormSchemaSection` met dezelfde naam te
vinden als de `AdvanceSection`. Werkt vandaag, maar:
1. **Hernoemen breekt**: een organisatie die "Algemeen" hernoemt naar
"Algemene info" via de FormBuilder UI verbreekt de match voor alle
bestaande engagements.
2. **Geen referential integrity**: dubbele/ontbrekende naam-matches
silenten falen i.p.v. een DB-niveau constraint.
3. **Geen migration-pad voor `Custom`-secties**: organisaties die eigen
secties toevoegen aan de FormBuilder schema krijgen geen
corresponderende `AdvanceSection`-rij per engagement.
**Wat:** Voeg `advance_sections.form_schema_section_id` toe (nullable
`foreignUlid`, `nullOnDelete`). Bij `ArtistEngagement::created` (nieuwe
observer of uitbreiding van bestaande) provisionineer één
`AdvanceSection`-rij per `FormSchemaSection` op de org's `artist_advance`
schema, met `form_schema_section_id` gevuld. Migratie voor bestaande
data: best-effort name-match per (organisation_id, schema_id) als
backfill, gevolgd door log-warning voor unmatched rijen. Update
`EngagementPortalController` om via FK te filteren i.p.v. naam.
**Prioriteit:** Middel — relevant zodra UI-rename of `Custom`-secties
voor het eerst in productie aanlopen tegen het bug. Voor pure default
seeded schema's werkt de huidige bridge.
---
@@ -1332,6 +1372,7 @@ deadline implementation).
- ~~**WS-7 Observability — closure (mei 2026)**: 4 PRs gemerged op `feat/ws-7-observability` (infra `5f6fc07`, backend SDK `bdb89a2..0379016`, frontend SDK `bc47783..5c42f27`, docs `754222f..e9da01f`). 1551 backend + 252 frontend tests groen. Acceptance criteria 1-14 voldaan; observability volledig operationeel op `monitoring.hausdesign.nl`. Implementation criteria 3, 4, 5, 6, 8, 11, 12, 13, 14 via PRs; operationele criteria 1, 2, 7, 9, 10 via deploy-checklist (DNS, TLS, superuser+2FA, prod DSNs, email-alerting, retention 90d, cron backup). Architecturale patronen vastgelegd in `dev-docs/ARCH-OBSERVABILITY.md` (730 regels) + 2 runbooks (`observability-triage.md`, `observability-erasure.md`). Twee GlitchTip projecten (`crewli-api` + `crewli-app`), één DSN per project, runtime context-split via `actor_scope` tag. Patronen: explicit > implicit listener registration, default-in-listener / override-in-middleware voor binary tags, tenant resolution chain (route-param → portal-token → super_admin platform → user fallback). Volgsporen: OBS-1, OBS-4, OBS-6, OBS-7, OBS-9 (zie "Observability follow-ups" sectie hieronder).~~ ✅
- ~~**WS-6 v1.3-delta — closure (mei 2026)**: Architecturele review-sessie 2026-05-07 identificeerde vijf verfijningen op RFC-WS-6 v1.2 (Q1 listener queueing, Q2 invariant cleanup, Q3 failure-UX additions, plus §19 BACKLOG-pointer). v1.3 amendement gecommit (`845b6e6`, 2026-05-07); v1.3.1 drift closure (`b255879`, 2026-05-08) sloot code-vs-docs gaten pre-implementation. Implementatie geland als D1 + D2: **D1** (PR #10 `c6f4d1b`) leverde de data-laag — `failure_response_code` kolom op `form_submissions`, abstract `FormBindingApplicatorException` hiërarchie + 4 reason-coded subclasses (`FormBindingSchemaConfigException`, `FormBindingInfraException`, `FormBindingApplicatorTimeoutException`, `FormBindingDataIntegrityException`), `IdentityMatchInvariantViolation` sibling, `FormBindingExceptionClassifier` helper, `FormSubmissionIdentityMatchResolved` broadcast event class, `FormFieldBindingMergeStrategy::validForTargetType` matrix method, cast + factory state. **D2** (PR #11 `23a5696`) wired alle building blocks in de listener-chain — `ApplyBindings` initial `pending` write + deadline wrapper + classifier in catch; `TriggerPersonIdentityMatch` queued + gating-invariant + invariant throw + broadcast dispatch; `routes/channels.php` + bootstrap routing (NIEUWE broadcast wiring, submitter-only auth); gating-invariant op `SyncTagPicker`; `AppServiceProvider::boot` v1.3 layout; `FormFailureRetryService::recordFailure` classifier + apply_completed_at symmetrie-fix; `apply_deadline_seconds` config key (default 5). Tests: pre-WS-6 baseline 1208 → pre-D1 1551 → post-D2 1621. 0 Larastan errors. Phase F (`ConditionalRequirement(public_token)` wrapper drop) was no-op — change had silently landed pre-D2. **Open follow-ups:** `TECH-CHANNEL-AUTH-ORG-ADMIN` (extend `submission.{id}` channel auth to org admins na Spatie Permission helper-audit); GlitchTip alert rule op `apply_status=failed AND form_schema.has_public_token=true` (operationele taak in GlitchTip web-UI op `monitoring.hausdesign.nl`; runbook procedure in `dev-docs/runbooks/observability-triage.md` §7); frontend Echo subscription voor `FormSubmissionIdentityMatchResolved` (separate frontend follow-up, out of WS-6 scope, backend-infra ready). `PARTIAL-BINDING-SUCCESS` en `FORM-SCHEMA-DRIFT-DETECTION` blijven open conform v1.3 amendement (trigger-condities nog niet gevuurd). Closure docs-PR: RFC-WS-6.md v1.3.1 implementation-status marker + §10 closure entry, ARCH-BINDINGS.md v1.2 onveranderd, runbook §7 toegevoegd.~~ ✅
- ~~**ARCH-09 — Artist Eloquent model + migration — closure (mei 2026)**: Foundation for the Artist & Timetable module landed as RFC-TIMETABLE v0.2 Session 1 op `feat/timetable-session-1`. Delivered: 10 migrations (genres, artists, companies.handles_buma column, artist_contacts, stages, stage_days, artist_engagements, performances, advance_sections, advance_submissions); 7 PHP enums under `App\Enums\Artist\` (`ArtistEngagementStatus` D9 with Dutch labels, `BumaHandledBy` D26, `FeeType`, `PaymentStatus`, `AdvanceSectionType`, `AdvanceSectionSubmissionStatus`, `AdvanceSubmissionStatus`); 9 Eloquent models with `OrganisationScope` (direct on Artist/Genre/ArtistEngagement, FK-chain via `tenantScopeStrategy()` on the rest) and `LogsActivity` baseline; 2 observers (`ArtistEngagementObserver` for `organisation_id` denorm + cross-tenant guard via `CrossTenantEngagementException` + cascade soft-delete to performances + hard-delete to advance_sections; `PerformanceObserver` for D14 optimistic-lock `version` bump on UPDATE); 8 factories + `ArtistTimetableDevSeeder` reproducing the prototype fixture (4 stages, 12 stage_days, 6 artists, 12 engagements, 13 performances incl. 1 parked); `PURPOSE_SUBJECT_FQCN` switched from string-literal to `Artist::class` (MorphMapAlignmentTest green); SCHEMA.md §3.5.7 rewritten in place (ARCH-PLANNED-MODULES.md was assumed by the RFC pre-amble but did not exist — see `RFC-TIMETABLE-V0.2-DOC-CLEANUP`); ARCH-FORM-BUILDER.md §3.2.5 updated for engagement-scoped sections and §17.3 footnote on `ArtistResolver::fromPortalToken` engagement context resolution. PR #XX, 2026-05-08.~~ ✅
- ~~**ART-OBSERVER-ADVANCE-AGGREGATE — closure (mei 2026)**: AdvanceSectionObserver implemented in RFC-TIMETABLE v0.2 Session 3 on `feat/timetable-session-3`. Recomputes `artist_engagements.advancing_completed_count` + `advancing_total_count` atomically on every section lifecycle event (created / updated-status-only / deleted). Concurrency safety via `DB::transaction` + `lockForUpdate` on both the parent engagement and sibling section rows; counter writes use `disableLogging()` so housekeeping doesn't pollute the activity log. Section's own `updated` event continues to log via `LogsActivity` on `AdvanceSection`.~~ ✅
- ~~**TECH-CHANNEL-AUTH-ORG-ADMIN — closure (mei 2026)**: `submission.{id}` private channel auth uitgebreid van submitter-only naar drie-paths: submitter (`submitted_by_user_id === user.id`) → super_admin Spatie HasRoles app-wide bypass → org_admin van submission's organisatie via pivot-table check op `user_organisation` (`->wherePivot('role', 'org_admin')`). Pattern: directe port van `FormSubmissionActionFailurePolicy::canAccess`, codebase canonical (gebruikt in 17+ policy sites). Spatie teams is disabled in `config/permission.php`, dus org-scoping leeft in de pivot, niet in Spatie. **super_admin bypass is een audit-surfaced bonus** (origineel BACKLOG entry vroeg alleen om org-admin extension; tijdens Phase A audit bleek dat elke analoge policy super_admin bypass heeft, dus toegevoegd voor consistency — zonder die bypass zouden super_admins op de admin-panel banner mysterieus geen live updates krijgen). Tests: 4 nieuw (`test_super_admin_can_subscribe`, `test_organisation_admin_of_submission_org_can_subscribe`, `test_organisation_admin_of_different_org_cannot_subscribe` (kritische cross-tenant guard), `test_regular_organisation_member_cannot_subscribe`); 1 verwijderd (de "should flip" denied-by-default test uit PR #11). Test count: 1621 → 1624 (+3 net). 0 Larastan errors. Inline TODO uit `routes/channels.php` verwijderd. Sibling `FRONTEND-ECHO-IDENTITY-MATCH-SUBSCRIPTION` blijft open (frontend portal IdentityMatchBanner subscription is de pair met deze backend-auth uitbreiding).~~ ✅
---