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>
241 lines
7.0 KiB
TypeScript
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),
|
|
})
|