Files
notify/backend/src/routes/todos.ts
Michael Dong a98e12f286 first commit
2026-02-05 11:24:40 +08:00

197 lines
6.0 KiB
TypeScript

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<Record<string, typeof offsets>>((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 });
});