L1 Stammdaten: Services, Dublettenprüfung, Server Actions und API v1

Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit
Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik
(lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte
/api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:26:07 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 1f8e6413fe
23 changed files with 1836 additions and 61 deletions
+97
View File
@@ -0,0 +1,97 @@
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { isTokenStillValid } from "@/server/sessions";
import { assertModuleEnabled, requireModule } from "@/server/modules";
import type { Permission } from "@/server/rbac";
import type { ModuleKey } from "@/lib/modules";
import type { ServiceCtx } from "@/server/services/context";
import { ApiError } from "@/server/api/respond";
/**
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
*
* Same authority model as `moduleGuard` (src/server/action-guard.ts, F-06): session cookie
* (Auth.js), then membership status, identity status, session kill switch, forced password
* change and the EFFECTIVE permissions are read from the database — never from the JWT.
* Differences: failures are thrown as `ApiError` (401/403) so handlers can answer with JSON,
* and `moduleKey` may be `null` for cross-module endpoints (document downloads are needed
* by field, reports and documents alike).
*/
export async function requireApiContext(moduleKey: ModuleKey | null, ...permissions: Permission[]): Promise<ServiceCtx> {
let session;
try {
session = await requireSession();
} catch {
throw new ApiError("unauthorized", "authentication required");
}
const tenantId = session.user.tenantId;
const db = dbForTenant(tenantId);
const account = await db.user.findFirst({
where: { id: session.user.id, status: "ACTIVE" },
select: {
userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } },
},
});
const identity = session.user.identityId
? await prisma.identity.findUnique({
where: { id: session.user.identityId },
select: { status: true, mustChangePassword: true, sessionsValidAfter: true },
})
: null;
if (!account || !identity || identity.status !== "ACTIVE") {
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "account_inactive", entityId: session.user.id });
throw new ApiError("unauthorized", "account inactive");
}
if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
throw new ApiError("unauthorized", "session invalidated");
}
if (identity.mustChangePassword) throw new ApiError("forbidden", "password change required");
const effective = new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
for (const p of permissions) {
if (!effective.has(p)) {
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "api", entityId: p });
throw new ApiError("forbidden", "forbidden");
}
}
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
return { db, tenantId, userId: session.user.id, permissions: effective };
}
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
*/
export function assertSameOrigin(req: Request): void {
const site = req.headers.get("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
const origin = req.headers.get("origin");
if (origin) {
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
let originHost: string | null = null;
try {
originHost = new URL(origin).host;
} catch {
originHost = null;
}
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
}
}
/**
* Read context for server components (pages). Uses the session's permission set (JWT), which
* is the documented behaviour for read paths (AGENTS.md "Rollen"); mutations go through
* moduleGuard / requireApiContext with DB-authoritative permissions. Also enforces the module gate.
*/
export async function requirePageContext(moduleKey: ModuleKey): Promise<ServiceCtx> {
const session = await requireModule(moduleKey);
return {
db: dbForTenant(session.user.tenantId),
tenantId: session.user.tenantId,
userId: session.user.id,
permissions: new Set(session.user.permissions ?? []),
};
}