Spaces:
Sleeping
Sleeping
| 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<string, unknown> = {} | |
| ) { | |
| 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<number, number> = {}; | |
| const activeMap: Record<number, number> = {}; | |
| const completedMap: Record<number, number> = {}; | |
| 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<typeof clientsTable.$inferInsert> = { | |
| 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; | |