feat: trash (soft delete) with admin tool management
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:
opencode
2026-08-02 02:02:16 +02:00
parent 32df998399
commit 2f20b1e5c2
25 changed files with 1404 additions and 28 deletions
+95 -9
View File
@@ -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({