From 0a533a65fd25ef919a0aa64acd87b6577a37acf2 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:37:00 +0200 Subject: [PATCH] feat(timetable): types + zod schemas + idempotency-key helper (Session 4 step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract generateIdempotencyKey() from useFormDraft into reusable lib/ - New types/timetable.ts mirrors PerformanceResource, ArtistEngagementResource, StageResource, GenreResource and the four enums verbatim - New schemas/timetable.ts adds zod parsers for runtime validation of API responses + form payloads (createPerformance, createStage, moveTimetable) RFC v0.2 §10 contract surface for the upcoming timetable canvas. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/composables/useFormDraft.ts | 20 +- apps/app/src/lib/idempotencyKey.ts | 26 +++ apps/app/src/schemas/timetable.ts | 229 +++++++++++++++++++ apps/app/src/types/timetable.ts | 273 +++++++++++++++++++++++ 4 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 apps/app/src/lib/idempotencyKey.ts create mode 100644 apps/app/src/schemas/timetable.ts create mode 100644 apps/app/src/types/timetable.ts diff --git a/apps/app/src/composables/useFormDraft.ts b/apps/app/src/composables/useFormDraft.ts index 57778c23..7b938f69 100644 --- a/apps/app/src/composables/useFormDraft.ts +++ b/apps/app/src/composables/useFormDraft.ts @@ -7,6 +7,7 @@ import { useSaveFormDraft, useSubmitForm, } from '@/composables/api/usePublicForm' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@/types/forms/formBuilder' /** sessionStorage key for reusing an idempotency key across reloads. */ @@ -19,25 +20,6 @@ export function draftSubmitterStorageKey(token: string): string { return `draft_submitter:${token}` } -function generateIdempotencyKey(): string { - const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto - if (c?.randomUUID) { - // UUID v4 (36 chars) exceeds backend max:30. Backend expects 6..30 - // chars so compress to 24 hex chars (still collision-resistant). - return c.randomUUID().replace(/-/g, '').slice(0, 24) - } - if (c?.getRandomValues) { - const buf = new Uint8Array(12) - - c.getRandomValues(buf) - - return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('') - } - - // Last-resort fallback — still within 6..30. - return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30) -} - interface UseFormDraftOptions { /** Preferred locale string for `submitted_in_locale` (e.g. `"nl"`). */ diff --git a/apps/app/src/lib/idempotencyKey.ts b/apps/app/src/lib/idempotencyKey.ts new file mode 100644 index 00000000..f4c11c60 --- /dev/null +++ b/apps/app/src/lib/idempotencyKey.ts @@ -0,0 +1,26 @@ +/** + * Generate a 24-hex idempotency key (UUID v4 with dashes stripped, sliced to 24). + * + * Backend constraint: Crewli's idempotency middleware accepts 6..30 chars + * (FORM-07). UUID v4's 36-char form exceeds the cap, so it is normalised + * to 24 hex chars — still collision-resistant for per-mutation keys. + * + * Safe fallbacks: `getRandomValues` (12 random bytes) when randomUUID is + * unavailable; finally a non-cryptographic time+random suffix so calling + * code never throws. + */ +export function generateIdempotencyKey(): string { + const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto + if (c?.randomUUID) + return c.randomUUID().replace(/-/g, '').slice(0, 24) + + if (c?.getRandomValues) { + const buf = new Uint8Array(12) + + c.getRandomValues(buf) + + return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('') + } + + return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30) +} diff --git a/apps/app/src/schemas/timetable.ts b/apps/app/src/schemas/timetable.ts new file mode 100644 index 00000000..cb4bced8 --- /dev/null +++ b/apps/app/src/schemas/timetable.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { + ArtistEngagementStatus, + BumaHandledBy, + FeeType, + PaymentStatus, +} from '@/types/timetable' + +const artistEngagementStatusSchema = z.enum([ + ArtistEngagementStatus.DRAFT, + ArtistEngagementStatus.REQUESTED, + ArtistEngagementStatus.OPTION, + ArtistEngagementStatus.OFFERED, + ArtistEngagementStatus.CONFIRMED, + ArtistEngagementStatus.CONTRACTED, + ArtistEngagementStatus.CANCELLED, + ArtistEngagementStatus.REJECTED, + ArtistEngagementStatus.DECLINED, +]) + +const bumaHandledBySchema = z.enum([ + BumaHandledBy.ORGANISATION, + BumaHandledBy.BOOKING_AGENCY, + BumaHandledBy.NOT_APPLICABLE, +]) + +const feeTypeSchema = z.enum([ + FeeType.FLAT, + FeeType.DOOR_SPLIT, + FeeType.GUARANTEE_PLUS_SPLIT, +]) + +const paymentStatusSchema = z.enum([ + PaymentStatus.NONE, + PaymentStatus.DEPOSIT_PAID, + PaymentStatus.PAID_IN_FULL, +]) + +function enumLabel(value: T) { + return z.object({ + value: value.nullable(), + label: z.string().nullable(), + }) +} + +export const genreSchema = z.object({ + id: z.string(), + organisation_id: z.string(), + name: z.string(), + color: z.string().nullable(), + sort_order: z.number(), + is_active: z.boolean(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), +}) + +export const artistContactSchema = z.object({ + id: z.string(), + artist_id: z.string(), + name: z.string(), + email: z.string().nullable(), + phone: z.string().nullable(), + role: z.string(), + is_primary: z.boolean(), + receives_briefing: z.boolean(), + receives_infosheet: z.boolean(), +}) + +export const artistSchema = z.object({ + id: z.string(), + organisation_id: z.string(), + name: z.string(), + slug: z.string(), + default_genre_id: z.string().nullable(), + default_genre: genreSchema.nullable().optional(), + default_draw: z.number().nullable(), + star_rating: z.number().nullable(), + home_base_country: z.string().nullable(), + agent_company_id: z.string().nullable(), + agent_company: z + .object({ + id: z.string().nullable(), + name: z.string().nullable(), + handles_buma: z.boolean(), + }) + .optional(), + notes: z.string().nullable(), + contacts: z.array(artistContactSchema).optional(), + engagements_summary: z.object({ + lifetime_count: z.number(), + upcoming_count: z.number(), + }), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + deleted_at: z.string().nullable(), +}) + +export const artistEngagementSchema = z.object({ + id: z.string(), + organisation_id: z.string(), + artist_id: z.string(), + event_id: z.string(), + artist: artistSchema.optional(), + project_leader_id: z.string().nullable(), + project_leader: z + .object({ + id: z.string().nullable(), + name: z.string(), + email: z.string().nullable(), + }) + .optional(), + booking_status: enumLabel(artistEngagementStatusSchema), + fee_amount: z.number().nullable(), + fee_currency: z.string(), + fee_type: enumLabel(feeTypeSchema), + buma_applicable: z.boolean(), + buma_percentage: z.number().nullable(), + buma_handled_by: enumLabel(bumaHandledBySchema), + vat_applicable: z.boolean(), + vat_percentage: z.number().nullable(), + deal_breakdown: z + .array(z.object({ label: z.string().optional(), amount: z.number() })) + .nullable(), + deposit_percentage: z.number().nullable(), + deposit_due_date: z.string().nullable(), + balance_due_date: z.string().nullable(), + payment_status: enumLabel(paymentStatusSchema), + crew_count: z.number(), + guests_count: z.number(), + requested_at: z.string().nullable(), + option_expires_at: z.string().nullable(), + advance_open_from: z.string().nullable(), + advance_open_to: z.string().nullable(), + advancing_completed_count: z.number(), + advancing_total_count: z.number(), + notes: z.string().nullable(), + computed: z.object({ + buma_amount: z.number(), + vat_grondslag: z.number(), + vat_amount: z.number(), + breakdown_total: z.number(), + total_cost: z.number(), + }), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + deleted_at: z.string().nullable(), +}) + +export const stageSchema = z.object({ + id: z.string(), + event_id: z.string(), + name: z.string(), + color: z.string(), + capacity: z.number().nullable(), + sort_order: z.number(), + stage_days: z.array(z.string()).optional(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), +}) + +export const performanceWarningSchema = z.enum([ + 'overlap', + 'b2b', + 'capacity', + 'b2b_left', + 'b2b_right', +]) + +export const performanceSchema = z.object({ + id: z.string(), + engagement_id: z.string(), + event_id: z.string(), + stage_id: z.string().nullable(), + lane: z.number(), + lane_resolved: z.number(), + start_at: z.string().nullable(), + end_at: z.string().nullable(), + version: z.number(), + notes: z.string().nullable(), + warnings: z.array(performanceWarningSchema), + engagement: artistEngagementSchema.optional(), + stage: stageSchema.nullable().optional(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + deleted_at: z.string().nullable(), +}) + +export const moveTimetableSuccessSchema = z.object({ + moved: performanceSchema, + cascaded: z.array(performanceSchema), +}) + +export const moveTimetableConflictSchema = z.object({ + conflict: z.literal('version_mismatch'), + current_version: z.number(), + client_version: z.number(), + server_data: performanceSchema, +}) + +// ─── Form-payload schemas (validated client-side before POST) ───────── + +export const createPerformancePayloadSchema = z.object({ + engagement_id: z.string().min(1, 'Engagement is verplicht.'), + event_id: z.string().min(1, 'Sub-event is verplicht.'), + stage_id: z.string().nullable(), + start_at: z.string().min(1, 'Starttijd is verplicht.'), + end_at: z.string().min(1, 'Eindtijd is verplicht.'), + lane: z.number().int().min(0).max(9).optional().nullable(), + notes: z.string().max(1000).optional().nullable(), +}).refine(p => p.start_at < p.end_at, { + message: 'Eindtijd moet na de starttijd liggen.', + path: ['end_at'], +}) + +export const createStagePayloadSchema = z.object({ + name: z.string().min(1, 'Naam is verplicht.').max(120), + color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Kleur moet een #RRGGBB hex zijn.'), + capacity: z.number().int().min(0).optional().nullable(), + sort_order: z.number().int().min(0).optional().nullable(), +}) + +export const moveTimetablePayloadSchema = z.object({ + performance_id: z.string(), + target_stage_id: z.string().nullable(), + target_start_at: z.string().nullable(), + target_end_at: z.string().nullable(), + target_lane: z.number().int().min(0).max(9).nullable(), + version: z.number().int().min(0), +}) diff --git a/apps/app/src/types/timetable.ts b/apps/app/src/types/timetable.ts new file mode 100644 index 00000000..14b4688a --- /dev/null +++ b/apps/app/src/types/timetable.ts @@ -0,0 +1,273 @@ +/** + * RFC-TIMETABLE v0.2 — frontend type mirrors of the backend resources. + * + * Source of truth: api/app/Http/Resources/Api/V1/Artist/*.php and the + * matching enums under api/app/Enums/Artist/*.php. Keep this file in + * lockstep with backend changes; runtime parsers live in + * src/schemas/timetable.ts and provide the safety net for drift. + */ + +// ─── Enums (mirror backend `enum: string` types verbatim) ──────────── + +export const ArtistEngagementStatus = { + DRAFT: 'draft', + REQUESTED: 'requested', + OPTION: 'option', + OFFERED: 'offered', + CONFIRMED: 'confirmed', + CONTRACTED: 'contracted', + CANCELLED: 'cancelled', + REJECTED: 'rejected', + DECLINED: 'declined', +} as const +export type ArtistEngagementStatus = typeof ArtistEngagementStatus[keyof typeof ArtistEngagementStatus] + +export const BumaHandledBy = { + ORGANISATION: 'organisation', + BOOKING_AGENCY: 'booking_agency', + NOT_APPLICABLE: 'not_applicable', +} as const +export type BumaHandledBy = typeof BumaHandledBy[keyof typeof BumaHandledBy] + +export const FeeType = { + FLAT: 'flat', + DOOR_SPLIT: 'door_split', + GUARANTEE_PLUS_SPLIT: 'guarantee_plus_split', +} as const +export type FeeType = typeof FeeType[keyof typeof FeeType] + +export const PaymentStatus = { + NONE: 'none', + DEPOSIT_PAID: 'deposit_paid', + PAID_IN_FULL: 'paid_in_full', +} as const +export type PaymentStatus = typeof PaymentStatus[keyof typeof PaymentStatus] + +// Backend wraps each enum in `{ value, label }` for i18n display. +export interface EnumLabel { + value: T | null + label: string | null +} + +// ─── Genre / Artist / Engagement ───────────────────────────────────── + +export interface Genre { + id: string + organisation_id: string + name: string + color: string | null + sort_order: number + is_active: boolean + created_at: string | null + updated_at: string | null +} + +export interface ArtistContact { + id: string + artist_id: string + name: string + email: string | null + phone: string | null + role: string + is_primary: boolean + receives_briefing: boolean + receives_infosheet: boolean +} + +export interface ArtistAgentCompany { + id: string | null + name: string | null + handles_buma: boolean +} + +export interface ArtistEngagementsSummary { + lifetime_count: number + upcoming_count: number +} + +export interface Artist { + id: string + organisation_id: string + name: string + slug: string + default_genre_id: string | null + default_genre?: Genre | null + default_draw: number | null + star_rating: number | null + home_base_country: string | null + agent_company_id: string | null + agent_company?: ArtistAgentCompany + notes: string | null + contacts?: ArtistContact[] + engagements_summary: ArtistEngagementsSummary + created_at: string | null + updated_at: string | null + deleted_at: string | null +} + +export interface ArtistEngagementProjectLeader { + id: string | null + name: string + email: string | null +} + +export interface DealBreakdownLine { + label?: string + amount: number +} + +export interface ArtistEngagementComputed { + buma_amount: number + vat_grondslag: number + vat_amount: number + breakdown_total: number + total_cost: number +} + +export interface ArtistEngagement { + id: string + organisation_id: string + artist_id: string + event_id: string + artist?: Artist + project_leader_id: string | null + project_leader?: ArtistEngagementProjectLeader + booking_status: EnumLabel + fee_amount: number | null + fee_currency: string + fee_type: EnumLabel + buma_applicable: boolean + buma_percentage: number | null + buma_handled_by: EnumLabel + vat_applicable: boolean + vat_percentage: number | null + deal_breakdown: DealBreakdownLine[] | null + deposit_percentage: number | null + deposit_due_date: string | null + balance_due_date: string | null + payment_status: EnumLabel + crew_count: number + guests_count: number + requested_at: string | null + option_expires_at: string | null + advance_open_from: string | null + advance_open_to: string | null + advancing_completed_count: number + advancing_total_count: number + notes: string | null + computed: ArtistEngagementComputed + performances?: Performance[] + created_at: string | null + updated_at: string | null + deleted_at: string | null +} + +// ─── Stage / StageDay ───────────────────────────────────────────────── + +export interface Stage { + id: string + event_id: string + name: string + color: string + capacity: number | null + sort_order: number + + /** Sub-event IDs the stage is active on. Present only when loaded. */ + stage_days?: string[] + created_at: string | null + updated_at: string | null +} + +// ─── Performance ────────────────────────────────────────────────────── + +export type PerformanceWarning = 'overlap' | 'b2b' | 'capacity' | 'b2b_left' | 'b2b_right' + +export interface Performance { + id: string + engagement_id: string + event_id: string + stage_id: string | null + + /** 0-indexed lane (raw, persisted). Use `lane_resolved` for rendering. */ + lane: number + + /** 0-indexed lane after server-side Pass 1 + Pass 2 resolution (RFC D19). */ + lane_resolved: number + start_at: string | null + end_at: string | null + + /** Optimistic-lock cursor (RFC D14). */ + version: number + notes: string | null + warnings: PerformanceWarning[] + engagement?: ArtistEngagement + stage?: Stage | null + created_at: string | null + updated_at: string | null + deleted_at: string | null +} + +// ─── Move endpoint contract (POST /timetable/move, RFC D18) ─────────── + +export interface MoveTimetablePayload { + performance_id: string + target_stage_id: string | null + target_start_at: string | null + target_end_at: string | null + target_lane: number | null + version: number +} + +export interface MoveTimetableSuccess { + moved: Performance + cascaded: Performance[] +} + +export interface MoveTimetableConflict { + conflict: 'version_mismatch' + current_version: number + client_version: number + server_data: Performance +} + +// ─── Performance create payload ─────────────────────────────────────── + +export interface CreatePerformancePayload { + engagement_id: string + event_id: string + stage_id: string | null + start_at: string + end_at: string + lane?: number | null + notes?: string | null +} + +export interface UpdatePerformancePayload { + notes?: string | null +} + +// ─── Stage create / update / reorder ────────────────────────────────── + +export interface CreateStagePayload { + name: string + color: string + capacity?: number | null + sort_order?: number | null +} + +export interface UpdateStagePayload extends Partial {} + +export interface ReorderStagesPayload { + stage_ids: string[] +} + +export interface ReplaceStageDaysPayload { + event_ids: string[] + force_orphan?: boolean +} + +export interface ReplaceStageDaysResponse { + stage: Stage + added_event_ids: string[] + removed_event_ids: string[] +}