130 lines
8.8 KiB
JavaScript
130 lines
8.8 KiB
JavaScript
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();
|