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
+27 -1
View File
@@ -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[] {
+1 -1
View File
@@ -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 })
+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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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;
+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({
+2
View File
@@ -12,6 +12,7 @@ import ToolEdit from "@/pages/tool-edit";
import Analytics from "@/pages/analytics";
import Admin from "@/pages/admin";
import Redundancy from "@/pages/redundancy";
import Trash from "@/pages/trash";
import Login from "@/pages/login";
import NotFound from "@/pages/not-found";
@@ -36,6 +37,7 @@ function Router() {
<Route path="/analytics" component={Analytics} />
<Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} />
<Route path="/trash" component={Trash} />
<Route component={NotFound} />
</Switch>
);
@@ -0,0 +1,233 @@
import { useEffect, useState } from "react";
import { Link } from "wouter";
import {
useListTools,
useTrashTools,
getListToolsQueryKey,
getListTrashedToolsQueryKey,
getListCategoriesQueryKey,
getListAllFeaturesQueryKey,
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type ToolWithStats,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
import { format } from "date-fns";
export function AdminToolsTab() {
const { isAdmin, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirmTrash, setConfirmTrash] = useState(false);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(t);
}, [searchInput]);
const { data: tools, isLoading: loading } = useListTools(
search ? { search } : undefined,
{ query: { queryKey: getListToolsQueryKey(search ? { search } : undefined), enabled: isAdmin } },
);
const trash = useTrashTools();
const allTools = tools ?? [];
function invalidate() {
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListTrashedToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
}
function toggle(id: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === allTools.length) {
setSelected(new Set());
} else {
setSelected(new Set(allTools.map((t) => t.id)));
}
}
function handleTrash(ids: number[]) {
trash.mutate(
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools moved to trash", description: `${res.trashed ?? ids.length} tool(s) moved to trash.` });
setSelected(new Set());
setConfirmTrash(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to move to trash", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
if (!authLoading && !isAdmin) {
return <p className="text-sm text-muted-foreground py-8 text-center">Admin access required.</p>;
}
const selectedIds = [...selected];
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle>All Tools</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`}
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8 w-56"
placeholder="Search tools…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<Button
size="sm"
variant="destructive"
disabled={selectedIds.length === 0 || trash.isPending}
onClick={() => setConfirmTrash(true)}
>
<Trash2 className="w-4 h-4 mr-2" /> Move to trash ({selectedIds.length})
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : allTools.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">{search ? "No tools match your search." : "No tools yet."}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selected.size === allTools.length && allTools.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Created by</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allTools.map((t: ToolWithStats) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
<TableCell>
<Checkbox
checked={selected.has(t.id)}
onCheckedChange={() => toggle(t.id)}
aria-label={`Select ${t.name}`}
/>
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<Badge variant="outline">{t.category}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{t.ratingCount > 0 ? (
<span className="inline-flex items-center gap-1">
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
{t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount})
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.createdBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(t.createdAt), "dd.MM.yyyy")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/tools/${t.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/tools/${t.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleTrash([t.id])}
disabled={trash.isPending}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
<AlertDialogDescription>
The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleTrash(selectedIds)}
>
Move to trash
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
+3 -2
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle } from "lucide-react";
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -7,13 +7,14 @@ import { ThemeToggle } from "@/components/theme-toggle";
export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation();
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, login, logout } = useAuth();
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/tools", label: "Browse Tools", icon: Wrench },
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []),
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
];
+9 -1
View File
@@ -22,8 +22,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle } from "lucide-react";
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench } from "lucide-react";
import { format } from "date-fns";
import { AdminToolsTab } from "@/components/admin-tools-tab";
export default function Admin() {
const [, setLocation] = useLocation();
@@ -147,6 +148,9 @@ export default function Admin() {
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" /> Users
</TabsTrigger>
<TabsTrigger value="tools" className="gap-2">
<Wrench className="w-4 h-4" /> Tools
</TabsTrigger>
<TabsTrigger value="audit" className="gap-2">
<ScrollText className="w-4 h-4" /> Audit Log
</TabsTrigger>
@@ -220,6 +224,10 @@ export default function Admin() {
</Card>
</TabsContent>
<TabsContent value="tools">
<AdminToolsTab />
</TabsContent>
<TabsContent value="audit">
<Card>
<CardHeader>
+6 -1
View File
@@ -76,6 +76,7 @@ export default function ToolDetail() {
const { user, isAdmin, hasFeature } = useAuth();
const canManageCosts = hasFeature("costs");
const hasTrash = hasFeature("trash");
const deleteTool = useDeleteTool();
const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null);
@@ -860,7 +861,11 @@ export default function ToolDetail() {
<AlertDialogHeader>
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.
{hasTrash ? (
<>This will move <span className="font-medium">{tool?.name}</span> to the trash. It can be restored later.</>
) : (
<>This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+324
View File
@@ -0,0 +1,324 @@
import { useEffect, useState } from "react";
import {
useListTrashedTools,
useRestoreTools,
useDeleteTrashedTools,
useEmptyTrash,
getListTrashedToolsQueryKey,
getListToolsQueryKey,
getListCategoriesQueryKey,
getListAllFeaturesQueryKey,
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type Tool,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
import { format } from "date-fns";
export default function Trash() {
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmEmpty, setConfirmEmpty] = useState(false);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(t);
}, [searchInput]);
const hasTrash = hasFeature("trash");
const { data: tools, isLoading: loading } = useListTrashedTools(
search ? { search } : undefined,
{ query: { queryKey: getListTrashedToolsQueryKey(search ? { search } : undefined), enabled: hasTrash } },
);
const restore = useRestoreTools();
const deletePermanent = useDeleteTrashedTools();
const empty = useEmptyTrash();
const trashed = tools ?? [];
function invalidate() {
queryClient.invalidateQueries({ queryKey: getListTrashedToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
}
function toggle(id: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === trashed.length) {
setSelected(new Set());
} else {
setSelected(new Set(trashed.map((t) => t.id)));
}
}
function handleRestore(ids: number[]) {
restore.mutate(
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools restored", description: `${res.restored ?? ids.length} tool(s) restored.` });
setSelected(new Set());
invalidate();
},
onError: (err) => {
toast({ title: "Failed to restore", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
function handleDeletePermanent(ids: number[]) {
deletePermanent.mutate(
{ data: { ids } },
{
onSuccess: () => {
toast({ title: "Tools deleted", description: `${ids.length} tool(s) permanently removed.` });
setSelected(new Set());
setConfirmDelete(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to delete", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
function handleEmpty() {
empty.mutate(
undefined,
{
onSuccess: (res) => {
toast({ title: "Trash emptied", description: `${res.deleted ?? 0} tool(s) permanently removed.` });
setSelected(new Set());
setConfirmEmpty(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to empty trash", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
if (!authLoading && !hasTrash) {
return (
<Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Trash requires a higher tier</h2>
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p>
<Button variant="outline" asChild>
<a href="/tools">Back to tools</a>
</Button>
</div>
</Layout>
);
}
const selectedIds = [...selected];
return (
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Trash</h1>
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle>Trashed Tools</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${trashed.length} tool(s) in trash`}
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8 w-56"
placeholder="Search trash…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={selectedIds.length === 0 || restore.isPending}
onClick={() => handleRestore(selectedIds)}
>
<RotateCcw className="w-4 h-4 mr-2" /> Restore ({selectedIds.length})
</Button>
{isAdmin && (
<>
<Button
size="sm"
variant="destructive"
disabled={selectedIds.length === 0 || deletePermanent.isPending}
onClick={() => setConfirmDelete(true)}
>
<TrashIcon className="w-4 h-4 mr-2" /> Delete permanently ({selectedIds.length})
</Button>
<Button
size="sm"
variant="outline"
disabled={trashed.length === 0 || empty.isPending}
onClick={() => setConfirmEmpty(true)}
>
<Trash2 className="w-4 h-4 mr-2" /> Empty trash
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : trashed.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
{search ? "No tools match your search." : "The trash is empty."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selected.size === trashed.length && trashed.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Deleted at</TableHead>
<TableHead>Deleted by</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trashed.map((t: Tool) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
<TableCell>
<Checkbox
checked={selected.has(t.id)}
onCheckedChange={() => toggle(t.id)}
aria-label={`Select ${t.name}`}
/>
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<Badge variant="outline">{t.category}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{t.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
<RotateCcw className="w-3.5 h-3.5" /> Restore
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDeletePermanent([t.id])}
disabled={deletePermanent.isPending}
>
<TrashIcon className="w-3.5 h-3.5" /> Delete
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDeletePermanent(selectedIds)}
>
Delete permanently
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleEmpty}
>
Empty trash
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}