Extract versioned Model Delivery shared contracts

This commit is contained in:
tomchukk committed 2026-09-07 19:20:09 +03:00
commit 9133b921a7
15 files changed
+1185

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
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']);
export type DeliverySlot = z.infer<typeof deliverySlotSchema>;
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),
};
type DeliveryTiming = { deliverySlot?: DeliverySlot | null; specificTime?: string | null; deliveryWindow?: { start: string; end: string } | null };
function validateDeliveryTiming(value: DeliveryTiming, context: z.RefinementCtx) {
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(),
]);
export type Operation = z.infer<typeof operationSchema>;
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 type Customer = z.infer<typeof customerSchema>;
export type CustomerAddress = z.infer<typeof addressSchema>;
export type DeliveryType = z.infer<typeof deliveryTypeSchema>;
export type Delivery = z.infer<typeof deliverySchema>;
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 type Change = z.infer<typeof changeSchema>;
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 type PullResponse = z.infer<typeof pullResponseSchema>;
export function totalPrice(unitPriceAgorot: number, shipmentUnitHundredths: number): number {
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 type User = z.infer<typeof userSchema>;
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 type Session = z.infer<typeof sessionSchema>;
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 type SsoSettings = z.infer<typeof ssoSettingsSchema>;
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 type DirectorySettings = z.infer<typeof directorySettingsSchema>;
export const directoryTestInput = z.object({ subject: z.string().trim().min(1).max(255) }).strict();