operation-cycle / api-server /src /routes /notifications.ts
o134's picture
Upload full TeamTasker system with all fixes
8314cf4 verified
Raw
History Blame Contribute Delete
6.47 kB
import { Router } from "express";
import { AuthRequest, requireAuth, requireAdmin } from "../middleware/auth.js";
import { checkDeadlines } from "../lib/deadline-checker.js";
import { User, isAdmin, isMember } from "../lib/auth-utils.js";
import { db } from "@workspace/db";
import { notificationsTable, usersTable, tasksTable } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import {
ListNotificationsQueryParams,
MarkNotificationReadParams,
MarkAllNotificationsReadBody,
} from "@workspace/api-zod";
import { logAudit } from "../lib/audit-logger.js";
import { z } from "zod";
const router = Router();
function serializeNotif(n: typeof notificationsTable.$inferSelect) {
return {
...n,
createdAt: n.createdAt.toISOString(),
};
}
router.get("/notifications", requireAuth, async (req: AuthRequest, res) => {
try {
const params = ListNotificationsQueryParams.parse(req.query);
const userId = Number(params.userId);
if (req.userId !== userId && req.user?.role !== "admin") {
res.status(403).json({ error: "Forbidden: you can only view your own notifications" });
return;
}
const conditions = [eq(notificationsTable.userId, userId)];
if (params.unreadOnly) {
conditions.push(eq(notificationsTable.isRead, false));
}
const notifications = await db.select().from(notificationsTable)
.where(and(...conditions))
.orderBy(notificationsTable.createdAt);
res.json(notifications.map(serializeNotif));
} catch (err) {
req.log.error({ err }, "Failed to list notifications");
res.status(500).json({ error: "Internal server error" });
}
});
router.patch("/notifications/:id/read", requireAuth, async (req: AuthRequest, res) => {
try {
const { id } = MarkNotificationReadParams.parse({ id: Number(req.params.id) });
const [existing] = await db.select().from(notificationsTable).where(eq(notificationsTable.id, id));
if (!existing) { res.status(404).json({ error: "Notification not found" }); return; }
if (existing.userId !== req.userId && req.user?.role !== "admin") {
res.status(403).json({ error: "Forbidden: you can only mark your own notifications as read" });
return;
}
const [notif] = await db.update(notificationsTable)
.set({ isRead: true })
.where(eq(notificationsTable.id, id))
.returning();
res.json(serializeNotif(notif));
} catch (err) {
req.log.error({ err }, "Failed to mark notification read");
res.status(500).json({ error: "Internal server error" });
}
});
router.patch("/notifications/read-all", requireAuth, async (req: AuthRequest, res) => {
try {
const { userId } = MarkAllNotificationsReadBody.parse(req.body);
if (userId !== req.userId && req.user?.role !== "admin") {
res.status(403).json({ error: "Forbidden: you can only mark your own notifications as read" });
return;
}
await db.update(notificationsTable)
.set({ isRead: true })
.where(eq(notificationsTable.userId, userId));
res.json({ success: true });
} catch (err) {
req.log.error({ err }, "Failed to mark all notifications read");
res.status(500).json({ error: "Internal server error" });
}
});
const createNotificationSchema = z.object({
recipientId: z.coerce.number().int().positive("معرف المستخدم غير صالح"),
type: z.string().trim().min(1, "نوع الإشعار مطلوب"),
taskId: z.coerce.number().int().positive("معرف المهمة غير صالح").optional(),
message: z.string().trim().min(1, "رسالة الإشعار مطلوبة").optional(),
}).strict();
router.post("/notifications", 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 send notifications" });
return;
}
const parseResult = createNotificationSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({ error: parseResult.error.errors[0].message });
return;
}
const { recipientId, type, taskId, message } = parseResult.data;
const [recipient] = await db.select().from(usersTable).where(eq(usersTable.id, recipientId));
if (!recipient) {
res.status(400).json({ error: "Recipient user not found" });
return;
}
if (!isAdmin(user)) {
if (taskId) {
const [task] = await db.select().from(tasksTable).where(eq(tasksTable.id, taskId));
if (!task || (task.fromTeam !== user.team && task.assignedTeam !== user.team)) {
res.status(403).json({ error: "Forbidden: You can only send notifications related to tasks in your team" });
return;
}
} else {
if (recipient.team !== user.team) {
res.status(403).json({ error: "Forbidden: You can only send notifications to users in your team" });
return;
}
}
}
const [notif] = await db.insert(notificationsTable).values({
userId: recipientId,
taskId: taskId ?? null,
type,
message: message ?? `إشعار جديد: ${type}`,
}).returning();
logAudit({
userId: user.id,
action: "notification_created",
entityType: "notification",
entityId: notif.id,
details: {
notificationId: notif.id,
recipientId: notif.userId,
taskId: notif.taskId,
type: notif.type,
message: notif.message,
},
req,
});
res.status(201).json(serializeNotif(notif));
} catch (err) {
req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to create notification");
res.status(500).json({ error: "Internal server error" });
}
});
router.post("/notifications/check-deadlines", requireAuth, requireAdmin, async (req: AuthRequest, res) => {
try {
const sent = await checkDeadlines();
logAudit({
userId: req.userId || 1,
action: "deadline_check_manual",
entityType: "notification",
details: {
sentCount: sent,
supabaseUid: req.user?.userId
},
req
});
res.json({ sent });
} catch (err) {
req.log.error({ err: err instanceof Error ? err.message : String(err) }, "Failed manual deadline check");
res.status(500).json({ error: "Internal server error" });
}
});
export default router;