Spaces:
Sleeping
Sleeping
| import { Router } from "express"; | |
| import { AuthRequest, requireManagerOrAdmin, requireAuth } from "../middleware/auth.js"; | |
| import { getTaskVisibilityFilter, canAccessTask, canModifyTask, User, isAdmin, isHeadOfTeam, isTeamLead, isMember } from "../lib/auth-utils.js"; | |
| import { logAudit } from "../lib/audit-logger.js"; | |
| import { z } from "zod"; | |
| import { invalidateDashboardCache } from "./dashboard.js"; | |
| import { db } from "@workspace/db"; | |
| import { tasksTable, usersTable, notificationsTable, commentsTable, clientsTable, taskAttachmentsTable } from "@workspace/db"; | |
| import { eq, and, count, sql, inArray } from "drizzle-orm"; | |
| import multer from "multer"; | |
| import { uploadAttachment, deleteAttachmentFromStorage } from "./auth.js"; | |
| import { | |
| CreateTaskBody, | |
| UpdateTaskBody, | |
| UpdateTaskParams, | |
| DeleteTaskParams, | |
| UpdateTaskStatusParams, | |
| UpdateTaskStatusBody, | |
| ListTasksQueryParams, | |
| } from "@workspace/api-zod"; | |
| const router = Router(); | |
| function serializeTask(task: typeof tasksTable.$inferSelect, extras: Record<string, unknown> = {}) { | |
| return { | |
| ...task, | |
| dueDate: task.dueDate ? task.dueDate.toISOString() : null, | |
| completedAt: task.completedAt ? task.completedAt.toISOString() : null, | |
| createdAt: task.createdAt.toISOString(), | |
| updatedAt: task.updatedAt.toISOString(), | |
| ...extras, | |
| }; | |
| } | |
| router.get("/tasks", async (req: AuthRequest, res) => { | |
| try { | |
| const user = req.user as User; | |
| const params = ListTasksQueryParams.parse(req.query); | |
| const conditions = []; | |
| // Apply visibility filter | |
| const visibilityFilter = getTaskVisibilityFilter(user); | |
| if (visibilityFilter) conditions.push(visibilityFilter); | |
| if (params.assignedTeam) conditions.push(eq(tasksTable.assignedTeam, params.assignedTeam)); | |
| if (params.fromTeam) conditions.push(eq(tasksTable.fromTeam, params.fromTeam)); | |
| if (params.status) conditions.push(eq(tasksTable.status, params.status)); | |
| if (params.taskType) conditions.push(eq(tasksTable.taskType, params.taskType)); | |
| if (params.assignedUserId) conditions.push(eq(tasksTable.assignedUserId, Number(params.assignedUserId))); | |
| if (params.clientId) conditions.push(eq(tasksTable.clientId, Number(params.clientId))); | |
| if (params.date) { | |
| const d = new Date(params.date); | |
| const next = new Date(d); | |
| next.setDate(next.getDate() + 1); | |
| conditions.push(sql`${tasksTable.createdAt} >= ${d.toISOString()} AND ${tasksTable.createdAt} < ${next.toISOString()}`); | |
| } | |
| const tasks = await db.select().from(tasksTable) | |
| .where(conditions.length > 0 ? and(...conditions) : undefined) | |
| .orderBy(sql`${tasksTable.createdAt} DESC`); | |
| const userIds = [...new Set([ | |
| ...tasks.map(t => t.createdByUserId).filter(Boolean), | |
| ...tasks.map(t => t.assignedUserId).filter(Boolean), | |
| ])] as number[]; | |
| const users = userIds.length > 0 | |
| ? await db.select().from(usersTable).where(inArray(usersTable.id, userIds)) | |
| : []; | |
| const userMap = Object.fromEntries(users.map(u => [u.id, { ...u, createdAt: u.createdAt.toISOString() }])); | |
| const commentCounts = await db.select({ | |
| taskId: commentsTable.taskId, | |
| cnt: count(), | |
| }).from(commentsTable).groupBy(commentsTable.taskId); | |
| const commentCountMap = Object.fromEntries(commentCounts.map(c => [c.taskId, Number(c.cnt)])); | |
| const clientIds = [...new Set(tasks.map(t => t.clientId).filter(Boolean))] as number[]; | |
| const clients = clientIds.length > 0 | |
| ? await db.select({ id: clientsTable.id, name: clientsTable.name }).from(clientsTable).where(inArray(clientsTable.id, clientIds)) | |
| : []; | |
| const clientMap = Object.fromEntries(clients.map(c => [c.id, c.name])); | |
| const result = tasks.map(task => serializeTask(task, { | |
| createdByUser: task.createdByUserId ? userMap[task.createdByUserId] : null, | |
| assignedUser: task.assignedUserId ? userMap[task.assignedUserId] : null, | |
| commentCount: commentCountMap[task.id] ?? 0, | |
| clientName: task.clientId ? (clientMap[task.clientId] ?? null) : null, | |
| })); | |
| res.json(result); | |
| } catch (err) { | |
| req.log.error({ err }, "Failed to list tasks"); | |
| res.status(500).json({ error: "Internal server error" }); | |
| } | |
| }); | |
| const createTaskSchema = z.object({ | |
| title: z.string().trim().min(1, "العنوان مطلوب"), | |
| description: z.string().optional().default(""), | |
| assignedUserId: z.coerce.number().int().positive("معرف المستخدم غير صالح").optional(), | |
| team: z.string().optional(), | |
| assignedTeam: z.string().optional(), | |
| fromTeam: z.string().optional(), | |
| status: z.enum(["open", "in_progress", "done", "not_started", "review", "completed", "cancelled"]).optional().default("not_started"), | |
| dueDate: z.string().or(z.date()).optional(), | |
| taskType: z.string().optional().default("general"), | |
| priority: z.string().optional().default("medium"), | |
| clientId: z.coerce.number().int().positive().optional(), | |
| createdByUserId: z.coerce.number().int().positive().optional(), | |
| }).strict(); | |
| router.post("/tasks", requireAuth, 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 tasks" }); | |
| return; | |
| } | |
| const parseResult = createTaskSchema.safeParse(req.body); | |
| if (!parseResult.success) { | |
| res.status(400).json({ error: parseResult.error.errors[0].message }); | |
| return; | |
| } | |
| const { title, description, assignedUserId, team, assignedTeam, fromTeam, status, dueDate, taskType, priority, clientId } = parseResult.data; | |
| const targetTeam = team ?? assignedTeam ?? user.team; | |
| const sourceTeam = fromTeam ?? user.team; | |
| let assignee = null; | |
| if (assignedUserId) { | |
| const [foundAssignee] = await db.select().from(usersTable).where(eq(usersTable.id, assignedUserId)); | |
| if (!foundAssignee) { | |
| res.status(400).json({ error: "Assigned user not found" }); | |
| return; | |
| } | |
| assignee = foundAssignee; | |
| } | |
| if (!isAdmin(user)) { | |
| if ((assignee && assignee.team !== user.team) || targetTeam !== user.team) { | |
| res.status(403).json({ error: "Forbidden: you can only create tasks for users in your team" }); | |
| return; | |
| } | |
| } | |
| const [task] = await db.insert(tasksTable).values({ | |
| title, | |
| description: description || null, | |
| taskType, | |
| status: status === "open" ? "not_started" : (status === "done" ? "completed" : status), | |
| priority, | |
| fromTeam: sourceTeam, | |
| assignedTeam: targetTeam, | |
| clientId: clientId ?? null, | |
| createdByUserId: user.id, | |
| assignedUserId: assignedUserId ?? null, | |
| dueDate: dueDate ? new Date(dueDate) : null, | |
| }).returning(); | |
| logAudit({ | |
| userId: user.id, | |
| action: "task_created", | |
| entityType: "task", | |
| entityId: task.id, | |
| details: { | |
| taskId: task.id, | |
| title: task.title, | |
| description: task.description, | |
| assignedUserId: task.assignedUserId, | |
| team: task.assignedTeam, | |
| priority: task.priority, | |
| status: task.status, | |
| dueDate: task.dueDate ? task.dueDate.toISOString() : null, | |
| clientId: task.clientId, | |
| }, | |
| req, | |
| }); | |
| if (assignedUserId) { | |
| await db.insert(notificationsTable).values({ | |
| userId: assignedUserId, | |
| taskId: task.id, | |
| type: "task_assigned", | |
| message: `مهمة جديدة: "${title}" من فريق ${sourceTeam}`, | |
| }); | |
| } | |
| invalidateDashboardCache(); | |
| res.status(201).json(serializeTask(task, { | |
| createdByUser: { ...user, createdAt: user.createdAt.toISOString() }, | |
| assignedUser: assignee ? { ...assignee, createdAt: assignee.createdAt.toISOString() } : null, | |
| commentCount: 0, | |
| })); | |
| } catch (err) { | |
| req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to create task"); | |
| res.status(500).json({ error: "Internal server error" }); | |
| } | |
| }); | |
| router.get("/tasks/:id", async (req: AuthRequest, res) => { | |
| try { | |
| const user = req.user as User; | |
| const { id } = UpdateTaskParams.parse({ id: Number(req.params.id) }); | |
| const [task] = await db.select().from(tasksTable).where(eq(tasksTable.id, id)); | |
| if (!task) { res.status(404).json({ error: "Task not found" }); return; } | |
| if (!canAccessTask(user, task)) { | |
| res.status(403).json({ error: "Forbidden: You do not have access to this task" }); | |
| return; | |
| } | |
| const [creator, assignee] = await Promise.all([ | |
| task.createdByUserId ? db.select().from(usersTable).where(eq(usersTable.id, task.createdByUserId)).then(r => r[0]) : null, | |
| task.assignedUserId ? db.select().from(usersTable).where(eq(usersTable.id, task.assignedUserId)).then(r => r[0]) : null, | |
| ]); | |
| const [{ cnt }] = await db.select({ cnt: count() }).from(commentsTable).where(eq(commentsTable.taskId, id)); | |
| res.json(serializeTask(task, { | |
| createdByUser: creator ? { ...creator, createdAt: creator.createdAt.toISOString() } : null, | |
| assignedUser: assignee ? { ...assignee, createdAt: assignee.createdAt.toISOString() } : null, | |
| commentCount: Number(cnt), | |
| })); | |
| } catch (err) { | |
| req.log.error({ err }, "Failed to get task"); | |
| res.status(500).json({ error: "Internal server error" }); | |
| } | |
| }); | |
| const updateTaskSchema = z.object({ | |
| title: z.string().trim().min(1, "العنوان مطلوب").optional(), | |
| description: z.string().optional(), | |
| assignedUserId: z.coerce.number().int().positive("معرف المستخدم غير صالح").optional(), | |
| team: z.string().optional(), | |
| assignedTeam: z.string().optional(), | |
| status: z.enum(["open", "in_progress", "done", "not_started", "review", "completed", "cancelled"]).optional(), | |
| dueDate: z.string().or(z.date()).optional(), | |
| taskType: z.string().optional(), | |
| priority: z.string().optional(), | |
| clientId: z.coerce.number().int().positive().optional(), | |
| }).strict(); | |
| const updateTaskParamsSchema = z.object({ | |
| id: z.coerce.number().int().positive("معرف المهمة غير صالح"), | |
| }); | |
| router.patch("/tasks/:id", requireAuth, async (req: AuthRequest, res) => { | |
| try { | |
| const user = req.user as User; | |
| if (!user) { res.status(401).json({ error: "Unauthorized" }); return; } | |
| const paramsResult = updateTaskParamsSchema.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 = updateTaskSchema.safeParse(req.body); | |
| if (!parseResult.success) { | |
| res.status(400).json({ error: parseResult.error.errors[0].message }); | |
| return; | |
| } | |
| const body = parseResult.data; | |
| const [existingTask] = await db.select().from(tasksTable).where(eq(tasksTable.id, id)); | |
| if (!existingTask) { res.status(404).json({ error: "Task not found" }); return; } | |
| if (isMember(user)) { | |
| if (existingTask.assignedUserId !== user.id) { | |
| res.status(403).json({ error: "Forbidden: You can only update tasks assigned to you" }); | |
| return; | |
| } | |
| if (body.title !== undefined || body.assignedUserId !== undefined || body.team !== undefined || body.assignedTeam !== undefined || body.clientId !== undefined || body.taskType !== undefined || body.priority !== undefined || body.dueDate !== undefined) { | |
| res.status(403).json({ error: "Forbidden: Members can only update status and description" }); | |
| return; | |
| } | |
| } else if (!isAdmin(user)) { | |
| let taskUserTeam: string | null = null; | |
| if (existingTask.assignedUserId) { | |
| const [assignee] = await db.select().from(usersTable).where(eq(usersTable.id, existingTask.assignedUserId)); | |
| if (assignee) taskUserTeam = assignee.team; | |
| } | |
| const isMyTeamTask = existingTask.assignedTeam === user.team || existingTask.fromTeam === user.team || taskUserTeam === user.team; | |
| if (!isMyTeamTask) { | |
| res.status(403).json({ error: "Forbidden: You can only update tasks in your team" }); | |
| return; | |
| } | |
| if (body.assignedUserId !== undefined && body.assignedUserId !== null) { | |
| const [newAssignee] = await db.select().from(usersTable).where(eq(usersTable.id, body.assignedUserId)); | |
| if (!newAssignee || newAssignee.team !== user.team) { | |
| res.status(403).json({ error: "Forbidden: You can only assign tasks to users in your team" }); | |
| return; | |
| } | |
| } | |
| } | |
| // Track changes for audit log | |
| const changes: any = {}; | |
| if (body.title !== undefined && body.title !== existingTask.title) changes.title = { old: existingTask.title, new: body.title }; | |
| if (body.description !== undefined && body.description !== existingTask.description) changes.description = { old: existingTask.description, new: body.description }; | |
| if (body.taskType !== undefined && body.taskType !== existingTask.taskType) changes.taskType = { old: existingTask.taskType, new: body.taskType }; | |
| if (body.priority !== undefined && body.priority !== existingTask.priority) changes.priority = { old: existingTask.priority, new: body.priority }; | |
| if (body.assignedUserId !== undefined && body.assignedUserId !== existingTask.assignedUserId) changes.assignedUserId = { old: existingTask.assignedUserId, new: body.assignedUserId }; | |
| if (body.dueDate !== undefined) { | |
| const oldDue = existingTask.dueDate ? existingTask.dueDate.toISOString() : null; | |
| const newDue = body.dueDate ? new Date(body.dueDate).toISOString() : null; | |
| if (oldDue !== newDue) changes.dueDate = { old: oldDue, new: newDue }; | |
| } | |
| if (body.status !== undefined) { | |
| const st = body.status === "open" ? "not_started" : (body.status === "done" ? "completed" : body.status); | |
| if (st !== existingTask.status) changes.status = { old: existingTask.status, new: st }; | |
| } | |
| if (body.team !== undefined && body.team !== existingTask.assignedTeam) changes.team = { old: existingTask.assignedTeam, new: body.team }; | |
| if (body.assignedTeam !== undefined && body.assignedTeam !== existingTask.assignedTeam) changes.team = { old: existingTask.assignedTeam, new: body.assignedTeam }; | |
| if (body.clientId !== undefined && body.clientId !== existingTask.clientId) changes.clientId = { old: existingTask.clientId, new: body.clientId }; | |
| const updates: Partial<typeof tasksTable.$inferInsert> = { | |
| updatedAt: new Date(), | |
| }; | |
| if (body.title !== undefined) updates.title = body.title; | |
| if (body.description !== undefined) updates.description = body.description ?? null; | |
| if (body.taskType !== undefined) updates.taskType = body.taskType; | |
| if (body.priority !== undefined) updates.priority = body.priority; | |
| if (body.assignedUserId !== undefined) updates.assignedUserId = body.assignedUserId ?? null; | |
| if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null; | |
| if (body.status !== undefined) { | |
| const st = body.status === "open" ? "not_started" : (body.status === "done" ? "completed" : body.status); | |
| updates.status = st; | |
| if (st === "completed") updates.completedAt = new Date(); | |
| } | |
| if (body.team !== undefined) updates.assignedTeam = body.team; | |
| if (body.assignedTeam !== undefined) updates.assignedTeam = body.assignedTeam; | |
| if (body.clientId !== undefined) updates.clientId = body.clientId; | |
| const [task] = await db.update(tasksTable).set(updates).where(eq(tasksTable.id, id)).returning(); | |
| if (!task) { res.status(404).json({ error: "Task not found" }); return; } | |
| invalidateDashboardCache(); | |
| if (Object.keys(changes).length > 0) { | |
| logAudit({ | |
| userId: user.id, | |
| action: "task_updated", | |
| entityType: "task", | |
| entityId: task.id, | |
| details: { | |
| taskId: task.id, | |
| title: task.title, | |
| changes, | |
| }, | |
| req, | |
| }); | |
| } | |
| const [creator, assignee] = await Promise.all([ | |
| task.createdByUserId ? db.select().from(usersTable).where(eq(usersTable.id, task.createdByUserId)).then(r => r[0]) : null, | |
| task.assignedUserId ? db.select().from(usersTable).where(eq(usersTable.id, task.assignedUserId)).then(r => r[0]) : null, | |
| ]); | |
| const [{ cnt }] = await db.select({ cnt: count() }).from(commentsTable).where(eq(commentsTable.taskId, id)); | |
| res.json(serializeTask(task, { | |
| createdByUser: creator ? { ...creator, createdAt: creator.createdAt.toISOString() } : null, | |
| assignedUser: assignee ? { ...assignee, createdAt: assignee.createdAt.toISOString() } : null, | |
| commentCount: Number(cnt), | |
| })); | |
| } catch (err) { | |
| req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to update task"); | |
| res.status(500).json({ error: "Internal server error" }); | |
| } | |
| }); | |
| router.delete("/tasks/:id", requireManagerOrAdmin, async (req: AuthRequest, res) => { | |
| try { | |
| const user = req.user as User; | |
| const { id } = DeleteTaskParams.parse({ id: Number(req.params.id) }); | |
| const [existingTask] = await db.select().from(tasksTable).where(eq(tasksTable.id, id)); | |
| if (!existingTask) { res.status(404).json({ error: "Task not found" }); return; } | |
| if (!canModifyTask(user, existingTask)) { | |
| res.status(403).json({ error: "Forbidden: You do not have permission to delete this task" }); | |
| return; | |
| } | |
| logAudit({ | |
| userId: user.id, | |
| action: "task_deleted", | |
| entityType: "task", | |
| entityId: existingTask.id, | |
| details: { | |
| taskId: existingTask.id, | |
| title: existingTask.title, | |
| assignedUserId: existingTask.assignedUserId, | |
| team: existingTask.assignedTeam, | |
| status: existingTask.status, | |
| dueDate: existingTask.dueDate ? existingTask.dueDate.toISOString() : null, | |
| }, | |
| req, | |
| }); | |
| await db.delete(tasksTable).where(eq(tasksTable.id, id)); | |
| invalidateDashboardCache(); | |
| res.status(204).send(); | |
| } catch (err) { | |
| req.log.error({ err }, "Failed to delete task"); | |
| res.status(500).json({ error: "Internal server error" }); | |
| } | |
| }); | |
| router.patch("/tasks/:id/status", async (req: AuthRequest, res) => { | |
| try { | |
| const user = req.user as User; | |
| const { id } = UpdateTaskStatusParams.parse({ id: Number(req.params.id) }); | |
| const { status } = req.body as { status: string }; | |
| const changedByUserId = req.userId; | |
| UpdateTaskStatusBody.parse({ status }); | |
| const [existingTask] = await db.select().from(tasksTable).where(eq(tasksTable.id, id)); | |
| if (!existingTask) { res.status(404).json({ error: "Task not found" }); return; } | |
| if (!canAccessTask(user, existingTask)) { | |
| res.status(403).json({ error: "Forbidden: You do not have permission to update this task's status" }); | |
| return; | |
| } | |
| const updates: Partial<typeof tasksTable.$inferInsert> = { | |
| status, | |
| updatedAt: new Date(), | |
| }; | |
| if (status === "completed") { | |
| updates.completedAt = new Date(); | |
| } | |
| const [task] = await db.update(tasksTable).set(updates).where(eq(tasksTable.id, id)).returning(); | |
| if (!task) { res.status(404).json({ error: "Task not found" }); return; } | |
| invalidateDashboardCache(); | |
| if (status !== existingTask.status) { | |
| logAudit({ | |
| userId: req.userId, | |
| action: "task_updated", | |
| entityType: "task", | |
| entityId: task.id, | |
| details: { | |
| taskId: task.id, | |
| title: task.title, | |
| changes: { | |
| status: { old: existingTask.status, new: task.status }, | |
| }, | |
| }, | |
| req, | |
| }); | |
| } | |
| const statusLabels: Record<string, string> = { | |
| not_started: "لم يبدأ", | |
| in_progress: "قيد التنفيذ", | |
| review: "قيد المراجعة", | |
| completed: "تم الإنجاز", | |
| cancelled: "ملغى", | |
| }; | |
| const statusLabel = statusLabels[status] ?? status; | |
| await db.insert(commentsTable).values({ | |
| taskId: id, | |
| userId: changedByUserId ?? null, | |
| content: `تم تغيير حالة المهمة إلى: ${statusLabel}`, | |
| isSystem: true, | |
| }); | |
| const notifyUserIds = new Set<number>(); | |
| if (task.createdByUserId) notifyUserIds.add(task.createdByUserId); | |
| if (task.assignedUserId) notifyUserIds.add(task.assignedUserId); | |
| if (notifyUserIds.size > 0) { | |
| await db.insert(notificationsTable).values( | |
| [...notifyUserIds].map(userId => ({ | |
| userId, | |
| taskId: task.id, | |
| type: "status_changed", | |
| message: `تم تحديث حالة المهمة "${task.title}" إلى: ${statusLabel}`, | |
| })) | |
| ); | |
| } | |
| const [creator, assignee] = await Promise.all([ | |
| task.createdByUserId ? db.select().from(usersTable).where(eq(usersTable.id, task.createdByUserId)).then(r => r[0]) : null, | |
| task.assignedUserId ? db.select().from(usersTable).where(eq(usersTable.id, task.assignedUserId)).then(r => r[0]) : null, | |
| ]); | |
| const [{ cnt }] = await db.select({ cnt: count() }).from(commentsTable).where(eq(commentsTable.taskId, id)); | |
| res.json(serializeTask(task, { | |
| createdByUser: creator ? { ...creator, createdAt: creator.createdAt.toISOString() } : null, | |
| assignedUser: assignee ? { ...assignee, createdAt: assignee.createdAt.toISOString() } : null, | |
| commentCount: Number(cnt), | |
| })); | |
| } catch (err) { | |
| res.status(400).json({ error: "Invalid request" }); | |
| } | |
| }); | |
| // Configure Multer with memory storage and 10MB file size limit | |
| const upload = multer({ | |
| storage: multer.memoryStorage(), | |
| limits: { fileSize: 10 * 1024 * 1024 } | |
| }); | |
| const ALLOWED_MIME_TYPES = [ | |
| "image/jpeg", | |
| "image/png", | |
| "image/gif", | |
| "image/webp", | |
| "application/pdf", | |
| "application/msword", // .doc | |
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx | |
| "application/vnd.ms-excel", // .xls | |
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" // .xlsx | |
| ]; | |
| const ALLOWED_EXTENSIONS = [ | |
| "jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "xls", "xlsx" | |
| ]; | |
| // POST /tasks/:id/attachments - Upload an attachment | |
| router.post("/tasks/:id/attachments", requireAuth, upload.single("file"), async (req: AuthRequest, res) => { | |
| try { | |
| const taskId = Number(req.params.id); | |
| const user = req.user as User; | |
| // Check if task exists and user has access to it | |
| const [task] = await db.select().from(tasksTable).where(eq(tasksTable.id, taskId)); | |
| if (!task) { | |
| return res.status(404).json({ error: "المهمة غير موجودة" }); | |
| } | |
| if (!canAccessTask(user, task)) { | |
| return res.status(403).json({ error: "ليس لديك صلاحية للوصول لهذه المهمة" }); | |
| } | |
| if (!req.file) { | |
| return res.status(400).json({ error: "لم يتم رفع أي ملف" }); | |
| } | |
| const ext = req.file.originalname.split(".").pop()?.toLowerCase(); | |
| if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) { | |
| return res.status(400).json({ error: "نوع الملف غير مدعوم" }); | |
| } | |
| // Upload to Supabase Storage | |
| const publicUrl = await uploadAttachment(req.file.buffer, req.file.originalname, req.file.mimetype); | |
| // Save to Database | |
| const [inserted] = await db.insert(taskAttachmentsTable).values({ | |
| taskId, | |
| fileName: req.file.originalname, | |
| fileUrl: publicUrl, | |
| fileSize: req.file.size, | |
| mimeType: req.file.mimetype, | |
| uploadedByUserId: user.id | |
| }).returning(); | |
| // Audit log | |
| await logAudit({ | |
| userId: String(user.id), | |
| action: "attachment_uploaded", | |
| entityType: "task", | |
| entityId: taskId, | |
| details: { | |
| attachmentId: inserted.id, | |
| fileName: inserted.fileName, | |
| fileSize: inserted.fileSize, | |
| supabaseUid: user.userId | |
| }, | |
| req | |
| }); | |
| res.status(201).json({ | |
| ...inserted, | |
| uploadedByUser: { | |
| id: user.id, | |
| name: user.name | |
| } | |
| }); | |
| return; | |
| } catch (err: any) { | |
| console.error("Error uploading attachment:", err); | |
| res.status(500).json({ error: err.message || "حدث خطأ أثناء رفع الملف" }); | |
| return; | |
| } | |
| }); | |
| // GET /tasks/:id/attachments - Get all task attachments | |
| router.get("/tasks/:id/attachments", requireAuth, async (req: AuthRequest, res) => { | |
| try { | |
| const taskId = Number(req.params.id); | |
| const user = req.user as User; | |
| // Check if task exists and user has access | |
| const [task] = await db.select().from(tasksTable).where(eq(tasksTable.id, taskId)); | |
| if (!task) { | |
| return res.status(404).json({ error: "المهمة غير موجودة" }); | |
| } | |
| if (!canAccessTask(user, task)) { | |
| return res.status(403).json({ error: "ليس لديك صلاحية للوصول لهذه المهمة" }); | |
| } | |
| // Retrieve attachments joined with user | |
| const attachments = await db | |
| .select({ | |
| id: taskAttachmentsTable.id, | |
| taskId: taskAttachmentsTable.taskId, | |
| fileName: taskAttachmentsTable.fileName, | |
| fileUrl: taskAttachmentsTable.fileUrl, | |
| fileSize: taskAttachmentsTable.fileSize, | |
| mimeType: taskAttachmentsTable.mimeType, | |
| uploadedByUserId: taskAttachmentsTable.uploadedByUserId, | |
| createdAt: taskAttachmentsTable.createdAt, | |
| uploadedByUser: { | |
| id: usersTable.id, | |
| name: usersTable.name | |
| } | |
| }) | |
| .from(taskAttachmentsTable) | |
| .leftJoin(usersTable, eq(taskAttachmentsTable.uploadedByUserId, usersTable.id)) | |
| .where(eq(taskAttachmentsTable.taskId, taskId)) | |
| .orderBy(taskAttachmentsTable.createdAt); | |
| res.json(attachments.map(att => ({ | |
| ...att, | |
| createdAt: att.createdAt.toISOString() | |
| }))); | |
| return; | |
| } catch (err: any) { | |
| console.error("Error fetching attachments:", err); | |
| res.status(500).json({ error: "حدث خطأ أثناء جلب المرفقات" }); | |
| return; | |
| } | |
| }); | |
| // DELETE /tasks/:id/attachments/:attachmentId - Delete an attachment | |
| router.delete("/tasks/:id/attachments/:attachmentId", requireAuth, async (req: AuthRequest, res) => { | |
| try { | |
| const taskId = Number(req.params.id); | |
| const attachmentId = Number(req.params.attachmentId); | |
| const user = req.user as User; | |
| const [attachment] = await db.select().from(taskAttachmentsTable).where(eq(taskAttachmentsTable.id, attachmentId)); | |
| if (!attachment) { | |
| return res.status(404).json({ error: "المرفق غير موجود" }); | |
| } | |
| if (attachment.taskId !== taskId) { | |
| return res.status(400).json({ error: "المرفق لا ينتمي لهذه المهمة" }); | |
| } | |
| const isUploader = attachment.uploadedByUserId === user.id; | |
| const canDelete = isUploader || isAdmin(user) || | |
| isHeadOfTeam(user) || isTeamLead(user); | |
| if (!canDelete) { | |
| return res.status(403).json({ error: "غير مصرح لك بحذف هذا المرفق" }); | |
| } | |
| // Delete from Supabase Storage | |
| try { | |
| await deleteAttachmentFromStorage(attachment.fileUrl); | |
| } catch (storageErr) { | |
| console.warn("Storage deletion warning (continuing DB deletion):", storageErr); | |
| } | |
| // Delete from DB | |
| await db.delete(taskAttachmentsTable).where(eq(taskAttachmentsTable.id, attachmentId)); | |
| // Audit log | |
| await logAudit({ | |
| userId: String(user.id), | |
| action: "attachment_deleted", | |
| entityType: "task", | |
| entityId: taskId, | |
| details: { | |
| attachmentId, | |
| fileName: attachment.fileName, | |
| supabaseUid: user.userId | |
| }, | |
| req | |
| }); | |
| res.status(204).end(); | |
| return; | |
| } catch (err: any) { | |
| console.error("Error deleting attachment:", err); | |
| res.status(500).json({ error: "حدث خطأ أثناء حذف المرفق" }); | |
| return; | |
| } | |
| }); | |
| export default router; | |