Files
craftvia/src/server/services/field/common.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

66 lines
2.2 KiB
TypeScript

import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
/** Shared helpers of the field services (lane L4). */
export type FieldOrder = {
id: string;
number: string;
status: WorkOrderStatus;
version: number;
siteId: string | null;
customerId: string;
assignedTeamId: string | null;
};
const FIELD_ORDER_SELECT = {
id: true,
number: true,
status: true,
version: true,
siteId: true,
customerId: true,
assignedTeamId: true,
} as const;
/**
* Loads a work order for a field mutation: requires `field:execute`, the order must be in the
* user's visibility scope (otherwise not_found — existence is never revealed) and, when
* `editable`, in a status that allows field documentation.
*/
export async function requireFieldOrder(ctx: ServiceCtx, workOrderId: string, opts: { editable?: boolean } = {}): Promise<FieldOrder> {
assertCan(ctx, "field:execute");
const wo = (await requireVisibleWorkOrder(ctx, workOrderId, FIELD_ORDER_SELECT)) as FieldOrder;
if (opts.editable && !FIELD_EDITABLE.includes(wo.status)) {
throw new ServiceError("invalid", `work order status ${wo.status} does not allow field documentation`);
}
return wo;
}
/** Client timestamp of an operation; future values (clock skew) are clamped to now. */
export function opTime(at?: string | null): Date {
const now = new Date();
if (!at) return now;
const d = new Date(at);
if (Number.isNaN(d.getTime())) throw new ServiceError("invalid", "invalid timestamp");
return d.getTime() > now.getTime() + 60_000 ? now : d;
}
export async function audit(
ctx: ServiceCtx,
action: "create" | "update" | "delete",
entity: string,
entityId: string,
before?: unknown,
after?: unknown,
): Promise<void> {
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
}
/** Prisma unique-constraint violation (used for idempotent creates under races). */
export function isUniqueViolation(err: unknown): boolean {
return (err as { code?: string })?.code === "P2002";
}