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,7 +1,7 @@
|
|||||||
import app from "./app";
|
import app from "./app";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { db, usersTable } from "@workspace/db";
|
import { db, usersTable, toolsTable } from "@workspace/db";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
const rawPort = process.env["PORT"];
|
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> {
|
async function start(): Promise<void> {
|
||||||
await ensureSessionsTable();
|
await ensureSessionsTable();
|
||||||
await ensureToolRelationsTable();
|
await ensureToolRelationsTable();
|
||||||
@@ -187,6 +204,15 @@ async function start(): Promise<void> {
|
|||||||
await ensureUserColumns();
|
await ensureUserColumns();
|
||||||
await seedAdminUser();
|
await seedAdminUser();
|
||||||
await ensureAdminTier();
|
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) => {
|
app.listen(port, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
|
|||||||
|
|
||||||
const TIER_FEATURES: Record<string, string[]> = {
|
const TIER_FEATURES: Record<string, string[]> = {
|
||||||
free: ["browse", "rate", "search"],
|
free: ["browse", "rate", "search"],
|
||||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced"],
|
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash"],
|
||||||
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "sso", "audit-export", "api-access"],
|
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[] {
|
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function buildRecommendation(a: any, b: any): { betterToolId: number; betterName
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get("/admin/redundancy", requireAdmin, async (_req, res): Promise<void> => {
|
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
|
const allRatings = await db
|
||||||
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
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 { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
GetTopToolsQueryParams,
|
GetTopToolsQueryParams,
|
||||||
@@ -11,7 +11,8 @@ const router: IRouter = Router();
|
|||||||
router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
||||||
const [toolStats] = await db
|
const [toolStats] = await db
|
||||||
.select({ totalTools: sql<number>`count(*)::int` })
|
.select({ totalTools: sql<number>`count(*)::int` })
|
||||||
.from(toolsTable);
|
.from(toolsTable)
|
||||||
|
.where(sql`${toolsTable.deletedAt} IS NULL`);
|
||||||
|
|
||||||
const [ratingStats] = await db
|
const [ratingStats] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -23,7 +24,8 @@ router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
|||||||
|
|
||||||
const [catStats] = await db
|
const [catStats] = await db
|
||||||
.select({ categoriesCount: sql<number>`count(distinct ${toolsTable.category})::int` })
|
.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
|
const avgCombined = ratingStats.avgUsefulness != null && ratingStats.avgUsability != null
|
||||||
? (Number(ratingStats.avgUsefulness) + Number(ratingStats.avgUsability)) / 2
|
? (Number(ratingStats.avgUsefulness) + Number(ratingStats.avgUsability)) / 2
|
||||||
@@ -42,7 +44,7 @@ router.get("/analytics/summary", async (_req, res): Promise<void> => {
|
|||||||
|
|
||||||
let mostRatedTool = null;
|
let mostRatedTool = null;
|
||||||
if (mostRatedRow) {
|
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) {
|
if (tool) {
|
||||||
const ratingRows = await db
|
const ratingRows = await db
|
||||||
.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
.select({ usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
@@ -92,6 +94,7 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
|
|||||||
})
|
})
|
||||||
.from(toolsTable)
|
.from(toolsTable)
|
||||||
.innerJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
.innerJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||||
|
.where(sql`${toolsTable.deletedAt} IS NULL`)
|
||||||
.groupBy(toolsTable.id)
|
.groupBy(toolsTable.id)
|
||||||
.orderBy(desc(scoreExpr))
|
.orderBy(desc(scoreExpr))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
@@ -121,6 +124,7 @@ router.get("/analytics/by-category", async (_req, res): Promise<void> => {
|
|||||||
})
|
})
|
||||||
.from(toolsTable)
|
.from(toolsTable)
|
||||||
.leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
.leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
|
||||||
|
.where(sql`${toolsTable.deletedAt} IS NULL`)
|
||||||
.groupBy(toolsTable.category)
|
.groupBy(toolsTable.category)
|
||||||
.orderBy(toolsTable.category);
|
.orderBy(toolsTable.category);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
||||||
import { requireAuth } from "../middleware/auth";
|
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);
|
const toolId = Number(req.params.id);
|
||||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
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; }
|
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
||||||
|
|
||||||
const costs = await db
|
const costs = await db
|
||||||
@@ -47,7 +47,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
|||||||
const toolId = Number(req.params.id);
|
const toolId = Number(req.params.id);
|
||||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
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; }
|
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
||||||
|
|
||||||
const parsed = CostCreateBody.safeParse(req.body);
|
const parsed = CostCreateBody.safeParse(req.body);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
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 { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
ListToolRatingsParams,
|
ListToolRatingsParams,
|
||||||
@@ -24,7 +24,7 @@ router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
|
|||||||
return;
|
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) {
|
if (!tool) {
|
||||||
res.status(404).json({ error: "Tool not found" });
|
res.status(404).json({ error: "Tool not found" });
|
||||||
return;
|
return;
|
||||||
@@ -54,7 +54,7 @@ router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise<void> =
|
|||||||
return;
|
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) {
|
if (!tool) {
|
||||||
res.status(404).json({ error: "Tool not found" });
|
res.status(404).json({ error: "Tool not found" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
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 { z } from "zod";
|
||||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
UpdateToolParams,
|
UpdateToolParams,
|
||||||
UpdateToolBody,
|
UpdateToolBody,
|
||||||
DeleteToolParams,
|
DeleteToolParams,
|
||||||
|
TrashToolsBody,
|
||||||
} from "@workspace/api-zod";
|
} from "@workspace/api-zod";
|
||||||
import { requireAuth, requireAdmin } from "../middleware/auth";
|
import { requireAuth, requireAdmin } from "../middleware/auth";
|
||||||
import { requireFeature } from "../middleware/feature";
|
import { requireFeature, hasFeature } from "../middleware/feature";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
@@ -53,7 +54,7 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
const { category, search, sort } = parsed.data;
|
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) {
|
if (category) {
|
||||||
query = query.where(eq(toolsTable.category, 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);
|
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> => {
|
router.get("/tools/:id", async (req, res): Promise<void> => {
|
||||||
const params = GetToolParams.safeParse(req.params);
|
const params = GetToolParams.safeParse(req.params);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
@@ -123,7 +199,7 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
|
|||||||
return;
|
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) {
|
if (!tool) {
|
||||||
res.status(404).json({ error: "Tool not found" });
|
res.status(404).json({ error: "Tool not found" });
|
||||||
return;
|
return;
|
||||||
@@ -229,8 +305,17 @@ router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
|
const user = req.session.user!;
|
||||||
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
|
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);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,12 +323,13 @@ router.get("/categories", async (_req, res): Promise<void> => {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.selectDistinct({ category: toolsTable.category })
|
.selectDistinct({ category: toolsTable.category })
|
||||||
.from(toolsTable)
|
.from(toolsTable)
|
||||||
|
.where(isNull(toolsTable.deletedAt))
|
||||||
.orderBy(toolsTable.category);
|
.orderBy(toolsTable.category);
|
||||||
res.json(rows.map((r) => r.category));
|
res.json(rows.map((r) => r.category));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/features/all", async (_req, res): Promise<void> => {
|
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>();
|
const featureSet = new Set<string>();
|
||||||
for (const t of tools) {
|
for (const t of tools) {
|
||||||
for (const f of t.features ?? []) {
|
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> => {
|
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>();
|
const tagSet = new Set<string>();
|
||||||
for (const t of tools) {
|
for (const t of tools) {
|
||||||
for (const tag of t.tags ?? []) {
|
for (const tag of t.tags ?? []) {
|
||||||
@@ -290,7 +376,7 @@ router.get("/tools/:id/similar", async (req, res): Promise<void> => {
|
|||||||
const allOthers = await db
|
const allOthers = await db
|
||||||
.select()
|
.select()
|
||||||
.from(toolsTable)
|
.from(toolsTable)
|
||||||
.where(not(eq(toolsTable.id, toolId)));
|
.where(and(not(eq(toolsTable.id, toolId)), isNull(toolsTable.deletedAt)));
|
||||||
|
|
||||||
const manualRelations = await db
|
const manualRelations = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ToolEdit from "@/pages/tool-edit";
|
|||||||
import Analytics from "@/pages/analytics";
|
import Analytics from "@/pages/analytics";
|
||||||
import Admin from "@/pages/admin";
|
import Admin from "@/pages/admin";
|
||||||
import Redundancy from "@/pages/redundancy";
|
import Redundancy from "@/pages/redundancy";
|
||||||
|
import Trash from "@/pages/trash";
|
||||||
import Login from "@/pages/login";
|
import Login from "@/pages/login";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ function Router() {
|
|||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={Analytics} />
|
||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
<Route path="/admin/redundancy" component={Redundancy} />
|
<Route path="/admin/redundancy" component={Redundancy} />
|
||||||
|
<Route path="/trash" component={Trash} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
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 { useAuth } from "@/hooks/use-auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -7,13 +7,14 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
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 = [
|
const links = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
||||||
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
||||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||||
|
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []),
|
||||||
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
|
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
|
||||||
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
|
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { format } from "date-fns";
|
||||||
|
import { AdminToolsTab } from "@/components/admin-tools-tab";
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
@@ -147,6 +148,9 @@ export default function Admin() {
|
|||||||
<TabsTrigger value="users" className="gap-2">
|
<TabsTrigger value="users" className="gap-2">
|
||||||
<Users className="w-4 h-4" /> Users
|
<Users className="w-4 h-4" /> Users
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tools" className="gap-2">
|
||||||
|
<Wrench className="w-4 h-4" /> Tools
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="audit" className="gap-2">
|
<TabsTrigger value="audit" className="gap-2">
|
||||||
<ScrollText className="w-4 h-4" /> Audit Log
|
<ScrollText className="w-4 h-4" /> Audit Log
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -220,6 +224,10 @@ export default function Admin() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tools">
|
||||||
|
<AdminToolsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="audit">
|
<TabsContent value="audit">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
const { user, isAdmin, hasFeature } = useAuth();
|
const { user, isAdmin, hasFeature } = useAuth();
|
||||||
const canManageCosts = hasFeature("costs");
|
const canManageCosts = hasFeature("costs");
|
||||||
|
const hasTrash = hasFeature("trash");
|
||||||
const deleteTool = useDeleteTool();
|
const deleteTool = useDeleteTool();
|
||||||
|
|
||||||
const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null);
|
const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null);
|
||||||
@@ -860,7 +861,11 @@ export default function ToolDetail() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
|
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -130,6 +130,10 @@ export interface Tool {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** @nullable */
|
||||||
|
deletedAt?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
deletedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolWithStats {
|
export interface ToolWithStats {
|
||||||
@@ -182,6 +186,14 @@ export interface ToolUpdate {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrashToolsInput {
|
||||||
|
/**
|
||||||
|
* @minItems 1
|
||||||
|
* @maxItems 500
|
||||||
|
*/
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Rating {
|
export interface Rating {
|
||||||
id: number;
|
id: number;
|
||||||
toolId: number;
|
toolId: number;
|
||||||
@@ -306,6 +318,22 @@ export const ListToolsSort = {
|
|||||||
most_reviewed: 'most_reviewed',
|
most_reviewed: 'most_reviewed',
|
||||||
} as const;
|
} 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 = {
|
export type GetTopToolsParams = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
metric?: GetTopToolsMetric;
|
metric?: GetTopToolsMetric;
|
||||||
|
|||||||
@@ -25,21 +25,26 @@ import type {
|
|||||||
AuthMode,
|
AuthMode,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
CategoryStats,
|
CategoryStats,
|
||||||
|
EmptyTrash200,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
GetRatingDistributionParams,
|
GetRatingDistributionParams,
|
||||||
GetTopToolsParams,
|
GetTopToolsParams,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
ListAuditLogsParams,
|
ListAuditLogsParams,
|
||||||
ListToolsParams,
|
ListToolsParams,
|
||||||
|
ListTrashedToolsParams,
|
||||||
LocalLoginInput,
|
LocalLoginInput,
|
||||||
Rating,
|
Rating,
|
||||||
RatingDistribution,
|
RatingDistribution,
|
||||||
RatingInput,
|
RatingInput,
|
||||||
|
RestoreTools200,
|
||||||
Tool,
|
Tool,
|
||||||
ToolInput,
|
ToolInput,
|
||||||
ToolUpdate,
|
ToolUpdate,
|
||||||
ToolWithStats,
|
ToolWithStats,
|
||||||
TopToolEntry,
|
TopToolEntry,
|
||||||
|
TrashTools200,
|
||||||
|
TrashToolsInput,
|
||||||
User,
|
User,
|
||||||
UserCreateInput,
|
UserCreateInput,
|
||||||
UserRoleUpdate
|
UserRoleUpdate
|
||||||
@@ -509,6 +514,373 @@ export const useDeleteTool = <TError = ErrorType<ErrorResponse>,
|
|||||||
return useMutation(getDeleteToolMutationOptions(options));
|
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<Tool[]> => {
|
||||||
|
|
||||||
|
return customFetch<Tool[]>(getListTrashedToolsUrl(params),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListTrashedToolsQueryKey = (params?: ListTrashedToolsParams,) => {
|
||||||
|
return [
|
||||||
|
`/api/tools/trash`, ...(params ? [params] : [])
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListTrashedToolsQueryOptions = <TData = Awaited<ReturnType<typeof listTrashedTools>>, TError = ErrorType<ErrorResponse>>(params?: ListTrashedToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listTrashedTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListTrashedToolsQueryKey(params);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listTrashedTools>>> = ({ signal }) => listTrashedTools(params, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listTrashedTools>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListTrashedToolsQueryResult = NonNullable<Awaited<ReturnType<typeof listTrashedTools>>>
|
||||||
|
export type ListTrashedToolsQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List trashed (soft-deleted) tools
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListTrashedTools<TData = Awaited<ReturnType<typeof listTrashedTools>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
params?: ListTrashedToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listTrashedTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getListTrashedToolsQueryOptions(params,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { 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<TrashTools200> => {
|
||||||
|
|
||||||
|
return customFetch<TrashTools200>(getTrashToolsUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
trashToolsInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getTrashToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, 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<Awaited<ReturnType<typeof trashTools>>, {data: BodyType<TrashToolsInput>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return trashTools(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type TrashToolsMutationResult = NonNullable<Awaited<ReturnType<typeof trashTools>>>
|
||||||
|
export type TrashToolsMutationBody = BodyType<TrashToolsInput>
|
||||||
|
export type TrashToolsMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Move tools to trash (admin)
|
||||||
|
*/
|
||||||
|
export const useTrashTools = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof trashTools>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<TrashToolsInput>},
|
||||||
|
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<void> => {
|
||||||
|
|
||||||
|
return customFetch<void>(getDeleteTrashedToolsUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
trashToolsInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getDeleteTrashedToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, 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<Awaited<ReturnType<typeof deleteTrashedTools>>, {data: BodyType<TrashToolsInput>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return deleteTrashedTools(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type DeleteTrashedToolsMutationResult = NonNullable<Awaited<ReturnType<typeof deleteTrashedTools>>>
|
||||||
|
export type DeleteTrashedToolsMutationBody = BodyType<TrashToolsInput>
|
||||||
|
export type DeleteTrashedToolsMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Permanently delete trashed tools (admin)
|
||||||
|
*/
|
||||||
|
export const useDeleteTrashedTools = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof deleteTrashedTools>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<TrashToolsInput>},
|
||||||
|
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<RestoreTools200> => {
|
||||||
|
|
||||||
|
return customFetch<RestoreTools200>(getRestoreToolsUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
trashToolsInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getRestoreToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, 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<Awaited<ReturnType<typeof restoreTools>>, {data: BodyType<TrashToolsInput>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return restoreTools(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type RestoreToolsMutationResult = NonNullable<Awaited<ReturnType<typeof restoreTools>>>
|
||||||
|
export type RestoreToolsMutationBody = BodyType<TrashToolsInput>
|
||||||
|
export type RestoreToolsMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Restore trashed tools
|
||||||
|
*/
|
||||||
|
export const useRestoreTools = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof restoreTools>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<TrashToolsInput>},
|
||||||
|
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<EmptyTrash200> => {
|
||||||
|
|
||||||
|
return customFetch<EmptyTrash200>(getEmptyTrashUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getEmptyTrashMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, 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<Awaited<ReturnType<typeof emptyTrash>>, void> = () => {
|
||||||
|
|
||||||
|
|
||||||
|
return emptyTrash(requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type EmptyTrashMutationResult = NonNullable<Awaited<ReturnType<typeof emptyTrash>>>
|
||||||
|
|
||||||
|
export type EmptyTrashMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Permanently delete all trashed tools (admin)
|
||||||
|
*/
|
||||||
|
export const useEmptyTrash = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof emptyTrash>>,
|
||||||
|
TError,
|
||||||
|
void,
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getEmptyTrashMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
export const getListToolRatingsUrl = (id: number,) => {
|
export const getListToolRatingsUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,128 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$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:
|
/tools/{id}/ratings:
|
||||||
get:
|
get:
|
||||||
operationId: listToolRatings
|
operationId: listToolRatings
|
||||||
@@ -658,6 +780,11 @@ components:
|
|||||||
updatedAt:
|
updatedAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
deletedAt:
|
||||||
|
type: ["string", "null"]
|
||||||
|
format: date-time
|
||||||
|
deletedBy:
|
||||||
|
type: ["string", "null"]
|
||||||
|
|
||||||
ToolWithStats:
|
ToolWithStats:
|
||||||
type: object
|
type: object
|
||||||
@@ -749,6 +876,17 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
TrashToolsInput:
|
||||||
|
type: object
|
||||||
|
required: [ids]
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 500
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
|
||||||
Rating:
|
Rating:
|
||||||
type: object
|
type: object
|
||||||
required: [id, toolId, usefulness, usability, createdAt]
|
required: [id, toolId, usefulness, usability, createdAt]
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ export const UpdateToolResponse = zod.object({
|
|||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"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
|
* @summary List ratings for a tool
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ export * from './authUser';
|
|||||||
export * from './authUserRole';
|
export * from './authUserRole';
|
||||||
export * from './authUserTier';
|
export * from './authUserTier';
|
||||||
export * from './categoryStats';
|
export * from './categoryStats';
|
||||||
|
export * from './emptyTrash200';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getRatingDistributionParams';
|
export * from './getRatingDistributionParams';
|
||||||
export * from './getTopToolsMetric';
|
export * from './getTopToolsMetric';
|
||||||
@@ -22,16 +23,20 @@ export * from './healthStatus';
|
|||||||
export * from './listAuditLogsParams';
|
export * from './listAuditLogsParams';
|
||||||
export * from './listToolsParams';
|
export * from './listToolsParams';
|
||||||
export * from './listToolsSort';
|
export * from './listToolsSort';
|
||||||
|
export * from './listTrashedToolsParams';
|
||||||
export * from './localLoginInput';
|
export * from './localLoginInput';
|
||||||
export * from './rating';
|
export * from './rating';
|
||||||
export * from './ratingDistribution';
|
export * from './ratingDistribution';
|
||||||
export * from './ratingInput';
|
export * from './ratingInput';
|
||||||
|
export * from './restoreTools200';
|
||||||
export * from './scoreBucket';
|
export * from './scoreBucket';
|
||||||
export * from './tool';
|
export * from './tool';
|
||||||
export * from './toolInput';
|
export * from './toolInput';
|
||||||
export * from './toolUpdate';
|
export * from './toolUpdate';
|
||||||
export * from './toolWithStats';
|
export * from './toolWithStats';
|
||||||
export * from './topToolEntry';
|
export * from './topToolEntry';
|
||||||
|
export * from './trashTools200';
|
||||||
|
export * from './trashToolsInput';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './userCreateInput';
|
export * from './userCreateInput';
|
||||||
export * from './userCreateInputRole';
|
export * from './userCreateInputRole';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -21,4 +21,8 @@ export interface Tool {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
/** @nullable */
|
||||||
|
deletedAt?: Date | null;
|
||||||
|
/** @nullable */
|
||||||
|
deletedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export const toolsTable = pgTable("tools", {
|
|||||||
tags: text("tags").array().notNull().default([]),
|
tags: text("tags").array().notNull().default([]),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
|
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 });
|
export const insertToolSchema = createInsertSchema(toolsTable).omit({ id: true, createdAt: true, updatedAt: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user