import { z } from 'zod'; export const uuid = z.uuid(); const timeValue = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); export const deliveryWindowSchema = z.object({ start: timeValue, end: timeValue }).strict().refine(value => value.start < value.end, { message: 'End time must be after start time' }); export const deliveryWindowsSchema = z.object({ morning: deliveryWindowSchema.nullable().default(null), afternoon: deliveryWindowSchema.nullable().default(null), lateNight: deliveryWindowSchema.nullable().default(null), }).strict(); export const customerInput = z.object({ name: z.string().trim().min(1).max(120), contactName: z.string().trim().max(100).default(''), phone: z.string().trim().max(30).regex(/^[0-9+().\-\s]*$/).default(''), comments: z.string().trim().max(1000).default(''), deliveryWindows: deliveryWindowsSchema.default({ morning: null, afternoon: null, lateNight: null }), }).strict(); export const customerUpdateInput = customerInput; export const customerRemoveInput = z.object({}).strict(); export const addressInput = z.object({ customerId: uuid, label: z.string().trim().min(1).max(80), address: z.string().trim().min(1).max(300), city: z.string().trim().max(100).default(''), street: z.string().trim().max(120).default(''), buildingNumber: z.string().trim().max(20).default(''), }).strict(); export const deliverySlotSchema = z.enum(['morning', 'afternoon', 'lateNight', 'custom']); const deliveryTimingFields = { // Null is accepted only for records/operations created by protocol versions // that predate delivery timing. Current clients always send one of four slots. deliverySlot: deliverySlotSchema.nullable().default(null), specificTime: timeValue.nullable().default(null), deliveryWindow: deliveryWindowSchema.nullable().default(null), }; function validateDeliveryTiming(value, context) { if (value.deliverySlot === undefined) { if (value.specificTime !== undefined || value.deliveryWindow !== undefined) context.addIssue({ code: 'custom', path: ['deliverySlot'], message: 'Delivery slot is required with timing fields' }); return; } if (value.deliverySlot === null) { if (value.specificTime !== null || value.deliveryWindow !== null) context.addIssue({ code: 'custom', path: ['deliverySlot'], message: 'Legacy delivery timing must be empty' }); return; } if (value.deliverySlot === 'custom') { if (!value.specificTime) context.addIssue({ code: 'custom', path: ['specificTime'], message: 'Specific time is required' }); if (value.deliveryWindow !== null) context.addIssue({ code: 'custom', path: ['deliveryWindow'], message: 'Custom time cannot include a delivery window' }); return; } if (value.specificTime !== null) context.addIssue({ code: 'custom', path: ['specificTime'], message: 'Named slots cannot include a specific time' }); } const deliveryInputObject = z.object({ customerId: uuid, addressId: uuid, deliveryTypeId: uuid, deliveryTypeVersion: z.number().int().positive(), shipmentUnitHundredths: z.number().int().min(1).max(10000), unitPriceAgorot: z.number().int().min(0).max(100000000), scheduledDate: z.iso.date().nullable().default(null), ...deliveryTimingFields, notes: z.string().trim().max(500), }).strict(); export const deliveryInput = deliveryInputObject.superRefine(validateDeliveryTiming); const envelope = { operationId: uuid, deviceId: uuid, entityId: uuid, baseVersion: z.literal(0) }; export const deliveryUpdateInput = z.object({ customerId: uuid, addressId: uuid, scheduledDate: z.iso.date().nullable().optional(), deliverySlot: deliverySlotSchema.nullable().optional(), specificTime: timeValue.nullable().optional(), deliveryWindow: deliveryWindowSchema.nullable().optional(), notes: z.string().trim().max(500), }).strict().superRefine(validateDeliveryTiming); export const deliveryCancelInput = z.object({ reason: z.string().trim().min(1).max(300) }).strict(); export const operationSchema = z.discriminatedUnion('kind', [ z.object({ ...envelope, kind: z.literal('customer.create'), data: customerInput }).strict(), z.object({ ...envelope, baseVersion: z.number().int().positive(), kind: z.literal('customer.update'), data: customerUpdateInput }).strict(), z.object({ ...envelope, baseVersion: z.number().int().positive(), kind: z.literal('customer.remove'), data: customerRemoveInput }).strict(), z.object({ ...envelope, kind: z.literal('address.create'), data: addressInput }).strict(), z.object({ ...envelope, kind: z.literal('delivery.create'), data: deliveryInput }).strict(), z.object({ ...envelope, baseVersion: z.number().int().positive(), kind: z.literal('delivery.update'), data: deliveryUpdateInput }).strict(), z.object({ ...envelope, baseVersion: z.number().int().positive(), kind: z.literal('delivery.cancel'), data: deliveryCancelInput }).strict(), ]); const meta = { id: uuid, version: z.number().int().positive(), createdAt: z.iso.datetime(), deletedAt: z.iso.datetime().nullable() }; export const customerSchema = customerInput.extend({ ...meta, updatedAt: z.iso.datetime().nullable().default(null) }); export const addressSchema = addressInput.extend(meta); export const deliveryTypeSchema = z.object({ ...meta, code: z.enum(['delivery', 'pickup', 'double']), name: z.string(), shipmentUnitHundredths: z.number().int().positive() }); export const deliverySchema = deliveryInputObject.extend({ ...meta, deliveryNumber: z.number().int().positive(), totalPriceAgorot: z.number().int().nonnegative(), status: z.enum(['created', 'cancelled']), updatedAt: z.iso.datetime().nullable().default(null), cancelledAt: z.iso.datetime().nullable().default(null), cancellationReason: z.string().nullable().default(null), }).superRefine(validateDeliveryTiming); export const changeSchema = z.discriminatedUnion('entityType', [ z.object({ entityType: z.literal('customer'), record: customerSchema }), z.object({ entityType: z.literal('address'), record: addressSchema }), z.object({ entityType: z.literal('deliveryType'), record: deliveryTypeSchema }), z.object({ entityType: z.literal('delivery'), record: deliverySchema }), ]); export const pushResponseSchema = z.object({ change: changeSchema, replayed: z.boolean() }); export const pullResponseSchema = z.object({ changes: z.array(z.object({ sequence: z.string().regex(/^\d+$/), change: changeSchema })), nextCursor: z.string().regex(/^\d+$/), hasMore: z.boolean(), }); export function totalPrice(unitPriceAgorot, shipmentUnitHundredths) { return Math.floor((unitPriceAgorot * shipmentUnitHundredths + 50) / 100); } export const usernameSchema = z.string().trim().toLowerCase().min(3).max(80).regex(/^[a-z0-9._@+-]+$/); export const loginInput = z.object({ username: usernameSchema, password: z.string().min(12).max(128) }).strict(); export const setupInput = loginInput.extend({ displayName: z.string().trim().min(1).max(80) }).strict(); export const userSchema = z.object({ id: uuid, username: z.string().min(1).max(255), displayName: z.string().min(1).max(80), role: z.enum(['admin', 'staff']), authProvider: z.enum(['local', 'oidc']).default('local'), groups: z.array(z.string().min(1).max(255)).max(100).default([]) }); export const profileInput = z.object({ displayName: z.string().trim().min(1).max(80).nullable() }).strict(); export const sessionSchema = z.object({ user: userSchema, expiresAt: z.iso.datetime() }); export const ssoSettingsSchema = z.object({ enabled: z.boolean(), issuer: z.string().max(512), discoveryUrl: z.string().max(1024), clientId: z.string().max(255), clientSecret: z.string().max(4096).default(''), redirectUri: z.string().max(1024), scopes: z.string().max(500), authMethod: z.enum(['client_secret_post', 'client_secret_basic', 'none']), offlineAccess: z.boolean(), promptConsent: z.boolean(), displayNameClaim: z.string().regex(/^[a-zA-Z0-9_]+$/).max(80), groupAccessEnabled: z.boolean().default(false), groupClaim: z.string().regex(/^[a-zA-Z0-9_]+$/).max(80).default('groups'), allowedGroups: z.array(z.string().trim().min(1).max(255)).max(100).default([]), }).strict().superRefine((settings, context) => { if (settings.groupAccessEnabled && !settings.allowedGroups.length) context.addIssue({ code: 'custom', path: ['allowedGroups'], message: 'At least one allowed group is required' }); }); export const directorySettingsSchema = z.object({ enabled: z.boolean(), url: z.string().trim().max(1024), baseDn: z.string().trim().max(1024), domain: z.string().trim().max(15).regex(/^[a-zA-Z0-9-]*$/), bindDn: z.string().trim().max(1024), bindPassword: z.string().max(4096), caCertificate: z.string().trim().max(16000), }).strict(); export const directoryTestInput = z.object({ subject: z.string().trim().min(1).max(255) }).strict();