add creator role

This commit is contained in:
NotARoomba 2026-03-04 11:24:41 -05:00
parent f97e52b0a0
commit a79c27c838
2 changed files with 183 additions and 10 deletions

View file

@ -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<string, string>) {
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<string, string>) {
return user;
}
async function requireCreator(headers: Record<string, string>) {
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<string, string>);
@ -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<string, string>);
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.

View file

@ -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 @@
></textarea>
</div>
<button
onclick={saveChanges}
disabled={saving}
class="cursor-pointer rounded-full bg-black px-6 py-2 font-bold text-white transition-all hover:bg-gray-800 disabled:opacity-50"
>
{saving ? $t.common.saving : $t.project.saveChanges}
</button>
<div class="flex items-center gap-3">
<button
onclick={saveChanges}
disabled={saving}
class="cursor-pointer rounded-full bg-black px-6 py-2 font-bold text-white transition-all hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? $t.common.saving : $t.project.saveChanges}
</button>
{#if currentUser?.role === 'creator'}
<button
onclick={() => (showDeleteUserConfirm = true)}
disabled={deletingUser}
class="cursor-pointer rounded-full border-4 border-red-600 px-6 py-2 font-bold text-red-600 transition-all duration-200 hover:border-dashed disabled:opacity-50 disabled:cursor-not-allowed"
>
delete user
</button>
{/if}
</div>
</div>
</div>
@ -1067,3 +1107,36 @@
</div>
</div>
{/if}
{#if showDeleteUserConfirm}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => e.target === e.currentTarget && (showDeleteUserConfirm = false)}
onkeydown={(e) => e.key === 'Escape' && (showDeleteUserConfirm = false)}
role="dialog"
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">delete user</h2>
<p class="mb-6 text-gray-600">
are you sure you want to permanently delete <strong>{targetUser?.username || 'this user'}</strong>?
this will remove all their data including projects, orders, bonuses, and sessions. this cannot be undone.
</p>
<div class="flex gap-3">
<button
onclick={() => (showDeleteUserConfirm = false)}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
cancel
</button>
<button
onclick={deleteUser}
disabled={deletingUser}
class="flex-1 cursor-pointer rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:opacity-50 disabled:cursor-not-allowed"
>
{deletingUser ? 'deleting...' : 'delete permanently'}
</button>
</div>
</div>
</div>
{/if}