mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat: v2.3.0 T1 — 站内信系统 (in-app notifications) (#307)
* 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>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `notifications` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`body` text NOT NULL DEFAULT '',
|
||||
`ref_type` text,
|
||||
`ref_id` text,
|
||||
`metadata` text,
|
||||
`read_at` integer,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notifications_user_created_idx` ON `notifications` (`user_id`,`created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notifications_user_read_idx` ON `notifications` (`user_id`,`read_at`);
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1745000000000,
|
||||
"tag": "0010_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1745100000000,
|
||||
"tag": "0011_notifications",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { Platform } from './platform/interface'
|
||||
import authProviders from './routes/auth-providers'
|
||||
import emailConfig from './routes/email-config'
|
||||
import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes'
|
||||
import { notifications } from './routes/notifications'
|
||||
import objects from './routes/objects'
|
||||
import profile from './routes/profile'
|
||||
import { adminQuotas, userQuotas } from './routes/quotas'
|
||||
@@ -61,6 +62,7 @@ export function createApp(platform: Platform, auth: Auth) {
|
||||
app.route('/api/quotas', userQuotas)
|
||||
app.route('/api/system', system)
|
||||
app.route('/api/auth-providers', authProviders)
|
||||
app.route('/api/notifications', notifications)
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }))
|
||||
|
||||
@@ -85,3 +87,4 @@ export type AuthProvidersRoute = typeof authProviders
|
||||
export type ProfileRoute = typeof profile
|
||||
export type TeamsRoute = typeof teams
|
||||
export type PublicTeamsRoute = typeof publicTeams
|
||||
export type NotificationsRoute = typeof notifications
|
||||
|
||||
@@ -68,6 +68,26 @@ export const teamInviteLinks = sqliteTable('team_invite_links', {
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
|
||||
export const notifications = sqliteTable(
|
||||
'notifications',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
type: text('type').notNull(), // e.g. 'share_received'
|
||||
title: text('title').notNull(),
|
||||
body: text('body').notNull().default(''),
|
||||
refType: text('ref_type'), // e.g. 'share'
|
||||
refId: text('ref_id'),
|
||||
metadata: text('metadata'), // JSON string for extra context
|
||||
readAt: integer('read_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('notifications_user_created_idx').on(t.userId, t.createdAt),
|
||||
index('notifications_user_read_idx').on(t.userId, t.readAt),
|
||||
],
|
||||
)
|
||||
|
||||
export const activityEvents = sqliteTable('activity_events', {
|
||||
id: text('id').primaryKey(),
|
||||
orgId: text('org_id').notNull(),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { env } from 'cloudflare:workers'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../app'
|
||||
import { createAuth } from '../auth'
|
||||
import { createCloudflarePlatform } from '../platform/cloudflare'
|
||||
|
||||
async function buildApp() {
|
||||
const platform = createCloudflarePlatform(env)
|
||||
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
|
||||
return createApp(platform, auth)
|
||||
}
|
||||
|
||||
async function authedHeaders(app: ReturnType<typeof buildApp>) {
|
||||
const email = `cf-notif-${Date.now()}@example.com`
|
||||
const res = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test', email, password: 'password123456' }),
|
||||
})
|
||||
const cookies = res.headers.getSetCookie()
|
||||
return { Cookie: cookies.join('; ') }
|
||||
}
|
||||
|
||||
describe('[CF] Notifications API', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const app = await buildApp()
|
||||
const res = await app.request('/api/notifications')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /api/notifications returns empty list', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/notifications', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; unreadCount: number }
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.unreadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('GET /api/notifications/unread-count returns 0', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/notifications/unread-count', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { count: number }
|
||||
expect(body.count).toBe(0)
|
||||
})
|
||||
|
||||
it('POST /api/notifications/read-all returns count 0 when empty', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { count: number }
|
||||
expect(body.count).toBe(0)
|
||||
})
|
||||
|
||||
it('POST /api/notifications/nonexistent/read returns 404', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/notifications/nonexistent/read', { method: 'POST', headers })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createNotification } from '../services/notification.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
|
||||
|
||||
async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) {
|
||||
const id = overrides.id ?? nanoid()
|
||||
await db.insert(authSchema.user).values({
|
||||
id,
|
||||
name: 'Test User',
|
||||
email: overrides.email ?? `${id}@example.com`,
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
async function signUpAndGetUser(app: TestApp, email: string) {
|
||||
const res = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test User', email, password: 'password123456' }),
|
||||
})
|
||||
const headers = { Cookie: res.headers.getSetCookie().join('; ') }
|
||||
const body = (await res.json()) as { user?: { id: string } }
|
||||
return { headers, userId: body.user?.id ?? '' }
|
||||
}
|
||||
|
||||
// ─── Auth guard ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/notifications (auth guard)', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/notifications')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GET /api/notifications ───────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/notifications', () => {
|
||||
it('returns empty list for a new user', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
const res = await app.request('/api/notifications', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; unreadCount: number }
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.total).toBe(0)
|
||||
expect(body.unreadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('returns notifications with pagination', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createNotification(db, { userId, type: 'test', title: `Notification ${i}` })
|
||||
}
|
||||
|
||||
const res = await app.request('/api/notifications?page=1&pageSize=3', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
|
||||
expect(body.items).toHaveLength(3)
|
||||
expect(body.total).toBe(5)
|
||||
expect(body.page).toBe(1)
|
||||
expect(body.pageSize).toBe(3)
|
||||
})
|
||||
|
||||
it('filters unread notifications', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'test', title: 'Read' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'Unread' })
|
||||
|
||||
await app.request(`/api/notifications/${n1.id}/read`, { method: 'POST', headers })
|
||||
|
||||
const res = await app.request('/api/notifications?unread=true', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: Array<{ title: string }> }
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0].title).toBe('Unread')
|
||||
})
|
||||
|
||||
it('does not return other users notifications', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const otherId = await insertUser(db)
|
||||
await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
|
||||
|
||||
const res = await app.request('/api/notifications', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[] }
|
||||
expect(body.items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GET /api/notifications/unread-count ─────────────────────────────────────
|
||||
|
||||
describe('GET /api/notifications/unread-count', () => {
|
||||
it('returns correct count', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
|
||||
const res = await app.request('/api/notifications/unread-count', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { count: number }
|
||||
expect(body.count).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── POST /api/notifications/:id/read ────────────────────────────────────────
|
||||
|
||||
describe('POST /api/notifications/:id/read', () => {
|
||||
it('marks notification as read and returns 204', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
|
||||
|
||||
const res = await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
|
||||
expect(res.status).toBe(204)
|
||||
|
||||
const countRes = await app.request('/api/notifications/unread-count', { headers })
|
||||
const body = (await countRes.json()) as { count: number }
|
||||
expect(body.count).toBe(0)
|
||||
})
|
||||
|
||||
it('is idempotent', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
|
||||
|
||||
await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
|
||||
const res = await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
|
||||
it('returns 404 for a notification owned by another user', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const otherId = await insertUser(db)
|
||||
const n = await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
|
||||
|
||||
const res = await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 for a non-existent id', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
const res = await app.request('/api/notifications/nonexistent/read', { method: 'POST', headers })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── POST /api/notifications/read-all ────────────────────────────────────────
|
||||
|
||||
describe('POST /api/notifications/read-all', () => {
|
||||
it('marks all notifications as read and returns count', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
|
||||
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { count: number }
|
||||
expect(body.count).toBe(2)
|
||||
|
||||
const countRes = await app.request('/api/notifications/unread-count', { headers })
|
||||
const countBody = (await countRes.json()) as { count: number }
|
||||
expect(countBody.count).toBe(0)
|
||||
})
|
||||
|
||||
it('only affects the current user', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const otherId = await insertUser(db)
|
||||
await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
|
||||
|
||||
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { count: number }
|
||||
expect(body.count).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 when nothing to mark', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { count: number }
|
||||
expect(body.count).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { listNotificationsQuerySchema } from '../../shared/schemas'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { listNotifications, markAllAsRead, markAsRead, unreadCount } from '../services/notification'
|
||||
|
||||
export const notifications = new Hono<Env>()
|
||||
.use(requireAuth)
|
||||
.get('/', zValidator('query', listNotificationsQuerySchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const { page: pageStr, pageSize: pageSizeStr, unread } = c.req.valid('query')
|
||||
const page = Number(pageStr ?? '1')
|
||||
const pageSize = Number(pageSizeStr ?? '20')
|
||||
const unreadOnly = unread === 'true'
|
||||
|
||||
const result = await listNotifications(db, userId, { page, pageSize, unreadOnly })
|
||||
return c.json({ ...result, page, pageSize })
|
||||
})
|
||||
.get('/unread-count', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const count = await unreadCount(db, userId)
|
||||
return c.json({ count })
|
||||
})
|
||||
.post('/:id/read', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const { id } = c.req.param()
|
||||
|
||||
const found = await markAsRead(db, userId, id)
|
||||
if (!found) return c.json({ error: 'Not found' }, 404)
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
.post('/read-all', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const result = await markAllAsRead(db, userId)
|
||||
return c.json(result)
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import {
|
||||
createNotification,
|
||||
listNotifications,
|
||||
markAllAsRead,
|
||||
markAsRead,
|
||||
unreadCount,
|
||||
} from '../services/notification.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) {
|
||||
const id = overrides.id ?? nanoid()
|
||||
await db.insert(authSchema.user).values({
|
||||
id,
|
||||
name: 'Test User',
|
||||
email: overrides.email ?? `${id}@example.com`,
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
describe('createNotification', () => {
|
||||
it('writes a row and returns it', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'You got a share' })
|
||||
|
||||
expect(n.id).toBeDefined()
|
||||
expect(n.userId).toBe(userId)
|
||||
expect(n.type).toBe('share_received')
|
||||
expect(n.title).toBe('You got a share')
|
||||
expect(n.body).toBe('')
|
||||
expect(n.readAt).toBeNull()
|
||||
expect(n.createdAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('stores optional fields', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n = await createNotification(db, {
|
||||
userId,
|
||||
type: 'share_received',
|
||||
title: 'Test',
|
||||
body: 'body text',
|
||||
refType: 'share',
|
||||
refId: 'ref-1',
|
||||
metadata: JSON.stringify({ token: 'abc' }),
|
||||
})
|
||||
|
||||
expect(n.body).toBe('body text')
|
||||
expect(n.refType).toBe('share')
|
||||
expect(n.refId).toBe('ref-1')
|
||||
expect(n.metadata).toBe(JSON.stringify({ token: 'abc' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotifications', () => {
|
||||
it('returns empty list for new user', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
|
||||
|
||||
expect(result.items).toHaveLength(0)
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.unreadCount).toBe(0)
|
||||
})
|
||||
|
||||
it('paginates correctly', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createNotification(db, { userId, type: 'test', title: `Notification ${i}` })
|
||||
}
|
||||
|
||||
const page1 = await listNotifications(db, userId, { page: 1, pageSize: 3 })
|
||||
expect(page1.items).toHaveLength(3)
|
||||
expect(page1.total).toBe(5)
|
||||
|
||||
const page2 = await listNotifications(db, userId, { page: 2, pageSize: 3 })
|
||||
expect(page2.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns accurate unreadCount regardless of filter', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
await markAsRead(db, userId, n1.id)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.unreadCount).toBe(1)
|
||||
})
|
||||
|
||||
it('filters unread only when requested', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
await markAsRead(db, userId, n1.id)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20, unreadOnly: true })
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].title).toBe('B')
|
||||
})
|
||||
|
||||
it('isolates between users', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId: user1, type: 'test', title: 'For user1' })
|
||||
|
||||
const result = await listNotifications(db, user2, { page: 1, pageSize: 20 })
|
||||
expect(result.items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markAsRead', () => {
|
||||
it('marks a notification as read (idempotent)', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
|
||||
|
||||
const first = await markAsRead(db, userId, n.id)
|
||||
expect(first).toBe(true)
|
||||
|
||||
const second = await markAsRead(db, userId, n.id)
|
||||
expect(second).toBe(true)
|
||||
|
||||
const count = await unreadCount(db, userId)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
it('returns false for a cross-user attempt', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const owner = await insertUser(db)
|
||||
const other = await insertUser(db)
|
||||
const n = await createNotification(db, { userId: owner, type: 'test', title: 'Test' })
|
||||
|
||||
const result = await markAsRead(db, other, n.id)
|
||||
expect(result).toBe(false)
|
||||
|
||||
const count = await unreadCount(db, owner)
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markAllAsRead', () => {
|
||||
it('marks all unread notifications and returns count', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
|
||||
const result = await markAllAsRead(db, userId)
|
||||
expect(result.count).toBe(2)
|
||||
|
||||
const count = await unreadCount(db, userId)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
it('only affects the requesting user', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId: user1, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId: user2, type: 'test', title: 'B' })
|
||||
|
||||
await markAllAsRead(db, user1)
|
||||
|
||||
expect(await unreadCount(db, user1)).toBe(0)
|
||||
expect(await unreadCount(db, user2)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 when nothing to mark', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await markAllAsRead(db, userId)
|
||||
expect(result.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unreadCount', () => {
|
||||
it('returns correct count', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
expect(await unreadCount(db, userId)).toBe(0)
|
||||
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
|
||||
expect(await unreadCount(db, userId)).toBe(2)
|
||||
|
||||
await markAsRead(db, userId, n.id)
|
||||
expect(await unreadCount(db, userId)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
}
|
||||
@@ -193,6 +193,20 @@ const APP_SCHEMA_SQL = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id);
|
||||
CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id);
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
ref_type TEXT,
|
||||
ref_id TEXT,
|
||||
metadata TEXT,
|
||||
read_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_read_idx ON notifications(user_id, read_at);
|
||||
`
|
||||
|
||||
export async function createTestApp() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type { ListNotificationsQuery } from './notification'
|
||||
export { listNotificationsQuerySchema } from './notification'
|
||||
export type { CreateShareInput, ShareKind } from './share'
|
||||
export { createShareSchema, listSharesQuerySchema, shareKindSchema, shareRecipientSchema } from './share'
|
||||
export type { CreateStorageInput, UpdateStorageInput } from './storage'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const listNotificationsQuerySchema = z.object({
|
||||
page: z.string().optional(),
|
||||
pageSize: z.string().optional(),
|
||||
unread: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ListNotificationsQuery = z.infer<typeof listNotificationsQuerySchema>
|
||||
@@ -118,6 +118,19 @@ export interface ShareRecipient {
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
userId: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
refType: string | null
|
||||
refId: string | null
|
||||
metadata: string | null
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string
|
||||
orgId: string
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import {
|
||||
@@ -185,46 +186,49 @@ export function AppSidebar() {
|
||||
<SidebarFooter className="border-t p-3">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="bg-sidebar-primary text-sidebar-primary-foreground text-xs font-semibold">
|
||||
{user ? getInitials(user.name || user.username || '?') : '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate text-left font-medium">{user?.name || user?.username}</span>
|
||||
<ChevronsUpDown className="ml-auto size-4 opacity-60" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="w-56">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/teams">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
{t('nav.teams')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="flex-1 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="bg-sidebar-primary text-sidebar-primary-foreground text-xs font-semibold">
|
||||
{user ? getInitials(user.name || user.username || '?') : '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate text-left font-medium">{user?.name || user?.username}</span>
|
||||
<ChevronsUpDown className="ml-auto size-4 opacity-60" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="w-56">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/storages">
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{t('nav.adminPanel')}
|
||||
<Link to="/settings">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('auth.signOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/teams">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
{t('nav.teams')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/storages">
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{t('nav.adminPanel')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('auth.signOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Tests for notification-bell.tsx — covers pure display logic.
|
||||
// React rendering is not available (no jsdom), so we test the badge logic directly.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Mirrors the badge display logic in NotificationBell:
|
||||
// const displayCount = count > 9 ? '9+' : count > 0 ? String(count) : null
|
||||
function badgeLabel(count: number): string | null {
|
||||
if (count > 9) return '9+'
|
||||
if (count > 0) return String(count)
|
||||
return null
|
||||
}
|
||||
|
||||
describe('NotificationBell — badge label', () => {
|
||||
it('returns null when count is 0 (no badge shown)', () => {
|
||||
expect(badgeLabel(0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the count as a string for 1', () => {
|
||||
expect(badgeLabel(1)).toBe('1')
|
||||
})
|
||||
|
||||
it('returns the count as a string for 9', () => {
|
||||
expect(badgeLabel(9)).toBe('9')
|
||||
})
|
||||
|
||||
it('returns "9+" for counts greater than 9', () => {
|
||||
expect(badgeLabel(10)).toBe('9+')
|
||||
expect(badgeLabel(99)).toBe('9+')
|
||||
expect(badgeLabel(1000)).toBe('9+')
|
||||
})
|
||||
|
||||
it('caps at "9+" regardless of how large the count is', () => {
|
||||
expect(badgeLabel(Number.MAX_SAFE_INTEGER)).toBe('9+')
|
||||
})
|
||||
})
|
||||
|
||||
// Polling interval constant — mirrors UNREAD_POLL_INTERVAL in notification-bell.tsx
|
||||
const UNREAD_POLL_INTERVAL = 30_000
|
||||
|
||||
describe('NotificationBell — polling interval', () => {
|
||||
it('polls every 30 seconds', () => {
|
||||
expect(UNREAD_POLL_INTERVAL).toBe(30_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { getUnreadCount } from '@/lib/api'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { NotificationDropdown } from './notification-dropdown'
|
||||
|
||||
const UNREAD_POLL_INTERVAL = 30_000
|
||||
|
||||
export function NotificationBell() {
|
||||
const { data: session } = useSession()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: getUnreadCount,
|
||||
enabled: !!session,
|
||||
refetchInterval: UNREAD_POLL_INTERVAL,
|
||||
})
|
||||
|
||||
const count = data?.count ?? 0
|
||||
const displayCount = count > 9 ? '9+' : count > 0 ? String(count) : null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative h-8 w-8" aria-label="Notifications">
|
||||
<Bell className="h-4 w-4" />
|
||||
{displayCount && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-0.5 text-[10px] font-bold text-destructive-foreground leading-none">
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<NotificationDropdown />
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Tests for notification-dropdown.tsx — covers pure display logic.
|
||||
// React rendering is not available (no jsdom), so we test extracted logic directly.
|
||||
|
||||
import type { Notification } from '@shared/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: 'n1',
|
||||
userId: 'u1',
|
||||
type: 'share_received',
|
||||
title: 'Test',
|
||||
body: '',
|
||||
refType: null,
|
||||
refId: null,
|
||||
metadata: null,
|
||||
readAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── "Mark all as read" visibility ───────────────────────────────────────────
|
||||
// Mirrors the `hasUnread` check: const hasUnread = (data?.unreadCount ?? 0) > 0
|
||||
|
||||
function shouldShowMarkAllRead(unreadCount: number | undefined): boolean {
|
||||
return (unreadCount ?? 0) > 0
|
||||
}
|
||||
|
||||
describe('NotificationDropdown — mark all read visibility', () => {
|
||||
it('is hidden when unreadCount is 0', () => {
|
||||
expect(shouldShowMarkAllRead(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('is hidden when unreadCount is undefined', () => {
|
||||
expect(shouldShowMarkAllRead(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('is visible when unreadCount is > 0', () => {
|
||||
expect(shouldShowMarkAllRead(1)).toBe(true)
|
||||
expect(shouldShowMarkAllRead(5)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
function hasItems(items: Notification[]): boolean {
|
||||
return items.length > 0
|
||||
}
|
||||
|
||||
describe('NotificationDropdown — empty state', () => {
|
||||
it('shows empty state when there are no items', () => {
|
||||
expect(hasItems([])).toBe(false)
|
||||
})
|
||||
|
||||
it('shows items list when there are notifications', () => {
|
||||
expect(hasItems([makeNotification()])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Query key contract ───────────────────────────────────────────────────────
|
||||
|
||||
const NOTIFICATIONS_QUERY_KEY = ['notifications', 'list']
|
||||
|
||||
describe('NotificationDropdown — query key', () => {
|
||||
it('uses ["notifications", "list"] as the query key', () => {
|
||||
expect(NOTIFICATIONS_QUERY_KEY).toEqual(['notifications', 'list'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { listNotifications, markAllNotificationsRead } from '@/lib/api'
|
||||
import { NotificationItem } from './notification-item'
|
||||
|
||||
export function NotificationDropdown() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['notifications', 'list'],
|
||||
queryFn: () => listNotifications(1, 10),
|
||||
})
|
||||
|
||||
const items = data?.items ?? []
|
||||
const hasUnread = (data?.unreadCount ?? 0) > 0
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await markAllNotificationsRead()
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
}
|
||||
|
||||
function handleItemRead() {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<DropdownMenuLabel className="p-0 text-sm font-semibold">{t('notification.title')}</DropdownMenuLabel>
|
||||
{hasUnread && (
|
||||
<Button variant="ghost" size="sm" className="h-auto py-0 px-1 text-xs" onClick={handleMarkAllRead}>
|
||||
{t('notification.markAllRead')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('notification.empty')}</p>
|
||||
) : (
|
||||
items.map((item) => <NotificationItem key={item.id} notification={item} onRead={handleItemRead} />)
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Tests for notification-item.tsx — covers pure logic extracted from the component.
|
||||
// React rendering is not available (no jsdom), so we test the functions directly.
|
||||
|
||||
import type { Notification } from '@shared/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// ─── resolveHref ─────────────────────────────────────────────────────────────
|
||||
// Mirrors the resolveHref function in notification-item.tsx
|
||||
|
||||
function resolveHref(notification: Notification): string | null {
|
||||
if (notification.refType === 'share' && notification.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(notification.metadata) as { token?: string }
|
||||
if (meta.token) return `/s/${meta.token}`
|
||||
} catch {
|
||||
// ignore malformed metadata
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: 'n1',
|
||||
userId: 'u1',
|
||||
type: 'share_received',
|
||||
title: 'Test',
|
||||
body: '',
|
||||
refType: null,
|
||||
refId: null,
|
||||
metadata: null,
|
||||
readAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveHref', () => {
|
||||
it('returns /s/:token when refType is share and metadata has token', () => {
|
||||
const n = makeNotification({ refType: 'share', metadata: JSON.stringify({ token: 'abc123' }) })
|
||||
expect(resolveHref(n)).toBe('/s/abc123')
|
||||
})
|
||||
|
||||
it('returns null when refType is not share', () => {
|
||||
const n = makeNotification({ refType: 'other', metadata: JSON.stringify({ token: 'abc' }) })
|
||||
expect(resolveHref(n)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when metadata is null', () => {
|
||||
const n = makeNotification({ refType: 'share', metadata: null })
|
||||
expect(resolveHref(n)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when metadata has no token field', () => {
|
||||
const n = makeNotification({ refType: 'share', metadata: JSON.stringify({ other: 'data' }) })
|
||||
expect(resolveHref(n)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed metadata JSON without crashing', () => {
|
||||
const n = makeNotification({ refType: 'share', metadata: 'not-json' })
|
||||
expect(resolveHref(n)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when refType is null', () => {
|
||||
const n = makeNotification({ refType: null, metadata: JSON.stringify({ token: 'abc' }) })
|
||||
expect(resolveHref(n)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── diffMinutes ──────────────────────────────────────────────────────────────
|
||||
|
||||
function diffMinutes(dateStr: string): number {
|
||||
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 60_000)
|
||||
}
|
||||
|
||||
describe('diffMinutes', () => {
|
||||
it('returns 0 for a timestamp within the last minute', () => {
|
||||
const now = new Date(Date.now() - 30_000).toISOString()
|
||||
expect(diffMinutes(now)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 5 for a timestamp 5 minutes ago', () => {
|
||||
const fiveMinsAgo = new Date(Date.now() - 5 * 60_000).toISOString()
|
||||
expect(diffMinutes(fiveMinsAgo)).toBe(5)
|
||||
})
|
||||
|
||||
it('returns 60 for a timestamp 1 hour ago', () => {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60_000).toISOString()
|
||||
expect(diffMinutes(oneHourAgo)).toBe(60)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isUnread ─────────────────────────────────────────────────────────────────
|
||||
// Mirrors the `isUnread = !notification.readAt` check
|
||||
|
||||
describe('isUnread', () => {
|
||||
it('is true when readAt is null', () => {
|
||||
const n = makeNotification({ readAt: null })
|
||||
expect(!n.readAt).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when readAt is set', () => {
|
||||
const n = makeNotification({ readAt: new Date().toISOString() })
|
||||
expect(!n.readAt).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Title style — bold for unread ────────────────────────────────────────────
|
||||
|
||||
function titleClass(isUnread: boolean): string {
|
||||
return isUnread ? 'font-semibold' : 'font-medium'
|
||||
}
|
||||
|
||||
describe('title style', () => {
|
||||
it('uses font-semibold for unread notifications', () => {
|
||||
expect(titleClass(true)).toBe('font-semibold')
|
||||
})
|
||||
|
||||
it('uses font-medium for read notifications', () => {
|
||||
expect(titleClass(false)).toBe('font-medium')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Notification } from '@shared/types'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { markNotificationRead } from '@/lib/api'
|
||||
|
||||
function diffMinutes(dateStr: string): number {
|
||||
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 60_000)
|
||||
}
|
||||
|
||||
function resolveHref(notification: Notification): string | null {
|
||||
if (notification.refType === 'share' && notification.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(notification.metadata) as { token?: string }
|
||||
if (meta.token) return `/s/${meta.token}`
|
||||
} catch {
|
||||
// ignore malformed metadata
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification
|
||||
onRead: () => void
|
||||
}
|
||||
|
||||
export function NotificationItem({ notification, onRead }: NotificationItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isUnread = !notification.readAt
|
||||
const href = resolveHref(notification)
|
||||
|
||||
function relativeTime(): string {
|
||||
const mins = diffMinutes(notification.createdAt)
|
||||
if (mins < 1) return t('notification.justNow')
|
||||
if (mins < 60) return t('notification.minutesAgo', { count: mins })
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return t('notification.hoursAgo', { count: hours })
|
||||
return t('notification.daysAgo', { count: Math.floor(hours / 24) })
|
||||
}
|
||||
|
||||
async function handleClick() {
|
||||
if (isUnread) {
|
||||
await markNotificationRead(notification.id).catch(() => undefined)
|
||||
onRead()
|
||||
}
|
||||
if (href) navigate({ to: href })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-accent transition-colors ${isUnread ? 'bg-accent/30' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isUnread && <span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />}
|
||||
{!isUnread && <span className="mt-1.5 h-2 w-2 shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm truncate ${isUnread ? 'font-semibold' : 'font-medium'}`}>{notification.title}</p>
|
||||
{notification.body && <p className="text-xs text-muted-foreground truncate">{notification.body}</p>}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{relativeTime()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -371,5 +371,13 @@
|
||||
"activity.target.folder": "folder",
|
||||
"activity.meta.from": "from",
|
||||
"activity.meta.to": "to",
|
||||
"activity.loadMore": "Load more"
|
||||
"activity.loadMore": "Load more",
|
||||
"notification.title": "Notifications",
|
||||
"notification.markAllRead": "Mark all as read",
|
||||
"notification.empty": "No notifications yet",
|
||||
"notification.viewAll": "View all",
|
||||
"notification.justNow": "Just now",
|
||||
"notification.minutesAgo": "{{count}}m ago",
|
||||
"notification.hoursAgo": "{{count}}h ago",
|
||||
"notification.daysAgo": "{{count}}d ago"
|
||||
}
|
||||
|
||||
@@ -371,5 +371,13 @@
|
||||
"activity.target.folder": "文件夹",
|
||||
"activity.meta.from": "从",
|
||||
"activity.meta.to": "到",
|
||||
"activity.loadMore": "加载更多"
|
||||
"activity.loadMore": "加载更多",
|
||||
"notification.title": "通知",
|
||||
"notification.markAllRead": "全部标为已读",
|
||||
"notification.empty": "暂无通知",
|
||||
"notification.viewAll": "查看全部",
|
||||
"notification.justNow": "刚刚",
|
||||
"notification.minutesAgo": "{{count}}分钟前",
|
||||
"notification.hoursAgo": "{{count}}小时前",
|
||||
"notification.daysAgo": "{{count}}天前"
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ import {
|
||||
getSession,
|
||||
getStorage,
|
||||
getSystemOption,
|
||||
getUnreadCount,
|
||||
getUserQuota,
|
||||
listAuthProviders,
|
||||
listNotifications,
|
||||
listObjects,
|
||||
listQuotas,
|
||||
listStorages,
|
||||
listSystemOptions,
|
||||
listUsers,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
restoreObject,
|
||||
setSystemOption,
|
||||
trashObject,
|
||||
@@ -897,4 +901,92 @@ describe('api', () => {
|
||||
await expect(getProfile('nobody')).rejects.toThrow('User not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotifications', () => {
|
||||
it('calls /api/notifications with default params', async () => {
|
||||
const payload = { items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await listNotifications()
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('/api/notifications')
|
||||
expect(url).toContain('page=1')
|
||||
expect(url).toContain('pageSize=20')
|
||||
expect(url).toContain('unread=false')
|
||||
})
|
||||
|
||||
it('passes page, pageSize, and unreadOnly params', async () => {
|
||||
const payload = { items: [], total: 5, unreadCount: 5, page: 2, pageSize: 10 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await listNotifications(2, 10, true)
|
||||
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('page=2')
|
||||
expect(url).toContain('pageSize=10')
|
||||
expect(url).toContain('unread=true')
|
||||
})
|
||||
|
||||
it('throws on error response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
||||
|
||||
await expect(listNotifications()).rejects.toThrow('unauthorized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('calls /api/notifications/unread-count and returns count', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ count: 3 }))
|
||||
|
||||
const result = await getUnreadCount()
|
||||
|
||||
expect(result).toEqual({ count: 3 })
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('/api/notifications/unread-count')
|
||||
})
|
||||
|
||||
it('throws on error response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
||||
|
||||
await expect(getUnreadCount()).rejects.toThrow('unauthorized')
|
||||
})
|
||||
})
|
||||
|
||||
describe('markNotificationRead', () => {
|
||||
it('posts to /api/notifications/:id/read and resolves on 204', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
|
||||
|
||||
await expect(markNotificationRead('notif-1')).resolves.toBeUndefined()
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/notifications/notif-1/read')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('throws ApiError on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response)
|
||||
|
||||
await expect(markNotificationRead('missing')).rejects.toThrow('Not Found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('markAllNotificationsRead', () => {
|
||||
it('posts to /api/notifications/read-all and returns count', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ count: 5 }))
|
||||
|
||||
const result = await markAllNotificationsRead()
|
||||
|
||||
expect(result).toEqual({ count: 5 })
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/notifications/read-all')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('throws on error response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
|
||||
|
||||
await expect(markAllNotificationsRead()).rejects.toThrow('unauthorized')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+41
-1
@@ -1,11 +1,19 @@
|
||||
import type { OAuthProviderConfig } from '@shared/oauth-providers'
|
||||
import type { ConflictStrategy, CreateStorageInput, UpdateStorageInput } from '@shared/schemas'
|
||||
import type { ActivityEvent, AuthProvider, PaginatedResponse, Storage, StorageObject } from '@shared/types'
|
||||
import type {
|
||||
ActivityEvent,
|
||||
AuthProvider,
|
||||
Notification,
|
||||
PaginatedResponse,
|
||||
Storage,
|
||||
StorageObject,
|
||||
} from '@shared/types'
|
||||
import {
|
||||
adminQuotas,
|
||||
authProviders,
|
||||
emailConfig,
|
||||
inviteCodes,
|
||||
notificationsApi,
|
||||
objects,
|
||||
profiles,
|
||||
storages,
|
||||
@@ -329,6 +337,38 @@ export function listTeamActivities(teamId: string, page = 1, pageSize = 20) {
|
||||
)
|
||||
}
|
||||
|
||||
// Notifications API
|
||||
|
||||
export type NotificationListResult = {
|
||||
items: Notification[]
|
||||
total: number
|
||||
unreadCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function listNotifications(page = 1, pageSize = 20, unreadOnly = false) {
|
||||
return unwrap<NotificationListResult>(
|
||||
notificationsApi.index.$get({
|
||||
query: { page: String(page), pageSize: String(pageSize), unread: String(unreadOnly) },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getUnreadCount() {
|
||||
return unwrap<{ count: number }>(notificationsApi['unread-count'].$get())
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string) {
|
||||
return notificationsApi[':id'].read.$post({ param: { id } }).then((res) => {
|
||||
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
|
||||
})
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead() {
|
||||
return unwrap<{ count: number }>(notificationsApi['read-all'].$post())
|
||||
}
|
||||
|
||||
// Auth API — Better Auth passthrough, not typed via Hono RPC
|
||||
export async function getSession(): Promise<{ session: unknown; user: unknown } | null> {
|
||||
const res = await fetch('/api/auth/get-session', { credentials: 'include' })
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AdminQuotasRoute,
|
||||
AuthProvidersRoute,
|
||||
EmailConfigRoute,
|
||||
NotificationsRoute,
|
||||
ObjectsRoute,
|
||||
ProfileRoute,
|
||||
PublicTeamsRoute,
|
||||
@@ -30,3 +31,4 @@ export const emailConfig = hc<EmailConfigRoute>('/api/admin/email-config', opts)
|
||||
export const profiles = hc<ProfileRoute>('/api/profiles')
|
||||
export const teamsApi = hc<TeamsRoute>('/api/teams', opts)
|
||||
export const publicTeamsApi = hc<PublicTeamsRoute>('/api/teams')
|
||||
export const notificationsApi = hc<NotificationsRoute>('/api/notifications', opts)
|
||||
|
||||
Reference in New Issue
Block a user