RFC-TIMETABLE v0.2 Session 3 — Form Builder integration #17
51
api/app/Console/Commands/SeedArtistAdvanceDefaultCommand.php
Normal file
51
api/app/Console/Commands/SeedArtistAdvanceDefaultCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
api/app/Exceptions/Artist/ArtistDeletedException.php
Normal file
25
api/app/Exceptions/Artist/ArtistDeletedException.php
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
21
api/app/Exceptions/Artist/InvalidPortalTokenException.php
Normal file
21
api/app/Exceptions/Artist/InvalidPortalTokenException.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
181
api/app/FormBuilder/Defaults/ArtistAdvanceDefault.php
Normal file
181
api/app/FormBuilder/Defaults/ArtistAdvanceDefault.php
Normal 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],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
59
api/app/FormBuilder/Resolvers/ArtistResolver.php
Normal file
59
api/app/FormBuilder/Resolvers/ArtistResolver.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
27
api/app/FormBuilder/Resolvers/ArtistResolverResult.php
Normal file
27
api/app/FormBuilder/Resolvers/ArtistResolverResult.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
96
api/app/Observers/AdvanceSectionObserver.php
Normal file
96
api/app/Observers/AdvanceSectionObserver.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
38
api/app/Observers/OrganisationObserver.php
Normal file
38
api/app/Observers/OrganisationObserver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
|
||||
25
api/config/artist_advance.php
Normal file
25
api/config/artist_advance.php
Normal 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),
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
145
api/tests/Feature/Artist/AdvanceSectionObserverTest.php
Normal file
145
api/tests/Feature/Artist/AdvanceSectionObserverTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
157
api/tests/Feature/Portal/EngagementPortalControllerTest.php
Normal file
157
api/tests/Feature/Portal/EngagementPortalControllerTest.php
Normal 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];
|
||||
}
|
||||
}
|
||||
86
api/tests/Unit/FormBuilder/Resolvers/ArtistResolverTest.php
Normal file
86
api/tests/Unit/FormBuilder/Resolvers/ArtistResolverTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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).~~ ✅
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user