import { Router } from "express"; import { db } from "@workspace/db"; import { clientsTable, tasksTable, usersTable } from "@workspace/db"; import { eq, ilike, count, and, inArray, isNotNull } from "drizzle-orm"; import { requireManagerOrAdmin, requireAdmin, requireAuth, AuthRequest } from "../middleware/auth.js"; import { getTaskVisibilityFilter, User, isAdmin, isTeamLead, canAccessAllTeams, isMember } from "../lib/auth-utils.js"; import { logAudit } from "../lib/audit-logger.js"; import { z } from "zod"; import { invalidateDashboardCache } from "./dashboard.js"; const router = Router(); function serializeClient( client: typeof clientsTable.$inferSelect, extras: Record = {} ) { return { ...client, createdAt: client.createdAt.toISOString(), updatedAt: client.updatedAt.toISOString(), ...extras, }; } router.get("/clients", async (req: AuthRequest, res) => { try { const user = req.user as User; const { search, isActive } = req.query as { search?: string; isActive?: string }; const conditions = []; if (search) conditions.push(ilike(clientsTable.name, `%${search}%`)); if (isActive !== undefined) conditions.push(eq(clientsTable.isActive, isActive === "true")); let clients: (typeof clientsTable.$inferSelect)[] = []; if (canAccessAllTeams(user)) { // Admins and Head of Team see all clients clients = await db.select().from(clientsTable) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(clientsTable.name); } else { // Team Leads and Members only see clients they have tasks for (based on their visibility) const visibilityFilter = getTaskVisibilityFilter(user); const taskClientIds = await db.select({ clientId: tasksTable.clientId }) .from(tasksTable) .where(and(isNotNull(tasksTable.clientId), visibilityFilter)); const uniqueIds = [...new Set(taskClientIds.map(t => t.clientId).filter(Boolean))] as number[]; if (uniqueIds.length === 0) { clients = []; } else { clients = await db.select().from(clientsTable) .where(and(inArray(clientsTable.id, uniqueIds), ...conditions)) .orderBy(clientsTable.name); } } const visibilityFilter = getTaskVisibilityFilter(user); const taskCounts = await db.select({ clientId: tasksTable.clientId, total: count(), }).from(tasksTable) .where(visibilityFilter) .groupBy(tasksTable.clientId); const activeCounts = await db.select({ clientId: tasksTable.clientId, cnt: count(), }).from(tasksTable) .where(and(eq(tasksTable.status, "in_progress"), visibilityFilter)) .groupBy(tasksTable.clientId); const completedCounts = await db.select({ clientId: tasksTable.clientId, cnt: count(), }).from(tasksTable) .where(and(eq(tasksTable.status, "completed"), visibilityFilter)) .groupBy(tasksTable.clientId); const totalMap: Record = {}; const activeMap: Record = {}; const completedMap: Record = {}; taskCounts.forEach(r => { if (r.clientId) totalMap[r.clientId] = Number(r.total); }); activeCounts.forEach(r => { if (r.clientId) activeMap[r.clientId] = Number(r.cnt); }); completedCounts.forEach(r => { if (r.clientId) completedMap[r.clientId] = Number(r.cnt); }); const result = clients.map(c => serializeClient(c, { totalTasks: totalMap[c.id] ?? 0, activeTasks: activeMap[c.id] ?? 0, completedTasks: completedMap[c.id] ?? 0, })); res.json(result); } catch (err) { req.log.error({ err }, "Failed to list clients"); res.status(500).json({ error: "Internal server error" }); } }); const createClientSchema = z.object({ name: z.string().trim().min(1, "اسم العميل مطلوب"), email: z.string().trim().email("بريد إلكتروني غير صالح").optional().or(z.literal("")), phone: z.string().trim().regex(/^\+?[0-9\s\-()]{7,20}$/, "رقم هاتف غير صالح").optional().or(z.literal("")), assignedTeam: z.string().trim().optional(), industry: z.string().trim().optional(), notes: z.string().trim().optional(), }); router.post("/clients", requireAuth, requireManagerOrAdmin, async (req: AuthRequest, res) => { try { const user = req.user as User; if (!user) { res.status(401).json({ error: "Unauthorized" }); return; } if (isMember(user)) { res.status(403).json({ error: "Forbidden: members cannot create clients" }); return; } const parseResult = createClientSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ error: parseResult.error.errors[0].message }); return; } const { name, email, phone, assignedTeam, industry, notes } = parseResult.data; if (assignedTeam) { const teamUsers = await db.select().from(usersTable).where(eq(usersTable.team, assignedTeam)); if (teamUsers.length === 0) { res.status(400).json({ error: "Assigned team does not exist" }); return; } if (!isAdmin(user)) { if (assignedTeam !== user.team) { res.status(403).json({ error: "Forbidden: You can only create clients for your team" }); return; } } } const clientNotes = [ notes, email ? `Email: ${email}` : null, phone ? `Phone: ${phone}` : null, assignedTeam ? `Team: ${assignedTeam}` : null, ].filter(Boolean).join("\n"); const [client] = await db.insert(clientsTable).values({ name, industry: industry ?? null, notes: clientNotes, isActive: true, createdByUserId: user.id, }).returning(); logAudit({ userId: user.id, action: "client_created", entityType: "client", entityId: client.id, details: { clientId: client.id, name: client.name, email, phone: phone ?? null, assignedTeam, industry: client.industry, notes: notes ?? null, isActive: client.isActive, }, req, }); invalidateDashboardCache(); res.status(201).json(serializeClient(client, { totalTasks: 0, activeTasks: 0, completedTasks: 0 })); } catch (err) { req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to create client"); res.status(500).json({ error: "Internal server error" }); } }); router.get("/clients/:id", async (req: AuthRequest, res) => { try { const user = req.user as User; const visibilityFilter = getTaskVisibilityFilter(user); const id = parseInt(req.params.id as string); const [client] = await db.select().from(clientsTable).where(eq(clientsTable.id, id)); if (!client) { res.status(404).json({ error: "Client not found" }); return; } const tasks = await db.select().from(tasksTable).where( and(eq(tasksTable.clientId, id), visibilityFilter) ).orderBy(tasksTable.createdAt); const total = tasks.length; const active = tasks.filter(t => t.status === "in_progress").length; const completed = tasks.filter(t => t.status === "completed").length; const serializedTasks = tasks.map(t => ({ ...t, dueDate: t.dueDate ? t.dueDate.toISOString() : null, completedAt: t.completedAt ? t.completedAt.toISOString() : null, createdAt: t.createdAt.toISOString(), updatedAt: t.updatedAt.toISOString(), createdByUser: null, assignedUser: null, commentCount: 0, clientName: client.name, })); res.json(serializeClient(client, { totalTasks: total, activeTasks: active, completedTasks: completed, tasks: serializedTasks, })); } catch (err) { req.log.error({ err }, "Failed to get client"); res.status(500).json({ error: "Internal server error" }); } }); const updateClientSchema = z.object({ name: z.string().trim().min(1, "اسم العميل مطلوب").optional(), email: z.string().trim().email("بريد إلكتروني غير صالح").optional().or(z.literal("")), phone: z.string().trim().regex(/^\+?[0-9\s\-()]{7,20}$/, "رقم هاتف غير صالح").optional().or(z.literal("")), assignedTeam: z.string().trim().optional(), industry: z.string().trim().optional(), notes: z.string().trim().optional(), isActive: z.boolean().optional(), }); const updateClientParamsSchema = z.object({ id: z.coerce.number().int().positive("معرف العميل غير صالح"), }); router.patch("/clients/:id", requireAuth, requireManagerOrAdmin, async (req: AuthRequest, res) => { try { const user = req.user as User; if (!user) { res.status(401).json({ error: "Unauthorized" }); return; } if (isMember(user)) { res.status(403).json({ error: "Forbidden: members cannot update clients" }); return; } const paramsResult = updateClientParamsSchema.safeParse({ id: req.params.id }); if (!paramsResult.success) { res.status(400).json({ error: paramsResult.error.errors[0].message }); return; } const { id } = paramsResult.data; const parseResult = updateClientSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ error: parseResult.error.errors[0].message }); return; } const body = parseResult.data; const [existingClient] = await db.select().from(clientsTable).where(eq(clientsTable.id, id)); if (!existingClient) { res.status(404).json({ error: "Client not found" }); return; } const oldNotesStr = existingClient.notes ?? ""; let oldEmail: string | null = null; let oldPhone: string | null = null; let oldTeam: string | null = null; const emMatch = oldNotesStr.match(/Email:\s*([^\n]+)/); if (emMatch) oldEmail = emMatch[1].trim(); const phMatch = oldNotesStr.match(/Phone:\s*([^\n]+)/); if (phMatch) oldPhone = phMatch[1].trim(); const tmMatch = oldNotesStr.match(/Team:\s*([^\n]+)/); if (tmMatch) oldTeam = tmMatch[1].trim(); const oldPureNotes = oldNotesStr.split("\n").filter(l => !l.startsWith("Email:") && !l.startsWith("Phone:") && !l.startsWith("Team:")).join("\n").trim(); if (!isAdmin(user)) { if (oldTeam && oldTeam !== user.team) { res.status(403).json({ error: "Forbidden: You can only update clients in your team" }); return; } } if (body.assignedTeam !== undefined) { const teamUsers = await db.select().from(usersTable).where(eq(usersTable.team, body.assignedTeam)); if (teamUsers.length === 0) { res.status(400).json({ error: "Assigned team does not exist" }); return; } } // Track changes for audit log const changes: any = {}; if (body.name !== undefined && body.name !== existingClient.name) changes.name = { old: existingClient.name, new: body.name }; if (body.email !== undefined && body.email !== oldEmail) changes.email = { old: oldEmail, new: body.email }; if (body.phone !== undefined && body.phone !== oldPhone) changes.phone = { old: oldPhone, new: body.phone }; if (body.assignedTeam !== undefined && body.assignedTeam !== oldTeam) changes.assignedTeam = { old: oldTeam, new: body.assignedTeam }; if (body.industry !== undefined && body.industry !== existingClient.industry) changes.industry = { old: existingClient.industry, new: body.industry }; if (body.notes !== undefined && body.notes.trim() !== oldPureNotes) changes.notes = { old: oldPureNotes, new: body.notes.trim() }; if (body.isActive !== undefined && body.isActive !== existingClient.isActive) changes.isActive = { old: existingClient.isActive, new: body.isActive }; let updatedNotes = body.notes !== undefined ? body.notes : existingClient.notes; if (body.email !== undefined || body.phone !== undefined || body.assignedTeam !== undefined) { const lines = oldNotesStr.split("\n").filter(l => !l.startsWith("Email:") && !l.startsWith("Phone:") && !l.startsWith("Team:")); let emailVal = body.email; if (emailVal === undefined) emailVal = oldEmail ?? undefined; let phoneVal = body.phone; if (phoneVal === undefined) phoneVal = oldPhone ?? undefined; let teamVal = body.assignedTeam; if (teamVal === undefined) teamVal = oldTeam ?? undefined; const newMeta = [ emailVal ? `Email: ${emailVal}` : null, phoneVal ? `Phone: ${phoneVal}` : null, teamVal ? `Team: ${teamVal}` : null, ].filter(Boolean); updatedNotes = [...lines, ...newMeta].filter(Boolean).join("\n"); } const updates: Partial = { updatedAt: new Date(), }; if (body.name !== undefined) updates.name = body.name; if (body.industry !== undefined) updates.industry = body.industry ?? null; if (updatedNotes !== undefined) updates.notes = updatedNotes ?? null; if (body.isActive !== undefined) updates.isActive = body.isActive; const [client] = await db.update(clientsTable).set(updates).where(eq(clientsTable.id, id)).returning(); if (!client) { res.status(404).json({ error: "Client not found" }); return; } invalidateDashboardCache(); if (Object.keys(changes).length > 0) { logAudit({ userId: user.id, action: "client_updated", entityType: "client", entityId: client.id, details: { clientId: client.id, name: client.name, changes, }, req, }); } const [{ cnt: total }] = await db.select({ cnt: count() }).from(tasksTable).where(eq(tasksTable.clientId, id)); const [{ cnt: active }] = await db.select({ cnt: count() }).from(tasksTable).where(and(eq(tasksTable.clientId, id), eq(tasksTable.status, "in_progress"))); const [{ cnt: completed }] = await db.select({ cnt: count() }).from(tasksTable).where(and(eq(tasksTable.clientId, id), eq(tasksTable.status, "completed"))); res.json(serializeClient(client, { totalTasks: Number(total), activeTasks: Number(active), completedTasks: Number(completed), })); } catch (err) { req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to update client"); res.status(500).json({ error: "Internal server error" }); } }); router.delete("/clients/:id", requireAuth, requireAdmin, async (req: AuthRequest, res) => { try { const user = req.user as User; if (!user) { res.status(401).json({ error: "Unauthorized" }); return; } const id = parseInt(req.params.id as string); if (isNaN(id)) { res.status(400).json({ error: "معرف العميل غير صالح" }); return; } const [client] = await db.select().from(clientsTable).where(eq(clientsTable.id, id)); if (!client) { res.status(404).json({ error: "العميل غير موجود" }); return; } // Delete all tasks associated with this client first (satisfies foreign key constraint) await db.delete(tasksTable).where(eq(tasksTable.clientId, id)); // Delete client await db.delete(clientsTable).where(eq(clientsTable.id, id)); invalidateDashboardCache(); logAudit({ userId: user.id, action: "client_deleted", entityType: "client", entityId: id, details: { clientId: id, name: client.name, }, req, }); res.status(200).json({ success: true, message: "تم حذف العميل بنجاح" }); } catch (err) { req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to delete client"); res.status(500).json({ error: "Internal server error" }); } }); export default router;