o134's picture
Upload full TeamTasker system with all fixes
8314cf4 verified
Raw
History Blame Contribute Delete
15.7 kB
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;