197 lines
6.0 KiB
TypeScript
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 });
|
|
});
|