diff --git a/migrations/0011_notifications.sql b/migrations/0011_notifications.sql new file mode 100644 index 00000000..97d80ebb --- /dev/null +++ b/migrations/0011_notifications.sql @@ -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`); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 703f40ee..7b08664b 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1745000000000, "tag": "0010_shares", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1745100000000, + "tag": "0011_notifications", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index ecae9e4e..e82d5e68 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 diff --git a/server/db/schema.ts b/server/db/schema.ts index c3463a2e..47ceab45 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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(), diff --git a/server/routes/notifications.cf-test.ts b/server/routes/notifications.cf-test.ts new file mode 100644 index 00000000..8ec24b45 --- /dev/null +++ b/server/routes/notifications.cf-test.ts @@ -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) { + 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) + }) +}) diff --git a/server/routes/notifications.integration.test.ts b/server/routes/notifications.integration.test.ts new file mode 100644 index 00000000..71ebf8dc --- /dev/null +++ b/server/routes/notifications.integration.test.ts @@ -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>['db'] +type TestApp = Awaited>['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) + }) +}) diff --git a/server/routes/notifications.ts b/server/routes/notifications.ts new file mode 100644 index 00000000..f79d0f40 --- /dev/null +++ b/server/routes/notifications.ts @@ -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() + .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) + }) diff --git a/server/services/notification.integration.test.ts b/server/services/notification.integration.test.ts new file mode 100644 index 00000000..95b0784b --- /dev/null +++ b/server/services/notification.integration.test.ts @@ -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>['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) + }) +}) diff --git a/server/services/notification.ts b/server/services/notification.ts new file mode 100644 index 00000000..6788675c --- /dev/null +++ b/server/services/notification.ts @@ -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 { + 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 { + 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 { + 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 { + const rows = await db + .select({ count: count() }) + .from(notifications) + .where(and(eq(notifications.userId, userId), isNull(notifications.readAt))) + + return rows[0]?.count ?? 0 +} diff --git a/server/test/setup.ts b/server/test/setup.ts index 567703ae..4a5411dd 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -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() { diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index cc7c1ba5..aa033e74 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -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' diff --git a/shared/schemas/notification.ts b/shared/schemas/notification.ts new file mode 100644 index 00000000..5a64de52 --- /dev/null +++ b/shared/schemas/notification.ts @@ -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 diff --git a/shared/types/index.ts b/shared/types/index.ts index 83166ba6..e5569d26 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -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 diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index d146e261..fe90c80f 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -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() { - - - - - - {user ? getInitials(user.name || user.username || '?') : '?'} - - - {user?.name || user?.username} - - - - - - - - {t('nav.settings')} - - - - - - {t('nav.teams')} - - - {isAdmin && ( +
+ + + + + + {user ? getInitials(user.name || user.username || '?') : '?'} + + + {user?.name || user?.username} + + + + - - - {t('nav.adminPanel')} + + + {t('nav.settings')} - )} - - - - {t('auth.signOut')} - - - + + + + {t('nav.teams')} + + + {isAdmin && ( + + + + {t('nav.adminPanel')} + + + )} + + + + {t('auth.signOut')} + + + + +
diff --git a/src/components/notifications/notification-bell.test.ts b/src/components/notifications/notification-bell.test.ts new file mode 100644 index 00000000..c88f4a3c --- /dev/null +++ b/src/components/notifications/notification-bell.test.ts @@ -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) + }) +}) diff --git a/src/components/notifications/notification-bell.tsx b/src/components/notifications/notification-bell.tsx new file mode 100644 index 00000000..f72d7acb --- /dev/null +++ b/src/components/notifications/notification-bell.tsx @@ -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 ( + + + + + + + ) +} diff --git a/src/components/notifications/notification-dropdown.test.ts b/src/components/notifications/notification-dropdown.test.ts new file mode 100644 index 00000000..449a84b0 --- /dev/null +++ b/src/components/notifications/notification-dropdown.test.ts @@ -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 { + 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']) + }) +}) diff --git a/src/components/notifications/notification-dropdown.tsx b/src/components/notifications/notification-dropdown.tsx new file mode 100644 index 00000000..e2f500a2 --- /dev/null +++ b/src/components/notifications/notification-dropdown.tsx @@ -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 ( + +
+ {t('notification.title')} + {hasUnread && ( + + )} +
+ +
+ {items.length === 0 ? ( +

{t('notification.empty')}

+ ) : ( + items.map((item) => ) + )} +
+
+ ) +} diff --git a/src/components/notifications/notification-item.test.ts b/src/components/notifications/notification-item.test.ts new file mode 100644 index 00000000..a9dd7b92 --- /dev/null +++ b/src/components/notifications/notification-item.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/components/notifications/notification-item.tsx b/src/components/notifications/notification-item.tsx new file mode 100644 index 00000000..5d8203ea --- /dev/null +++ b/src/components/notifications/notification-item.tsx @@ -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 ( + + ) +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ff50604c..9435e8c4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index be2e135b..521e8fb5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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}}天前" } diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 4bcb1916..d4b305a5 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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') + }) + }) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 64036123..7b4b6076 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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( + 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' }) diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index 13b15085..cdcee48b 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -3,6 +3,7 @@ import type { AdminQuotasRoute, AuthProvidersRoute, EmailConfigRoute, + NotificationsRoute, ObjectsRoute, ProfileRoute, PublicTeamsRoute, @@ -30,3 +31,4 @@ export const emailConfig = hc('/api/admin/email-config', opts) export const profiles = hc('/api/profiles') export const teamsApi = hc('/api/teams', opts) export const publicTeamsApi = hc('/api/teams') +export const notificationsApi = hc('/api/notifications', opts)