feat(clients): hard-delete with email-code confirmation (single + bulk)
Permanent client deletion is now reachable from: - archived single-client detail page (icon button, gated by new admin.permanently_delete_clients perm) - archived clients list bulk action Both flows are 2-stage: request a 4-digit code (sent to operator's account email, 10min Redis TTL), then enter both code AND a typed confirmation (client name single, "DELETE N CLIENTS" bulk). Cascade strategy preserves audit trails: signed documents, email threads, files and reminders are detached but retained; addresses, contacts, notes, portal user, GDPR records, interests and reservations are deleted via FK cascade or explicit tx delete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
src/app/api/v1/clients/[id]/hard-delete-request/route.ts
Normal file
38
src/app/api/v1/clients/[id]/hard-delete-request/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { requestHardDeleteCode } from '@/lib/services/client-hard-delete.service';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Send a one-time confirmation code to the operator's account email so
|
||||
* they can permanently delete an archived client. Two-permission gate:
|
||||
* `clients.delete` (the standard archive permission) is enforced by the
|
||||
* route wrapper; the service additionally requires the client to be
|
||||
* archived. The dedicated `admin.permanently_delete_clients` flag is
|
||||
* checked by the partner /hard-delete route — see route comment there.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'permanently_delete_clients', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
|
||||
const result = await requestHardDeleteCode({
|
||||
clientId: id,
|
||||
portId: ctx.portId,
|
||||
requesterUserId: ctx.userId,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
47
src/app/api/v1/clients/[id]/hard-delete/route.ts
Normal file
47
src/app/api/v1/clients/[id]/hard-delete/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { hardDeleteClient } from '@/lib/services/client-hard-delete.service';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
|
||||
const hardDeleteSchema = z.object({
|
||||
code: z.string().regex(/^\d{4}$/, '4-digit code required'),
|
||||
typedName: z.string().min(1, 'Type the client name to confirm'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Permanently delete an archived client. Gated on the dedicated
|
||||
* `admin.permanently_delete_clients` permission AND requires both:
|
||||
* - the 4-digit code that was emailed to the operator (10 min TTL),
|
||||
* - the typed full name of the client (case-insensitive equality).
|
||||
* The service additionally enforces that the client is already archived.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'permanently_delete_clients', async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
const body = await parseBody(req, hardDeleteSchema);
|
||||
|
||||
const result = await hardDeleteClient({
|
||||
clientId: id,
|
||||
portId: ctx.portId,
|
||||
requesterUserId: ctx.userId,
|
||||
code: body.code,
|
||||
typedName: body.typedName,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
33
src/app/api/v1/clients/bulk-hard-delete-request/route.ts
Normal file
33
src/app/api/v1/clients/bulk-hard-delete-request/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { requestBulkHardDeleteCode } from '@/lib/services/client-hard-delete.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const bodySchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(100),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'permanently_delete_clients', async (req, ctx) => {
|
||||
try {
|
||||
const { ids } = await parseBody(req, bodySchema);
|
||||
const result = await requestBulkHardDeleteCode({
|
||||
clientIds: ids,
|
||||
portId: ctx.portId,
|
||||
requesterUserId: ctx.userId,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
37
src/app/api/v1/clients/bulk-hard-delete/route.ts
Normal file
37
src/app/api/v1/clients/bulk-hard-delete/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { bulkHardDeleteClients } from '@/lib/services/client-hard-delete.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const bodySchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(100),
|
||||
code: z.string().regex(/^\d{4}$/, '4-digit code required'),
|
||||
typedPhrase: z.string().min(1, 'Type the confirmation phrase'),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'permanently_delete_clients', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
const result = await bulkHardDeleteClients({
|
||||
clientIds: body.ids,
|
||||
portId: ctx.portId,
|
||||
requesterUserId: ctx.userId,
|
||||
code: body.code,
|
||||
typedPhrase: body.typedPhrase,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user