import { z } from "zod"; import type { SyncOpType } from "./envelope"; import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; import { emergencyCreatePayload } from "@/lib/emergency/schemas"; import { reportTextsSchema } from "@/lib/reports/content"; /** * Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to * build ops and by src/server/services/sync/apply.ts to validate them. * `clientId` fields are device-generated ids (uuid) that make additive creates idempotent * and are mapped to server ids in SyncOpResult.idMap. */ const id = z.string().min(1).max(64); const clientId = z.string().uuid(); const isoDate = z.string().datetime({ offset: true }); const lat = z.number().min(-90).max(90); const lng = z.number().min(-180).max(180); const quantity = z.number().min(0).max(1_000_000); export const NOTE_KINDS = [ "work_done", "deviation", "problem", "additional_work", "not_executable", "follow_up", "recommendation", "customer_note", "general", ] as const; export type NoteKind = (typeof NOTE_KINDS)[number]; export const PHOTO_PHASES = ["before", "during", "after"] as const; export type PhotoPhase = (typeof PHOTO_PHASES)[number]; export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const; export type MaterialUsageStatus = (typeof MATERIAL_USAGE_STATUSES)[number]; /** Unit suggestions for the material stepper (free text stays allowed). */ export const UNIT_SUGGESTIONS = ["Stk", "m", "m²", "m³", "kg", "l", "Pkg", "Rolle", "Satz", "h"] as const; export const sessionStartPayload = z.object({ workOrderId: id, clientId: clientId.optional(), /** travel = "Losfahren" (in Anfahrt), work = "Arbeit starten" */ mode: z.enum(["travel", "work"]).default("work"), at: isoDate.optional(), latitude: lat.optional(), longitude: lng.optional(), offline: z.boolean().default(false), deviceInfo: z.string().max(200).optional(), /** L12 auto switch: pause the user's running session on another order in the same transaction */ switchFromOther: z.boolean().optional(), }); export const sessionControlPayload = z.object({ workOrderId: id, at: isoDate.optional(), }); /** session.resume (L12): like session.start, may pause a running session on another order. */ export const sessionResumePayload = sessionControlPayload.extend({ switchFromOther: z.boolean().optional() }); /** Segment types a technician can switch to inside a running session (L12). */ export const SESSION_SEGMENT_TYPES = ["work", "return_travel", "material_procurement"] as const; export type SessionSegmentType = (typeof SESSION_SEGMENT_TYPES)[number]; export const sessionSegmentPayload = z.object({ workOrderId: id, type: z.enum(SESSION_SEGMENT_TYPES), at: isoDate.optional(), }); /** Time entry types (Prisma enum TimeEntryType). */ export const TIME_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const; export type TimeType = (typeof TIME_TYPES)[number]; /** * time.add_manual (L12): manual time entry. Own entries become `pending` (approval by team lead / * back office); with `field:correct_time` for a team member (`forUserId`) they are approved directly. * Either `endedAt` or `durationMinutes` is required. */ export const timeAddManualPayload = z.object({ workOrderId: id, clientId: clientId.optional(), type: z.enum(TIME_TYPES).default("work"), startedAt: isoDate, endedAt: isoDate.optional(), durationMinutes: z.number().int().min(1).max(16 * 60).optional(), reason: z.string().trim().min(3).max(500), note: z.string().trim().max(2000).optional(), forUserId: id.optional(), }); /** time.propose_correction (L12): correction proposal for an own entry; old values count until approval. */ export const timeProposeCorrectionPayload = z.object({ workOrderId: id, timeEntryId: id, type: z.enum(TIME_TYPES).optional(), startedAt: isoDate, endedAt: isoDate, reason: z.string().trim().min(3).max(500), }); export const workOrderTransitionPayload = z.object({ workOrderId: id, to: z.enum(WORK_ORDER_STATUSES), reason: z.string().max(1000).optional(), }); export const noteCreatePayload = z.object({ workOrderId: id, clientId: clientId.optional(), kind: z.enum(NOTE_KINDS).default("general"), text: z.string().trim().min(1).max(10_000), }); export const checklistTogglePayload = z.object({ workOrderId: id, itemId: id, checked: z.boolean(), comment: z.string().max(2000).nullish(), }); export const materialUpsertPayload = z.object({ workOrderId: id, clientId: clientId.optional(), materialPlanId: id.nullish(), name: z.string().trim().max(200).optional(), articleNumber: z.string().trim().max(100).nullish(), quantity, unit: z.string().trim().min(1).max(20), usageStatus: z.enum(MATERIAL_USAGE_STATUSES), deviationReason: z.string().trim().max(2000).nullish(), notes: z.string().trim().max(2000).nullish(), photoId: id.nullish(), }); export const photoAttachPayload = z.object({ workOrderId: id, clientId: clientId.optional(), documentId: id, phase: z.enum(PHOTO_PHASES).nullish(), photoRequirementId: id.nullish(), checklistItemId: id.nullish(), comment: z.string().trim().max(2000).nullish(), takenAt: isoDate.optional(), latitude: lat.optional(), longitude: lng.optional(), }); export const voiceAttachPayload = z.object({ workOrderId: id, clientId: clientId.optional(), documentId: id, durationSeconds: z.number().int().min(0).max(300).optional(), recordedAt: isoDate.optional(), /** optional note kind the transcript is filed under */ kind: z.enum(NOTE_KINDS).optional(), }); /** * report.save_draft / report.submit (L10b, registered in services/sync/external-ops.ts → * services/reports/sync-ops.ts). `workOrderId` is required: the sync pipeline checks scope and the * conflict version (`baseVersion` = WorkOrder.version seen by the device) on that order. */ export const reportSaveDraftPayload = z.object({ workOrderId: id, reportId: id, texts: reportTextsSchema.partial(), }); export const reportSubmitPayload = z.object({ workOrderId: id, reportId: id, /** L9 Freigabeprinzip: "Ich habe den Vorschlag vom Lotsen geprüft" — mandatory for Lotse drafts */ aiReviewed: z.boolean().optional(), }); /** Schemas of ops owned by other lanes are validated there (signature.capture: not offline-capable yet). */ const passthrough = z.record(z.string(), z.unknown()); export const OP_PAYLOAD_SCHEMAS = { "session.start": sessionStartPayload, "session.pause": sessionControlPayload, "session.resume": sessionResumePayload, "session.end": sessionControlPayload, "work_order.transition": workOrderTransitionPayload, "note.create": noteCreatePayload, "checklist.toggle": checklistTogglePayload, "material.upsert": materialUpsertPayload, "photo.attach": photoAttachPayload, "voice.attach": voiceAttachPayload, "report.save_draft": reportSaveDraftPayload, "report.submit": reportSubmitPayload, "signature.capture": passthrough, "emergency.create": emergencyCreatePayload, "session.stop_day": sessionControlPayload, "session.segment": sessionSegmentPayload, "time.add_manual": timeAddManualPayload, "time.propose_correction": timeProposeCorrectionPayload, "milestone.reach": passthrough, // L14: validated in services/billing/sync-ops.ts (lib/billing/schemas.ts#milestoneReachPayload) } satisfies Record; export type OpPayload = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; export type ParsedOpPayload = z.output<(typeof OP_PAYLOAD_SCHEMAS)[T]>;