import { Router } from "express"; import bcrypt from "bcryptjs"; import { db } from "@workspace/db"; import { usersTable, filesTable, conversionsTable, foldersTable, sharesTable, refreshTokensTable } from "@workspace/db"; import { eq, and, count, like, sql, ne } from "drizzle-orm"; import { requireAdmin, AuthRequest } from "../middlewares/auth.js"; function avg(col: any) { return sql`AVG(${col})`; } const router = Router(); router.use(requireAdmin); // GET /api/admin/users router.get("/users", async (req: AuthRequest, res) => { try { const { status, role, search } = req.query as Record; const conditions: Parameters[0][] = []; if (status) conditions.push(eq(usersTable.status, status as "active" | "suspended" | "pending")); if (role) conditions.push(eq(usersTable.role, role as "user" | "admin")); if (search) conditions.push(like(usersTable.email, `%${search}%`)); const users = conditions.length > 0 ? await db.query.usersTable.findMany({ where: and(...conditions) }) : await db.query.usersTable.findMany(); const result = await Promise.all(users.map(async (u) => { const [{ count: fc }] = await db.select({ count: count() }).from(filesTable).where(eq(filesTable.ownerId, u.id)); return { id: u.id, email: u.email, displayName: u.displayName, role: u.role, status: u.status, fileCount: Number(fc), lastLoginAt: u.lastLoginAt, createdAt: u.createdAt, }; })); res.json(result); } catch (err) { req.log?.error({ err }, "admin list users error"); res.status(500).json({ error: "server_error", message: "فشل جلب قائمة المستخدمين" }); } }); // POST /api/admin/users router.post("/users", async (req: AuthRequest, res) => { try { const { email, password, displayName, role } = req.body; if (!email || !password) { res.status(400).json({ error: "validation", message: "البريد الإلكتروني وكلمة المرور مطلوبان" }); return; } if (password.length < 8) { res.status(400).json({ error: "validation", message: "كلمة المرور يجب أن تكون 8 أحرف على الأقل" }); return; } const existing = await db.query.usersTable.findFirst({ where: eq(usersTable.email, email.toLowerCase()) }); if (existing) { res.status(409).json({ error: "conflict", message: "البريد الإلكتروني مستخدم بالفعل" }); return; } const passwordHash = await bcrypt.hash(password, 12); const [user] = await db.insert(usersTable).values({ email: email.toLowerCase(), passwordHash, displayName: displayName || null, role: role || "user", status: "active", }).returning(); res.status(201).json({ id: user.id, email: user.email, displayName: user.displayName, role: user.role, status: user.status, fileCount: 0, lastLoginAt: null, createdAt: user.createdAt }); } catch (err) { req.log?.error({ err }, "admin create user error"); res.status(500).json({ error: "server_error", message: "فشل إنشاء المستخدم" }); } }); // PATCH /api/admin/users/:id router.patch("/users/:id", async (req: AuthRequest, res) => { try { const userId = req.params.id as string; const { role, status } = req.body; const user = await db.query.usersTable.findFirst({ where: eq(usersTable.id, userId) }); if (!user) { res.status(404).json({ error: "not_found", message: "المستخدم غير موجود" }); return; } const [updated] = await db.update(usersTable) .set({ ...(role && { role }), ...(status && { status }), updatedAt: new Date() }) .where(eq(usersTable.id, userId)).returning(); const [{ count: fc }] = await db.select({ count: count() }).from(filesTable).where(eq(filesTable.ownerId, updated.id)); res.json({ id: updated.id, email: updated.email, displayName: updated.displayName, role: updated.role, status: updated.status, fileCount: Number(fc), lastLoginAt: updated.lastLoginAt, createdAt: updated.createdAt }); } catch (err) { req.log?.error({ err }, "admin update user error"); res.status(500).json({ error: "server_error", message: "فشل تحديث المستخدم" }); } }); // DELETE /api/admin/users/:id — cascade delete all user data router.delete("/users/:id", async (req: AuthRequest, res) => { try { const userId = req.params.id as string; // Guard: cannot delete your own account if (userId === req.userId) { res.status(400).json({ error: "bad_request", message: "لا يمكنك حذف حسابك الخاص" }); return; } // Cascade: delete shares, conversions, files, folders, refresh tokens, then user await db.delete(sharesTable).where(eq(sharesTable.ownerId, userId)); await db.delete(sharesTable).where(eq(sharesTable.sharedWithId, userId)); // Get user's files to delete their conversions const userFiles = await db.query.filesTable.findMany({ where: eq(filesTable.ownerId, userId) }); for (const f of userFiles) { await db.delete(conversionsTable).where(eq(conversionsTable.fileId, f.id)); } await db.delete(filesTable).where(eq(filesTable.ownerId, userId)); await db.delete(foldersTable).where(eq(foldersTable.ownerId, userId)); await db.delete(refreshTokensTable).where(eq(refreshTokensTable.userId, userId)); await db.delete(usersTable).where(eq(usersTable.id, userId)); res.json({ message: "تم حذف المستخدم وجميع بياناته" }); } catch (err) { req.log?.error({ err }, "admin delete user error"); res.status(500).json({ error: "server_error", message: "فشل حذف المستخدم" }); } }); // GET /api/admin/join-requests router.get("/join-requests", async (req: AuthRequest, res) => { try { const pending = await db.query.usersTable.findMany({ where: eq(usersTable.status, "pending") }); res.json(pending.map((u) => ({ id: u.id, email: u.email, displayName: u.displayName, requestedAt: u.createdAt }))); } catch (err) { req.log?.error({ err }, "list join requests error"); res.status(500).json({ error: "server_error", message: "فشل جلب الطلبات" }); } }); // PATCH /api/admin/join-requests/:id router.patch("/join-requests/:id", async (req: AuthRequest, res) => { try { const requestId = req.params.id as string; const { action } = req.body; if (!action || !["approve", "reject"].includes(action)) { res.status(400).json({ error: "validation", message: "الإجراء يجب أن يكون approve أو reject" }); return; } const user = await db.query.usersTable.findFirst({ where: eq(usersTable.id, requestId) }); if (!user) { res.status(404).json({ error: "not_found", message: "الطلب غير موجود" }); return; } if (action === "approve") { await db.update(usersTable).set({ status: "active", updatedAt: new Date() }).where(eq(usersTable.id, requestId)); res.json({ message: "تمت الموافقة على الطلب" }); } else { await db.delete(usersTable).where(eq(usersTable.id, requestId)); res.json({ message: "تم رفض الطلب" }); } } catch (err) { req.log?.error({ err }, "process join request error"); res.status(500).json({ error: "server_error", message: "فشلت العملية" }); } }); // GET /api/admin/stats — real computed stats router.get("/stats", async (req: AuthRequest, res) => { try { const [{ count: totalUsers }] = await db.select({ count: count() }).from(usersTable); const [{ count: activeUsers }] = await db.select({ count: count() }).from(usersTable).where(eq(usersTable.status, "active")); const [{ count: pendingRequests }] = await db.select({ count: count() }).from(usersTable).where(eq(usersTable.status, "pending")); const [{ count: totalFiles }] = await db.select({ count: count() }).from(filesTable).where(eq(filesTable.status, "done")); const today = new Date(); today.setHours(0, 0, 0, 0); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); const [{ count: todayFiles }] = await db.select({ count: count() }).from(conversionsTable) .where(sql`${conversionsTable.createdAt} >= ${today} AND ${conversionsTable.status} = 'done'`); const [{ count: weekFiles }] = await db.select({ count: count() }).from(conversionsTable) .where(sql`${conversionsTable.createdAt} >= ${weekAgo} AND ${conversionsTable.status} = 'done'`); const [{ count: failedFiles }] = await db.select({ count: count() }).from(conversionsTable).where(eq(conversionsTable.status, "failed")); const [{ count: allConversions }] = await db.select({ count: count() }).from(conversionsTable); // Real averages from DB const [{ avg: avgQuality }] = await db.select({ avg: avg(filesTable.qualityScore) }).from(filesTable).where(eq(filesTable.status, "done")); const [{ avg: avgSeconds }] = await db.select({ avg: avg(conversionsTable.elapsedSeconds) }).from(conversionsTable).where(eq(conversionsTable.status, "done")); // Real file type distribution const typeRows = await db.select({ type: filesTable.originalType, cnt: count() }) .from(filesTable) .where(eq(filesTable.status, "done")) .groupBy(filesTable.originalType); const conversionsByType: Record = {}; let topFileType = "PDF"; let topCount = 0; for (const row of typeRows) { const label = row.type ? row.type.split("/")[1]?.toUpperCase() || row.type.toUpperCase() : "غير معروف"; conversionsByType[label] = (conversionsByType[label] || 0) + Number(row.cnt); if (Number(row.cnt) > topCount) { topCount = Number(row.cnt); topFileType = label; } } const total = Number(allConversions); const failed = Number(failedFiles); const successRate = total > 0 ? ((total - failed) / total) * 100 : 0; res.json({ totalUsers: Number(totalUsers), activeUsers: Number(activeUsers), pendingRequests: Number(pendingRequests), totalFiles: Number(totalFiles), filesConvertedToday: Number(todayFiles), filesConvertedThisWeek: Number(weekFiles), successRate: Math.round(successRate * 10) / 10, averageQualityScore: avgQuality ? Math.round(Number(avgQuality) * 10) / 10 : 0, averageProcessingSeconds: avgSeconds ? Math.round(Number(avgSeconds) * 10) / 10 : 0, topFileType, conversionsByType, }); } catch (err) { req.log?.error({ err }, "get stats error"); res.status(500).json({ error: "server_error", message: "فشل جلب الإحصائيات" }); } }); // GET /api/admin/trash — admin can see ALL users' trashed items router.get("/trash", async (req: AuthRequest, res) => { try { const files = await db.query.filesTable.findMany({ where: eq(filesTable.status, "trashed") }); const folders = await db.query.foldersTable.findMany({ where: eq(foldersTable.trashed, true) }); const fileOwnerIds = [...new Set(files.map(f => f.ownerId))]; const folderOwnerIds = [...new Set(folders.map(f => f.ownerId))]; const allOwnerIds = [...new Set([...fileOwnerIds, ...folderOwnerIds])]; const owners: Record = {}; for (const oid of allOwnerIds) { const u = await db.query.usersTable.findFirst({ where: eq(usersTable.id, oid) }); owners[oid] = u?.email || oid; } res.json({ files: files.map((f) => ({ id: f.id, name: f.name, folderId: f.folderId, ownerId: f.ownerId, ownerEmail: owners[f.ownerId] || f.ownerId, originalName: f.originalName, originalType: f.originalType, sizeBytes: f.sizeBytes, status: f.status, qualityScore: f.qualityScore, wordCount: f.wordCount, language: f.language, isShared: false, sharedWith: [], permission: "owner", createdAt: f.createdAt, updatedAt: f.updatedAt, })), folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId, ownerId: f.ownerId, ownerEmail: owners[f.ownerId] || f.ownerId, fileCount: 0, createdAt: f.createdAt, updatedAt: f.updatedAt, })), }); } catch (err) { req.log?.error({ err }, "list trash error"); res.status(500).json({ error: "server_error", message: "فشل جلب المهملات" }); } }); // DELETE /api/admin/trash/empty — admin empties their own trash only router.delete("/trash/empty", async (req: AuthRequest, res) => { try { const userId = req.userId!; await db.delete(filesTable).where(and(eq(filesTable.ownerId, userId), eq(filesTable.status, "trashed"))); await db.delete(foldersTable).where(and(eq(foldersTable.ownerId, userId), eq(foldersTable.trashed, true))); res.json({ message: "تم إفراغ سلة المهملات" }); } catch (err) { req.log?.error({ err }, "empty trash error"); res.status(500).json({ error: "server_error", message: "فشل إفراغ السلة" }); } }); export default router;