Notifications
Notifications
In-app notification system. Notifications are stored in the shared Postgres notifications table, scoped by user_id, and surfaced via a bell icon in the dashboard header.
Architecture
- Stored in the shared
notificationspgTable (src/lib/db/schema.ts) - Created via the
notify()helper (src/lib/db/notify.ts) , call it from any mutation, insidewithUserTransaction, after a write - Read status tracked with a boolean
readcolumn href, if present, must be a same-origin relative path (/..., not//...) , the bell UI navigates viawindow.location.href = hrefunconditionally on click, so an absolute/external/javascript:href would be a self-XSS-on-click vector.notify()validates this and drops an unsafehreftonullrather than trusting the caller.title/bodyare truncated (200 / 2000 chars) before insert
Key files
| File | Purpose |
|---|---|
src/lib/db/notify.ts | notify() helper , inserts a notification row |
src/features/notifications/server/notifications.queries.ts | listNotificationsFn |
src/features/notifications/server/notifications.mutations.ts | markReadFn, markAllReadFn |
src/features/notifications/server/notifications.server.ts | DB operations |
src/features/notifications/index.ts | Public barrel |
Server functions
| Function | Method | Description |
|---|---|---|
listNotificationsFn | GET | All notifications for current user |
markReadFn | POST | Mark single notification read |
markAllReadFn | POST | Mark all notifications read |
Correctness note on markAllReadFn: the underlying markAllNotificationsRead had zero WHERE clause in the base template's per-user-SQLite version , safe there only because per-user file isolation made a missing filter harmless. This fork's shared-table version scopes it explicitly (eq(notifications.userId, userId)), with a cross-tenant regression test , see architecture/postgres-port.md for why a forgotten user_id filter is now a real risk class, not a hypothetical.
Creating a notification
Import notify directly from @/lib/db/notify (not via barrel , server-only). Call inside withUserTransaction, using the transaction handle, after a successful write:
import { notify } from '@/lib/db/notify';
import { logUserEvent } from '@/lib/db/user-events';
import { withUserTransaction } from '@/lib/db/user-db';
return withUserTransaction(user.id, async (tx, userId) => {
const result = await createSomething(tx, userId, data);
if (!result.ok) return result;
await logUserEvent(tx, userId, 'something.created', { id: result.data.id });
await notify(tx, userId, {
title: 'Widget created',
href: `/dashboard/widgets/${result.data.id}`
});
return result;
});
notify() signature:
notify(db: UserDb, userId: string, input: {
title: string;
body?: string;
href?: string;
}): Promise<void>
href makes the notification clickable (must be a safe same-origin relative path, see above). body adds a subtitle line.
Notes
- Notes feature (
src/features/notes) is the reference implementation showingnotify()+logUserEventtogether. - Not all features need notifications , use it when the action is async, delayed, or warrants persistent acknowledgment.
- There is no push/real-time delivery. Notifications are polled via
listNotificationsFn.