import { Router } from "express"; import { z } from "zod"; import { prisma } from "../db"; import { requireAuth, type AuthRequest } from "../middleware/auth"; export const todoRouter = Router(); todoRouter.use(requireAuth); const recurrenceSchema = z.object({ type: z.enum(["hourly", "daily", "weekly", "monthly", "yearly"]), interval: z.number().int().min(1).optional(), byWeekday: z.number().int().min(0).max(6).optional(), byMonthday: z.number().int().min(1).max(31).optional(), timezone: z.string().optional(), }); const offsetSchema = z.object({ offsetMinutes: z.number().int().min(0), channelInapp: z.boolean().optional(), channelBark: z.boolean().optional(), }); const todoSchema = z.object({ title: z.string().min(1), description: z.string().optional(), dueAt: z.string().datetime(), recurrenceRule: recurrenceSchema.optional(), offsets: z.array(offsetSchema).optional(), }); todoRouter.get("/", async (req: AuthRequest, res) => { const items = await prisma.todo.findMany({ where: { ownerId: req.userId! }, include: { recurrenceRule: true }, orderBy: { dueAt: "asc" }, }); const offsets = await prisma.reminderOffset.findMany({ where: { targetType: "todo", targetId: { in: items.map((item) => item.id) } }, }); const offsetsById = offsets.reduce>((acc, offset) => { acc[offset.targetId] = acc[offset.targetId] ?? []; acc[offset.targetId].push(offset); return acc; }, {}); const withOffsets = items.map((item) => ({ ...item, offsets: offsetsById[item.id] ?? [], })); return res.json(withOffsets); }); todoRouter.post("/", async (req: AuthRequest, res) => { const parsed = todoSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "Invalid payload" }); } const { recurrenceRule, offsets = [], ...data } = parsed.data; const todo = await prisma.$transaction(async (tx) => { const rule = recurrenceRule ? await tx.recurrenceRule.create({ data: { type: recurrenceRule.type, interval: recurrenceRule.interval ?? 1, byWeekday: recurrenceRule.byWeekday, byMonthday: recurrenceRule.byMonthday, timezone: recurrenceRule.timezone ?? "Asia/Shanghai", }, }) : null; const created = await tx.todo.create({ data: { ownerId: req.userId!, title: data.title, description: data.description, dueAt: new Date(data.dueAt), recurrenceRuleId: rule?.id, }, }); if (offsets.length > 0) { await tx.reminderOffset.createMany({ data: offsets.map((offset) => ({ targetType: "todo", targetId: created.id, offsetMinutes: offset.offsetMinutes, channelInapp: offset.channelInapp ?? true, channelBark: offset.channelBark ?? false, })), }); } return created; }); return res.json(todo); }); todoRouter.get("/:id", async (req: AuthRequest, res) => { const todo = await prisma.todo.findFirst({ where: { id: req.params.id, ownerId: req.userId! }, include: { recurrenceRule: true }, }); if (!todo) { return res.status(404).json({ error: "Not found" }); } const offsets = await prisma.reminderOffset.findMany({ where: { targetType: "todo", targetId: todo.id }, }); return res.json({ ...todo, offsets }); }); todoRouter.put("/:id", async (req: AuthRequest, res) => { const parsed = todoSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "Invalid payload" }); } const { recurrenceRule, offsets = [], ...data } = parsed.data; const updated = await prisma.$transaction(async (tx) => { const existing = await tx.todo.findFirst({ where: { id: req.params.id, ownerId: req.userId! }, }); if (!existing) { throw new Error("Not found"); } let recurrenceRuleId = existing.recurrenceRuleId; if (recurrenceRule) { if (recurrenceRuleId) { await tx.recurrenceRule.update({ where: { id: recurrenceRuleId }, data: { type: recurrenceRule.type, interval: recurrenceRule.interval ?? 1, byWeekday: recurrenceRule.byWeekday, byMonthday: recurrenceRule.byMonthday, timezone: recurrenceRule.timezone ?? "Asia/Shanghai", }, }); } else { const created = await tx.recurrenceRule.create({ data: { type: recurrenceRule.type, interval: recurrenceRule.interval ?? 1, byWeekday: recurrenceRule.byWeekday, byMonthday: recurrenceRule.byMonthday, timezone: recurrenceRule.timezone ?? "Asia/Shanghai", }, }); recurrenceRuleId = created.id; } } else if (recurrenceRuleId) { await tx.recurrenceRule.delete({ where: { id: recurrenceRuleId } }); recurrenceRuleId = null; } await tx.reminderOffset.deleteMany({ where: { targetType: "todo", targetId: existing.id } }); if (offsets.length > 0) { await tx.reminderOffset.createMany({ data: offsets.map((offset) => ({ targetType: "todo", targetId: existing.id, offsetMinutes: offset.offsetMinutes, channelInapp: offset.channelInapp ?? true, channelBark: offset.channelBark ?? false, })), }); } return tx.todo.update({ where: { id: existing.id }, data: { title: data.title, description: data.description, dueAt: new Date(data.dueAt), recurrenceRuleId, }, }); }); return res.json(updated); }); todoRouter.delete("/:id", async (req: AuthRequest, res) => { const deleted = await prisma.todo.deleteMany({ where: { id: req.params.id, ownerId: req.userId! }, }); if (deleted.count === 0) { return res.status(404).json({ error: "Not found" }); } await prisma.reminderOffset.deleteMany({ where: { targetType: "todo", targetId: req.params.id } }); return res.json({ ok: true }); });