Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions nextjs_space/app/api/super-admin/subprocessors/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { NextResponse } from "next/server";
import type { ZodError } from "zod";
import { withSuperAdminParams } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
import { apiError, apiValidationError } from "@/lib/api-error";
import { parseJsonBody } from "@/lib/validation/body";
import { createAuditLog, getClientInfo } from "@/lib/audit-log";
import {
retireSchema,
subprocessorUpdateSchema,
} from "@/lib/legal/subprocessor-schema";
import { announceSubprocessor } from "@/lib/legal/subprocessor-announce";
import { logger } from "@/lib/logger";

/**
* Amend, announce or retire a register entry.
*
* Announcing is a POST to this route rather than a side effect of saving,
* because it emails every operator and starts an objection window that cannot
* be un-started.
*
* See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012).
*/

function firstIssue(error: ZodError): string {
return error.issues[0]?.message ?? "Invalid request.";
}

/** Announce the entry to every active operator. */
export const POST = withSuperAdminParams(async (request, { user }, params) => {
const route = "POST /api/super-admin/subprocessors/[id]";
try {
const result = await announceSubprocessor(params.id);

await createAuditLog({
action: "SUBPROCESSOR_ANNOUNCE_REQUESTED",
entityType: "subprocessor",
entityId: params.id,
userId: user.id,
userEmail: user.email,
metadata: { ...result },
...getClientInfo(request.headers),
});

return NextResponse.json({ success: true, ...result });
} catch (error) {
// announceSubprocessor throws when the notice period is too short — that is
// operator-correctable, not a server fault.
const message = error instanceof Error ? error.message : "";
if (message.includes("Refusing to announce")) {
return apiError(error, { route, status: 422, safeMessage: message });
}
return apiError(error, { route });
}
});

export const PATCH = withSuperAdminParams(async (request, { user }, params) => {
const route = "PATCH /api/super-admin/subprocessors/[id]";
try {
const body = await parseJsonBody<Record<string, unknown>>(request);
const parsed = subprocessorUpdateSchema.safeParse(body);

if (!parsed.success) {
return apiValidationError(firstIssue(parsed.error), route);
}

const existing = await prisma.subprocessors.findFirst({
where: { id: params.id },
});
if (!existing) {
return apiError(new Error("Not found"), {
route,
status: 404,
safeMessage: "Register entry not found.",
});
}

const now = new Date();
const updated = await prisma.subprocessors.update({
where: { id: params.id },
data: { ...parsed.data, updatedAt: now },
});

await createAuditLog({
action: "SUBPROCESSOR_UPDATED",
entityType: "subprocessor",
entityId: params.id,
userId: user.id,
userEmail: user.email,
metadata: {
changed: Object.keys(parsed.data),
// An already-announced entry changing its terms matters: operators were
// told something that is no longer true.
alreadyAnnounced: Boolean(existing.announcedAt),
},
...getClientInfo(request.headers),
});

if (existing.announcedAt) {
logger.warn("[Legal] Announced sub-processor amended after notice went out", {
entryId: params.id,
changed: Object.keys(parsed.data),
});
}

return NextResponse.json({ success: true, entry: updated });
} catch (error) {
return apiError(error, { route });
}
});

/** Retire an entry. Kept in the register with a retiredAt, never deleted. */
export const DELETE = withSuperAdminParams(async (request, { user }, params) => {
const route = "DELETE /api/super-admin/subprocessors/[id]";
try {
const body = await parseJsonBody<Record<string, unknown>>(request);
const parsed = retireSchema.safeParse(body);

if (!parsed.success) {
return apiValidationError(firstIssue(parsed.error), route);
}

const now = new Date();
// Retired rather than deleted: the register is the evidence that a vendor
// once processed operator data, and for how long. Deleting the row destroys
// the only record that the relationship existed.
const retired = await prisma.subprocessors.update({
where: { id: params.id },
data: {
status: "retired",
retiredAt: now,
updatedAt: now,
notes: parsed.data.reason,
},
});

await createAuditLog({
action: "SUBPROCESSOR_RETIRED",
entityType: "subprocessor",
entityId: params.id,
userId: user.id,
userEmail: user.email,
metadata: { name: retired.name, reason: parsed.data.reason },
...getClientInfo(request.headers),
});

logger.info("[Legal] Sub-processor retired", {
entryId: params.id,
name: retired.name,
});

return NextResponse.json({ success: true, entry: retired });
} catch (error) {
return apiError(error, { route });
}
});
140 changes: 140 additions & 0 deletions nextjs_space/app/api/super-admin/subprocessors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import type { ZodError } from "zod";
import { withSuperAdmin } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
import { apiError, apiValidationError } from "@/lib/api-error";
import { parseJsonBody } from "@/lib/validation/body";
import { createAuditLog, getClientInfo } from "@/lib/audit-log";
import { subprocessorSchema } from "@/lib/legal/subprocessor-schema";
import {
MIN_NOTICE_DAYS,
earliestEffectiveFrom,
hasSufficientNotice,
} from "@/lib/legal/subprocessor-notice";
import { logger } from "@/lib/logger";

