From a79c27c8385f736e3faec132e7ecc47fb99e9ded Mon Sep 17 00:00:00 2001 From: NotARoomba Date: Wed, 4 Mar 2026 11:24:41 -0500 Subject: [PATCH] add creator role --- backend/src/routes/admin.ts | 104 +++++++++++++++++- .../src/routes/admin/users/[id]/+page.svelte | 89 +++++++++++++-- 2 files changed, 183 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index a2517aa..b671be8 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -2,6 +2,8 @@ import { Elysia } from "elysia"; import { eq, ne, and, inArray, sql, desc, asc, or, isNull } from "drizzle-orm"; import { db } from "../db"; import { usersTable, userBonusesTable } from "../schemas/users"; +import { sessionsTable } from "../schemas/sessions"; +import { userActivityTable } from "../schemas/user-emails"; import { projectsTable } from "../schemas/projects"; import { reviewsTable } from "../schemas/reviews"; import { @@ -42,7 +44,7 @@ const admin = new Elysia({ prefix: "/admin" }); async function requireReviewer(headers: Record) { const user = await getUserFromSession(headers); if (!user) return null; - if (user.role !== "reviewer" && user.role !== "admin") return null; + if (user.role !== "reviewer" && user.role !== "admin" && user.role !== "creator") return null; return user; } @@ -53,6 +55,13 @@ async function requireAdmin(headers: Record) { return user; } +async function requireCreator(headers: Record) { + const user = await getUserFromSession(headers); + if (!user) return null; + if (user.role !== "creator") return null; + return user; +} + // Get admin stats (info page) admin.get("/stats", async ({ headers, status }) => { const user = await requireReviewer(headers as Record); @@ -548,7 +557,7 @@ admin.put("/users/:id/role", async ({ params, body, headers, status }) => { } const { role } = body as { role: string }; - if (!["member", "reviewer", "admin", "banned"].includes(role)) { + if (!["member", "reviewer", "admin", "creator", "banned"].includes(role)) { return status(400, { error: "Invalid role" }); } @@ -3581,6 +3590,97 @@ admin.post("/recalculate-shop-pricing", async ({ headers, status }) => { } }); +// Delete user and all associated records (creator only) +admin.delete("/users/:id", async ({ params, headers, status }) => { + const user = await requireCreator(headers as Record); + if (!user) { + return status(401, { error: "Unauthorized" }); + } + + const targetId = parseInt(params.id); + if (isNaN(targetId)) { + return status(400, { error: "Invalid user ID" }); + } + + if (user.id === targetId) { + return status(400, { error: "Cannot delete yourself" }); + } + + try { + // Get all project IDs for the user before deleting anything + const userProjects = await db + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(eq(projectsTable.userId, targetId)); + const projectIds = userProjects.map((p) => p.id); + + // 1. Delete sessions + await db.delete(sessionsTable).where(eq(sessionsTable.userId, targetId)); + + // 2. Delete user activity + await db.delete(userActivityTable).where(eq(userActivityTable.userId, targetId)); + + // 3. Delete shop hearts + await db.delete(shopHeartsTable).where(eq(shopHeartsTable.userId, targetId)); + + // 4. Delete shop penalties + await db.delete(shopPenaltiesTable).where(eq(shopPenaltiesTable.userId, targetId)); + + // 5. Delete shop rolls + await db.delete(shopRollsTable).where(eq(shopRollsTable.userId, targetId)); + + // 6. Delete refinery spending history + await db.delete(refinerySpendingHistoryTable).where(eq(refinerySpendingHistoryTable.userId, targetId)); + + // 7. Delete refinery orders + await db.delete(refineryOrdersTable).where(eq(refineryOrdersTable.userId, targetId)); + + // 8. Delete shop orders + await db.delete(shopOrdersTable).where(eq(shopOrdersTable.userId, targetId)); + + // 9. Null out givenBy on bonuses given by this user (so other users' bonuses remain) + await db + .update(userBonusesTable) + .set({ givenBy: null }) + .where(eq(userBonusesTable.givenBy, targetId)); + + // 10. Delete user's own bonuses + await db.delete(userBonusesTable).where(eq(userBonusesTable.userId, targetId)); + + // 11. Delete reviews done by this user as reviewer + await db.delete(reviewsTable).where(eq(reviewsTable.reviewerId, targetId)); + + if (projectIds.length > 0) { + // 12. Delete project activity for user's projects (from any user) + await db.delete(projectActivityTable).where(inArray(projectActivityTable.projectId, projectIds)); + + // 13. Delete reviews on user's projects + await db.delete(reviewsTable).where(inArray(reviewsTable.projectId, projectIds)); + } + + // 14. Delete project activity by this user (on any project) + await db.delete(projectActivityTable).where(eq(projectActivityTable.userId, targetId)); + + // 15. Delete user's projects + await db.delete(projectsTable).where(eq(projectsTable.userId, targetId)); + + // 16. Delete the user + const deleted = await db + .delete(usersTable) + .where(eq(usersTable.id, targetId)) + .returning(); + + if (!deleted[0]) { + return status(404, { error: "User not found" }); + } + + return { success: true }; + } catch (err) { + console.error("[ADMIN] Delete user error:", err); + return status(500, { error: "Failed to delete user" }); + } +}); + admin.get("/config", async () => { // Public endpoint: returns canonical pricing constants for the frontend to consume. // These are intentionally read from the server-side constants so UI and server stay in sync. diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte index 6af5f9a..a0156b3 100644 --- a/frontend/src/routes/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -123,6 +123,9 @@ let unshipReason = $state(''); let unshipping = $state(false); + let showDeleteUserConfirm = $state(false); + let deletingUser = $state(false); + // lightweight toast helper (DOM-based, so no extra markup required) function _showToast(message: string, type: 'success' | 'error' | 'info' = 'info') { try { @@ -166,7 +169,7 @@ onMount(async () => { currentUser = await getUser(); - if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'reviewer')) { + if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'reviewer' && currentUser.role !== 'creator')) { goto('/dashboard'); return; } @@ -372,6 +375,30 @@ } } + async function deleteUser() { + if (!targetUser) return; + deletingUser = true; + try { + const res = await fetch(`${API_URL}/admin/users/${targetUser.id}`, { + method: 'DELETE', + credentials: 'include' + }); + const result = await res.json(); + if (!res.ok) { + _showToast(result.error || 'Failed to delete user', 'error'); + return; + } + _showToast('User deleted successfully', 'success'); + goto('/admin/users'); + } catch (e) { + console.error('Failed to delete user:', e); + _showToast('Failed to delete user', 'error'); + } finally { + deletingUser = false; + showDeleteUserConfirm = false; + } + } + function getTimelineIcon(type: string) { switch (type) { case 'earned': @@ -425,6 +452,8 @@ switch (role) { case 'admin': return 'bg-red-100 text-red-700'; + case 'creator': + return 'bg-purple-100 text-purple-700'; case 'reviewer': return 'bg-blue-100 text-blue-700'; case 'banned': @@ -654,13 +683,24 @@ > - +
+ + {#if currentUser?.role === 'creator'} + + {/if} +
@@ -1067,3 +1107,36 @@ {/if} + +{#if showDeleteUserConfirm} +
e.target === e.currentTarget && (showDeleteUserConfirm = false)} + onkeydown={(e) => e.key === 'Escape' && (showDeleteUserConfirm = false)} + role="dialog" + tabindex="-1" + > +
+

delete user

+

+ are you sure you want to permanently delete {targetUser?.username || 'this user'}? + this will remove all their data including projects, orders, bonuses, and sessions. this cannot be undone. +

+
+ + +
+
+
+{/if}