Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte

- Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls)
- TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt
- moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services
- Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes,
  Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope
- docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD

Gate: tsc, lint, build, 22/22 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:49:21 +02:00
co-authored by Claude Opus 5
parent 1701db0a62
commit bf4456718e
21 changed files with 2670 additions and 12 deletions
+32
View File
@@ -0,0 +1,32 @@
// Domain event catalogue (client-safe). Lanes emit via src/server/events.ts#emitEvent;
// the notifications lane maps events to recipients, in-app notifications and e-mails.
export const EVENT_TYPES = [
"work_order.assigned",
"work_order.changed",
"work_order.cancelled",
"work_order.started",
"work_order.daily_report_created",
"work_order.technically_completed",
"work_order.signature_missing",
"work_order.missing_required",
"work_order.released_for_billing",
"report.submitted",
"report.approved",
"report.rejected",
"emergency.created",
"emergency.completed",
"import.ready_for_review",
"import.failed",
"sync.failed",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export type DomainEvent = {
type: EventType;
entityType: "work_order" | "report" | "import_job" | "sync_operation";
entityId: string;
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
data?: Record<string, string | number | boolean | null>;
};
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
/**
* Offline sync envelope (ARCHITEKTUR §4.6). The per-op payload schemas live in
* src/lib/sync/ops.ts (owned by lane field); this file only fixes the wire format.
*/
export const SYNC_OP_TYPES = [
"session.start",
"session.pause",
"session.resume",
"session.end",
"work_order.transition",
"note.create",
"checklist.toggle",
"material.upsert",
"photo.attach",
"voice.attach",
"report.save_draft",
"report.submit",
"signature.capture",
"emergency.create",
] as const;
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
/** Ops that compare WorkOrder.version and may produce a conflict. All others are additive. */
export const CONFLICTING_OPS: readonly SyncOpType[] = ["work_order.transition", "report.submit"];
export const syncOperationSchema = z.object({
clientOpId: z.string().uuid(),
opType: z.enum(SYNC_OP_TYPES),
entityType: z.string().max(40).optional(),
entityId: z.string().max(64).optional(),
baseVersion: z.number().int().positive().optional(),
payload: z.record(z.string(), z.unknown()),
clientCreatedAt: z.string().datetime(),
});
export type SyncOperationInput = z.infer<typeof syncOperationSchema>;
export const syncRequestSchema = z.object({
deviceId: z.string().max(64),
operations: z.array(syncOperationSchema).min(1).max(100),
});
export type SyncOpResult = {
clientOpId: string;
status: "applied" | "duplicate" | "conflict" | "rejected";
/** server ids created by the op, keyed by the client id they replace */
idMap?: Record<string, string>;
entityVersion?: number;
errorCode?: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal";
message?: string;
};
export type SyncResponse = { results: SyncOpResult[]; serverTime: string };
+129
View File
@@ -0,0 +1,129 @@
// Work order status machine (client-safe: no server imports).
// Server enforcement lives in src/server/services/work-orders/transition.ts,
// which MUST call canTransition()/requiredPermission() — never update status directly.
export const WORK_ORDER_STATUSES = [
"draft",
"review_required",
"planned",
"assigned",
"accepted",
"en_route",
"in_progress",
"paused",
"waiting_material",
"daily_report_created",
"technically_completed",
"signature_pending",
"in_review",
"released_for_billing",
"billed",
"cancelled",
] as const;
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
const T: Record<WorkOrderStatus, readonly WorkOrderStatus[]> = {
draft: ["review_required", "planned", "assigned", "cancelled"],
review_required: ["planned", "assigned", "cancelled"],
planned: ["assigned", "cancelled"],
assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"],
accepted: ["en_route", "in_progress", "assigned", "cancelled"],
en_route: ["in_progress", "cancelled"],
in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"],
paused: ["in_progress", "en_route", "cancelled"],
waiting_material: ["in_progress", "en_route", "cancelled"],
daily_report_created: ["en_route", "in_progress", "cancelled"],
technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"],
signature_pending: ["in_review", "cancelled"],
in_review: ["released_for_billing", "in_progress", "cancelled"],
released_for_billing: ["billed", "in_review"],
billed: [],
cancelled: [],
};
export function allowedTransitions(from: WorkOrderStatus): readonly WorkOrderStatus[] {
return T[from];
}
export function canTransition(from: WorkOrderStatus, to: WorkOrderStatus): boolean {
return T[from].includes(to);
}
/** Permission required for a transition (server additionally checks work order scope for field roles). */
export function requiredPermission(from: WorkOrderStatus, to: WorkOrderStatus): string {
if (to === "cancelled") return "work_order:cancel";
if (to === "released_for_billing" || to === "billed") return "work_order:release_billing";
if (from === "released_for_billing" && to === "in_review") return "work_order:release_billing";
if (from === "in_review" && to === "in_progress") return "report:approve_team"; // or report:approve (checked server-side)
if (["draft", "review_required", "planned"].includes(from)) return to === "assigned" ? "work_order:assign" : "work_order:write";
if (from === "assigned" && to === "planned") return "work_order:assign";
return "field:execute";
}
/** Statuses in which field users may record time/material/photos/notes. */
export const FIELD_EDITABLE: readonly WorkOrderStatus[] = [
"assigned",
"accepted",
"en_route",
"in_progress",
"paused",
"waiting_material",
"daily_report_created",
"technically_completed",
"signature_pending",
];
export const OPEN_STATUSES: readonly WorkOrderStatus[] = WORK_ORDER_STATUSES.filter(
(s) => !["billed", "cancelled", "released_for_billing"].includes(s),
);
/** UI groups per Brandbook §12.3 — label keys live in messages/<locale>/workOrders.json → statusGroup.<key>. */
export const STATUS_GROUP: Record<WorkOrderStatus, StatusGroup> = {
draft: "new",
review_required: "new",
planned: "planned",
assigned: "planned",
accepted: "planned",
en_route: "en_route",
in_progress: "in_progress",
paused: "in_progress",
waiting_material: "in_progress",
daily_report_created: "in_progress",
technically_completed: "documentation_incomplete",
signature_pending: "documentation_incomplete",
in_review: "in_review",
released_for_billing: "ready_for_billing",
billed: "billed",
cancelled: "cancelled",
};
export type StatusGroup =
| "new"
| "planned"
| "en_route"
| "in_progress"
| "documentation_incomplete"
| "in_review"
| "ready_for_billing"
| "billed"
| "cancelled";
/** Semantic tone for badges (always combined with text, Brandbook §11.4). */
export const STATUS_GROUP_TONE: Record<StatusGroup, "neutral" | "info" | "accent" | "warning" | "success" | "danger"> = {
new: "neutral",
planned: "info",
en_route: "accent",
in_progress: "accent",
documentation_incomplete: "warning",
in_review: "info",
ready_for_billing: "success",
billed: "success",
cancelled: "danger",
};
export type CompletionBlocker =
| { kind: "checklist_item"; itemId: string; label: string }
| { kind: "photo_requirement"; requirementId: string; label: string }
| { kind: "running_session"; sessionId: string; userId: string }
| { kind: "missing_field"; field: string };