Files
craftvia/src/lib/sync/ops.ts
T
msolarczekandClaude Opus 5 952e74cddd L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente,
  eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit,
  Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen,
  Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads
  (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle
- services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung,
  Registry für Ops anderer Lanes, lane-lokaler requireApiContext
- /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id]
- lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts
- Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3),
  getSiteHistory (L1)
- Processor image-derivatives + Registrierung, Audit-Entity-Labels
- Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:29:48 +02:00

137 lines
4.5 KiB
TypeScript

import { z } from "zod";
import type { SyncOpType } from "./envelope";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
/**
* 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(),
});
export const sessionControlPayload = z.object({
workOrderId: id,
at: isoDate.optional(),
});
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(),
});
/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */
const passthrough = z.record(z.string(), z.unknown());
export const OP_PAYLOAD_SCHEMAS = {
"session.start": sessionStartPayload,
"session.pause": sessionControlPayload,
"session.resume": sessionControlPayload,
"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": passthrough,
"report.submit": passthrough,
"signature.capture": passthrough,
"emergency.create": passthrough,
} satisfies Record<SyncOpType, z.ZodType>;
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
export type ParsedOpPayload<T extends SyncOpType> = z.output<(typeof OP_PAYLOAD_SCHEMAS)[T]>;