Skip to content

Commit 58b424d

Browse files
author
赵汝波
committed
feat: Implement due date reminders and notification system
- Added a new `NotificationKind` enum to categorize notifications for mentions and due date reminders. - Enhanced the `Notification` model to include a `dedupeKey` for ensuring unique notifications. - Created a Vercel Cron job to trigger daily reminders for tasks due today and tomorrow, integrating with the existing notification system. - Updated the Prisma schema and added migration scripts to support the new notification features. - Implemented logic for generating dedupe keys for due date notifications and integrated it into the notification creation process. - Enhanced the UI to display due date reminders alongside mention notifications, improving user engagement and task management.
1 parent 93caa55 commit 58b424d

11 files changed

Lines changed: 250 additions & 27 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/taskflow_mvp?schema=
88
AUTH_SECRET="请替换为随机长字符串"
99

1010
# 生产部署到 Vercel 时,直接将 DATABASE_URL 设置为托管 PG 连接串即可
11+
12+
# Vercel Cron 调用 `/api/cron/due-reminders` 时校验;与 Vercel 项目环境变量同名即可
13+
# CRON_SECRET="openssl rand -base64 32"

PROJECT_PLAN.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
- [x] `P1` 梳理并落地 ACL 权限设计第一版
3030
- [ ] `P1` 提升 README 与项目说明的展示质量
3131
- [~] `P0` 对标 Linear 第一波:App Shell 左侧导航、密度更高的列表样式
32-
- [ ] `P0`(backlog)附件上传或截止提醒 Cron(择一)
32+
- [x] `P0`(backlog)截止提醒 Cron(站内通知,UTC)
33+
- [ ] `P0`(backlog)附件上传
3334

3435
---
3536

@@ -187,7 +188,7 @@
187188

188189
- [ ] **附件**:任务/评论上传;对象存储 + 大小/类型限制;删除任务时级联或孤儿策略。
189190
- [x] **活动流 / 审计日志**:任务详情「活动流」+ `/dashboard/activity` 全部动态;`TaskActivity` 覆盖创建/状态/详情/负责人/标签/清单/评论。
190-
- [ ] **截止提醒**今日/明日到期视图;Cron + 邮件或站内提醒(推送权限可后置)
191+
- [x] **截止提醒(站内)**URL `due=today|tomorrow|…` 快捷视图;每日 UTC Cron 写入 `Notification``due_today` / `due_tomorrow`);邮件可后置
191192
- [ ] **项目成员协作**:项目维度成员与权限(在现有 ACL 上扩展资源范围)。
192193

193194
### P2 — 体验与扩展
@@ -260,3 +261,4 @@
260261
- [x] 评论 @ 与通知:`Notification`、提及解析、`/dashboard/notifications`、侧栏角标、⌘K 入口
261262
- [x] 任务活动流:`TaskActivity` + 详情时间线;关键操作写入审计摘要
262263
- [x] 全局全部动态:`/dashboard/activity`、侧栏与 ⌘K 入口
264+
- [x] 截止日站内提醒:`NotificationKind` + `dedupeKey` 幂等、`/api/cron/due-reminders``CRON_SECRET``vercel.json` 每日 UTC 0 点
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- CreateEnum
2+
CREATE TYPE "NotificationKind" AS ENUM ('mention', 'due_today', 'due_tomorrow');
3+
4+
-- AlterTable
5+
ALTER TABLE "Notification" ADD COLUMN "kind" "NotificationKind" NOT NULL DEFAULT 'mention';
6+
7+
-- AlterTable
8+
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
9+
10+
-- Backfill dedupeKey for existing mention notifications
11+
UPDATE "Notification" SET "dedupeKey" = 'm:' || "userId" || ':' || "commentId";
12+
13+
-- AlterTable
14+
ALTER TABLE "Notification" ALTER COLUMN "dedupeKey" SET NOT NULL;
15+
16+
-- DropIndex
17+
DROP INDEX "Notification_userId_commentId_key";
18+
19+
-- CreateIndex
20+
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
21+
22+
-- AlterTable (mentions keep commentId; due reminders leave null)
23+
ALTER TABLE "Notification" ALTER COLUMN "commentId" DROP NOT NULL;

prisma/schema.prisma

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ enum TaskPriority {
2222
urgent
2323
}
2424

