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:
@@ -1,61 +0,0 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
|
||||
/**
|
||||
* Download-Route für Dateien im Objektspeicher (Garage/S3) anhand ihres Storage-Keys.
|
||||
*
|
||||
* Mandanten-Isolation:
|
||||
* Der Key ist mandantenpräfixiert (`<tenantId>/…`). Er MUSS mit dem Tenant der
|
||||
* aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404 abgewiesen
|
||||
* (keine Existenz-Preisgabe).
|
||||
*
|
||||
* TODO(documents): Defense in Depth wiederherstellen — sobald das Craftvia-Document-Modell
|
||||
* existiert, zusätzlich prüfen, dass der Key in einer mandantengebundenen Referenz
|
||||
* (Document.storageKey) vorkommt und der Nutzer das Dokument sehen darf
|
||||
* (document:read bzw. document:read_internal für interne Dokumente).
|
||||
*
|
||||
* Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options:
|
||||
* nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing.
|
||||
*
|
||||
* Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig
|
||||
* über `requireSession` erzwungen.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ key: string[] }> },
|
||||
) {
|
||||
const session = await requireSession();
|
||||
const tenantId = session.user.tenantId;
|
||||
|
||||
const { key: segments } = await params;
|
||||
// Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen.
|
||||
const key = (segments ?? []).join("/");
|
||||
|
||||
// Pfad-Traversal ausschließen und Mandantenpräfix erzwingen.
|
||||
if (
|
||||
!tenantId ||
|
||||
!key ||
|
||||
key.includes("..") ||
|
||||
key.includes("\0") ||
|
||||
!key.startsWith(`${tenantId}/`)
|
||||
) {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const content = await storage.get(key);
|
||||
if (!content) {
|
||||
// Kein Byte-Backend (Stub) oder Objekt fehlt → 404.
|
||||
return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
}
|
||||
|
||||
const filename = content.filename.replace(/["\\]/g, "_");
|
||||
const headers = new Headers({
|
||||
"Content-Type": content.contentType ?? "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
});
|
||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||
|
||||
return new Response(content.stream, { headers });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { json, readJson, withApi } from "@/server/api/respond";
|
||||
import { getCustomer, updateCustomer } from "@/server/services/customers/customers";
|
||||
import type { CustomerPatchInput } from "@/server/services/customers/schemas";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
/** GET /api/v1/customers/:id — customer incl. contacts (scope applies, otherwise 404). */
|
||||
export const GET = withApi(async (_req: Request, { params }: Ctx) => {
|
||||
const ctx = await requireApiContext("customers", "customer:read");
|
||||
const { id } = await params;
|
||||
return json({ data: await getCustomer(ctx, id) });
|
||||
});
|
||||
|
||||
/** PATCH /api/v1/customers/:id — absent fields unchanged, null clears. */
|
||||
export const PATCH = withApi(async (req: Request, { params }: Ctx) => {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("customers", "customer:write");
|
||||
const { id } = await params;
|
||||
const body = (await readJson(req)) as CustomerPatchInput | null;
|
||||
return json({ data: await updateCustomer(ctx, id, body ?? {}) });
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond";
|
||||
import { createCustomer, CUSTOMER_LIST_STATUSES, listCustomers } from "@/server/services/customers/customers";
|
||||
import type { CustomerCreateInput } from "@/server/services/customers/schemas";
|
||||
|
||||
const statusParam = z.enum([...CUSTOMER_LIST_STATUSES, "all"]).optional();
|
||||
|
||||
/** GET /api/v1/customers?q&status&page&pageSize */
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("customers", "customer:read");
|
||||
const url = new URL(req.url);
|
||||
const { page, pageSize } = parsePagination(url);
|
||||
const status = statusParam.parse(url.searchParams.get("status") ?? undefined);
|
||||
const result = await listCustomers(ctx, { q: url.searchParams.get("q") ?? undefined, status, page, pageSize });
|
||||
return paginated(result.items, result.total, result.page, result.pageSize);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/customers — body: customer fields + optional `acknowledgeDuplicates: true`.
|
||||
* Possible duplicates without acknowledgement → 409 `{ error: { code: "conflict", details: { reason: "possible_duplicates", candidates } } }`.
|
||||
*/
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("customers", "customer:write");
|
||||
const body = (await readJson(req)) as Record<string, unknown> | null;
|
||||
const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, {
|
||||
acknowledgeDuplicates: body?.acknowledgeDuplicates === true,
|
||||
});
|
||||
return json({ data: customer }, { status: 201 });
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, parsePagination, withApi } from "@/server/api/respond";
|
||||
import { getSiteHistory } from "@/server/services/sites/history";
|
||||
|
||||
/**
|
||||
* GET /api/v1/sites/:id/history?onlyApproved=true&page&pageSize
|
||||
* Field roles always receive released deployments only (see getSiteHistory).
|
||||
*/
|
||||
export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("sites", "site:read");
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const { page, pageSize } = parsePagination(url, { pageSize: 50 });
|
||||
const flag = url.searchParams.get("onlyApproved");
|
||||
const result = await getSiteHistory(ctx, id, { onlyApproved: flag === "true" || flag === "1", page, pageSize });
|
||||
return json({
|
||||
data: result.items,
|
||||
pagination: { page: result.page, pageSize: result.pageSize, total: result.total },
|
||||
meta: { onlyApproved: result.onlyApproved },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond";
|
||||
import { createSite, listSites, SITE_STATUSES, type SiteCreateInput } from "@/server/services/sites/sites";
|
||||
|
||||
const statusParam = z.enum([...SITE_STATUSES, "all"]).optional();
|
||||
|
||||
/** GET /api/v1/sites?q&customerId&status&page&pageSize */
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("sites", "site:read");
|
||||
const url = new URL(req.url);
|
||||
const { page, pageSize } = parsePagination(url);
|
||||
const result = await listSites(ctx, {
|
||||
q: url.searchParams.get("q") ?? undefined,
|
||||
customerId: url.searchParams.get("customerId") ?? undefined,
|
||||
status: statusParam.parse(url.searchParams.get("status") ?? undefined),
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return paginated(result.items, result.total, result.page, result.pageSize);
|
||||
});
|
||||
|
||||
/** POST /api/v1/sites */
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("sites", "site:write");
|
||||
const body = (await readJson(req)) as SiteCreateInput | null;
|
||||
return json({ data: await createSite(ctx, body ?? ({} as SiteCreateInput)) }, { status: 201 });
|
||||
});
|
||||
Reference in New Issue
Block a user