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,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