25+
enum NotificationKind {
26+
mention
27+
due_today
28+
due_tomorrow
29+
}
30+
2531
model User {
2632
id String @id @default(cuid())
2733
email String @unique
@@ -118,19 +124,21 @@ model Comment {
118124
@@index([taskId, createdAt])
119125
}
120126

121-
/// 站内通知(MVP:评论 @ 提及
127+
/// 站内通知:评论 @ 提及、截止日提醒(Cron)等
122128
model Notification {
123-
id String @id @default(cuid())
129+
id String @id @default(cuid())
124130
userId String
125-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
131+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
126132
taskId String
127-
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
128-
commentId String
129-
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
133+
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
134+
kind NotificationKind @default(mention)
135+
commentId String?
136+
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
137+
/// 幂等键:提及 `m:{userId}:{commentId}`;截止 `due:{...}`(见 `lib/due-reminders.ts`)
138+
dedupeKey String @unique
130139
readAt DateTime?
131-
createdAt DateTime @default(now())
140+
createdAt DateTime @default(now())
132141
133-
@@unique([userId, commentId])
134142
@@index([userId, readAt])
135143
@@index([userId, createdAt])
136144
}

src/app/actions/notifications.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export async function createMentionNotificationsForComment(params: {
4646
data: recipientIds.map(userId => ({
4747
userId,
4848
taskId: params.taskId,
49+
kind: 'mention' as const,
4950
commentId: params.commentId,
51+
dedupeKey: `m:${userId}:${params.commentId}`,
5052
})),
5153
skipDuplicates: true,
5254
})
@@ -81,12 +83,15 @@ export async function listNotificationsAction() {
8183
id: n.id,
8284
readAt: n.readAt?.toISOString() ?? null,
8385
createdAt: n.createdAt.toISOString(),
86+
kind: n.kind,
8487
taskId: n.task.id,
8588
taskTitle: n.task.title,
86-
commentId: n.comment.id,
87-
commentSnippet: n.comment.body.slice(0, 160),
89+
commentId: n.comment?.id ?? null,
90+
commentSnippet: n.comment?.body.slice(0, 160) ?? null,
8891
authorLabel:
89-
n.comment.user.name?.trim() || n.comment.user.email.split('@')[0],
92+
n.comment?.user.name?.trim() ||
93+
n.comment?.user.email.split('@')[0] ||
94+
null,
9095
})),
9196
}
9297
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextResponse } from 'next/server'
2+
import { runDueReminderNotificationSweep } from '@/lib/due-reminders'
3+
4+
export const dynamic = 'force-dynamic'
5+
6+
/**
7+
* Vercel Cron:每日 UTC 0 点触发(见根目录 `vercel.json`)。
8+
* 需在环境变量中配置 `CRON_SECRET`;请求头 `Authorization: Bearer <CRON_SECRET>`。
9+
*/
10+
export async function GET(request: Request) {
11+
const secret = process.env.CRON_SECRET
12+
if (!secret) {
13+
return NextResponse.json(
14+
{ ok: false as const, error: 'CRON_SECRET 未配置' },
15+
{ status: 500 }
16+
)
17+
}
18+
19+
const auth = request.headers.get('authorization')
20+
if (auth !== `Bearer ${secret}`) {
21+
return new NextResponse('Unauthorized', { status: 401 })
22+
}
23+
24+
const { insertedToday, insertedTomorrow } =
25+
await runDueReminderNotificationSweep()
26+
27+
return NextResponse.json({
28+
ok: true as const,
29+
insertedToday,
30+
insertedTomorrow,
31+
})
32+
}

src/app/dashboard/notifications/notifications-client.tsx

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import Link from 'next/link'
44
import { useTransition } from 'react'
55
import { markNotificationReadAction } from '@/app/actions/notifications'
66

7+
export type NotificationKindUi = 'mention' | 'due_today' | 'due_tomorrow'
8+
79
export type NotificationRow = {
810
id: string
911
readAt: string | null
1012
createdAt: string
13+
kind: NotificationKindUi
1114
taskId: string
1215
taskTitle: string
13-
commentSnippet: string
14-
authorLabel: string
16+
commentSnippet: string | null
17+
authorLabel: string | null
18+
commentId: string | null
1519
}
1620

