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:
Jasper Van
2026-04-20 01:23:54 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Bob
parent 2affe21c45
commit 2c8e2cc837
25 changed files with 1312 additions and 40 deletions
+3
View File
@@ -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
+20
View File
@@ -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(),
+65
View File
@@ -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)
})
})
+42
View File
@@ -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)
})
})
+115
View File
@@ -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
}
+14
View File
@@ -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() {