first commit
This commit is contained in:
39
backend/src/app.ts
Normal file
39
backend/src/app.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import helmet from "helmet";
|
||||
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { inviteRouter } from "./routes/invites";
|
||||
import { meRouter } from "./routes/me";
|
||||
import { notificationRouter } from "./routes/notifications";
|
||||
import { reminderTaskRouter } from "./routes/reminderTasks";
|
||||
import { todoRouter } from "./routes/todos";
|
||||
import { userRouter } from "./routes/users";
|
||||
|
||||
export const createApp = () => {
|
||||
const app = express();
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 120,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
})
|
||||
);
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/invites", inviteRouter);
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/notifications", notificationRouter);
|
||||
app.use("/api/reminder-tasks", reminderTaskRouter);
|
||||
app.use("/api/todos", todoRouter);
|
||||
app.use("/api/users", userRouter);
|
||||
|
||||
return app;
|
||||
};
|
||||
3
backend/src/db.ts
Normal file
3
backend/src/db.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
11
backend/src/index.ts
Normal file
11
backend/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { createApp } from "./app";
|
||||
|
||||
const port = Number(process.env.PORT || 4000);
|
||||
|
||||
const app = createApp();
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Notify API running on :${port}`);
|
||||
});
|
||||
21
backend/src/middleware/auth.ts
Normal file
21
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
export type AuthRequest = Request & { userId?: string };
|
||||
|
||||
export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
const token = header.slice("Bearer ".length);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || "dev-secret") as {
|
||||
userId: string;
|
||||
};
|
||||
req.userId = payload.userId;
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
};
|
||||
93
backend/src/routes/auth.ts
Normal file
93
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
password: z.string().min(6),
|
||||
inviteCode: z.string().min(4),
|
||||
});
|
||||
|
||||
authRouter.post("/register", async (req, res) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "Invalid payload" });
|
||||
}
|
||||
|
||||
const { username, password, inviteCode } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const invite = await tx.invite.findFirst({
|
||||
where: {
|
||||
code: inviteCode,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
});
|
||||
if (!invite || invite.usedCount >= invite.maxUses) {
|
||||
throw new Error("Invalid invite");
|
||||
}
|
||||
|
||||
const existing = await tx.user.findUnique({ where: { username } });
|
||||
if (existing) {
|
||||
throw new Error("Username taken");
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await tx.user.create({
|
||||
data: { username, passwordHash },
|
||||
});
|
||||
|
||||
await tx.invite.update({
|
||||
where: { id: invite.id },
|
||||
data: { usedCount: invite.usedCount + 1 },
|
||||
});
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
const token = jwt.sign({ userId: result.id }, process.env.JWT_SECRET || "dev-secret", {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
return res.json({ token, user: { id: result.id, username: result.username } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Register failed";
|
||||
const status = message === "Invalid invite" ? 400 : 409;
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(3),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
authRouter.post("/login", async (req, res) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "Invalid payload" });
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET || "dev-secret", {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
return res.json({ token, user: { id: user.id, username: user.username } });
|
||||
});
|
||||
55
backend/src/routes/invites.ts
Normal file
55
backend/src/routes/invites.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth, type AuthRequest } from "../middleware/auth";
|
||||
|
||||
export const inviteRouter = Router();
|
||||
|
||||
inviteRouter.use(requireAuth);
|
||||
|
||||
const createSchema = z.object({
|
||||
maxUses: z.number().int().min(1).max(20).optional(),
|
||||
expiresInDays: z.number().int().min(1).max(30).optional(),
|
||||
});
|
||||
|
||||
inviteRouter.post("/", async (req: AuthRequest, res) => {
|
||||
const parsed = createSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "Invalid payload" });
|
||||
}
|
||||
|
||||
const maxUses = parsed.data.maxUses ?? 5;
|
||||
const expiresInDays = parsed.data.expiresInDays ?? 7;
|
||||
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||
const code = `INV-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
|
||||
const invite = await prisma.invite.create({
|
||||
data: {
|
||||
code,
|
||||
creatorId: req.userId!,
|
||||
maxUses,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
return res.json(invite);
|
||||
});
|
||||
|
||||
inviteRouter.get("/", async (req: AuthRequest, res) => {
|
||||
const invites = await prisma.invite.findMany({
|
||||
where: { creatorId: req.userId! },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return res.json(invites);
|
||||
});
|
||||
|
||||
inviteRouter.post("/:id/revoke", async (req: AuthRequest, res) => {
|
||||
const invite = await prisma.invite.updateMany({
|
||||
where: { id: req.params.id, creatorId: req.userId! },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (invite.count === 0) {
|
||||
return res.status(404).json({ error: "Invite not found" });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
53
backend/src/routes/me.ts
Normal file
53
backend/src/routes/me.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth, type AuthRequest } from "../middleware/auth";
|
||||
|
||||
export const meRouter = Router();
|
||||
meRouter.use(requireAuth);
|
||||
|
||||
meRouter.get("/", async (req: AuthRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId! },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
timezone: true,
|
||||
barkUrl: true,
|
||||
inappEnabled: true,
|
||||
barkEnabled: true,
|
||||
},
|
||||
});
|
||||
return res.json(user);
|
||||
});
|
||||
|
||||
const settingsSchema = z.object({
|
||||
avatar: z.string().url().optional().nullable(),
|
||||
timezone: z.string().optional(),
|
||||
barkUrl: z.string().url().optional().nullable(),
|
||||
inappEnabled: z.boolean().optional(),
|
||||
barkEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
meRouter.put("/settings", async (req: AuthRequest, res) => {
|
||||
const parsed = settingsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "Invalid payload" });
|
||||
}
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId! },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
timezone: true,
|
||||
barkUrl: true,
|
||||
inappEnabled: true,
|
||||
barkEnabled: true,
|
||||
},
|
||||
});
|
||||
return res.json(user);
|
||||
});
|
||||
40
backend/src/routes/notifications.ts
Normal file
40
backend/src/routes/notifications.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth, type AuthRequest } from "../middleware/auth";
|
||||
|
||||
export const notificationRouter = Router();
|
||||
notificationRouter.use(requireAuth);
|
||||
|
||||
notificationRouter.get("/", async (req: AuthRequest, res) => {
|
||||
const status = (req.query.status as string | undefined) ?? "all";
|
||||
const where =
|
||||
status === "unread"
|
||||
? { recipientId: req.userId!, readAt: null }
|
||||
: { recipientId: req.userId! };
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { triggerAt: "desc" },
|
||||
});
|
||||
return res.json(notifications);
|
||||
});
|
||||
|
||||
notificationRouter.post("/:id/read", async (req: AuthRequest, res) => {
|
||||
const updated = await prisma.notification.updateMany({
|
||||
where: { id: req.params.id, recipientId: req.userId! },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
notificationRouter.post("/read-all", async (req: AuthRequest, res) => {
|
||||
await prisma.notification.updateMany({
|
||||
where: { recipientId: req.userId!, readAt: null },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
213
backend/src/routes/reminderTasks.ts
Normal file
213
backend/src/routes/reminderTasks.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth, type AuthRequest } from "../middleware/auth";
|
||||
|
||||
export const reminderTaskRouter = Router();
|
||||
reminderTaskRouter.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 taskSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
dueAt: z.string().datetime(),
|
||||
recipientIds: z.array(z.string().min(1)),
|
||||
recurrenceRule: recurrenceSchema.optional(),
|
||||
offsets: z.array(offsetSchema).optional(),
|
||||
});
|
||||
|
||||
reminderTaskRouter.get("/", async (req: AuthRequest, res) => {
|
||||
const items = await prisma.reminderTask.findMany({
|
||||
where: { creatorId: req.userId! },
|
||||
include: { recipients: true, recurrenceRule: true },
|
||||
orderBy: { dueAt: "asc" },
|
||||
});
|
||||
const offsets = await prisma.reminderOffset.findMany({
|
||||
where: { targetType: "reminder_task", 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);
|
||||
});
|
||||
|
||||
reminderTaskRouter.post("/", async (req: AuthRequest, res) => {
|
||||
const parsed = taskSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "Invalid payload" });
|
||||
}
|
||||
const { recurrenceRule, offsets = [], recipientIds, ...data } = parsed.data;
|
||||
|
||||
const task = 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.reminderTask.create({
|
||||
data: {
|
||||
creatorId: req.userId!,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
dueAt: new Date(data.dueAt),
|
||||
recurrenceRuleId: rule?.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.reminderTaskRecipient.createMany({
|
||||
data: recipientIds.map((userId) => ({ taskId: created.id, userId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
if (offsets.length > 0) {
|
||||
await tx.reminderOffset.createMany({
|
||||
data: offsets.map((offset) => ({
|
||||
targetType: "reminder_task",
|
||||
targetId: created.id,
|
||||
offsetMinutes: offset.offsetMinutes,
|
||||
channelInapp: offset.channelInapp ?? true,
|
||||
channelBark: offset.channelBark ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return res.json(task);
|
||||
});
|
||||
|
||||
reminderTaskRouter.get("/:id", async (req: AuthRequest, res) => {
|
||||
const task = await prisma.reminderTask.findFirst({
|
||||
where: { id: req.params.id, creatorId: req.userId! },
|
||||
include: { recipients: true, recurrenceRule: true },
|
||||
});
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
const offsets = await prisma.reminderOffset.findMany({
|
||||
where: { targetType: "reminder_task", targetId: task.id },
|
||||
});
|
||||
return res.json({ ...task, offsets });
|
||||
});
|
||||
|
||||
reminderTaskRouter.put("/:id", async (req: AuthRequest, res) => {
|
||||
const parsed = taskSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "Invalid payload" });
|
||||
}
|
||||
const { recurrenceRule, offsets = [], recipientIds, ...data } = parsed.data;
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.reminderTask.findFirst({
|
||||
where: { id: req.params.id, creatorId: 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.reminderTaskRecipient.deleteMany({ where: { taskId: existing.id } });
|
||||
await tx.reminderTaskRecipient.createMany({
|
||||
data: recipientIds.map((userId) => ({ taskId: existing.id, userId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
await tx.reminderOffset.deleteMany({
|
||||
where: { targetType: "reminder_task", targetId: existing.id },
|
||||
});
|
||||
if (offsets.length > 0) {
|
||||
await tx.reminderOffset.createMany({
|
||||
data: offsets.map((offset) => ({
|
||||
targetType: "reminder_task",
|
||||
targetId: existing.id,
|
||||
offsetMinutes: offset.offsetMinutes,
|
||||
channelInapp: offset.channelInapp ?? true,
|
||||
channelBark: offset.channelBark ?? false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return tx.reminderTask.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
dueAt: new Date(data.dueAt),
|
||||
recurrenceRuleId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
reminderTaskRouter.delete("/:id", async (req: AuthRequest, res) => {
|
||||
const deleted = await prisma.reminderTask.deleteMany({
|
||||
where: { id: req.params.id, creatorId: req.userId! },
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
await prisma.reminderOffset.deleteMany({
|
||||
where: { targetType: "reminder_task", targetId: req.params.id },
|
||||
});
|
||||
await prisma.reminderTaskRecipient.deleteMany({ where: { taskId: req.params.id } });
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
196
backend/src/routes/todos.ts
Normal file
196
backend/src/routes/todos.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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 });
|
||||
});
|
||||
24
backend/src/routes/users.ts
Normal file
24
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
|
||||
export const userRouter = Router();
|
||||
userRouter.use(requireAuth);
|
||||
|
||||
userRouter.get("/", async (req, res) => {
|
||||
const query = (req.query.query as string | undefined)?.trim();
|
||||
const users = await prisma.user.findMany({
|
||||
where: query
|
||||
? {
|
||||
username: {
|
||||
contains: query,
|
||||
mode: "insensitive",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
select: { id: true, username: true, avatar: true },
|
||||
orderBy: { username: "asc" },
|
||||
});
|
||||
return res.json(users);
|
||||
});
|
||||
23
backend/src/services/bark.ts
Normal file
23
backend/src/services/bark.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
type BarkPayload = {
|
||||
title: string;
|
||||
body: string;
|
||||
group?: string;
|
||||
url?: string;
|
||||
sound?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export const sendBarkPush = async (baseUrl: string, payload: BarkPayload) => {
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Bark error: ${response.status}`);
|
||||
(error as Error & { response?: unknown }).response = data;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
51
backend/src/services/recurrence.ts
Normal file
51
backend/src/services/recurrence.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { RecurrenceRule } from "@prisma/client";
|
||||
|
||||
const clampDay = (year: number, monthIndex: number, day: number) => {
|
||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||
return Math.min(day, lastDay);
|
||||
};
|
||||
|
||||
export const addMonthsWithClamp = (date: Date, months: number) => {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = date.getUTCMonth();
|
||||
const day = date.getUTCDate();
|
||||
const hour = date.getUTCHours();
|
||||
const minute = date.getUTCMinutes();
|
||||
const second = date.getUTCSeconds();
|
||||
|
||||
const targetMonth = month + months;
|
||||
const targetYear = year + Math.floor(targetMonth / 12);
|
||||
const normalizedMonth = ((targetMonth % 12) + 12) % 12;
|
||||
const clampedDay = clampDay(targetYear, normalizedMonth, day);
|
||||
|
||||
return new Date(Date.UTC(targetYear, normalizedMonth, clampedDay, hour, minute, second));
|
||||
};
|
||||
|
||||
export const addYearsWithClamp = (date: Date, years: number) => {
|
||||
const targetYear = date.getUTCFullYear() + years;
|
||||
const month = date.getUTCMonth();
|
||||
const day = date.getUTCDate();
|
||||
const hour = date.getUTCHours();
|
||||
const minute = date.getUTCMinutes();
|
||||
const second = date.getUTCSeconds();
|
||||
const clampedDay = clampDay(targetYear, month, day);
|
||||
return new Date(Date.UTC(targetYear, month, clampedDay, hour, minute, second));
|
||||
};
|
||||
|
||||
export const nextDueAt = (dueAt: Date, rule: RecurrenceRule) => {
|
||||
const interval = rule.interval ?? 1;
|
||||
switch (rule.type) {
|
||||
case "hourly":
|
||||
return new Date(dueAt.getTime() + interval * 60 * 60 * 1000);
|
||||
case "daily":
|
||||
return new Date(dueAt.getTime() + interval * 24 * 60 * 60 * 1000);
|
||||
case "weekly":
|
||||
return new Date(dueAt.getTime() + interval * 7 * 24 * 60 * 60 * 1000);
|
||||
case "monthly":
|
||||
return addMonthsWithClamp(dueAt, interval);
|
||||
case "yearly":
|
||||
return addYearsWithClamp(dueAt, interval);
|
||||
default:
|
||||
return dueAt;
|
||||
}
|
||||
};
|
||||
255
backend/src/worker.ts
Normal file
255
backend/src/worker.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "./db";
|
||||
import { sendBarkPush } from "./services/bark";
|
||||
import { nextDueAt } from "./services/recurrence";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const calcBackoffMs = (attemptNo: number) => {
|
||||
const steps = [60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000, 4 * 60 * 60_000];
|
||||
return steps[Math.min(attemptNo - 1, steps.length - 1)];
|
||||
};
|
||||
|
||||
const buildOffsets = (offsets: { offsetMinutes: number; channelInapp: boolean; channelBark: boolean }[]) => {
|
||||
if (offsets.length === 0) {
|
||||
return [{ offsetMinutes: 0, channelInapp: true, channelBark: false }];
|
||||
}
|
||||
return offsets;
|
||||
};
|
||||
|
||||
const generateNotifications = async (now: Date) => {
|
||||
const maxOffset = await prisma.reminderOffset.aggregate({
|
||||
_max: { offsetMinutes: true },
|
||||
});
|
||||
const offsetMinutes = maxOffset._max.offsetMinutes ?? 0;
|
||||
const upperBound = new Date(now.getTime() + offsetMinutes * 60 * 1000);
|
||||
|
||||
const todos = await prisma.todo.findMany({
|
||||
where: { dueAt: { lte: upperBound } },
|
||||
include: { owner: true, recurrenceRule: true },
|
||||
});
|
||||
const todoOffsets = await prisma.reminderOffset.findMany({
|
||||
where: { targetType: "todo", targetId: { in: todos.map((todo) => todo.id) } },
|
||||
});
|
||||
const todoOffsetsById = todoOffsets.reduce<Record<string, typeof todoOffsets>>((acc, offset) => {
|
||||
acc[offset.targetId] = acc[offset.targetId] ?? [];
|
||||
acc[offset.targetId].push(offset);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const todo of todos) {
|
||||
const offsets = buildOffsets(todoOffsetsById[todo.id] ?? []);
|
||||
const data = [];
|
||||
for (const offset of offsets) {
|
||||
const triggerAt = new Date(todo.dueAt.getTime() - offset.offsetMinutes * 60 * 1000);
|
||||
if (triggerAt > now) continue;
|
||||
if (offset.channelInapp && todo.owner.inappEnabled) {
|
||||
data.push({
|
||||
recipientId: todo.ownerId,
|
||||
targetType: "todo" as const,
|
||||
targetId: todo.id,
|
||||
triggerAt,
|
||||
channel: "inapp" as const,
|
||||
});
|
||||
}
|
||||
if (offset.channelBark && todo.owner.barkEnabled && todo.owner.barkUrl) {
|
||||
data.push({
|
||||
recipientId: todo.ownerId,
|
||||
targetType: "todo" as const,
|
||||
targetId: todo.id,
|
||||
triggerAt,
|
||||
channel: "bark" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
await prisma.notification.createMany({ data, skipDuplicates: true });
|
||||
}
|
||||
|
||||
if (todo.recurrenceRule && todo.dueAt <= now) {
|
||||
const next = nextDueAt(todo.dueAt, todo.recurrenceRule);
|
||||
await prisma.todo.update({ where: { id: todo.id }, data: { dueAt: next } });
|
||||
}
|
||||
}
|
||||
|
||||
const tasks = await prisma.reminderTask.findMany({
|
||||
where: { dueAt: { lte: upperBound } },
|
||||
include: {
|
||||
recurrenceRule: true,
|
||||
recipients: { include: { user: true } },
|
||||
},
|
||||
});
|
||||
const taskOffsets = await prisma.reminderOffset.findMany({
|
||||
where: { targetType: "reminder_task", targetId: { in: tasks.map((task) => task.id) } },
|
||||
});
|
||||
const taskOffsetsById = taskOffsets.reduce<Record<string, typeof taskOffsets>>((acc, offset) => {
|
||||
acc[offset.targetId] = acc[offset.targetId] ?? [];
|
||||
acc[offset.targetId].push(offset);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const task of tasks) {
|
||||
const offsets = buildOffsets(taskOffsetsById[task.id] ?? []);
|
||||
const data = [];
|
||||
for (const offset of offsets) {
|
||||
const triggerAt = new Date(task.dueAt.getTime() - offset.offsetMinutes * 60 * 1000);
|
||||
if (triggerAt > now) continue;
|
||||
for (const recipient of task.recipients) {
|
||||
const user = recipient.user;
|
||||
if (offset.channelInapp && user.inappEnabled) {
|
||||
data.push({
|
||||
recipientId: user.id,
|
||||
targetType: "reminder_task" as const,
|
||||
targetId: task.id,
|
||||
triggerAt,
|
||||
channel: "inapp" as const,
|
||||
});
|
||||
}
|
||||
if (offset.channelBark && user.barkEnabled && user.barkUrl) {
|
||||
data.push({
|
||||
recipientId: user.id,
|
||||
targetType: "reminder_task" as const,
|
||||
targetId: task.id,
|
||||
triggerAt,
|
||||
channel: "bark" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
await prisma.notification.createMany({ data, skipDuplicates: true });
|
||||
}
|
||||
|
||||
if (task.recurrenceRule && task.dueAt <= now) {
|
||||
const next = nextDueAt(task.dueAt, task.recurrenceRule);
|
||||
await prisma.reminderTask.update({ where: { id: task.id }, data: { dueAt: next } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deliverNotifications = async (now: Date) => {
|
||||
const expiredLock = new Date(now.getTime() - LOCK_TIMEOUT_MS);
|
||||
const pending = await prisma.notification.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
triggerAt: { lte: now },
|
||||
OR: [{ lockedAt: null }, { lockedAt: { lt: expiredLock } }],
|
||||
},
|
||||
include: { recipient: true },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
for (const notification of pending) {
|
||||
const locked = await prisma.notification.updateMany({
|
||||
where: { id: notification.id, status: "pending" },
|
||||
data: { status: "queued", lockedAt: now },
|
||||
});
|
||||
if (locked.count === 0) continue;
|
||||
|
||||
try {
|
||||
if (notification.channel === "inapp") {
|
||||
await prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
data: { status: "sent", sentAt: now, lockedAt: null },
|
||||
});
|
||||
await prisma.deliveryLog.create({
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
attemptNo: 1,
|
||||
channel: "inapp",
|
||||
status: "sent",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = notification.recipient;
|
||||
if (!recipient.barkEnabled || !recipient.barkUrl) {
|
||||
await prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
data: { status: "failed", lockedAt: null },
|
||||
});
|
||||
await prisma.deliveryLog.create({
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
attemptNo: 1,
|
||||
channel: "bark",
|
||||
status: "failed",
|
||||
responseMeta: { reason: "bark_disabled" },
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = "Notify 提醒";
|
||||
const body = `触发时间:${notification.triggerAt.toISOString()}`;
|
||||
await sendBarkPush(recipient.barkUrl, {
|
||||
title,
|
||||
body,
|
||||
group: "notify",
|
||||
});
|
||||
|
||||
await prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
data: { status: "sent", sentAt: now, lockedAt: null },
|
||||
});
|
||||
await prisma.deliveryLog.create({
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
attemptNo: 1,
|
||||
channel: "bark",
|
||||
status: "sent",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const logs = await prisma.deliveryLog.findMany({
|
||||
where: { notificationId: notification.id },
|
||||
orderBy: { attemptNo: "desc" },
|
||||
take: 1,
|
||||
});
|
||||
const attemptNo = (logs[0]?.attemptNo ?? 0) + 1;
|
||||
const shouldRetry = attemptNo < 5;
|
||||
const retryAt = new Date(now.getTime() + calcBackoffMs(attemptNo));
|
||||
|
||||
await prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
data: {
|
||||
status: shouldRetry ? "pending" : "failed",
|
||||
lockedAt: null,
|
||||
triggerAt: shouldRetry ? retryAt : notification.triggerAt,
|
||||
},
|
||||
});
|
||||
await prisma.deliveryLog.create({
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
attemptNo,
|
||||
channel: notification.channel,
|
||||
status: shouldRetry ? "pending" : "failed",
|
||||
responseMeta: { message: (error as Error).message },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loop = async () => {
|
||||
const now = new Date();
|
||||
await generateNotifications(now);
|
||||
await deliverNotifications(now);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Notify worker started");
|
||||
await loop();
|
||||
setInterval(loop, 30 * 1000);
|
||||
};
|
||||
|
||||
start().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Worker error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user