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
+8 -4
View File
@@ -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);