first commit

This commit is contained in:
Michael Dong
2026-02-05 11:24:40 +08:00
commit a98e12f286
144 changed files with 26459 additions and 0 deletions

39
backend/src/app.ts Normal file
View 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
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

11
backend/src/index.ts Normal file
View 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}`);
});

View 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" });
}
};

View 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 } });
});

View 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
View 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);
});

View 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 });
});

View 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
View 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 });
});

View 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);
});

View 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;
};

View 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
View 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);
});