diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 422f074..a58080e 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -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 { } } +async function purgeTrash(): Promise { + 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 { await ensureSessionsTable(); await ensureToolRelationsTable(); @@ -187,6 +204,15 @@ async function start(): Promise { 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) { diff --git a/artifacts/api-server/src/middleware/feature.ts b/artifacts/api-server/src/middleware/feature.ts index 12a15f0..39e6188 100644 --- a/artifacts/api-server/src/middleware/feature.ts +++ b/artifacts/api-server/src/middleware/feature.ts @@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express"; const TIER_FEATURES: Record = { 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[] { diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts index 7e6c0ac..49a52b6 100644 --- a/artifacts/api-server/src/routes/admin.ts +++ b/artifacts/api-server/src/routes/admin.ts @@ -71,7 +71,7 @@ function buildRecommendation(a: any, b: any): { betterToolId: number; betterName } router.get("/admin/redundancy", requireAdmin, async (_req, res): Promise => { - 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 }) diff --git a/artifacts/api-server/src/routes/analytics.ts b/artifacts/api-server/src/routes/analytics.ts index 686c702..d3e6921 100644 --- a/artifacts/api-server/src/routes/analytics.ts +++ b/artifacts/api-server/src/routes/analytics.ts @@ -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 => { const [toolStats] = await db .select({ totalTools: sql`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 => { const [catStats] = await db .select({ categoriesCount: sql`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 => { 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 => { }) .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 => { }) .from(toolsTable) .leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id)) + .where(sql`${toolsTable.deletedAt} IS NULL`) .groupBy(toolsTable.category) .orderBy(toolsTable.category); diff --git a/artifacts/api-server/src/routes/costs.ts b/artifacts/api-server/src/routes/costs.ts index e054f26..2eeba0d 100644 --- a/artifacts/api-server/src/routes/costs.ts +++ b/artifacts/api-server/src/routes/costs.ts @@ -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 => { 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); diff --git a/artifacts/api-server/src/routes/ratings.ts b/artifacts/api-server/src/routes/ratings.ts index 64ba218..1e10a57 100644 --- a/artifacts/api-server/src/routes/ratings.ts +++ b/artifacts/api-server/src/routes/ratings.ts @@ -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 => { 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 = 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; diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index 912412e..bf7a053 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -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 => { } 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 => { 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { const params = GetToolParams.safeParse(req.params); if (!params.success) { @@ -123,7 +199,7 @@ router.get("/tools/:id", async (req, res): Promise => { 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 => { 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 => { 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 => { - 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(); for (const t of tools) { for (const f of t.features ?? []) { @@ -254,7 +340,7 @@ router.get("/features/all", async (_req, res): Promise => { }); router.get("/tags/all", async (_req, res): Promise => { - 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(); for (const t of tools) { for (const tag of t.tags ?? []) { @@ -290,7 +376,7 @@ router.get("/tools/:id/similar", async (req, res): Promise => { 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({ diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index 5bcbe44..b490c7b 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -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() { + ); diff --git a/artifacts/toolrate/src/components/admin-tools-tab.tsx b/artifacts/toolrate/src/components/admin-tools-tab.tsx new file mode 100644 index 0000000..19217bb --- /dev/null +++ b/artifacts/toolrate/src/components/admin-tools-tab.tsx @@ -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>(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

Admin access required.

; + } + + const selectedIds = [...selected]; + + return ( + + +
+ All Tools + + {selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`} + +
+
+
+ + setSearchInput(e.target.value)} + /> +
+ +
+
+ + {loading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : allTools.length === 0 ? ( +

{search ? "No tools match your search." : "No tools yet."}

+ ) : ( + + + + + 0} + onCheckedChange={toggleAll} + aria-label="Select all" + /> + + Name + Category + Rating + Created by + Created at + Actions + + + + {allTools.map((t: ToolWithStats) => ( + + + toggle(t.id)} + aria-label={`Select ${t.name}`} + /> + + {t.name} + + {t.category} + + + {t.ratingCount > 0 ? ( + + + {t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount}) + + ) : ( + "—" + )} + + {t.createdBy ?? "—"} + + {format(new Date(t.createdAt), "dd.MM.yyyy")} + + +
+ + + +
+
+
+ ))} +
+
+ )} +
+ + + + + Move {selectedIds.length} tool(s) to trash? + + The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted. + + + + Cancel + handleTrash(selectedIds)} + > + Move to trash + + + + +
+ ); +} diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 3e26985..3a1b175 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -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 }] : []), ]; diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx index 3effb14..808f4d5 100644 --- a/artifacts/toolrate/src/pages/admin.tsx +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -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() { Users + + Tools + Audit Log @@ -220,6 +224,10 @@ export default function Admin() { + + + + diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index 40653ba..9adacbe 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -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() { Delete this tool? - This will permanently remove {tool?.name} and all its ratings. This cannot be undone. + {hasTrash ? ( + <>This will move {tool?.name} to the trash. It can be restored later. + ) : ( + <>This will permanently remove {tool?.name} and all its ratings. This cannot be undone. + )} diff --git a/artifacts/toolrate/src/pages/trash.tsx b/artifacts/toolrate/src/pages/trash.tsx new file mode 100644 index 0000000..150f4bb --- /dev/null +++ b/artifacts/toolrate/src/pages/trash.tsx @@ -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>(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 ( + +
+ +

Trash requires a higher tier

+

The trash is available to Premium and Enterprise users.

+ +
+
+ ); + } + + const selectedIds = [...selected]; + + return ( + +
+
+

Trash

+

Deleted tools are kept here until they are restored or permanently removed.

+
+ + + +
+ Trashed Tools + + {selectedIds.length > 0 ? `${selectedIds.length} selected` : `${trashed.length} tool(s) in trash`} + +
+
+
+ + setSearchInput(e.target.value)} + /> +
+ + {isAdmin && ( + <> + + + + )} +
+
+ + {loading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : trashed.length === 0 ? ( +
+ + {search ? "No tools match your search." : "The trash is empty."} +
+ ) : ( + + + + + 0} + onCheckedChange={toggleAll} + aria-label="Select all" + /> + + Name + Category + Deleted at + Deleted by + Actions + + + + {trashed.map((t: Tool) => ( + + + toggle(t.id)} + aria-label={`Select ${t.name}`} + /> + + {t.name} + + {t.category} + + + {t.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"} + + {t.deletedBy ?? "—"} + +
+ + {isAdmin && ( + + )} +
+
+
+ ))} +
+
+ )} +
+
+
+ + + + + Delete {selectedIds.length} tool(s) permanently? + + This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone. + + + + Cancel + handleDeletePermanent(selectedIds)} + > + Delete permanently + + + + + + + + + Empty the trash? + + This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone. + + + + Cancel + + Empty trash + + + + +
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index aef5831..290c94b 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -130,6 +130,10 @@ export interface Tool { tags?: string[]; createdAt: string; updatedAt: string; + /** @nullable */ + deletedAt?: string | null; + /** @nullable */ + deletedBy?: string | null; } export interface ToolWithStats { @@ -182,6 +186,14 @@ export interface ToolUpdate { tags?: string[]; } +export interface TrashToolsInput { + /** + * @minItems 1 + * @maxItems 500 + */ + ids: number[]; +} + export interface Rating { id: number; toolId: number; @@ -306,6 +318,22 @@ export const ListToolsSort = { most_reviewed: 'most_reviewed', } as const; +export type ListTrashedToolsParams = { +search?: string; +}; + +export type TrashTools200 = { + trashed?: number; +}; + +export type RestoreTools200 = { + restored?: number; +}; + +export type EmptyTrash200 = { + deleted?: number; +}; + export type GetTopToolsParams = { limit?: number; metric?: GetTopToolsMetric; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index b463a0a..9632ed6 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -25,21 +25,26 @@ import type { AuthMode, AuthUser, CategoryStats, + EmptyTrash200, ErrorResponse, GetRatingDistributionParams, GetTopToolsParams, HealthStatus, ListAuditLogsParams, ListToolsParams, + ListTrashedToolsParams, LocalLoginInput, Rating, RatingDistribution, RatingInput, + RestoreTools200, Tool, ToolInput, ToolUpdate, ToolWithStats, TopToolEntry, + TrashTools200, + TrashToolsInput, User, UserCreateInput, UserRoleUpdate @@ -509,6 +514,373 @@ export const useDeleteTool = , return useMutation(getDeleteToolMutationOptions(options)); } +export const getListTrashedToolsUrl = (params?: ListTrashedToolsParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/tools/trash?${stringifiedParams}` : `/api/tools/trash` +} + +/** + * @summary List trashed (soft-deleted) tools + */ +export const listTrashedTools = async (params?: ListTrashedToolsParams, options?: RequestInit): Promise => { + + return customFetch(getListTrashedToolsUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getListTrashedToolsQueryKey = (params?: ListTrashedToolsParams,) => { + return [ + `/api/tools/trash`, ...(params ? [params] : []) + ] as const; + } + + +export const getListTrashedToolsQueryOptions = >, TError = ErrorType>(params?: ListTrashedToolsParams, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListTrashedToolsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listTrashedTools(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type ListTrashedToolsQueryResult = NonNullable>> +export type ListTrashedToolsQueryError = ErrorType + + +/** + * @summary List trashed (soft-deleted) tools + */ + +export function useListTrashedTools>, TError = ErrorType>( + params?: ListTrashedToolsParams, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getListTrashedToolsQueryOptions(params,options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + +export const getTrashToolsUrl = () => { + + + + + return `/api/tools/trash` +} + +/** + * @summary Move tools to trash (admin) + */ +export const trashTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise => { + + return customFetch(getTrashToolsUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + trashToolsInput,) + } +);} + + + + +export const getTrashToolsMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, TContext> => { + +const mutationKey = ['trashTools']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return trashTools(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type TrashToolsMutationResult = NonNullable>> + export type TrashToolsMutationBody = BodyType + export type TrashToolsMutationError = ErrorType + + /** + * @summary Move tools to trash (admin) + */ +export const useTrashTools = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + TContext + > => { + return useMutation(getTrashToolsMutationOptions(options)); + } + +export const getDeleteTrashedToolsUrl = () => { + + + + + return `/api/tools/trash` +} + +/** + * @summary Permanently delete trashed tools (admin) + */ +export const deleteTrashedTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise => { + + return customFetch(getDeleteTrashedToolsUrl(), + { + ...options, + method: 'DELETE', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + trashToolsInput,) + } +);} + + + + +export const getDeleteTrashedToolsMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, TContext> => { + +const mutationKey = ['deleteTrashedTools']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return deleteTrashedTools(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteTrashedToolsMutationResult = NonNullable>> + export type DeleteTrashedToolsMutationBody = BodyType + export type DeleteTrashedToolsMutationError = ErrorType + + /** + * @summary Permanently delete trashed tools (admin) + */ +export const useDeleteTrashedTools = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + TContext + > => { + return useMutation(getDeleteTrashedToolsMutationOptions(options)); + } + +export const getRestoreToolsUrl = () => { + + + + + return `/api/tools/trash/restore` +} + +/** + * @summary Restore trashed tools + */ +export const restoreTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise => { + + return customFetch(getRestoreToolsUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + trashToolsInput,) + } +);} + + + + +export const getRestoreToolsMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, TContext> => { + +const mutationKey = ['restoreTools']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return restoreTools(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type RestoreToolsMutationResult = NonNullable>> + export type RestoreToolsMutationBody = BodyType + export type RestoreToolsMutationError = ErrorType + + /** + * @summary Restore trashed tools + */ +export const useRestoreTools = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + TContext + > => { + return useMutation(getRestoreToolsMutationOptions(options)); + } + +export const getEmptyTrashUrl = () => { + + + + + return `/api/tools/trash/empty` +} + +/** + * @summary Permanently delete all trashed tools (admin) + */ +export const emptyTrash = async ( options?: RequestInit): Promise => { + + return customFetch(getEmptyTrashUrl(), + { + ...options, + method: 'POST' + + + } +);} + + + + +export const getEmptyTrashMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,void, TContext> => { + +const mutationKey = ['emptyTrash']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, void> = () => { + + + return emptyTrash(requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type EmptyTrashMutationResult = NonNullable>> + + export type EmptyTrashMutationError = ErrorType + + /** + * @summary Permanently delete all trashed tools (admin) + */ +export const useEmptyTrash = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + return useMutation(getEmptyTrashMutationOptions(options)); + } + export const getListToolRatingsUrl = (id: number,) => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 0474079..d8f4eb2 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -165,6 +165,128 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /tools/trash: + get: + operationId: listTrashedTools + tags: [tools] + summary: List trashed (soft-deleted) tools + parameters: + - name: search + in: query + required: false + schema: + type: string + responses: + "200": + description: List of trashed tools + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Tool" + "403": + description: Feature "trash" requires a higher tier + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + operationId: trashTools + tags: [tools] + summary: Move tools to trash (admin) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TrashToolsInput" + responses: + "200": + description: Tools trashed + content: + application/json: + schema: + type: object + properties: + trashed: + type: integer + "403": + description: Admin required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: deleteTrashedTools + tags: [tools] + summary: Permanently delete trashed tools (admin) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TrashToolsInput" + responses: + "204": + description: Deleted + "403": + description: Admin required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /tools/trash/restore: + post: + operationId: restoreTools + tags: [tools] + summary: Restore trashed tools + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TrashToolsInput" + responses: + "200": + description: Tools restored + content: + application/json: + schema: + type: object + properties: + restored: + type: integer + "403": + description: Feature "trash" requires a higher tier + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /tools/trash/empty: + post: + operationId: emptyTrash + tags: [tools] + summary: Permanently delete all trashed tools (admin) + responses: + "200": + description: Trash emptied + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + "403": + description: Admin required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /tools/{id}/ratings: get: operationId: listToolRatings @@ -658,6 +780,11 @@ components: updatedAt: type: string format: date-time + deletedAt: + type: ["string", "null"] + format: date-time + deletedBy: + type: ["string", "null"] ToolWithStats: type: object @@ -749,6 +876,17 @@ components: items: type: string + TrashToolsInput: + type: object + required: [ids] + properties: + ids: + type: array + minItems: 1 + maxItems: 500 + items: + type: integer + Rating: type: object required: [id, toolId, usefulness, usability, createdAt] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 1365e36..d1a9c50 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -122,7 +122,9 @@ export const UpdateToolResponse = zod.object({ "features": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(), "createdAt": zod.coerce.date(), - "updatedAt": zod.coerce.date() + "updatedAt": zod.coerce.date(), + "deletedAt": zod.coerce.date().nullish(), + "deletedBy": zod.string().nullish() }) @@ -134,6 +136,83 @@ export const DeleteToolParams = zod.object({ }) +/** + * @summary List trashed (soft-deleted) tools + */ +export const ListTrashedToolsQueryParams = zod.object({ + "search": zod.coerce.string().optional() +}) + +export const ListTrashedToolsResponseItem = zod.object({ + "id": zod.number(), + "name": zod.string(), + "description": zod.string(), + "category": zod.string(), + "websiteUrl": zod.string().nullish(), + "iconUrl": zod.string().nullish(), + "createdBy": zod.string().nullish(), + "features": zod.array(zod.string()).optional(), + "tags": zod.array(zod.string()).optional(), + "createdAt": zod.coerce.date(), + "updatedAt": zod.coerce.date(), + "deletedAt": zod.coerce.date().nullish(), + "deletedBy": zod.string().nullish() +}) +export const ListTrashedToolsResponse = zod.array(ListTrashedToolsResponseItem) + + +/** + * @summary Move tools to trash (admin) + */ +export const trashToolsBodyIdsMax = 500; + + + +export const TrashToolsBody = zod.object({ + "ids": zod.array(zod.number()).min(1).max(trashToolsBodyIdsMax) +}) + +export const TrashToolsResponse = zod.object({ + "trashed": zod.number().optional() +}) + + +/** + * @summary Permanently delete trashed tools (admin) + */ +export const deleteTrashedToolsBodyIdsMax = 500; + + + +export const DeleteTrashedToolsBody = zod.object({ + "ids": zod.array(zod.number()).min(1).max(deleteTrashedToolsBodyIdsMax) +}) + + +/** + * @summary Restore trashed tools + */ +export const restoreToolsBodyIdsMax = 500; + + + +export const RestoreToolsBody = zod.object({ + "ids": zod.array(zod.number()).min(1).max(restoreToolsBodyIdsMax) +}) + +export const RestoreToolsResponse = zod.object({ + "restored": zod.number().optional() +}) + + +/** + * @summary Permanently delete all trashed tools (admin) + */ +export const EmptyTrashResponse = zod.object({ + "deleted": zod.number().optional() +}) + + /** * @summary List ratings for a tool */ diff --git a/lib/api-zod/src/generated/types/emptyTrash200.ts b/lib/api-zod/src/generated/types/emptyTrash200.ts new file mode 100644 index 0000000..415c6ef --- /dev/null +++ b/lib/api-zod/src/generated/types/emptyTrash200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type EmptyTrash200 = { + deleted?: number; +}; diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index d76bfce..ad49d0a 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -14,6 +14,7 @@ export * from './authUser'; export * from './authUserRole'; export * from './authUserTier'; export * from './categoryStats'; +export * from './emptyTrash200'; export * from './errorResponse'; export * from './getRatingDistributionParams'; export * from './getTopToolsMetric'; @@ -22,16 +23,20 @@ export * from './healthStatus'; export * from './listAuditLogsParams'; export * from './listToolsParams'; export * from './listToolsSort'; +export * from './listTrashedToolsParams'; export * from './localLoginInput'; export * from './rating'; export * from './ratingDistribution'; export * from './ratingInput'; +export * from './restoreTools200'; export * from './scoreBucket'; export * from './tool'; export * from './toolInput'; export * from './toolUpdate'; export * from './toolWithStats'; export * from './topToolEntry'; +export * from './trashTools200'; +export * from './trashToolsInput'; export * from './user'; export * from './userCreateInput'; export * from './userCreateInputRole'; diff --git a/lib/api-zod/src/generated/types/listTrashedToolsParams.ts b/lib/api-zod/src/generated/types/listTrashedToolsParams.ts new file mode 100644 index 0000000..6e14d39 --- /dev/null +++ b/lib/api-zod/src/generated/types/listTrashedToolsParams.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type ListTrashedToolsParams = { +search?: string; +}; diff --git a/lib/api-zod/src/generated/types/restoreTools200.ts b/lib/api-zod/src/generated/types/restoreTools200.ts new file mode 100644 index 0000000..68e9b81 --- /dev/null +++ b/lib/api-zod/src/generated/types/restoreTools200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type RestoreTools200 = { + restored?: number; +}; diff --git a/lib/api-zod/src/generated/types/tool.ts b/lib/api-zod/src/generated/types/tool.ts index 974681d..15fe42f 100644 --- a/lib/api-zod/src/generated/types/tool.ts +++ b/lib/api-zod/src/generated/types/tool.ts @@ -21,4 +21,8 @@ export interface Tool { tags?: string[]; createdAt: Date; updatedAt: Date; + /** @nullable */ + deletedAt?: Date | null; + /** @nullable */ + deletedBy?: string | null; } diff --git a/lib/api-zod/src/generated/types/trashTools200.ts b/lib/api-zod/src/generated/types/trashTools200.ts new file mode 100644 index 0000000..d33413a --- /dev/null +++ b/lib/api-zod/src/generated/types/trashTools200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type TrashTools200 = { + trashed?: number; +}; diff --git a/lib/api-zod/src/generated/types/trashToolsInput.ts b/lib/api-zod/src/generated/types/trashToolsInput.ts new file mode 100644 index 0000000..50513c3 --- /dev/null +++ b/lib/api-zod/src/generated/types/trashToolsInput.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export interface TrashToolsInput { + /** + * @minItems 1 + * @maxItems 500 + */ + ids: number[]; +} diff --git a/lib/db/src/schema/tools.ts b/lib/db/src/schema/tools.ts index 7b61545..5c57d5c 100644 --- a/lib/db/src/schema/tools.ts +++ b/lib/db/src/schema/tools.ts @@ -14,6 +14,8 @@ export const toolsTable = pgTable("tools", { tags: text("tags").array().notNull().default([]), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + deletedBy: text("deleted_by"), }); export const insertToolSchema = createInsertSchema(toolsTable).omit({ id: true, createdAt: true, updatedAt: true });