feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). Invariants: soft-deleted users can't log in (login rejects deleted_at; no-lockout counts live admins only); a soft-deleted subscription doesn't open the barrier; plans are versioned so a delete stamps all versions of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role (guard resolves missing role to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4 integration: delete -> can't-login -> restore -> login, purge, gating, 409 reuse). server 103/103; build+lint+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||
import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
import { siteTz } from "../subscription-window.js";
|
||||
|
||||
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||
@@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
|
||||
// need the current list; the admin catalog screen asks for ?all=1.
|
||||
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
|
||||
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
|
||||
// Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
|
||||
// versioned; a soft-delete stamps every version row of the planId.)
|
||||
const rows = db
|
||||
.select()
|
||||
.from(subscriptionPlans)
|
||||
.where(isNull(subscriptionPlans.deletedAt))
|
||||
.orderBy(desc(subscriptionPlans.effectiveFrom))
|
||||
.all();
|
||||
if (req.query?.all) return { plans: rows };
|
||||
const now = new Date().toISOString();
|
||||
// Newest-effective active version wins per planId.
|
||||
@@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
||||
// version). A referenced plan version MUST survive: a subscription's planVersionId is
|
||||
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
|
||||
// when in use (the admin should retire instead). Removes all versions of the planId.
|
||||
// when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
|
||||
// versions of the planId; a restore brings the plan back; purge does the real removal.
|
||||
app.delete<{ Params: { planId: string } }>(
|
||||
"/api/subscription-plans/:planId",
|
||||
{ preHandler: planGuard },
|
||||
async (req, reply) => {
|
||||
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
|
||||
// Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
|
||||
// itself in the bin; if it's restored later, the plan can be restored too).
|
||||
const refs = db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
|
||||
.all();
|
||||
if (refs.length > 0) {
|
||||
return reply.code(409).send({
|
||||
error: "plan is in use and cannot be deleted",
|
||||
@@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
subscribers: refs.length,
|
||||
});
|
||||
}
|
||||
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
|
||||
const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
|
||||
if (!ok) return reply.code(404).send({ error: "plan not found" });
|
||||
return { planId: req.params.planId, deleted: true };
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user