Files
crewli-old/apps/app/src/schemas/timetable.ts
bert.hausmans bce3081cb2 test(timetable): add real-API contract fixtures as schema regression test (3 shape variants) (B6)
apps/app/tests/unit/schemas/timetableContractShape.test.ts (NEW, 5 tests):
  - base shape: one performance with stage assigned + full engagement
    (Bert's browser-tested sample, field-for-field). Asserts decimal-as-
    string contract on fee_amount/buma_percentage/vat_percentage AND
    enum-label wrapper on booking_status AND nested computed object.
  - parked shape: stage_id=null, stage=null (Wachtrij case)
  - multi-perf shape: two performances sharing engagement_id
    (RFC §D17 "Friday + Saturday under one combined deal")
  - sanity: individual performanceSchema parses each fixture element
  - regression guard: a payload with NUMBER fee_amount throws (locks
    out the pre-B5 bug class)

Every fixture spells out explicit `null` for the schema's nullable-but-
required fields (timestamps, notes, deal_breakdown) so the
nullable() vs optional() distinction is exercised, not glossed over.

Schema surface change to support the test:
  apps/app/src/schemas/timetable.ts now EXPORTS performanceArraySchema
  (previously a private const inside useTimetable.ts).
  apps/app/src/composables/api/useTimetable.ts imports the shared one
  instead of redeclaring it locally — single source of truth for the
  array shape consumers and tests share.

Test count: 397 → 402 (+5). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:33:13 +02:00

241 lines
7.0 KiB
TypeScript

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<T extends z.ZodTypeAny>(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),
// Decimal columns serialise as strings on the wire (Laravel default for
// `decimal(N,M)` without an explicit cast — preserves precision). The
// schema mirrors that; consumers that need arithmetic coerce via
// Number()/parseFloat at the use site.
fee_amount: z.string().nullable(),
fee_currency: z.string(),
fee_type: enumLabel(feeTypeSchema),
buma_applicable: z.boolean(),
buma_percentage: z.string().nullable(),
buma_handled_by: enumLabel(bumaHandledBySchema),
vat_applicable: z.boolean(),
vat_percentage: z.string().nullable(),
deal_breakdown: z
.array(z.object({ label: z.string().optional(), amount: z.number() }))
.nullable(),
deposit_percentage: z.string().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(),
})
/**
* Wire-shape of `GET …/performances` and the `cascaded[]` payload from
* `POST …/timetable/move`. Useful for contract regression tests.
*/
export const performanceArraySchema = z.array(performanceSchema)
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),
})