feat: trash (soft delete) with admin tool management
Build & Push Docker Image / build (push) Successful in 2m15s
Build & Push Docker Image / build (push) Successful in 2m15s
- tools: add deletedAt/deletedBy, soft delete via DELETE /tools/:id when actor has trash entitlement, else immediate hard delete - trash endpoints: GET /tools/trash, POST /tools/trash (admin bulk), POST /tools/trash/restore, DELETE /tools/trash, POST /tools/trash/empty - trash feature for premium/enterprise; exclude trashed from all public surfaces (browse, categories, features, tags, similar, ratings, costs, analytics, redundancy) - TRASH_RETENTION_DAYS env (0 = keep forever) with hourly purge job - frontend: /trash page (premium+, restore for all, permanent delete + empty for admin), admin Tools tab with multi-select bulk trash, sidebar Trash link, tool-detail delete hint
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import app from "./app";
|
||||
import { logger } from "./lib/logger";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { db, usersTable, toolsTable } from "@workspace/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const rawPort = process.env["PORT"];
|
||||
@@ -178,6 +178,23 @@ async function ensureAdminTier(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeTrash(): Promise<void> {
|
||||
const retentionDays = Number(process.env["TRASH_RETENTION_DAYS"] ?? "0");
|
||||
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
const result = await db
|
||||
.delete(toolsTable)
|
||||
.where(sql`${toolsTable.deletedAt} IS NOT NULL AND ${toolsTable.deletedAt} < ${cutoff}`)
|
||||
.returning({ id: toolsTable.id });
|
||||
if (result.length > 0) {
|
||||
logger.info({ count: result.length }, "Purged expired trash entries");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to purge trash");
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
await ensureSessionsTable();
|
||||
await ensureToolRelationsTable();
|
||||
@@ -187,6 +204,15 @@ async function start(): Promise<void> {
|
||||
await ensureUserColumns();
|
||||
await seedAdminUser();
|
||||
await ensureAdminTier();
|
||||
await purgeTrash();
|
||||
|
||||
const retentionDays = Number(process.env["TRASH_RETENTION_DAYS"] ?? "0");
|
||||
if (Number.isFinite(retentionDays) && retentionDays > 0) {
|
||||
setInterval(() => {
|
||||
void purgeTrash();
|
||||
}, 60 * 60 * 1000);
|
||||
logger.info({ retentionDays }, "Trash auto-purge enabled");
|
||||
}
|
||||
|
||||
app.listen(port, (err) => {
|
||||
if (err) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
|
||||
|
||||
const TIER_FEATURES: Record<string, string[]> = {
|
||||
free: ["browse", "rate", "search"],
|
||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced"],
|
||||
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "sso", "audit-export", "api-access"],
|
||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash"],
|
||||
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "sso", "audit-export", "api-access"],
|
||||
};
|
||||
|
||||
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||
|
||||
@@ -71,7 +71,7 @@ function buildRecommendation(a: any, b: any): { betterToolId: number; betterName
|
||||
}
|
||||
|
||||
router.get("/admin/redundancy", requireAdmin, async (_req, res): Promise<void> => {
|
||||
const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name);
|
||||
const tools = await db.select().from(toolsTable).where(sql`${toolsTable.deletedAt} IS NULL`).orderBy(toolsTable.category, toolsTable.name);
|
||||
|
||||
const allRatings = await db
|
||||
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, sql, desc } from "drizzle-orm";
|
||||
import { eq, sql, desc, and } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import {
|
||||
GetTopToolsQueryParams,
|
||||
@@ -11,7 +11,8 @@ const router: IRouter = Router();
|
||||
router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
||||
const [toolStats] = await db
|
||||
.select({ totalTools: sql<number>`count(*)::int` })
|
||||
.from(toolsTable);
|
||||
.from(toolsTable)
|
||||
.where(sql`${toolsTable.deletedAt} IS NULL`);
|
||||
|
||||
const [ratingStats] = await db
|
||||
.select({
|
||||
@@ -23,7 +24,8 @@ router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
||||
|
||||
const [catStats] = await db
|
||||
.select({ categoriesCount: sql<number>`count(distinct ${toolsTable.category})::int` })
|
||||
.from(toolsTable);
|
||||
.from(toolsTable)
|
||||
.where(sql`${toolsTable.deletedAt} IS NULL`);
|
||||
|
||||
const avgCombined = ratingStats.avgUsefulness != null && ratingStats.avgUsability != null
|
||||
? (Number(ratingStats.avgUsefulness) + Number(ratingStats.avgUsability)) / 2
|
||||
@@ -42,7 +44,7 @@ router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
||||
|
||||
let mostRatedTool = null;
|
||||
if (mostRatedRow) {
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, mostRatedRow.toolId));
|
||||
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, mostRatedRow.toolId), sql`${toolsTable.deletedAt} IS NULL`));
|
||||
if (tool) {
|
||||
const ratingRows = await db
|
||||
.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||
@@ -92,6 +94,7 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
|
||||
})
|
||||
.from(toolsTable)
|
||||
.innerJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||
.where(sql`${toolsTable.deletedAt} IS NULL`)
|
||||
.groupBy(toolsTable.id)
|
||||
.orderBy(desc(scoreExpr))
|
||||
.limit(limit);
|
||||
@@ -121,6 +124,7 @@ router.get("/analytics/by-category", async (_req, res): Promise<void> => {
|
||||
})
|
||||
.from(toolsTable)
|
||||
.leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||
.where(sql`${toolsTable.deletedAt} IS NULL`)
|
||||
.groupBy(toolsTable.category)
|
||||
.orderBy(toolsTable.category);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
@@ -31,7 +31,7 @@ router.get("/tools/:id/costs", async (req, res): Promise<void> => {
|
||||
const toolId = Number(req.params.id);
|
||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
|
||||
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, toolId), sql`${toolsTable.deletedAt} IS NULL`));
|
||||
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
||||
|
||||
const costs = await db
|
||||
@@ -47,7 +47,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
||||
const toolId = Number(req.params.id);
|
||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
|
||||
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, toolId), sql`${toolsTable.deletedAt} IS NULL`));
|
||||
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
||||
|
||||
const parsed = CostCreateBody.safeParse(req.body);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import {
|
||||
ListToolRatingsParams,
|
||||
@@ -24,7 +24,7 @@ router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, params.data.id), sql`${toolsTable.deletedAt} IS NULL`));
|
||||
if (!tool) {
|
||||
res.status(404).json({ error: "Tool not found" });
|
||||
return;
|
||||
@@ -54,7 +54,7 @@ router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, params.data.id), sql`${toolsTable.deletedAt} IS NULL`));
|
||||
if (!tool) {
|
||||
res.status(404).json({ error: "Tool not found" });
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, desc, sql, and, not } from "drizzle-orm";
|
||||
import { eq, desc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||
import {
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
UpdateToolParams,
|
||||
UpdateToolBody,
|
||||
DeleteToolParams,
|
||||
TrashToolsBody,
|
||||
} from "@workspace/api-zod";
|
||||
import { requireAuth, requireAdmin } from "../middleware/auth";
|
||||
import { requireFeature } from "../middleware/feature";
|
||||
import { requireFeature, hasFeature } from "../middleware/feature";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -53,7 +54,7 @@ router.get("/tools", async (req, res): Promise<void> => {
|
||||
}
|
||||
const { category, search, sort } = parsed.data;
|
||||
|
||||
let query = db.select().from(toolsTable).$dynamic();
|
||||
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
||||
if (category) {
|
||||
query = query.where(eq(toolsTable.category, category));
|
||||
}
|
||||
@@ -116,6 +117,81 @@ router.post("/tools", requireAuth, async (req, res): Promise<void> => {
|
||||
res.status(201).json(tool);
|
||||
});
|
||||
|
||||
function deletedByUser(req: import("express").Request): string {
|
||||
const user = req.session.user!;
|
||||
return user.preferred_username || user.name || user.sub;
|
||||
}
|
||||
|
||||
router.get("/tools/trash", requireAuth, requireFeature("trash"), async (req, res): Promise<void> => {
|
||||
const { search } = req.query;
|
||||
let query = db
|
||||
.select()
|
||||
.from(toolsTable)
|
||||
.where(sql`${toolsTable.deletedAt} IS NOT NULL`)
|
||||
.$dynamic();
|
||||
if (search) {
|
||||
const escaped = String(search).replace(/[%_\\]/g, (m) => `\\${m}`);
|
||||
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
||||
}
|
||||
const tools = await query.orderBy(desc(toolsTable.deletedAt));
|
||||
res.json(tools);
|
||||
});
|
||||
|
||||
router.post("/tools/trash", requireAuth, requireFeature("trash"), requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = TrashToolsBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const rows = await db.update(toolsTable)
|
||||
.set({ deletedAt: new Date(), deletedBy: deletedByUser(req) })
|
||||
.where(and(inArray(toolsTable.id, parsed.data.ids), isNull(toolsTable.deletedAt)))
|
||||
.returning({ id: toolsTable.id });
|
||||
for (const r of rows) {
|
||||
await writeAuditLog(req, "tool", r.id, "trash", {});
|
||||
}
|
||||
res.json({ trashed: rows.length });
|
||||
});
|
||||
|
||||
router.post("/tools/trash/restore", requireAuth, requireFeature("trash"), async (req, res): Promise<void> => {
|
||||
const parsed = TrashToolsBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const rows = await db.update(toolsTable)
|
||||
.set({ deletedAt: null, deletedBy: null })
|
||||
.where(and(inArray(toolsTable.id, parsed.data.ids), sql`${toolsTable.deletedAt} IS NOT NULL`))
|
||||
.returning({ id: toolsTable.id });
|
||||
for (const r of rows) {
|
||||
await writeAuditLog(req, "tool", r.id, "restore", {});
|
||||
}
|
||||
res.json({ restored: rows.length });
|
||||
});
|
||||
|
||||
router.delete("/tools/trash", requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = TrashToolsBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const rows = await db.delete(toolsTable)
|
||||
.where(and(inArray(toolsTable.id, parsed.data.ids), sql`${toolsTable.deletedAt} IS NOT NULL`))
|
||||
.returning({ id: toolsTable.id, name: toolsTable.name });
|
||||
for (const r of rows) {
|
||||
await writeAuditLog(req, "tool", r.id, "permanent_delete", { name: r.name });
|
||||
}
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.post("/tools/trash/empty", requireAdmin, async (req, res): Promise<void> => {
|
||||
const rows = await db.delete(toolsTable)
|
||||
.where(sql`${toolsTable.deletedAt} IS NOT NULL`)
|
||||
.returning({ id: toolsTable.id });
|
||||
await writeAuditLog(req, "tool", null, "empty_trash", { count: rows.length });
|
||||
res.json({ deleted: rows.length });
|
||||
});
|
||||
|
||||
router.get("/tools/:id", async (req, res): Promise<void> => {
|
||||
const params = GetToolParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
@@ -123,7 +199,7 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||
const [tool] = await db.select().from(toolsTable).where(and(eq(toolsTable.id, params.data.id), isNull(toolsTable.deletedAt)));
|
||||
if (!tool) {
|
||||
res.status(404).json({ error: "Tool not found" });
|
||||
return;
|
||||
@@ -229,8 +305,17 @@ router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
|
||||
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||
const user = req.session.user!;
|
||||
if (hasFeature(user.tier, "trash", user.role)) {
|
||||
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name, trashed: true });
|
||||
await db.update(toolsTable).set({
|
||||
deletedAt: new Date(),
|
||||
deletedBy: user.preferred_username || user.name || user.sub,
|
||||
}).where(eq(toolsTable.id, params.data.id));
|
||||
} else {
|
||||
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
|
||||
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||
}
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
@@ -238,12 +323,13 @@ router.get("/categories", async (_req, res): Promise<void> => {
|
||||
const rows = await db
|
||||
.selectDistinct({ category: toolsTable.category })
|
||||
.from(toolsTable)
|
||||
.where(isNull(toolsTable.deletedAt))
|
||||
.orderBy(toolsTable.category);
|
||||
res.json(rows.map((r) => r.category));
|
||||
});
|
||||
|
||||
router.get("/features/all", async (_req, res): Promise<void> => {
|
||||
const tools = await db.select({ features: toolsTable.features }).from(toolsTable);
|
||||
const tools = await db.select({ features: toolsTable.features }).from(toolsTable).where(isNull(toolsTable.deletedAt));
|
||||
const featureSet = new Set<string>();
|
||||
for (const t of tools) {
|
||||
for (const f of t.features ?? []) {
|
||||
@@ -254,7 +340,7 @@ router.get("/features/all", async (_req, res): Promise<void> => {
|
||||
});
|
||||
|
||||
router.get("/tags/all", async (_req, res): Promise<void> => {
|
||||
const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable);
|
||||
const tools = await db.select({ tags: toolsTable.tags }).from(toolsTable).where(isNull(toolsTable.deletedAt));
|
||||
const tagSet = new Set<string>();
|
||||
for (const t of tools) {
|
||||
for (const tag of t.tags ?? []) {
|
||||
@@ -290,7 +376,7 @@ router.get("/tools/:id/similar", async (req, res): Promise<void> => {
|
||||
const allOthers = await db
|
||||
.select()
|
||||
.from(toolsTable)
|
||||
.where(not(eq(toolsTable.id, toolId)));
|
||||
.where(and(not(eq(toolsTable.id, toolId)), isNull(toolsTable.deletedAt)));
|
||||
|
||||
const manualRelations = await db
|
||||
.select({
|
||||
|
||||
Reference in New Issue
Block a user