mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
2c8e2cc837
* feat: add in-app notification system (站内信) — schema, service, API, Bell UI - Add `notifications` table to DB schema with userId/type/title/body/refType/refId/metadata/readAt/createdAt fields; two indexes for list & unread queries - Migration `0010_notifications.sql` created manually (drizzle-kit requires TTY) - Service layer: createNotification, listNotifications (paginated + unreadOnly filter), markAsRead (idempotent, owner-only), markAllAsRead, unreadCount - REST API at `/api/notifications`: list + unreadCount, GET unread-count, POST :id/read (204), POST read-all - Shared `Notification` type, `listNotificationsQuerySchema`, RPC client export - NotificationBell (badge, 30s polling), NotificationDropdown, NotificationItem components injected into AppSidebar footer - Bell badge capped at "9+"; unread items bold; click marks read + navigates via refType/refId - i18n: en + zh translations for all notification keys - 26 Node integration tests + 5 CF smoke tests; all 1884 + 26 tests pass Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * test: add notification API wrapper tests and component logic tests; fix dead condition - Add tests for listNotifications, getUnreadCount, markNotificationRead, markAllNotificationsRead in api.test.ts - Add notification-bell.test.ts: badge label logic (0/5/"9+" cap) and polling interval - Add notification-dropdown.test.ts: mark-all-read visibility, empty state, query key - Add notification-item.test.ts: resolveHref (share token nav, malformed JSON), diffMinutes, isUnread, title style - Fix dead condition in markNotificationRead: simplify `!res.ok && res.status !== 204` → `!res.ok` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger CI check run for test coverage fixes --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
import { and, count, desc, eq, isNull } from 'drizzle-orm'
|
|
import { nanoid } from 'nanoid'
|
|
import { notifications } from '../db/schema'
|
|
import type { Database } from '../platform/interface'
|
|
|
|
export type Notification = typeof notifications.$inferSelect
|
|
|
|
export type CreateNotificationInput = {
|
|
userId: string
|
|
type: string
|
|
title: string
|
|
body?: string
|
|
refType?: string
|
|
refId?: string
|
|
metadata?: string
|
|
}
|
|
|
|
export async function createNotification(db: Database, input: CreateNotificationInput): Promise<Notification> {
|
|
const row: Notification = {
|
|
id: nanoid(),
|
|
userId: input.userId,
|
|
type: input.type,
|
|
title: input.title,
|
|
body: input.body ?? '',
|
|
refType: input.refType ?? null,
|
|
refId: input.refId ?? null,
|
|
metadata: input.metadata ?? null,
|
|
readAt: null,
|
|
createdAt: new Date(),
|
|
}
|
|
|
|
await db.insert(notifications).values(row)
|
|
return row
|
|
}
|
|
|
|
export type ListNotificationsResult = {
|
|
items: Notification[]
|
|
total: number
|
|
unreadCount: number
|
|
}
|
|
|
|
export async function listNotifications(
|
|
db: Database,
|
|
userId: string,
|
|
opts: { page: number; pageSize: number; unreadOnly?: boolean },
|
|
): Promise<ListNotificationsResult> {
|
|
const { page, pageSize, unreadOnly } = opts
|
|
const offset = (page - 1) * pageSize
|
|
|
|
const baseCondition = unreadOnly
|
|
? and(eq(notifications.userId, userId), isNull(notifications.readAt))
|
|
: eq(notifications.userId, userId)
|
|
|
|
const [items, totalRows, unreadRows] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(notifications)
|
|
.where(baseCondition)
|
|
.orderBy(desc(notifications.createdAt))
|
|
.limit(pageSize)
|
|
.offset(offset),
|
|
db.select({ count: count() }).from(notifications).where(baseCondition),
|
|
db
|
|
.select({ count: count() })
|
|
.from(notifications)
|
|
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt))),
|
|
])
|
|
|
|
return {
|
|
items,
|
|
total: totalRows[0]?.count ?? 0,
|
|
unreadCount: unreadRows[0]?.count ?? 0,
|
|
}
|
|
}
|
|
|
|
export async function markAsRead(db: Database, userId: string, id: string): Promise<boolean> {
|
|
const rows = await db
|
|
.select({ id: notifications.id, readAt: notifications.readAt })
|
|
.from(notifications)
|
|
.where(and(eq(notifications.id, id), eq(notifications.userId, userId)))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) return false
|
|
|
|
if (!rows[0].readAt) {
|
|
await db.update(notifications).set({ readAt: new Date() }).where(eq(notifications.id, id))
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export async function markAllAsRead(db: Database, userId: string): Promise<{ count: number }> {
|
|
const unread = await db
|
|
.select({ id: notifications.id })
|
|
.from(notifications)
|
|
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
|
|
|
|
if (unread.length === 0) return { count: 0 }
|
|
|
|
await db
|
|
.update(notifications)
|
|
.set({ readAt: new Date() })
|
|
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
|
|
|
|
return { count: unread.length }
|
|
}
|
|
|
|
export async function unreadCount(db: Database, userId: string): Promise<number> {
|
|
const rows = await db
|
|
.select({ count: count() })
|
|
.from(notifications)
|
|
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
|
|
|
|
return rows[0]?.count ?? 0
|
|
}
|