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 notifications pgTable (src/lib/db/schema.ts)
  • Created via the notify() helper (src/lib/db/notify.ts) , call it from any mutation, inside withUserTransaction, after a write
  • Read status tracked with a boolean read column
  • href, if present, must be a same-origin relative path (/..., not //...) , the bell UI navigates via window.location.href = href unconditionally on click, so an absolute/external/javascript: href would be a self-XSS-on-click vector. notify() validates this and drops an unsafe href to null rather than trusting the caller.
  • title/body are truncated (200 / 2000 chars) before insert

Key files

FilePurpose
src/lib/db/notify.tsnotify() helper , inserts a notification row
src/features/notifications/server/notifications.queries.tslistNotificationsFn
src/features/notifications/server/notifications.mutations.tsmarkReadFn, markAllReadFn
src/features/notifications/server/notifications.server.tsDB operations
src/features/notifications/index.tsPublic barrel

Server functions

FunctionMethodDescription
listNotificationsFnGETAll notifications for current user
markReadFnPOSTMark single notification read
markAllReadFnPOSTMark 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 showing notify() + logUserEvent together.
  • 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.