Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht

- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2
- Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes,
  readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage
- Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als
  imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts
- Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf
- L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay
- PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF
- Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned
- Dateilinks in Auftragsdetail auf /files/[documentId]
- proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect
- ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams)

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:49:19 +02:00
co-authored by Claude Opus 5
parent 2984cc18d3
commit 3883296300
37 changed files with 216 additions and 798 deletions
+2
View File
@@ -26,6 +26,8 @@ Siehe `src/server/rbac.ts` (vom Fundament angelegt). Rollen: `tenant-admin`, `ba
- Kunden/Objekte für Monteure: nur lesbar, wenn über einen sichtbaren Auftrag erreichbar (`customerScope`, `siteScope` in derselben Datei).
Jede Query auf WorkOrder und abhängige Entitäten (Photos, Reports, Documents …) für Nicht-Backoffice-Rollen MUSS diesen Scope verwenden.
**Objekt-Historie für Feldrollen (Entscheidung, US-005/US-011):** Monteure/Teamleiter sehen an einem Objekt, das sie über einen eigenen sichtbaren Auftrag erreichen (`siteScope`), die FREIGEGEBENEN Einsätze aller Teams (Aufträge mit freigegebenem Bericht; `getSiteHistory` in `services/sites/history.ts`). Interne Hinweise sind nie Teil der Historie.
**Dokument-Sichtbarkeit:** `documentVisibilityFilter(ctx)`: `backoffice_only` nur mit `document:read_internal`; `team_lead` nur Teamleiter/Backoffice; `team`/`customer_report` für berechtigte Auftragsbeteiligte.
## 3. Statusmodell Auftrag
+1 -1
View File
@@ -22,7 +22,7 @@ import { rejectReport } from "../src/server/services/reports/reject";
import { createNewVersion } from "../src/server/services/reports/new-version";
import { requireVisibleReport } from "../src/server/services/reports/common";
import { listReports } from "../src/server/services/reports/queries";
import { storeFile } from "../src/server/services/reports/_stubs/documents";
import { storeFile } from "../src/server/services/documents/store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
+3 -2
View File
@@ -15,7 +15,8 @@ import { submitReport } from "../src/server/services/reports/submit";
import { approveReport } from "../src/server/services/reports/approve";
import { generateReportPdf } from "../src/server/services/reports/pdf";
import { openReportFile } from "../src/server/services/reports/files";
import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents";
import { readStoredBytes } from "../src/server/services/documents/read";
import { sha256Hex, storeFile } from "../src/server/services/documents/store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
@@ -101,7 +102,7 @@ async function main() {
ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`);
ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag");
ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert");
const bytes = await readFileBytes(doc.storageKey);
const bytes = await readStoredBytes(doc.storageKey);
if (bytes) {
ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes");
+1 -1
View File
@@ -13,7 +13,7 @@ import { upsertMaterialUsage } from "../src/server/services/field/materials";
import { toggleChecklistItem } from "../src/server/services/field/checklist";
import { createNote } from "../src/server/services/field/notes";
import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries";
import { transitionWorkOrder } from "../src/server/services/field/stubs/work-order-transition";
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
const min = (n: number) => n * 60 * 1000;
+2 -1
View File
@@ -180,7 +180,8 @@ async function main() {
const wo1 = await prisma.workOrder.findUnique({ where: { id: res1.workOrderId }, include: { statusHistory: { orderBy: { createdAt: "asc" } }, materialPlans: true } });
ok(wo1?.status === "planned" && wo1.sourceImportId === job1.id && wo1.customerId === existing.id && wo1.siteId === existingSite.id, "(K3) work order planned, linked to import, customer and site");
ok(wo1?.number.startsWith("A-") === true && wo1.externalOrderNumber === "AB-2026-0815" && wo1.plannedStart?.toISOString().startsWith("2026-10-12") === true, "(K4) number allocated, document order number and dates taken over");
ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "review_required>planned", "(K5) status history review_required → planned");
// The review happens on the ImportJob ("Entwurf – Prüfung erforderlich"); the confirmed order starts planned.
ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "planned", "(K5) confirmed order starts with status history → planned");
ok(wo1?.materialPlans.length === 1 && wo1.materialPlans[0].articleNumber === "WP-AT-12", "(K6) only positions marked as material become material plan");
ok((await prisma.customer.count({ where: { tenantId: tA.id } })) === customersBefore, "(K7) no new customer when existing one is used");
const docAfter = await prisma.document.findUnique({ where: { id: job1.documentId } });
+4 -3
View File
@@ -9,8 +9,9 @@ import type { WorkOrderExtraction } from "../src/server/ai/providers";
import { emptyExtraction, extractionJsonSchema, parseExtraction, EXTRACTION_FIELDS } from "../src/lib/imports/extraction";
import { checkPlausibility, parseDate, toIsoDate, VIOLATION_CONFIDENCE } from "../src/lib/imports/plausibility";
import { computeCorrections, extractionToForm, formFieldMeta, reviewFormSchema } from "../src/lib/imports/review";
import { normalizeCompany, normalizeStreet } from "../src/server/services/imports/duplicates-stub";
import { normalizeFileName, sniffMime } from "../src/server/services/imports/document-store-stub";
import { normalizeCompanyName as normalizeCompany, normalizeStreet } from "../src/lib/customers/duplicates";
import { detectMime as sniffMime } from "../src/server/services/documents/scanner";
import { normalizeFileName } from "../src/server/services/documents/store";
import { buildPdf } from "./make-sample-pdfs";
let failures = 0;
@@ -191,7 +192,7 @@ function sample(): WorkOrderExtraction {
ok(sniffMime(pdf) === "application/pdf", "(F1) generated sample is a PDF by magic bytes");
ok(sniffMime(Buffer.from([0xff, 0xd8, 0xff, 0xe0])) === "image/jpeg", "(F2) JPEG magic bytes");
ok(sniffMime(Buffer.from("MZ\x90\x00 fake exe", "latin1")) === null, "(F3) executable rejected");
ok(normalizeFileName("../../etc/pass<wd>.pdf") === "pa_ss_wd_.pdf", "(F4) file name without path and control characters");
ok(normalizeFileName("../../etc/pass<wd>.pdf") === "pass_wd_.pdf", "(F4) file name without path, control characters removed, reserved characters replaced");
const raw = pdf.toString("latin1");
ok(raw.includes("(Auftragsbest\\344tigung Gr\\366\\337e 5 \\200)") && raw.trimEnd().endsWith("%%EOF"), "(F5) PDF writer encodes umlauts/€ as WinAnsi octal escapes and closes the file");
}
@@ -271,8 +271,8 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
{h.workOrderNumber} · {h.workOrderTitle}
</span>
</span>
{h.pdfDocumentId && (
<a href={`/api/v1/field/documents/${h.pdfDocumentId}`} target="_blank" rel="noopener" className="inline-flex min-h-12 items-center px-2 text-[13px] font-semibold text-primary">
{h.pdfHref && (
<a href={h.pdfHref} target="_blank" rel="noopener" className="inline-flex min-h-12 items-center px-2 text-[13px] font-semibold text-primary">
{t("detail.openDocument")}
</a>
)}
+3 -3
View File
@@ -339,9 +339,9 @@ export async function PhotosTab({ ctx, wo, locale, tz }: TabProps) {
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{photos.map((p) => (
<li key={p.id} className="shadow-card overflow-hidden rounded-xl border bg-card">
<a href={`/files/${p.document.storageKey}`} className="block aspect-[4/3] bg-muted">
<a href={`/files/${p.document.id}`} className="block aspect-[4/3] bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- tenant file route, no next/image optimisation for private files */}
<img src={`/files/${p.document.previewKey ?? p.document.storageKey}`} alt={p.comment ?? p.photoRequirement?.label ?? p.document.fileName} className="size-full object-cover" loading="lazy" />
<img src={`/files/${p.document.id}`} alt={p.comment ?? p.photoRequirement?.label ?? p.document.fileName} className="size-full object-cover" loading="lazy" />
</a>
<div className="p-2 text-xs">
<p className="flex items-center gap-1 font-semibold">
@@ -452,7 +452,7 @@ export async function DocumentsTab({ ctx, wo, locale, tz, uploadError, uploaded
</p>
</div>
<div className="flex gap-1">
<a href={`/files/${d.storageKey}`} className={buttonCls("outline")}>
<a href={`/files/${d.id}`} className={buttonCls("outline")}>
<Download className="size-4" aria-hidden />
{t("documents.download")}
</a>
+7
View File
@@ -43,6 +43,13 @@ export function proxy(request: NextRequest) {
request.cookies.has("__Secure-authjs.session-token");
if (!hasSessionCookie) {
// Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page.
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {
return NextResponse.json(
{ error: { code: "unauthorized", message: "Nicht angemeldet." } },
{ status: 401, headers: { "Cache-Control": "no-store" } },
);
}
const loginUrl = new URL("/login", request.url);
if (pathname !== "/") loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
+1 -1
View File
@@ -7,7 +7,7 @@ import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { requireVisibleReport } from "@/server/services/reports/common";
import { captureSignature } from "@/server/services/reports/signature";
// TODO(merge L4): signature PNG could go through POST /api/v1/uploads once available
import { storeFile } from "@/server/services/reports/_stubs/documents";
import { storeFile } from "@/server/services/documents/store";
import { errorState, okState, str } from "./_state";
const guard = moduleGuard("reports");
@@ -3,7 +3,7 @@ import type { JobPayload } from "@/server/jobs/queues";
import type { ServiceCtx } from "@/server/services/context";
import { getExtractionProvider } from "@/server/ai/extraction/anthropic";
import { processImport } from "@/server/services/imports/process";
import { readDocumentBytes } from "@/server/services/imports/document-store-stub";
import { readStoredBytes } from "@/server/services/documents/read";
/**
* BullMQ processor for "import-extraction" (ARCHITEKTUR §4.4). System context: tenant from the
@@ -20,6 +20,6 @@ export async function process(payload: JobPayload): Promise<void> {
};
await processImport(ctx, payload.entityId, {
provider: getExtractionProvider(),
loadBytes: (doc) => readDocumentBytes(doc.storageKey),
loadBytes: (doc) => readStoredBytes(doc.storageKey),
});
}
+3 -2
View File
@@ -5,14 +5,15 @@ import type { JobPayload } from "../queues";
/**
* Queue "report-pdf": renders the PDF of an approved report (lane L5).
* System context: tenant-bound db, read access to all reports of the tenant — nothing else.
* System context: tenant-bound db, read access to all reports of the tenant, and storing the PDF document.
*/
export async function process(payload: JobPayload): Promise<void> {
const ctx: ServiceCtx = {
db: dbForTenant(payload.tenantId),
tenantId: payload.tenantId,
userId: payload.actorId ?? "system",
permissions: new Set(["report:read", "work_order:read_all", "document:read_internal"]),
// document:write: the system stores the rendered PDF via the central document service.
permissions: new Set(["report:read", "work_order:read_all", "document:read_internal", "document:write"]),
};
const res = await generateReportPdf(ctx, payload.entityId);
console.info(`[report-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`);
+45
View File
@@ -0,0 +1,45 @@
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { sha256Hex } from "@/server/services/documents/store";
/**
* Server-side byte access to stored documents (workers, PDF rendering, import extraction).
*
* NO visibility/scope check here — this is not a download path. User-facing downloads must go
* through `services/documents/access.ts#openDocumentContent` (/files/[documentId]).
*/
/** Read raw bytes by storage key; null when the object does not exist (or storage is the stub). */
export async function readStoredBytes(storageKey: string): Promise<Buffer | null> {
const content = await storage.get(storageKey);
if (!content) return null;
const chunks: Uint8Array[] = [];
const reader = content.stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return Buffer.concat(chunks);
}
/**
* Load a tenant document's bytes and verify its SHA-256 checksum.
* Throws `not_found` (missing/soft-deleted/foreign tenant/no content) or `invalid` (checksum mismatch).
*/
export async function readDocumentBytes(
ctx: ServiceCtx,
documentId: string,
): Promise<{ bytes: Buffer; mimeType: string; fileName: string; checksum: string }> {
const document = await ctx.db.document.findFirst({
where: { id: documentId, deletedAt: null },
select: { storageKey: true, mimeType: true, fileName: true, checksum: true },
});
if (!document) throw new ServiceError("not_found", "document not found");
// defense in depth: the key must carry the tenant prefix
if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available");
const bytes = await readStoredBytes(document.storageKey);
if (!bytes) throw new ServiceError("not_found", "document content not available");
if (sha256Hex(bytes) !== document.checksum) throw new ServiceError("invalid", "document checksum mismatch");
return { bytes, mimeType: document.mimeType, fileName: document.fileName, checksum: document.checksum };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Magic-byte detection for mobile uploads (photo/voice note/preview): decides BEFORE storing
* whether the payload matches the declared kind. Final validation happens in
* services/documents/store.ts#storeFile (lane master data).
*/
const MB = 1024 * 1024;
export type Kind = "image" | "pdf" | "audio";
const LIMITS: Record<Kind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
/** Detects the real MIME type from magic bytes (null = not allowed). */
export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null {
const b = bytes;
const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v);
const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s;
if (b.length < 12) return null;
if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" };
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" };
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" };
if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" };
if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" };
if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" };
if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" };
if (ascii(4, "ftyp")) {
const brand = b.toString("latin1", 8, 12);
if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" };
return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder
}
if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" };
return null;
}
+36 -5
View File
@@ -4,8 +4,39 @@ import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } fr
import { STATUS_GROUP, type CompletionBlocker, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
import { ACTIVE_SESSION_STATUSES } from "./sessions";
// TODO(merge L2/L1): replace with the lane implementations
import { completionBlockers } from "./stubs/work-order-transition";
import { getSiteHistory, type SiteHistoryEntry } from "./stubs/site-history";
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
import { getSiteHistory } from "@/server/services/sites/history";
/** One released report at the site, as shown in the mobile order detail (US-005). */
export type SiteHistoryEntry = {
reportId: string;
reportType: "daily" | "completion";
reportDate: Date;
workOrderId: string;
workOrderNumber: string;
workOrderTitle: string;
/** Immutable PDF of the approved report (lane reports API; requires report:read). */
pdfHref: string;
};
/** Adapter over the site history service (lane master data): flattens approved reports, newest first. */
async function fieldSiteHistory(ctx: ServiceCtx, siteId: string, limit = 20): Promise<SiteHistoryEntry[]> {
const { items } = await getSiteHistory(ctx, siteId, { onlyApproved: true, pageSize: Math.min(100, limit * 2) });
return items
.flatMap((i) =>
i.approvedReports.map((r) => ({
reportId: r.id,
reportType: r.type,
reportDate: r.reportDate,
workOrderId: i.workOrderId,
workOrderNumber: i.number,
workOrderTitle: i.title,
pdfHref: `/api/v1/reports/${encodeURIComponent(r.id)}/pdf`,
})),
)
.sort((a, b) => b.reportDate.getTime() - a.reportDate.getTime())
.slice(0, limit);
}
/** Read models of the mobile app (Spec §11.2, §22, US-005). All reads go through workOrderScope. */
@@ -208,8 +239,8 @@ export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string):
const [orderDocs, siteDocs, siteHistory, blockers] = await Promise.all([
ctx.db.document.findMany({ where: { ...docWhere, workOrderId: wo.id }, orderBy: { createdAt: "desc" }, select: docSelect }),
wo.siteId ? ctx.db.document.findMany({ where: { ...docWhere, siteId: wo.siteId, workOrderId: null }, orderBy: { createdAt: "desc" }, select: docSelect }) : Promise.resolve([]),
wo.siteId ? getSiteHistory(ctx, wo.siteId, { onlyApproved: true }).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]),
completionBlockers(ctx, wo.id),
wo.siteId ? fieldSiteHistory(ctx, wo.siteId).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]),
getCompletionBlockers(ctx, wo.id),
]);
// only the newest version per lineage
@@ -268,7 +299,7 @@ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) {
const siteIds = [...new Set(orders.map((o) => o.site?.id).filter((id): id is string => !!id))];
const histories = Object.fromEntries(
await Promise.all(siteIds.map(async (id) => [id, await getSiteHistory(ctx, id, { onlyApproved: true, limit: 5 }).catch(() => [])] as const)),
await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)),
);
return {
serverTime: serverTime.toISOString(),
+1 -1
View File
@@ -4,7 +4,7 @@ import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
import type { ParsedOpPayload } from "@/lib/sync/ops";
import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common";
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "./stubs/work-order-transition";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
/**
* Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists
@@ -1,97 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L4) — stands in for `src/server/services/documents/store.ts#storeFile` (ARCHITEKTUR §4.3),
* which does not exist on the base commit. Same signature; validates allowlist, size limit per
* kind, magic bytes, normalises the file name, computes SHA-256 and stores via the storage adapter.
* On merge of the documents contract: delete this file and import the shared implementation.
*/
export type StoreFileInput = {
bytes: Buffer;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
lineageId?: string;
title?: string | null;
};
const MB = 1024 * 1024;
type Kind = "image" | "pdf" | "audio";
const LIMITS: Record<Kind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
/** Detects the real MIME type from magic bytes (null = not allowed). */
export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null {
const b = bytes;
const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v);
const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s;
if (b.length < 12) return null;
if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" };
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" };
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" };
if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" };
if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" };
if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" };
if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" };
if (ascii(4, "ftyp")) {
const brand = b.toString("latin1", 8, 12);
if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" };
return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder
}
if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" };
return null;
}
export function normalizeFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "datei";
return base.normalize("NFC").replace(/[\u0000-\u001f<>:"|?*]+/g, "_").trim().slice(0, 180) || "datei";
}
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
const sniffed = sniffMime(input.bytes);
if (!sniffed) throw new ServiceError("invalid", "file type not allowed");
if (input.bytes.byteLength > LIMITS[sniffed.kind]) throw new ServiceError("invalid", "file too large");
const declaredBase = input.declaredMime.split(";")[0].trim().toLowerCase();
// declared type must at least belong to the same family (image/*, audio/*, video/webm|mp4 for audio containers, pdf)
const family = declaredBase.split("/")[0];
const familyOk =
sniffed.kind === "pdf" ? declaredBase === "application/pdf" : sniffed.kind === "image" ? family === "image" : family === "audio" || declaredBase === "video/webm" || declaredBase === "video/mp4";
if (!familyOk) throw new ServiceError("invalid", "declared MIME type does not match content");
const fileName = normalizeFileName(input.fileName);
const checksum = createHash("sha256").update(input.bytes).digest("hex");
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed.mime, bytes: input.bytes });
let version = 1;
const lineageId = input.lineageId ?? randomUUID();
if (input.lineageId) {
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
if (last) version = last.version + 1;
}
return ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
customerId: input.links.customerId ?? null,
siteId: input.links.siteId ?? null,
workOrderId: input.links.workOrderId ?? null,
category: input.category,
title: input.title ?? null,
fileName,
storageKey: stored.storageKey,
mimeType: sniffed.mime,
fileSize: input.bytes.byteLength,
checksum,
version,
lineageId,
visibility: input.visibility,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
}
@@ -1,51 +0,0 @@
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { siteScope } from "@/server/services/work-orders/visibility";
/**
* STUB (lane L4) — stands in for L1 `src/server/services/sites/history.ts#getSiteHistory`
* (Spec §8.3) until lane "Stammdaten" is merged. Contract used by the mobile order detail:
* getSiteHistory(ctx, siteId, { onlyApproved }) → SiteHistoryEntry[] (newest first)
* Read-only; the site must be in the user's site scope (otherwise not_found).
*/
export type SiteHistoryEntry = {
reportId: string;
reportType: "daily" | "completion";
reportDate: Date;
approvedAt: Date | null;
pdfDocumentId: string | null;
workOrderId: string;
workOrderNumber: string;
workOrderTitle: string;
};
export async function getSiteHistory(ctx: ServiceCtx, siteId: string, opts: { onlyApproved: boolean; limit?: number }): Promise<SiteHistoryEntry[]> {
const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } });
if (!site) throw new ServiceError("not_found", "site not found");
const reports = await ctx.db.report.findMany({
where: {
workOrder: { siteId, deletedAt: null },
...(opts.onlyApproved ? { status: "approved" as const } : { status: { not: "superseded" as const } }),
},
orderBy: [{ reportDate: "desc" }, { createdAt: "desc" }],
take: opts.limit ?? 20,
select: {
id: true,
type: true,
reportDate: true,
approvedAt: true,
pdfDocumentId: true,
workOrder: { select: { id: true, number: true, title: true } },
},
});
return reports.map((r) => ({
reportId: r.id,
reportType: r.type,
reportDate: r.reportDate,
approvedAt: r.approvedAt,
pdfDocumentId: r.pdfDocumentId,
workOrderId: r.workOrder.id,
workOrderNumber: r.workOrder.number,
workOrderTitle: r.workOrder.title,
}));
}
@@ -1,97 +0,0 @@
import { emitEvent } from "@/server/events";
import { writeAuditLog } from "@/server/audit";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
import type { EventType } from "@/lib/events";
import {
canTransition,
requiredPermission,
type CompletionBlocker,
type WorkOrderStatus,
} from "@/lib/work-orders/status";
/**
* STUB (lane L4) — stands in for L2 `src/server/services/work-orders/transition.ts#transitionWorkOrder`
* until lane "Aufträge" is merged. Same contract as ARCHITEKTUR §3:
* transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version }
* throws ServiceError not_found | forbidden | invalid | conflict | blocked (details: CompletionBlocker[]).
* On merge: delete this file and point the imports in services/field + services/sync to L2's module.
*/
export type TransitionInput = {
workOrderId: string;
to: WorkOrderStatus;
reason?: string | null;
/** optimistic concurrency: must match WorkOrder.version when given */
baseVersion?: number;
};
export type TransitionResult = { id: string; from: WorkOrderStatus; status: WorkOrderStatus; version: number };
const EVENT_FOR: Partial<Record<WorkOrderStatus, EventType>> = {
in_progress: "work_order.started",
daily_report_created: "work_order.daily_report_created",
technically_completed: "work_order.technically_completed",
released_for_billing: "work_order.released_for_billing",
cancelled: "work_order.cancelled",
};
/** Completion guards (ARCHITEKTUR §3): required checklist items, photo requirements, running sessions. */
export async function completionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
const [items, requirements, sessions] = await Promise.all([
ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }),
ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }),
ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }),
]);
return [
...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })),
...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })),
...sessions.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })),
];
}
export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<TransitionResult> {
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, version: true });
const from = wo.status as WorkOrderStatus;
if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`);
const permission = requiredPermission(from, input.to);
if (permission === "report:approve_team") {
if (!can(ctx, "report:approve_team") && !can(ctx, "report:approve")) throw new ServiceError("forbidden", "missing permission report:approve_team");
} else {
assertCan(ctx, permission);
}
if (input.baseVersion !== undefined && input.baseVersion !== wo.version) {
throw new ServiceError("conflict", "work order was changed in the meantime", { currentVersion: wo.version });
}
if (input.to === "technically_completed") {
const blockers = await completionBlockers(ctx, wo.id);
if (blockers.length) throw new ServiceError("blocked", "completion requirements missing", blockers);
}
const updated = await ctx.db.workOrder.updateMany({
where: { id: wo.id, version: wo.version },
data: { status: input.to, version: { increment: 1 } },
});
if (updated.count !== 1) throw new ServiceError("conflict", "work order was changed in the meantime");
await ctx.db.workOrderStatusChange.create({
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null },
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "work_order",
entityId: wo.id,
before: { status: from, version: wo.version },
after: { status: input.to, version: wo.version + 1, reason: input.reason ?? null },
});
await emitEvent(ctx, {
type: EVENT_FOR[input.to] ?? "work_order.changed",
entityType: "work_order",
entityId: wo.id,
data: { number: wo.number, from, to: input.to },
});
return { id: wo.id, from, status: input.to, version: wo.version + 1 };
}
+16 -11
View File
@@ -4,8 +4,8 @@ import { dispatchJob } from "@/server/jobs/dispatch";
import { JOB_QUEUES } from "@/server/jobs/queues";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { audit, isUniqueViolation, requireFieldOrder } from "./common";
// TODO(merge documents contract §4.3): replace with "@/server/services/documents/store"
import { sniffMime, storeFile } from "./stubs/documents-store";
import { storeFile } from "@/server/services/documents/store";
import { sniffMime } from "./mime";
/**
* Binary uploads of the mobile app (ARCHITEKTUR §4.6: POST /api/v1/uploads → documentId).
@@ -52,19 +52,24 @@ export async function storeFieldUpload(
const expectedKind = meta.kind === "photo" ? "image" : "audio";
if (!sniffed || sniffed.kind !== expectedKind) throw new ServiceError("invalid", `file is not a valid ${expectedKind}`);
// The central store treats `lineageId` as "new version of an existing document", so the file is
// stored as a fresh document and the deterministic upload lineage (idempotency key per device
// clientId) is attached afterwards. A concurrent upload with the same clientId loses the unique
// (lineageId, version) race: its document is soft-deleted and the winner is returned.
const created = await storeFile(ctx, {
bytes: file.bytes,
fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"),
declaredMime: file.type || sniffed.mime,
category: meta.kind,
visibility: "team",
links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId },
});
let doc;
try {
doc = await storeFile(ctx, {
bytes: file.bytes,
fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"),
declaredMime: file.type || sniffed.mime,
category: meta.kind,
visibility: "team",
links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId },
lineageId,
});
doc = await ctx.db.document.update({ where: { id: created.id }, data: { lineageId } });
} catch (err) {
if (isUniqueViolation(err)) {
await ctx.db.document.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
const raced = await replay();
if (raced) return raced;
}
+2 -8
View File
@@ -5,7 +5,7 @@ import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/serve
import { readStoredExtraction } from "@/lib/imports/extraction";
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
// TODO(L3→L2): replace with the L2 work order service after merge (same input type).
import { createWorkOrder } from "./work-orders-stub";
import { createWorkOrder } from "@/server/services/work-orders/create";
import { startExtraction, type Dispatch } from "./upload";
import { dispatchJob } from "@/server/jobs/dispatch";
@@ -168,13 +168,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } });
if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } });
await writeAuditLog({
...base,
action: "create",
entity: "work_order",
entityId: result.workOrderId,
after: { number: result.workOrderNumber, status: "planned", customerId: result.customerId, siteId: result.siteId, sourceImportId: job.id },
});
// work order creation is audited by services/work-orders/create (entity work_order)
await writeAuditLog({
...base,
action: "import",
@@ -1,101 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L3) for the document service contract ARCHITEKTUR §4.3
* `src/server/services/documents/store.ts#storeFile` — not present on the base commit.
* Same signature; replace the import in services/imports/upload.ts once the real service exists.
*
* Implements the parts the import needs: allowlist (PDF/JPEG/PNG), size limit per type
* (PDF 25 MB, images 15 MB), magic-byte check, normalised file name, SHA-256, storage.put,
* Document row with lineage.
*/
export type StoreFileInput = {
bytes: Buffer;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
lineageId?: string;
};
const LIMITS: Record<string, number> = {
"application/pdf": 25 * 1024 * 1024,
"image/jpeg": 15 * 1024 * 1024,
"image/png": 15 * 1024 * 1024,
};
/** Detect the real type from magic bytes (null = not allowed). */
export function sniffMime(bytes: Buffer): string | null {
if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-") return "application/pdf";
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
return null;
}
export function normalizeFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
const cleaned = base.normalize("NFC").replace(/[\x00-\x1f<>:"|?*]+/g, "_").replace(/\s+/g, " ").trim();
return (cleaned || "datei").slice(0, 180);
}
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
if (input.bytes.byteLength === 0) throw new ServiceError("invalid", "file_empty");
const sniffed = sniffMime(input.bytes);
if (!sniffed) throw new ServiceError("invalid", "file_type_not_allowed");
const declared = input.declaredMime === "image/jpg" ? "image/jpeg" : input.declaredMime;
// Browsers sometimes send application/octet-stream — the magic bytes are authoritative,
// but a declared allowed type must match them.
if (declared in LIMITS && declared !== sniffed) throw new ServiceError("invalid", "file_type_mismatch");
if (input.bytes.byteLength > LIMITS[sniffed]) throw new ServiceError("invalid", "file_too_large");
const fileName = normalizeFileName(input.fileName);
const checksum = createHash("sha256").update(input.bytes).digest("hex");
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed, bytes: input.bytes });
let version = 1;
const lineageId = input.lineageId ?? randomUUID();
if (input.lineageId) {
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
version = (last?.version ?? 0) + 1;
}
return ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
customerId: input.links.customerId ?? null,
siteId: input.links.siteId ?? null,
workOrderId: input.links.workOrderId ?? null,
category: input.category,
title: fileName,
fileName,
storageKey: stored.storageKey,
mimeType: sniffed,
fileSize: input.bytes.byteLength,
checksum,
version,
lineageId,
visibility: input.visibility,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
}
/** Read the bytes of a stored document (null if the backend keeps no bytes, e.g. stub storage). */
export async function readDocumentBytes(storageKey: string): Promise<Buffer | null> {
const content = await storage.get(storageKey);
if (!content) return null;
const chunks: Uint8Array[] = [];
const reader = content.stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return Buffer.concat(chunks);
}
@@ -1,142 +0,0 @@
import type { TenantDb } from "@/server/db";
import type { DuplicateCandidate, SiteCandidate } from "@/lib/imports/extraction";
/**
* STUB (lane L3) for the L1 contract `src/lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → Candidate[]`
* (ARCHITEKTUR §6, spec §7.3). Same signature and result shape `{ customerId, score, reasons[] }`;
* replace the import in services/imports/process.ts after the L1 merge.
*
* Compares customer number, company/person name, address, e-mail and phone. Never merges —
* it only proposes candidates; the backoffice decides (US-003).
*/
export type CustomerCandidateInput = {
customerNumber?: string | null;
companyName?: string | null;
firstName?: string | null;
lastName?: string | null;
street?: string | null;
houseNumber?: string | null;
postalCode?: string | null;
city?: string | null;
email?: string | null;
phone?: string | null;
};
const LEGAL_FORMS = /\b(gmbh|mbh|ag|kg|ohg|gbr|ug|e\.?\s?k|e\.?\s?v|co|haftungsbeschränkt|und|&)\b/g;
export function normalizeCompany(v: string | null | undefined): string {
return (v ?? "")
.toLowerCase()
.replace(/[.,;:()"'`´+/-]/g, " ")
.replace(LEGAL_FORMS, " ")
.replace(/\s+/g, " ")
.trim();
}
export function normalizeStreet(v: string | null | undefined): string {
return (v ?? "")
.toLowerCase()
.replace(/ß/g, "ss")
.replace(/strasse|str\./g, "str")
.replace(/[^a-z0-9äöü]/g, "");
}
const digits = (v: string | null | undefined) => (v ?? "").replace(/\D/g, "").replace(/^49/, "0").replace(/^00/, "0");
const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase();
export async function findDuplicateCustomers(db: TenantDb, candidate: CustomerCandidateInput): Promise<DuplicateCandidate[]> {
const company = normalizeCompany(candidate.companyName);
const firstWord = company.split(" ").find((w) => w.length >= 3);
const or: object[] = [];
if (candidate.customerNumber) or.push({ customerNumber: candidate.customerNumber.trim() });
if (candidate.email) or.push({ email: { equals: candidate.email.trim(), mode: "insensitive" } });
if (firstWord) or.push({ companyName: { contains: firstWord, mode: "insensitive" } });
if (candidate.lastName) or.push({ lastName: { equals: candidate.lastName.trim(), mode: "insensitive" } });
if (candidate.postalCode) or.push({ postalCode: candidate.postalCode.trim() });
if (candidate.phone) or.push({ phone: { not: null } });
if (or.length === 0) return [];
const rows = await db.customer.findMany({
where: { deletedAt: null, status: { not: "merged" }, OR: or },
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, email: true, phone: true, mobile: true },
take: 200,
});
const out: DuplicateCandidate[] = [];
for (const c of rows) {
let score = 0;
const reasons: string[] = [];
if (candidate.customerNumber && c.customerNumber && c.customerNumber.trim() === candidate.customerNumber.trim()) {
score += 0.6;
reasons.push("customer_number");
}
const cc = normalizeCompany(c.companyName);
if (company && cc) {
if (cc === company) {
score += 0.4;
reasons.push("company_name");
} else if (cc.includes(company) || company.includes(cc)) {
score += 0.25;
reasons.push("company_name_similar");
}
}
if (candidate.lastName && lc(c.lastName) === lc(candidate.lastName) && (!candidate.firstName || lc(c.firstName) === lc(candidate.firstName))) {
score += 0.3;
reasons.push("person_name");
}
if (candidate.email && lc(c.email) === lc(candidate.email)) {
score += 0.3;
reasons.push("email");
}
const phone = digits(candidate.phone);
if (phone.length >= 6 && [c.phone, c.mobile].some((p) => digits(p) === phone)) {
score += 0.2;
reasons.push("phone");
}
if (
candidate.postalCode &&
c.postalCode === candidate.postalCode.trim() &&
normalizeStreet(c.street) !== "" &&
normalizeStreet(c.street) === normalizeStreet(candidate.street)
) {
score += 0.25;
reasons.push("address");
}
if (score >= 0.25) out.push({ customerId: c.id, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
/** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */
export async function findSiteCandidates(
db: TenantDb,
customerIds: string[],
address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null,
): Promise<SiteCandidate[]> {
if (!customerIds.length || !address || (!address.street && !address.name)) return [];
const sites = await db.site.findMany({
where: { customerId: { in: customerIds }, deletedAt: null },
select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true },
take: 200,
});
const out: SiteCandidate[] = [];
for (const s of sites) {
let score = 0;
const reasons: string[] = [];
if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) {
score += 0.6;
reasons.push("address");
if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) {
score += 0.3;
reasons.push("house_number");
}
}
if (address.name && lc(s.name) === lc(address.name)) {
score += 0.3;
reasons.push("site_name");
}
if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
+3 -2
View File
@@ -11,7 +11,8 @@ import {
} from "@/lib/imports/extraction";
import { checkPlausibility } from "@/lib/imports/plausibility";
// TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface).
import { findDuplicateCustomers, findSiteCandidates } from "./duplicates-stub";
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
import { findSiteCandidates } from "./site-candidates";
export type ProcessDeps = {
provider: DocumentExtractionProvider | null;
@@ -76,7 +77,7 @@ export async function processImport(ctx: ServiceCtx, importId: string, deps: Pro
}
const address = fields.customerAddress.value ?? {};
const duplicates = await findDuplicateCustomers(ctx.db, {
const duplicates = await findDuplicateCustomers(ctx, {
customerNumber: fields.customerNumber.value,
companyName: fields.companyName.value,
firstName: fields.customerFirstName.value,
@@ -0,0 +1,38 @@
import type { TenantDb } from "@/server/db";
import { normalizeStreet } from "@/lib/customers/duplicates";
import type { SiteCandidate } from "@/lib/imports/extraction";
const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase();
/** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */
export async function findSiteCandidates(
db: TenantDb,
customerIds: string[],
address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null,
): Promise<SiteCandidate[]> {
if (!customerIds.length || !address || (!address.street && !address.name)) return [];
const sites = await db.site.findMany({
where: { customerId: { in: customerIds }, deletedAt: null },
select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true },
take: 200,
});
const out: SiteCandidate[] = [];
for (const s of sites) {
let score = 0;
const reasons: string[] = [];
if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) {
score += 0.6;
reasons.push("address");
if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) {
score += 0.3;
reasons.push("house_number");
}
}
if (address.name && lc(s.name) === lc(address.name)) {
score += 0.3;
reasons.push("site_name");
}
if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons });
}
return out.sort((a, b) => b.score - a.score).slice(0, 5);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { JobPayload } from "@/server/jobs/queues";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
// TODO(L3→documents): replace with "@/server/services/documents/store" once available (ARCHITEKTUR §4.3).
import { storeFile } from "./document-store-stub";
import { storeFile } from "@/server/services/documents/store";
export type UploadFile = { bytes: Buffer; fileName: string; mimeType: string };
export type Dispatch = (payload: JobPayload) => Promise<unknown>;
@@ -1,87 +0,0 @@
import type { WorkOrder } from "@prisma/client";
import { nextNumber } from "@/server/services/numbering";
import { assertCan, type ServiceCtx } from "@/server/services/context";
/**
* STUB (lane L3) for the L2 contract `src/server/services/work-orders/*#createWorkOrder(ctx, input)`
* (ARCHITEKTUR §6: "Bestätigung → … Auftrag (über L2-Service createWorkOrder)").
* Exported input type + function; after the L2 merge, services/imports/confirm.ts imports the
* real service instead. Kept minimal on purpose: number allocation, status history
* (review_required → planned), material plan. No events/audit here — the caller audits.
*
* `ctx.db` may be a transaction client (the confirm transaction passes one).
*/
export type CreateWorkOrderInput = {
customerId: string;
siteId?: string | null;
contactId?: string | null;
title: string;
description?: string | null;
scope?: string | null;
externalOrderNumber?: string | null;
offerNumber?: string | null;
plannedStart?: Date | null;
plannedEnd?: Date | null;
internalNotes?: string | null;
sourceImportId?: string | null;
/** Initial status; imports pass "planned" after confirmation (history: review_required → planned). */
status?: "draft" | "review_required" | "planned";
materials?: Array<{ name: string; articleNumber?: string | null; plannedQuantity: number; unit: string }>;
};
export async function createWorkOrder(ctx: ServiceCtx, input: CreateWorkOrderInput): Promise<WorkOrder> {
assertCan(ctx, "work_order:write");
const number = await nextNumber(ctx.db, ctx.tenantId, "work_order");
const status = input.status ?? "draft";
const wo = await ctx.db.workOrder.create({
data: {
tenantId: ctx.tenantId,
number,
customerId: input.customerId,
siteId: input.siteId ?? null,
contactId: input.contactId ?? null,
title: input.title,
description: input.description ?? null,
scope: input.scope ?? null,
externalOrderNumber: input.externalOrderNumber ?? null,
offerNumber: input.offerNumber ?? null,
plannedStart: input.plannedStart ?? null,
plannedEnd: input.plannedEnd ?? null,
internalNotes: input.internalNotes ?? null,
sourceImportId: input.sourceImportId ?? null,
status,
createdById: ctx.userId,
},
});
const history: Array<{ fromStatus: WorkOrder["status"] | null; toStatus: WorkOrder["status"] }> =
status === "planned" && input.sourceImportId
? [
{ fromStatus: null, toStatus: "review_required" },
{ fromStatus: "review_required", toStatus: "planned" },
]
: [{ fromStatus: null, toStatus: status }];
for (const h of history) {
await ctx.db.workOrderStatusChange.create({
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: h.fromStatus, toStatus: h.toStatus, actorId: ctx.userId },
});
}
let sort = 0;
for (const m of input.materials ?? []) {
await ctx.db.materialPlan.create({
data: {
tenantId: ctx.tenantId,
workOrderId: wo.id,
name: m.name,
articleNumber: m.articleNumber ?? null,
plannedQuantity: m.plannedQuantity,
unit: m.unit,
sortOrder: sort++,
},
});
}
return wo;
}
@@ -1,88 +0,0 @@
/**
* STUB (lane L5 „Berichte") for the shared file contract ARCHITEKTUR §4.3
* `src/server/services/documents/store.ts#storeFile` (not yet provided by the architect / documents lane).
*
* Minimal implementation behind the contracted interface: allowlist + magic bytes + size limit,
* SHA-256, storage.put, Document row, lineage versioning. At merge the import in
* services/reports/*.ts is switched to the real store and this file is deleted.
*/
import { createHash, randomUUID } from "node:crypto";
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
export type StoreFileInput = {
bytes: Uint8Array;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
lineageId?: string;
title?: string | null;
};
const LIMITS: Record<string, number> = { "application/pdf": 25 * 1024 * 1024, "image/png": 15 * 1024 * 1024, "image/jpeg": 15 * 1024 * 1024, "image/webp": 15 * 1024 * 1024 };
export function sniffMime(bytes: Uint8Array): string | null {
const b = bytes;
if (b.length >= 5 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return "application/pdf";
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp";
return null;
}
export function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
const mime = sniffMime(input.bytes);
if (!mime || mime !== input.declaredMime) throw new ServiceError("invalid", "file type not allowed or does not match content");
if (input.bytes.byteLength === 0 || input.bytes.byteLength > (LIMITS[mime] ?? 0)) throw new ServiceError("invalid", "file size not allowed");
const fileName = input.fileName.normalize("NFC").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "datei";
const checksum = sha256Hex(input.bytes);
const put = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: mime, bytes: input.bytes });
let version = 1;
const lineageId = input.lineageId ?? randomUUID();
if (input.lineageId) {
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
version = (last?.version ?? 0) + 1;
}
return ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
customerId: input.links.customerId ?? null,
siteId: input.links.siteId ?? null,
workOrderId: input.links.workOrderId ?? null,
category: input.category,
title: input.title ?? null,
fileName,
storageKey: put.storageKey,
mimeType: mime,
fileSize: input.bytes.byteLength,
checksum,
version,
lineageId,
visibility: input.visibility,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
}
/** Read the bytes of a stored document (worker/PDF rendering). null if the backend has no bytes. */
export async function readFileBytes(storageKey: string): Promise<Uint8Array | null> {
const obj = await storage.get(storageKey);
if (!obj) return null;
const chunks: Uint8Array[] = [];
const reader = obj.stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return Buffer.concat(chunks);
}
@@ -1,80 +0,0 @@
/**
* STUB (lane L5 „Berichte") for contracts owned by lane L2 „Aufträge":
* - services/work-orders/transition.ts#transitionWorkOrder
* - services/work-orders/guards.ts#getCompletionBlockers (ARCHITEKTUR §3 „Guards vor Abschluss")
*
* Interface follows ARCHITEKTUR §3. At merge the architect replaces the imports in
* services/reports/*.ts with the L2 implementations and deletes this file.
* Contract extension used by L5 (for L6 mail deduplication): optional `eventData` is merged into the emitted event's
* `data` (e.g. `{ occurrenceId }`) — the L2 implementation should accept it as well.
*/
import type { WorkOrderStatus as DbWorkOrderStatus } from "@prisma/client";
import type { DomainEvent } from "@/lib/events";
import { canTransition, requiredPermission, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { emitEvent } from "@/server/events";
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
export type TransitionInput = {
workOrderId: string;
to: WorkOrderStatus;
reason?: string | null;
/** optimistic concurrency (offline sync) */
expectedVersion?: number;
/** extra event data, e.g. { occurrenceId } for repeatable events */
eventData?: DomainEvent["data"];
};
export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<{ id: string; status: WorkOrderStatus; version: number }> {
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true, version: true, number: true });
const from = wo.status as WorkOrderStatus;
if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`);
const perm = requiredPermission(from, input.to);
const allowed = perm === "report:approve_team" ? can(ctx, "report:approve_team") || can(ctx, "report:approve") : can(ctx, perm);
if (!allowed) throw new ServiceError("forbidden", `missing permission ${perm}`);
if (input.expectedVersion !== undefined && input.expectedVersion !== wo.version) {
throw new ServiceError("conflict", "work order version changed");
}
const res = await ctx.db.workOrder.updateMany({
where: { id: wo.id, version: wo.version },
data: { status: input.to as DbWorkOrderStatus, version: { increment: 1 } },
});
if (res.count !== 1) throw new ServiceError("conflict", "work order changed concurrently");
await ctx.db.workOrderStatusChange.create({
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null },
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "work_order",
entityId: wo.id,
before: { status: from },
after: { status: input.to, reason: input.reason ?? null },
});
const eventType =
input.to === "daily_report_created"
? "work_order.daily_report_created"
: input.to === "technically_completed"
? "work_order.technically_completed"
: input.to === "signature_pending"
? "work_order.signature_missing"
: "work_order.changed";
await emitEvent(ctx, { type: eventType, entityType: "work_order", entityId: wo.id, data: { number: wo.number, from, to: input.to, ...input.eventData } });
return { id: wo.id, status: input.to, version: wo.version + 1 };
}
export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
await requireVisibleWorkOrder(ctx, workOrderId, { id: true });
const [items, requirements, running] = await Promise.all([
ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }),
ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }),
ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }),
]);
return [
...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })),
...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })),
...running.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })),
];
}
+2 -1
View File
@@ -7,7 +7,8 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont
import { nextNumber } from "@/server/services/numbering";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
// TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards"
import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders";
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { buildReportContent, tenantTimeZone } from "./build-content";
import { auditReport, reportAuditView } from "./common";
+3 -3
View File
@@ -4,8 +4,8 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createTranslator } from "next-intl";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
// TODO(merge documents): import from "@/server/services/documents/store"
import { readFileBytes, storeFile } from "./_stubs/documents";
import { readStoredBytes } from "@/server/services/documents/read";
import { storeFile } from "@/server/services/documents/store";
import { tenantTimeZone } from "./build-content";
import { auditReport, contentOf, requireVisibleReport } from "./common";
@@ -18,7 +18,7 @@ async function loadReportMessages(locale: string): Promise<Record<string, unknow
async function dataUri(ctx: ServiceCtx, documentId: string): Promise<string | null> {
const doc = await ctx.db.document.findFirst({ where: { id: documentId, deletedAt: null }, select: { storageKey: true, mimeType: true } });
if (!doc || !doc.mimeType.startsWith("image/")) return null;
const bytes = await readFileBytes(doc.storageKey).catch(() => null);
const bytes = await readStoredBytes(doc.storageKey).catch(() => null);
return bytes ? `data:${doc.mimeType};base64,${Buffer.from(bytes).toString("base64")}` : null;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type { CompletionBlocker } from "@/lib/work-orders/status";
import { can, type ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
// TODO(merge L2): import from "@/server/services/work-orders/guards"
import { getCompletionBlockers } from "./_stubs/work-orders";
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
import { tenantTimeZone } from "./build-content";
import { contentOf, reportScope, requireVisibleReport } from "./common";
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { emitEvent } from "@/server/events";
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
// TODO(merge L2): import from "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "./_stubs/work-orders";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { auditReport, contentOf, orderNumberOf, reportAuditView, requireVisibleReport } from "./common";
export const rejectReportSchema = z.object({
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureBlock } from "@/lib/reports/content";
import { can, assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
// TODO(merge L2): import from "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "./_stubs/work-orders";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { auditReport, contentOf, requireVisibleReport } from "./common";
const optText = (max: number) =>
+2 -1
View File
@@ -5,7 +5,8 @@ import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/statu
import { emitEvent } from "@/server/events";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
// TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards"
import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders";
import { getCompletionBlockers } from "@/server/services/work-orders/completion";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
export const submitReportSchema = z.object({
+1 -1
View File
@@ -12,7 +12,7 @@ import { upsertMaterialUsage } from "@/server/services/field/materials";
import { attachPhoto } from "@/server/services/field/photos";
import { attachVoiceNote } from "@/server/services/field/voice";
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "@/server/services/field/stubs/work-order-transition";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops";
/**
Binary file not shown.