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:
@@ -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
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
Reference in New Issue
Block a user