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

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.env
.env.*
*.log
.git
.gitignore
README.md

8
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
build
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

44
backend/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Generate Prisma client
RUN npx prisma generate
# Copy source code
COPY . .
# Build TypeScript
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy package files and install production dependencies only
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci --omit=dev
# Generate Prisma client in production
RUN npx prisma generate
# Copy built files from builder
COPY --from=builder /app/dist ./dist
# Run migrations and start the app
EXPOSE 4000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

1821
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "notify-backend",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"worker": "tsx src/worker.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "^5.13.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.2",
"prisma": "^5.13.0",
"tsx": "^4.16.0",
"typescript": "^5.5.2"
}
}

View File

@@ -0,0 +1,203 @@
-- CreateEnum
CREATE TYPE "RecurrenceType" AS ENUM ('hourly', 'daily', 'weekly', 'monthly', 'yearly');
-- CreateEnum
CREATE TYPE "TargetType" AS ENUM ('todo', 'reminder_task');
-- CreateEnum
CREATE TYPE "ChannelType" AS ENUM ('inapp', 'bark');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('pending', 'queued', 'sent', 'failed');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai',
"barkUrl" TEXT,
"inappEnabled" BOOLEAN NOT NULL DEFAULT true,
"barkEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"maxUses" INTEGER NOT NULL DEFAULT 5,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecurrenceRule" (
"id" TEXT NOT NULL,
"type" "RecurrenceType" NOT NULL,
"interval" INTEGER NOT NULL DEFAULT 1,
"byWeekday" INTEGER,
"byMonthday" INTEGER,
"timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RecurrenceRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Todo" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"dueAt" TIMESTAMP(3) NOT NULL,
"recurrenceRuleId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Todo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReminderTask" (
"id" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"dueAt" TIMESTAMP(3) NOT NULL,
"recurrenceRuleId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReminderTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReminderTaskRecipient" (
"taskId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ReminderTaskRecipient_pkey" PRIMARY KEY ("taskId","userId")
);
-- CreateTable
CREATE TABLE "ReminderOffset" (
"id" TEXT NOT NULL,
"targetType" "TargetType" NOT NULL,
"targetId" TEXT NOT NULL,
"offsetMinutes" INTEGER NOT NULL,
"channelInapp" BOOLEAN NOT NULL DEFAULT true,
"channelBark" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReminderOffset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"recipientId" TEXT NOT NULL,
"targetType" "TargetType" NOT NULL,
"targetId" TEXT NOT NULL,
"triggerAt" TIMESTAMP(3) NOT NULL,
"channel" "ChannelType" NOT NULL,
"status" "NotificationStatus" NOT NULL DEFAULT 'pending',
"lockedAt" TIMESTAMP(3),
"sentAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DeliveryLog" (
"id" TEXT NOT NULL,
"notificationId" TEXT NOT NULL,
"attemptNo" INTEGER NOT NULL,
"channel" "ChannelType" NOT NULL,
"status" "NotificationStatus" NOT NULL,
"responseMeta" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeliveryLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
-- CreateIndex
CREATE INDEX "Invite_creatorId_idx" ON "Invite"("creatorId");
-- CreateIndex
CREATE INDEX "Invite_expiresAt_idx" ON "Invite"("expiresAt");
-- CreateIndex
CREATE INDEX "Todo_ownerId_dueAt_idx" ON "Todo"("ownerId", "dueAt");
-- CreateIndex
CREATE INDEX "Todo_recurrenceRuleId_idx" ON "Todo"("recurrenceRuleId");
-- CreateIndex
CREATE INDEX "ReminderTask_creatorId_dueAt_idx" ON "ReminderTask"("creatorId", "dueAt");
-- CreateIndex
CREATE INDEX "ReminderTask_recurrenceRuleId_idx" ON "ReminderTask"("recurrenceRuleId");
-- CreateIndex
CREATE INDEX "ReminderTaskRecipient_userId_idx" ON "ReminderTaskRecipient"("userId");
-- CreateIndex
CREATE INDEX "ReminderOffset_targetType_targetId_idx" ON "ReminderOffset"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "Notification_status_triggerAt_idx" ON "Notification"("status", "triggerAt");
-- CreateIndex
CREATE INDEX "Notification_recipientId_readAt_idx" ON "Notification"("recipientId", "readAt");
-- CreateIndex
CREATE UNIQUE INDEX "Notification_recipientId_targetType_targetId_triggerAt_chan_key" ON "Notification"("recipientId", "targetType", "targetId", "triggerAt", "channel");
-- CreateIndex
CREATE INDEX "DeliveryLog_notificationId_idx" ON "DeliveryLog"("notificationId");
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Todo" ADD CONSTRAINT "Todo_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Todo" ADD CONSTRAINT "Todo_recurrenceRuleId_fkey" FOREIGN KEY ("recurrenceRuleId") REFERENCES "RecurrenceRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReminderTask" ADD CONSTRAINT "ReminderTask_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReminderTask" ADD CONSTRAINT "ReminderTask_recurrenceRuleId_fkey" FOREIGN KEY ("recurrenceRuleId") REFERENCES "RecurrenceRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReminderTaskRecipient" ADD CONSTRAINT "ReminderTaskRecipient_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ReminderTask"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReminderTaskRecipient" ADD CONSTRAINT "ReminderTaskRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeliveryLog" ADD CONSTRAINT "DeliveryLog_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avatar" TEXT;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,176 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum RecurrenceType {
hourly
daily
weekly
monthly
yearly
}
enum TargetType {
todo
reminder_task
}
enum ChannelType {
inapp
bark
}
enum NotificationStatus {
pending
queued
sent
failed
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
avatar String?
timezone String @default("Asia/Shanghai")
barkUrl String?
inappEnabled Boolean @default(true)
barkEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invites Invite[] @relation("InviteCreator")
todos Todo[] @relation("TodoOwner")
createdTasks ReminderTask[] @relation("TaskCreator")
taskRecipients ReminderTaskRecipient[]
notifications Notification[] @relation("NotificationRecipient")
}
model Invite {
id String @id @default(cuid())
code String @unique
creatorId String
maxUses Int @default(5)
usedCount Int @default(0)
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
creator User @relation("InviteCreator", fields: [creatorId], references: [id])
@@index([creatorId])
@@index([expiresAt])
}
model RecurrenceRule {
id String @id @default(cuid())
type RecurrenceType
interval Int @default(1)
byWeekday Int?
byMonthday Int?
timezone String @default("Asia/Shanghai")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
todos Todo[]
tasks ReminderTask[]
}
model Todo {
id String @id @default(cuid())
ownerId String
title String
description String?
dueAt DateTime
recurrenceRuleId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("TodoOwner", fields: [ownerId], references: [id])
recurrenceRule RecurrenceRule? @relation(fields: [recurrenceRuleId], references: [id])
@@index([ownerId, dueAt])
@@index([recurrenceRuleId])
}
model ReminderTask {
id String @id @default(cuid())
creatorId String
title String
description String?
dueAt DateTime
recurrenceRuleId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
recurrenceRule RecurrenceRule? @relation(fields: [recurrenceRuleId], references: [id])
recipients ReminderTaskRecipient[]
@@index([creatorId, dueAt])
@@index([recurrenceRuleId])
}
model ReminderTaskRecipient {
taskId String
userId String
task ReminderTask @relation(fields: [taskId], references: [id])
user User @relation(fields: [userId], references: [id])
@@id([taskId, userId])
@@index([userId])
}
model ReminderOffset {
id String @id @default(cuid())
targetType TargetType
targetId String
offsetMinutes Int
channelInapp Boolean @default(true)
channelBark Boolean @default(false)
createdAt DateTime @default(now())
@@index([targetType, targetId])
}
model Notification {
id String @id @default(cuid())
recipientId String
targetType TargetType
targetId String
triggerAt DateTime
channel ChannelType
status NotificationStatus @default(pending)
lockedAt DateTime?
sentAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipient User @relation("NotificationRecipient", fields: [recipientId], references: [id])
logs DeliveryLog[]
@@unique([recipientId, targetType, targetId, triggerAt, channel])
@@index([status, triggerAt])
@@index([recipientId, readAt])
}
model DeliveryLog {
id String @id @default(cuid())
notificationId String
attemptNo Int
channel ChannelType
status NotificationStatus
responseMeta Json?
createdAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id])
@@index([notificationId])
}

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

12
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}