/**
* Sub-processor register — super-admin CRUD.
*
* Adding a vendor here starts the DPA §6 clock. It does NOT announce: creation
* and announcement are separate so an entry can be drafted, checked and only
* then sent to every operator. An email to the whole customer base is not
* something to trigger by saving a form.
*
* See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012).
*/

const ROUTE = "POST /api/super-admin/subprocessors";

function firstIssue(error: ZodError): string {
return error.issues[0]?.message ?? "Invalid sub-processor.";
}

export const GET = withSuperAdmin(async () => {
try {
const entries = await prisma.subprocessors.findMany({
orderBy: [{ status: "asc" }, { name: "asc" }],
include: {
_count: { select: { objections: { where: { status: "open" } } } },
},
});
return NextResponse.json({ entries, minNoticeDays: MIN_NOTICE_DAYS });
} catch (error) {
return apiError(error, { route: "GET /api/super-admin/subprocessors" });
}
});

export const POST = withSuperAdmin(async (request, { user }) => {
try {
const body = await parseJsonBody<Record<string, unknown>>(request);
const parsed = subprocessorSchema.safeParse(body);

if (!parsed.success) {
return apiValidationError(firstIssue(parsed.error), ROUTE);
}

const input = parsed.data;
const now = new Date();

const existing = await prisma.subprocessors.findFirst({
where: { id: input.id },
select: { id: true },
});
if (existing) {
return apiValidationError(
`A register entry with id "${input.id}" already exists.`,
ROUTE,
);
}

// The 30-day floor is enforced here rather than trusted to the caller.
// Shortening it is possible but must be deliberate and reasoned, because it
// takes away notice the DPA already promised operators.
if (!hasSufficientNotice(now, input.effectiveFrom)) {
if (!input.overrideNoticePeriod) {
return apiValidationError(
`Operators are entitled to ${MIN_NOTICE_DAYS} days' notice. The earliest ` +
`effective date is ${earliestEffectiveFrom(now).toISOString().slice(0, 10)}. ` +
`To go sooner, set overrideNoticePeriod with a reason.`,
ROUTE,
);
}
if (!input.overrideReason) {
return apiValidationError(
"Shortening the notice period requires a reason.",
ROUTE,
);
}
}

const created = await prisma.subprocessors.create({
data: {
id: input.id,
name: input.name,
purpose: input.purpose,
region: input.region,
transferMechanism: input.transferMechanism,
dpaUrl: input.dpaUrl ?? null,
effectiveFrom: input.effectiveFrom,
notes: input.notes ?? null,
status: "pending",
announcedAt: null,
createdAt: now,
updatedAt: now,
},
});

await createAuditLog({
action: "SUBPROCESSOR_CREATED",
entityType: "subprocessor",
entityId: created.id,
userId: user.id,
userEmail: user.email,
metadata: {
name: created.name,
effectiveFrom: created.effectiveFrom.toISOString(),
noticeDays: Math.round(
(created.effectiveFrom.getTime() - now.getTime()) / 86_400_000,
),
noticeOverridden: Boolean(input.overrideNoticePeriod),
overrideReason: input.overrideReason ?? null,
},
...getClientInfo(request.headers),
});

logger.info("[Legal] Sub-processor drafted", {
entryId: created.id,
effectiveFrom: created.effectiveFrom,
});

return NextResponse.json({
success: true,
entry: created,
// Explicit: saving does not tell anyone. Announcing does.
announced: false,
next: "Announce this entry to notify operators and start the objection window.",
});
} catch (error) {
return apiError(error, { route: ROUTE });
}
});
Loading
Loading