7680d9a0ed
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
191 lines
8.5 KiB
TypeScript
191 lines
8.5 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import type { FastifyInstance } from "fastify";
|
||
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
|
||
// from (so they never type a price). Mirrors the tariff composer: plans are
|
||
// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan
|
||
// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so
|
||
// a past sale reprices identically against its recorded planVersionId. Retire =
|
||
// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays
|
||
// operator-grade (`subscription:create`). See wiki/entities/subscription.md.
|
||
|
||
interface PlanBody {
|
||
/** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to
|
||
* publish a new version of an existing plan. Slugified server-side. */
|
||
planId?: string;
|
||
name?: string;
|
||
period?: SubscriptionPeriod;
|
||
pricePerPeriodMinor?: number;
|
||
currency?: string;
|
||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||
effectiveFrom?: string;
|
||
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
|
||
timeframes?: PlanTimeframes | null;
|
||
}
|
||
|
||
/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */
|
||
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
|
||
if (tf == null) return null;
|
||
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
|
||
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
|
||
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
|
||
return "days must be integers 0–6 (0=Sun..6=Sat)";
|
||
}
|
||
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
|
||
return null;
|
||
}
|
||
|
||
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
|
||
function slugify(s: string): string {
|
||
return s
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 48);
|
||
}
|
||
|
||
export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||
const readGuard = requirePermission("subscription:read");
|
||
const planGuard = requirePermission("subscription:plan");
|
||
|
||
function validate(b: PlanBody): string[] {
|
||
const errs: string[] = [];
|
||
if (!b.name?.trim()) errs.push("name is required");
|
||
if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) {
|
||
errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`);
|
||
}
|
||
if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) {
|
||
errs.push("pricePerPeriodMinor must be a positive integer (minor units)");
|
||
}
|
||
if (!b.currency?.trim()) errs.push("currency is required");
|
||
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
|
||
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
|
||
}
|
||
const tfErr = validTimeframes(b.timeframes);
|
||
if (tfErr) errs.push(tfErr);
|
||
return errs;
|
||
}
|
||
|
||
// List plans. ?all=1 → every version (history); default → the CURRENT sellable plan
|
||
// 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) => {
|
||
// 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.
|
||
const current = new Map<string, (typeof rows)[number]>();
|
||
for (const r of rows) {
|
||
if (!r.active || r.effectiveFrom > now) continue;
|
||
if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first
|
||
}
|
||
return { plans: [...current.values()] };
|
||
});
|
||
|
||
// Publish a plan version (create a plan, or a new version of an existing planId).
|
||
app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => {
|
||
const b = req.body ?? ({} as PlanBody);
|
||
const problems = validate(b);
|
||
if (problems.length) return reply.code(400).send({ error: "invalid plan", problems });
|
||
|
||
const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID();
|
||
const now = new Date().toISOString();
|
||
const effectiveFrom = b.effectiveFrom?.trim() || now;
|
||
// Backdating would retroactively reprice — refuse (mirrors tariff publish).
|
||
if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) {
|
||
return reply.code(400).send({
|
||
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
|
||
});
|
||
}
|
||
// Stamp the site tz into the timeframes so the windows evaluate in the site's
|
||
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
|
||
const timeframes =
|
||
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
|
||
|
||
const row = {
|
||
id: randomUUID(),
|
||
planId,
|
||
name: b.name!.trim(),
|
||
period: b.period!,
|
||
pricePerPeriodMinor: b.pricePerPeriodMinor!,
|
||
currency: b.currency!.trim(),
|
||
effectiveFrom,
|
||
timeframes,
|
||
active: true,
|
||
createdBy: req.user?.username ?? null,
|
||
};
|
||
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
|
||
return reply.code(201).send(row);
|
||
});
|
||
|
||
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
|
||
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
|
||
app.post<{ Params: { planId: string } }>(
|
||
"/api/subscription-plans/:planId/retire",
|
||
{ preHandler: planGuard },
|
||
async (req) => {
|
||
db.update(subscriptionPlans)
|
||
.set({ active: false })
|
||
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||
.run();
|
||
return { planId: req.params.planId, retired: true };
|
||
},
|
||
);
|
||
|
||
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
|
||
// latest-effective version becomes "in force" again. (The inverse of retire.)
|
||
app.post<{ Params: { planId: string } }>(
|
||
"/api/subscription-plans/:planId/reactivate",
|
||
{ preHandler: planGuard },
|
||
async (req) => {
|
||
db.update(subscriptionPlans)
|
||
.set({ active: true })
|
||
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||
.run();
|
||
return { planId: req.params.planId, reactivated: true };
|
||
},
|
||
);
|
||
|
||
// 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). 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) => {
|
||
// 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",
|
||
code: "plan_in_use",
|
||
subscribers: refs.length,
|
||
});
|
||
}
|
||
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 };
|
||
},
|
||
);
|
||
}
|