1721
export function NotificationsClient({ items }: { items: NotificationRow[] }) {
@@ -28,7 +32,7 @@ export function NotificationsClient({ items }: { items: NotificationRow[] }) {
2832
<p className='rounded-lg border border-dashed border-zinc-200 bg-zinc-50/80 px-4 py-8 text-center text-sm text-zinc-500'>
2933
暂无通知。在任务评论中使用 <span className='font-mono text-violet-600'>@邮箱</span>{' '}
3034
<span className='font-mono text-violet-600'>@显示名</span>{' '}
31-
即可提及他人
35+
可提及他人;截止日当天与前一天也会收到站内提醒(需部署 Cron)
3236
</p>
3337
)
3438
}
@@ -44,15 +48,45 @@ export function NotificationsClient({ items }: { items: NotificationRow[] }) {
4448
!n.readAt ? 'bg-violet-50/40' : ''
4549
} ${pending ? 'pointer-events-none opacity-70' : ''}`}
4650
>
47-
<p className='text-sm text-zinc-800'>
48-
<span className='font-medium text-zinc-900'>{n.authorLabel}</span>
49-
<span className='text-zinc-600'></span>
50-
<span className='font-medium text-violet-700'>{n.taskTitle}</span>
51-
<span className='text-zinc-600'> 的评论中提到了你</span>
52-
</p>
53-
<p className='mt-1 line-clamp-2 text-xs text-zinc-500'>
54-
{n.commentSnippet}
55-
</p>
51+
{n.kind === 'mention' ? (
52+
<>
53+
<p className='text-sm text-zinc-800'>
54+
<span className='font-medium text-zinc-900'>
55+
{n.authorLabel ?? '用户'}
56+
</span>
57+
<span className='text-zinc-600'></span>
58+
<span className='font-medium text-violet-700'>
59+
{n.taskTitle}
60+
</span>
61+
<span className='text-zinc-600'> 的评论中提到了你</span>
62+
</p>
63+
{n.commentSnippet ? (
64+
<p className='mt-1 line-clamp-2 text-xs text-zinc-500'>
65+
{n.commentSnippet}
66+
</p>
67+
) : null}
68+
</>
69+
) : n.kind === 'due_today' ? (
70+
<p className='text-sm text-zinc-800'>
71+
<span className='font-medium text-amber-800'>今日到期</span>
72+
<span className='text-zinc-600'> · </span>
73+
<span className='font-medium text-violet-700'>{n.taskTitle}</span>
74+
<span className='text-zinc-600'>
75+
{' '}
76+
按 UTC 日历日已到截止日;打开任务处理或调整日期。
77+
</span>
78+
</p>
79+
) : (
80+
<p className='text-sm text-zinc-800'>
81+
<span className='font-medium text-zinc-800'>明日到期</span>
82+
<span className='text-zinc-600'> · </span>
83+
<span className='font-medium text-violet-700'>{n.taskTitle}</span>
84+
<span className='text-zinc-600'>
85+
{' '}
86+
将于明日(UTC)到期,可提前安排。
87+
</span>
88+
</p>
89+
)}
5690
<p className='mt-1 text-[11px] text-zinc-400'>
5791
{new Date(n.createdAt).toLocaleString('zh-CN')}
5892
{!n.readAt ? (

src/app/dashboard/notifications/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { redirect } from 'next/navigation'
66

77
export const metadata = {
88
title: '通知',
9-
description: '评论 @ 提及与未读通知',
9+
description: '评论 @ 提及、截止日站内提醒与未读通知',
1010
}
1111

1212
export default async function NotificationsPage() {
@@ -31,7 +31,8 @@ export default async function NotificationsPage() {
3131
<p className='mt-2 text-sm text-zinc-500'>
3232
评论中使用 <code className='rounded bg-zinc-100 px-1 text-xs'>@完整邮箱</code>{' '}
3333
<code className='rounded bg-zinc-100 px-1 text-xs'>@账户显示名</code>{' '}
34-
提及已注册用户;对方将收到一条站内通知。
34+
提及已注册用户;对方将收到一条站内通知。未完成任务在截止日当天与前一天(UTC)也会收到提醒(需配置{' '}
35+
<code className='rounded bg-zinc-100 px-1 text-xs'>CRON_SECRET</code> 并部署 Cron)。
3536
</p>
3637
</div>
3738
<form action={markAllNotificationsReadAction}>

src/lib/due-reminders.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { dueTodayDedupeKey, dueTomorrowDedupeKey } from '@/lib/due-reminders'
3+
4+
describe('due reminder dedupe keys', () => {
5+
it('今日键含 UTC 日历日', () => {
6+
const dueDate = new Date(Date.UTC(2026, 3, 21, 15, 30, 0))
7+
expect(
8+
dueTodayDedupeKey({ taskId: 'task_a', userId: 'user_b', dueDate })
9+
).toBe('due:today:task_a:user_b:2026-04-21')
10+
})
11+
12+
it('明日键与今日键前缀不同', () => {
13+
const dueDate = new Date(Date.UTC(2026, 3, 22, 0, 0, 0))
14+
expect(dueTomorrowDedupeKey({ taskId: 't', userId: 'u', dueDate })).toBe(
15+
'due:tomorrow:t:u:2026-04-22'
16+
)
17+
})
18+
})

src/lib/due-reminders.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { prisma } from '@/lib/db'
2+
import { dueDateFilterFromBucket, formatUtcYmd } from '@/lib/date-query'
3+
4+
/** 今日到期提醒的幂等键(UTC 日历日与任务绑定)。 */
5+
export function dueTodayDedupeKey(params: {
6+
taskId: string
7+
userId: string
8+
dueDate: Date
9+
}): string {
10+
const ymd = formatUtcYmd(params.dueDate)
11+
return `due:today:${params.taskId}:${params.userId}:${ymd}`
12+
}
13+
14+
/** 明日到期提醒的幂等键。 */
15+
export function dueTomorrowDedupeKey(params: {
16+
taskId: string
17+
userId: string
18+
dueDate: Date
19+
}): string {
20+
const ymd = formatUtcYmd(params.dueDate)
21+
return `due:tomorrow:${params.taskId}:${params.userId}:${ymd}`
22+
}
23+
24+
/**
25+
* 扫描未完成任务中「今日 / 明日」到期(UTC 日界),写入站内通知;重复键跳过。
26+
* 由 Vercel Cron 或本地带 `Authorization: Bearer CRON_SECRET` 调用。
27+
*/
28+
export async function runDueReminderNotificationSweep(): Promise<{
29+
insertedToday: number
30+
insertedTomorrow: number
31+
}> {
32+
const [todayTasks, tomorrowTasks] = await Promise.all([
33+
prisma.task.findMany({
34+
where: {
35+
status: { not: 'done' },
36+
dueDate: dueDateFilterFromBucket('today'),
37+
},
38+
select: { id: true, userId: true, dueDate: true },
39+
}),
40+
prisma.task.findMany({
41+
where: {
42+
status: { not: 'done' },
43+
dueDate: dueDateFilterFromBucket('tomorrow'),
44+
},
45+
select: { id: true, userId: true, dueDate: true },
46+
}),
47+
])
48+
49+
let insertedToday = 0
50+
let insertedTomorrow = 0
51+
52+
if (todayTasks.length > 0) {
53+
const r = await prisma.notification.createMany({
54+
data: todayTasks.map(t => ({
55+
userId: t.userId,
56+
taskId: t.id,
57+
kind: 'due_today' as const,
58+
commentId: null,
59+
dedupeKey: dueTodayDedupeKey({
60+
taskId: t.id,
61+
userId: t.userId,
62+
dueDate: t.dueDate!,
63+
}),
64+
})),
65+
skipDuplicates: true,
66+
})
67+
insertedToday = r.count
68+
}
69+
70+
if (tomorrowTasks.length > 0) {
71+
const r = await prisma.notification.createMany({
72+
data: tomorrowTasks.map(t => ({
73+
userId: t.userId,
74+
taskId: t.id,
75+
kind: 'due_tomorrow' as const,
76+
commentId: null,
77+
dedupeKey: dueTomorrowDedupeKey({
78+
taskId: t.id,
79+
userId: t.userId,
80+
dueDate: t.dueDate!,
81+
}),
82+
})),
83+
skipDuplicates: true,
84+
})
85+
insertedTomorrow = r.count
86+
}
87+
88+
return { insertedToday, insertedTomorrow }
89+
}

0 commit comments

Comments
 (0)