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
+43
View File
@@ -0,0 +1,43 @@
import type { Session } from "next-auth";
import type { TenantDb } from "@/server/db";
/**
* Context passed to every domain service. Created by server actions (from moduleGuard)
* and by /api/v1 route handlers (from requireApiContext). Services never read the
* session themselves and never create their own db client.
*/
export type ServiceCtx = {
db: TenantDb;
tenantId: string;
userId: string;
/** DB-authoritative effective permissions (never the JWT copy). */
permissions: ReadonlySet<string>;
};
export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: ReadonlySet<string> }): ServiceCtx {
return {
db: g.db,
tenantId: g.session.user.tenantId,
userId: g.session.user.id,
permissions: g.permissions,
};
}
export function can(ctx: ServiceCtx, permission: string): boolean {
return ctx.permissions.has(permission);
}
export class ServiceError extends Error {
constructor(
public code: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked",
message: string,
public details?: unknown,
) {
super(message);
this.name = "ServiceError";
}
}
export function assertCan(ctx: ServiceCtx, permission: string): void {
if (!can(ctx, permission)) throw new ServiceError("forbidden", `missing permission ${permission}`);
}
@@ -0,0 +1,10 @@
import type { DomainEvent } from "@/lib/events";
import type { ServiceCtx } from "@/server/services/context";
/**
* Placeholder — replaced by lane "notifications" (recipient rules, in-app Notification rows,
* e-mail via the mail queue). Keeps emitEvent() callable for all other lanes meanwhile.
*/
export async function handleEvent(_ctx: ServiceCtx, _event: DomainEvent): Promise<void> {
// intentionally empty
}
+41
View File
@@ -0,0 +1,41 @@
import type { TenantDb } from "@/server/db";
export type NumberKey = "customer" | "work_order" | "emergency" | "report";
const DEFAULT_PREFIX: Record<NumberKey, string> = {
customer: "K-",
work_order: "A-",
emergency: "N-",
report: "B-",
};
/**
* Allocate the next number of a tenant sequence, e.g. "A-00042".
* The increment is a single UPDATE … RETURNING (atomic); the very first call per key
* creates the row and retries once if a concurrent call created it first.
*/
export async function nextNumber(db: TenantDb, tenantId: string, key: NumberKey): Promise<string> {
for (let attempt = 0; attempt < 3; attempt++) {
const existing = await db.numberSequence.findFirst({ where: { tenantId, key }, select: { id: true } });
if (existing) {
const seq = await db.numberSequence.update({
where: { id: existing.id },
data: { nextValue: { increment: 1 } },
});
return format(seq.prefix, seq.nextValue - 1, seq.padding);
}
try {
const seq = await db.numberSequence.create({
data: { tenantId, key, prefix: DEFAULT_PREFIX[key], nextValue: 2 },
});
return format(seq.prefix, 1, seq.padding);
} catch (err) {
if ((err as { code?: string }).code !== "P2002") throw err; // unique race → retry via update path
}
}
throw new Error(`could not allocate number for ${key}`);
}
function format(prefix: string, value: number, padding: number): string {
return `${prefix}${String(value).padStart(padding, "0")}`;
}
@@ -0,0 +1,68 @@
import type { Prisma } from "@prisma/client";
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Central work order visibility (spec §4.4: technicians only see their own/team orders).
* EVERY read of work orders or dependent entities for non-backoffice users MUST use these scopes.
* The tenant itself is enforced by dbForTenant/RLS; these filters restrict within the tenant.
*/
/** Active team ids of the current user (member or leader). */
export async function activeTeamIds(ctx: ServiceCtx): Promise<string[]> {
const now = new Date();
const [memberships, led] = await Promise.all([
ctx.db.teamMember.findMany({
where: { userId: ctx.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } },
select: { teamId: true },
}),
ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } }),
]);
return [...new Set([...memberships.map((m) => m.teamId), ...led.map((t) => t.id)])];
}
export async function workOrderScope(ctx: ServiceCtx): Promise<Prisma.WorkOrderWhereInput> {
if (can(ctx, "work_order:read_all")) return { deletedAt: null };
if (!can(ctx, "work_order:read_team")) return { id: "__none__" };
const teamIds = await activeTeamIds(ctx);
return {
deletedAt: null,
OR: [
...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []),
{ assignees: { some: { userId: ctx.userId } } },
{ teamLeadUserId: ctx.userId },
{ isEmergency: true, createdById: ctx.userId },
],
};
}
/** Customers readable by the user: all for customer:read + read_all, otherwise only via visible orders. */
export async function customerScope(ctx: ServiceCtx): Promise<Prisma.CustomerWhereInput> {
if (!can(ctx, "customer:read")) return { id: "__none__" };
if (can(ctx, "work_order:read_all")) return { deletedAt: null };
return { deletedAt: null, workOrders: { some: await workOrderScope(ctx) } };
}
export async function siteScope(ctx: ServiceCtx): Promise<Prisma.SiteWhereInput> {
if (!can(ctx, "site:read")) return { id: "__none__" };
if (can(ctx, "work_order:read_all")) return { deletedAt: null };
return { deletedAt: null, workOrders: { some: await workOrderScope(ctx) } };
}
/** Load a work order the user may see, or throw not_found (never reveal existence). */
export async function requireVisibleWorkOrder<S extends Prisma.WorkOrderSelect | undefined = undefined>(
ctx: ServiceCtx,
workOrderId: string,
select?: S,
) {
const scope = await workOrderScope(ctx);
const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, ...(select ? { select } : {}) });
if (!wo) throw new ServiceError("not_found", "work order not found");
return wo;
}
/** Document visibility levels the user may read (spec §24.3). */
export function allowedDocumentVisibility(ctx: ServiceCtx): Array<"backoffice_only" | "team_lead" | "team" | "customer_report"> {
if (can(ctx, "document:read_internal")) return ["backoffice_only", "team_lead", "team", "customer_report"];
if (can(ctx, "report:approve_team")) return ["team_lead", "team", "customer_report"];
return ["team", "customer_report"];
}