o134 commited on
Commit
09de430
·
verified ·
1 Parent(s): dc40372

Upload api-server\src\routes\clients.ts with huggingface_hub

Browse files
Files changed (1) hide show
  1. api-server//src//routes//clients.ts +409 -0
api-server//src//routes//clients.ts ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { db } from "@workspace/db";
3
+ import { clientsTable, tasksTable, usersTable } from "@workspace/db";
4
+ import { eq, ilike, count, and, inArray, isNotNull } from "drizzle-orm";
5
+ import { requireManagerOrAdmin, requireAdmin, requireAuth, AuthRequest } from "../middleware/auth.js";
6
+ import { getTaskVisibilityFilter, User, isAdmin, isTeamLead, canAccessAllTeams, isMember } from "../lib/auth-utils.js";
7
+ import { logAudit } from "../lib/audit-logger.js";
8
+ import { z } from "zod";
9
+ import { invalidateDashboardCache } from "./dashboard.js";
10
+
11
+
12
+ const router = Router();
13
+
14
+ function serializeClient(
15
+ client: typeof clientsTable.$inferSelect,
16
+ extras: Record<string, unknown> = {}
17
+ ) {
18
+ return {
19
+ ...client,
20
+ createdAt: client.createdAt.toISOString(),
21
+ updatedAt: client.updatedAt.toISOString(),
22
+ ...extras,
23
+ };
24
+ }
25
+
26
+ router.get("/clients", async (req: AuthRequest, res) => {
27
+ try {
28
+ const user = req.user as User;
29
+ const { search, isActive } = req.query as { search?: string; isActive?: string };
30
+ const conditions = [];
31
+ if (search) conditions.push(ilike(clientsTable.name, `%${search}%`));
32
+ if (isActive !== undefined) conditions.push(eq(clientsTable.isActive, isActive === "true"));
33
+
34
+ let clients: (typeof clientsTable.$inferSelect)[] = [];
35
+ if (canAccessAllTeams(user)) {
36
+ // Admins and Head of Team see all clients
37
+ clients = await db.select().from(clientsTable)
38
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
39
+ .orderBy(clientsTable.name);
40
+ } else {
41
+ // Team Leads and Members only see clients they have tasks for (based on their visibility)
42
+ const visibilityFilter = getTaskVisibilityFilter(user);
43
+ const taskClientIds = await db.select({ clientId: tasksTable.clientId })
44
+ .from(tasksTable)
45
+ .where(and(isNotNull(tasksTable.clientId), visibilityFilter));
46
+
47
+ const uniqueIds = [...new Set(taskClientIds.map(t => t.clientId).filter(Boolean))] as number[];
48
+ if (uniqueIds.length === 0) {
49
+ clients = [];
50
+ } else {
51
+ clients = await db.select().from(clientsTable)
52
+ .where(and(inArray(clientsTable.id, uniqueIds), ...conditions))
53
+ .orderBy(clientsTable.name);
54
+ }
55
+ }
56
+
57
+ const visibilityFilter = getTaskVisibilityFilter(user);
58
+
59
+ const taskCounts = await db.select({
60
+ clientId: tasksTable.clientId,
61
+ total: count(),
62
+ }).from(tasksTable)
63
+ .where(visibilityFilter)
64
+ .groupBy(tasksTable.clientId);
65
+
66
+ const activeCounts = await db.select({
67
+ clientId: tasksTable.clientId,
68
+ cnt: count(),
69
+ }).from(tasksTable)
70
+ .where(and(eq(tasksTable.status, "in_progress"), visibilityFilter))
71
+ .groupBy(tasksTable.clientId);
72
+
73
+ const completedCounts = await db.select({
74
+ clientId: tasksTable.clientId,
75
+ cnt: count(),
76
+ }).from(tasksTable)
77
+ .where(and(eq(tasksTable.status, "completed"), visibilityFilter))
78
+ .groupBy(tasksTable.clientId);
79
+
80
+ const totalMap: Record<number, number> = {};
81
+ const activeMap: Record<number, number> = {};
82
+ const completedMap: Record<number, number> = {};
83
+
84
+ taskCounts.forEach(r => { if (r.clientId) totalMap[r.clientId] = Number(r.total); });
85
+ activeCounts.forEach(r => { if (r.clientId) activeMap[r.clientId] = Number(r.cnt); });
86
+ completedCounts.forEach(r => { if (r.clientId) completedMap[r.clientId] = Number(r.cnt); });
87
+
88
+ const result = clients.map(c => serializeClient(c, {
89
+ totalTasks: totalMap[c.id] ?? 0,
90
+ activeTasks: activeMap[c.id] ?? 0,
91
+ completedTasks: completedMap[c.id] ?? 0,
92
+ }));
93
+
94
+ res.json(result);
95
+ } catch (err) {
96
+ req.log.error({ err }, "Failed to list clients");
97
+ res.status(500).json({ error: "Internal server error" });
98
+ }
99
+ });
100
+
101
+ const createClientSchema = z.object({
102
+ name: z.string().trim().min(1, "اسم العميل مطلوب"),
103
+ email: z.string().trim().email("بريد إلكتروني غير صالح").optional().or(z.literal("")),
104
+ phone: z.string().trim().regex(/^\+?[0-9\s\-()]{7,20}$/, "رقم هاتف غير صالح").optional().or(z.literal("")),
105
+ assignedTeam: z.string().trim().optional(),
106
+ industry: z.string().trim().optional(),
107
+ notes: z.string().trim().optional(),
108
+ });
109
+
110
+ router.post("/clients", requireAuth, requireManagerOrAdmin, async (req: AuthRequest, res) => {
111
+ try {
112
+ const user = req.user as User;
113
+ if (!user) { res.status(401).json({ error: "Unauthorized" }); return; }
114
+
115
+ if (isMember(user)) {
116
+ res.status(403).json({ error: "Forbidden: members cannot create clients" });
117
+ return;
118
+ }
119
+
120
+ const parseResult = createClientSchema.safeParse(req.body);
121
+ if (!parseResult.success) {
122
+ res.status(400).json({ error: parseResult.error.errors[0].message });
123
+ return;
124
+ }
125
+
126
+ const { name, email, phone, assignedTeam, industry, notes } = parseResult.data;
127
+
128
+ if (assignedTeam) {
129
+ const teamUsers = await db.select().from(usersTable).where(eq(usersTable.team, assignedTeam));
130
+ if (teamUsers.length === 0) {
131
+ res.status(400).json({ error: "Assigned team does not exist" });
132
+ return;
133
+ }
134
+
135
+ if (!isAdmin(user)) {
136
+ if (assignedTeam !== user.team) {
137
+ res.status(403).json({ error: "Forbidden: You can only create clients for your team" });
138
+ return;
139
+ }
140
+ }
141
+ }
142
+
143
+ const clientNotes = [
144
+ notes,
145
+ email ? `Email: ${email}` : null,
146
+ phone ? `Phone: ${phone}` : null,
147
+ assignedTeam ? `Team: ${assignedTeam}` : null,
148
+ ].filter(Boolean).join("\n");
149
+
150
+ const [client] = await db.insert(clientsTable).values({
151
+ name,
152
+ industry: industry ?? null,
153
+ notes: clientNotes,
154
+ isActive: true,
155
+ createdByUserId: user.id,
156
+ }).returning();
157
+
158
+ logAudit({
159
+ userId: user.id,
160
+ action: "client_created",
161
+ entityType: "client",
162
+ entityId: client.id,
163
+ details: {
164
+ clientId: client.id,
165
+ name: client.name,
166
+ email,
167
+ phone: phone ?? null,
168
+ assignedTeam,
169
+ industry: client.industry,
170
+ notes: notes ?? null,
171
+ isActive: client.isActive,
172
+ },
173
+ req,
174
+ });
175
+
176
+ invalidateDashboardCache();
177
+ res.status(201).json(serializeClient(client, { totalTasks: 0, activeTasks: 0, completedTasks: 0 }));
178
+ } catch (err) {
179
+ req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to create client");
180
+ res.status(500).json({ error: "Internal server error" });
181
+ }
182
+ });
183
+
184
+ router.get("/clients/:id", async (req: AuthRequest, res) => {
185
+ try {
186
+ const user = req.user as User;
187
+ const visibilityFilter = getTaskVisibilityFilter(user);
188
+ const id = parseInt(req.params.id as string);
189
+ const [client] = await db.select().from(clientsTable).where(eq(clientsTable.id, id));
190
+ if (!client) { res.status(404).json({ error: "Client not found" }); return; }
191
+
192
+ const tasks = await db.select().from(tasksTable).where(
193
+ and(eq(tasksTable.clientId, id), visibilityFilter)
194
+ ).orderBy(tasksTable.createdAt);
195
+
196
+ const total = tasks.length;
197
+ const active = tasks.filter(t => t.status === "in_progress").length;
198
+ const completed = tasks.filter(t => t.status === "completed").length;
199
+
200
+ const serializedTasks = tasks.map(t => ({
201
+ ...t,
202
+ dueDate: t.dueDate ? t.dueDate.toISOString() : null,
203
+ completedAt: t.completedAt ? t.completedAt.toISOString() : null,
204
+ createdAt: t.createdAt.toISOString(),
205
+ updatedAt: t.updatedAt.toISOString(),
206
+ createdByUser: null,
207
+ assignedUser: null,
208
+ commentCount: 0,
209
+ clientName: client.name,
210
+ }));
211
+
212
+ res.json(serializeClient(client, {
213
+ totalTasks: total,
214
+ activeTasks: active,
215
+ completedTasks: completed,
216
+ tasks: serializedTasks,
217
+ }));
218
+ } catch (err) {
219
+ req.log.error({ err }, "Failed to get client");
220
+ res.status(500).json({ error: "Internal server error" });
221
+ }
222
+ });
223
+
224
+ const updateClientSchema = z.object({
225
+ name: z.string().trim().min(1, "اسم العميل مطلوب").optional(),
226
+ email: z.string().trim().email("بريد إلكتروني غير صالح").optional().or(z.literal("")),
227
+ phone: z.string().trim().regex(/^\+?[0-9\s\-()]{7,20}$/, "رقم هاتف غير صالح").optional().or(z.literal("")),
228
+ assignedTeam: z.string().trim().optional(),
229
+ industry: z.string().trim().optional(),
230
+ notes: z.string().trim().optional(),
231
+ isActive: z.boolean().optional(),
232
+ });
233
+
234
+ const updateClientParamsSchema = z.object({
235
+ id: z.coerce.number().int().positive("معرف العميل غير صالح"),
236
+ });
237
+
238
+ router.patch("/clients/:id", requireAuth, requireManagerOrAdmin, async (req: AuthRequest, res) => {
239
+ try {
240
+ const user = req.user as User;
241
+ if (!user) { res.status(401).json({ error: "Unauthorized" }); return; }
242
+
243
+ if (isMember(user)) {
244
+ res.status(403).json({ error: "Forbidden: members cannot update clients" });
245
+ return;
246
+ }
247
+
248
+ const paramsResult = updateClientParamsSchema.safeParse({ id: req.params.id });
249
+ if (!paramsResult.success) {
250
+ res.status(400).json({ error: paramsResult.error.errors[0].message });
251
+ return;
252
+ }
253
+ const { id } = paramsResult.data;
254
+
255
+ const parseResult = updateClientSchema.safeParse(req.body);
256
+ if (!parseResult.success) {
257
+ res.status(400).json({ error: parseResult.error.errors[0].message });
258
+ return;
259
+ }
260
+ const body = parseResult.data;
261
+
262
+ const [existingClient] = await db.select().from(clientsTable).where(eq(clientsTable.id, id));
263
+ if (!existingClient) { res.status(404).json({ error: "Client not found" }); return; }
264
+
265
+ const oldNotesStr = existingClient.notes ?? "";
266
+ let oldEmail: string | null = null;
267
+ let oldPhone: string | null = null;
268
+ let oldTeam: string | null = null;
269
+ const emMatch = oldNotesStr.match(/Email:\s*([^\n]+)/);
270
+ if (emMatch) oldEmail = emMatch[1].trim();
271
+ const phMatch = oldNotesStr.match(/Phone:\s*([^\n]+)/);
272
+ if (phMatch) oldPhone = phMatch[1].trim();
273
+ const tmMatch = oldNotesStr.match(/Team:\s*([^\n]+)/);
274
+ if (tmMatch) oldTeam = tmMatch[1].trim();
275
+ const oldPureNotes = oldNotesStr.split("\n").filter(l => !l.startsWith("Email:") && !l.startsWith("Phone:") && !l.startsWith("Team:")).join("\n").trim();
276
+
277
+ if (!isAdmin(user)) {
278
+ if (oldTeam && oldTeam !== user.team) {
279
+ res.status(403).json({ error: "Forbidden: You can only update clients in your team" });
280
+ return;
281
+ }
282
+ }
283
+
284
+ if (body.assignedTeam !== undefined) {
285
+ const teamUsers = await db.select().from(usersTable).where(eq(usersTable.team, body.assignedTeam));
286
+ if (teamUsers.length === 0) {
287
+ res.status(400).json({ error: "Assigned team does not exist" });
288
+ return;
289
+ }
290
+ }
291
+
292
+ // Track changes for audit log
293
+ const changes: any = {};
294
+ if (body.name !== undefined && body.name !== existingClient.name) changes.name = { old: existingClient.name, new: body.name };
295
+ if (body.email !== undefined && body.email !== oldEmail) changes.email = { old: oldEmail, new: body.email };
296
+ if (body.phone !== undefined && body.phone !== oldPhone) changes.phone = { old: oldPhone, new: body.phone };
297
+ if (body.assignedTeam !== undefined && body.assignedTeam !== oldTeam) changes.assignedTeam = { old: oldTeam, new: body.assignedTeam };
298
+ if (body.industry !== undefined && body.industry !== existingClient.industry) changes.industry = { old: existingClient.industry, new: body.industry };
299
+ if (body.notes !== undefined && body.notes.trim() !== oldPureNotes) changes.notes = { old: oldPureNotes, new: body.notes.trim() };
300
+ if (body.isActive !== undefined && body.isActive !== existingClient.isActive) changes.isActive = { old: existingClient.isActive, new: body.isActive };
301
+
302
+ let updatedNotes = body.notes !== undefined ? body.notes : existingClient.notes;
303
+ if (body.email !== undefined || body.phone !== undefined || body.assignedTeam !== undefined) {
304
+ const lines = oldNotesStr.split("\n").filter(l => !l.startsWith("Email:") && !l.startsWith("Phone:") && !l.startsWith("Team:"));
305
+
306
+ let emailVal = body.email;
307
+ if (emailVal === undefined) emailVal = oldEmail ?? undefined;
308
+ let phoneVal = body.phone;
309
+ if (phoneVal === undefined) phoneVal = oldPhone ?? undefined;
310
+ let teamVal = body.assignedTeam;
311
+ if (teamVal === undefined) teamVal = oldTeam ?? undefined;
312
+
313
+ const newMeta = [
314
+ emailVal ? `Email: ${emailVal}` : null,
315
+ phoneVal ? `Phone: ${phoneVal}` : null,
316
+ teamVal ? `Team: ${teamVal}` : null,
317
+ ].filter(Boolean);
318
+
319
+ updatedNotes = [...lines, ...newMeta].filter(Boolean).join("\n");
320
+ }
321
+
322
+ const updates: Partial<typeof clientsTable.$inferInsert> = {
323
+ updatedAt: new Date(),
324
+ };
325
+ if (body.name !== undefined) updates.name = body.name;
326
+ if (body.industry !== undefined) updates.industry = body.industry ?? null;
327
+ if (updatedNotes !== undefined) updates.notes = updatedNotes ?? null;
328
+ if (body.isActive !== undefined) updates.isActive = body.isActive;
329
+
330
+ const [client] = await db.update(clientsTable).set(updates).where(eq(clientsTable.id, id)).returning();
331
+ if (!client) { res.status(404).json({ error: "Client not found" }); return; }
332
+
333
+ invalidateDashboardCache();
334
+
335
+ if (Object.keys(changes).length > 0) {
336
+ logAudit({
337
+ userId: user.id,
338
+ action: "client_updated",
339
+ entityType: "client",
340
+ entityId: client.id,
341
+ details: {
342
+ clientId: client.id,
343
+ name: client.name,
344
+ changes,
345
+ },
346
+ req,
347
+ });
348
+ }
349
+
350
+ const [{ cnt: total }] = await db.select({ cnt: count() }).from(tasksTable).where(eq(tasksTable.clientId, id));
351
+ const [{ cnt: active }] = await db.select({ cnt: count() }).from(tasksTable).where(and(eq(tasksTable.clientId, id), eq(tasksTable.status, "in_progress")));
352
+ const [{ cnt: completed }] = await db.select({ cnt: count() }).from(tasksTable).where(and(eq(tasksTable.clientId, id), eq(tasksTable.status, "completed")));
353
+
354
+ res.json(serializeClient(client, {
355
+ totalTasks: Number(total),
356
+ activeTasks: Number(active),
357
+ completedTasks: Number(completed),
358
+ }));
359
+ } catch (err) {
360
+ req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to update client");
361
+ res.status(500).json({ error: "Internal server error" });
362
+ }
363
+ });
364
+
365
+ router.delete("/clients/:id", requireAuth, requireAdmin, async (req: AuthRequest, res) => {
366
+ try {
367
+ const user = req.user as User;
368
+ if (!user) { res.status(401).json({ error: "Unauthorized" }); return; }
369
+
370
+ const id = parseInt(req.params.id as string);
371
+ if (isNaN(id)) {
372
+ res.status(400).json({ error: "معرف العميل غير صالح" });
373
+ return;
374
+ }
375
+
376
+ const [client] = await db.select().from(clientsTable).where(eq(clientsTable.id, id));
377
+ if (!client) {
378
+ res.status(404).json({ error: "العميل غير موجود" });
379
+ return;
380
+ }
381
+
382
+ // Delete all tasks associated with this client first (satisfies foreign key constraint)
383
+ await db.delete(tasksTable).where(eq(tasksTable.clientId, id));
384
+
385
+ // Delete client
386
+ await db.delete(clientsTable).where(eq(clientsTable.id, id));
387
+
388
+ invalidateDashboardCache();
389
+
390
+ logAudit({
391
+ userId: user.id,
392
+ action: "client_deleted",
393
+ entityType: "client",
394
+ entityId: id,
395
+ details: {
396
+ clientId: id,
397
+ name: client.name,
398
+ },
399
+ req,
400
+ });
401
+
402
+ res.status(200).json({ success: true, message: "تم حذف العميل بنجاح" });
403
+ } catch (err) {
404
+ req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to delete client");
405
+ res.status(500).json({ error: "Internal server error" });
406
+ }
407
+ });
408
+
409
+ export default router;