bell: scope the preference to a user, and make every_turn mean every turn

review of 73cdc2cb before building the phone half on it. three changes, and
the third is the one that matters for mobile.

1. "every end of turn" was silent when the agent asked you something.
   the two kinds are disjoint at the source: turn-ended fires on
   result/error/stopped, input-needed at a plan or question gate — which PARK
   the turn and never emit a result. matching them one-to-one meant whoever
   picked the most inclusive setting got nothing for the most urgent event.
   every_turn is a superset now.

2. the preference is keyed on (user_id, session_id), not session_id alone.
   the route skipped an ownership check on the reasoning that a preference for
   a session you cannot reach is inert. it was not: delivery targets the
   userId the sidecar reports while the preference was read globally, so any
   account holding `chat` that knew a session uuid could set 'none' on someone
   else's conversation and switch off their notifications, or every_turn to
   make their phone buzz all night. surrogate id plus a unique INDEX rather
   than a composite primary key, which drizzle-kit re-creates on every push.

3. the decision moved out of the browser socket into notify-decision.ts.
   it lived inside notify-delivery.ts, which was fine while the browser was
   the only listener and stops being fine the moment push subscribes to the
   same signal. two copies of the rule drift, and the symptom is a bell and a
   phone disagreeing about one conversation — close to undebuggable from
   either end. both channels ask the same function now, and the rule is pure
   so it is tested without a database.

the rest of the commit reviewed well: the ws upgrade is authenticated AND
permission-gated, there is a registry entry so totality passes, the list query
is batched rather than per-row, reads are defensively caught, and
Notification.requestPermission is requested lazily on opt-in.

alpha was missing the table entirely — reads degraded via .catch but the PATCH
would have 500'd. pushed there too.
This commit is contained in:
2026-08-19 21:53:11 +00:00
parent 73cdc2cb77
commit 72d327e360
6 changed files with 127 additions and 29 deletions
@@ -1,25 +1,32 @@
import { inArray } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '../db';
import { chatNotificationPrefs } from './schema';
import type { NotifyPreference } from '../types';
/** Set a session's notification preference. Upsert — one row per session, last write wins. */
export async function setChatNotifyPreference(sessionId: string, preference: NotifyPreference): Promise<void> {
// Every query here is scoped to a user. That is the whole point of the column — see ./schema.ts.
/** Set one session's notification preference for one user. Upsert; last write wins. */
export async function setChatNotifyPreference(
userId: number,
sessionId: string,
preference: NotifyPreference,
): Promise<void> {
await db
.insert(chatNotificationPrefs)
.values({ sessionId, preference })
.values({ userId, sessionId, preference })
.onConflictDoUpdate({
target: chatNotificationPrefs.sessionId,
// The unique index is the arbiter — a composite primary key would churn on every push.
target: [chatNotificationPrefs.userId, chatNotificationPrefs.sessionId],
set: { preference, updatedAt: new Date() },
});
}
/** One session's stored preference, or `null` when nothing has been set yet (treat as `'none'`). */
export async function getChatNotifyPreference(sessionId: string): Promise<NotifyPreference | null> {
/** One session's stored preference, or `null` when nothing has been set (treat as `'none'`). */
export async function getChatNotifyPreference(userId: number, sessionId: string): Promise<NotifyPreference | null> {
const [row] = await db
.select({ preference: chatNotificationPrefs.preference })
.from(chatNotificationPrefs)
.where(inArray(chatNotificationPrefs.sessionId, [sessionId]))
.where(and(eq(chatNotificationPrefs.userId, userId), eq(chatNotificationPrefs.sessionId, sessionId)))
.limit(1);
return (row?.preference as NotifyPreference | undefined) ?? null;
}
@@ -29,11 +36,14 @@ export async function getChatNotifyPreference(sessionId: string): Promise<Notify
* this per row, and the per-row alternative is one query per session. Returns an empty Map for an empty
* input rather than issuing `IN ()`, which Postgres rejects.
*/
export async function getChatNotifyPreferences(sessionIds: string[]): Promise<Map<string, NotifyPreference>> {
export async function getChatNotifyPreferences(
userId: number,
sessionIds: string[],
): Promise<Map<string, NotifyPreference>> {
if (!sessionIds.length) return new Map();
const rows = await db
.select({ sessionId: chatNotificationPrefs.sessionId, preference: chatNotificationPrefs.preference })
.from(chatNotificationPrefs)
.where(inArray(chatNotificationPrefs.sessionId, sessionIds));
.where(and(eq(chatNotificationPrefs.userId, userId), inArray(chatNotificationPrefs.sessionId, sessionIds)));
return new Map(rows.map((r) => [r.sessionId, r.preference as NotifyPreference]));
}
@@ -1,22 +1,42 @@
import { pgTable, text, timestamp, check } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, timestamp, check, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// Whether a chat session should tell you about itself — the bell menu on a chat's header.
//
// There is no `chat_sessions` table (a session IS its transcript, per `chat-session-models/schema.ts`),
// so this claims only the field the transcript cannot express, keyed the same way that table is.
// so this claims only the field the transcript cannot express.
//
// ── Why `user_id` is here, and not optional ──
//
// This was keyed on `session_id` alone, on the reasoning that a preference for a session the caller
// cannot reach is inert. It is not: delivery targets the userId the SIDECAR reports for the running
// session, while the preference was read globally by session id. So any account holding `chat` that knew
// a session's uuid could set `none` on someone else's conversation and silently switch off their
// notifications — or `every_turn` to make their phone buzz all night.
//
// Scoped both ways now: the row belongs to a user, and delivery only ever reads that user's own.
//
// Surrogate `id` plus a unique INDEX rather than a composite primary key — a composite `primaryKey` is
// re-created on every `db:push` by a drizzle-kit diffing bug with no index form to escape to. See
// src/databases/CLAUDE.md → "Composite keys".
const NOTIFY_PREFERENCES = ['every_turn', 'input_needed', 'none'] as const;
export const chatNotificationPrefs = pgTable(
'chat_notification_prefs',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The session's transcript uuid — same natural key as `chat_session_models.sessionId`. */
sessionId: text('session_id').primaryKey(),
sessionId: text('session_id').notNull(),
preference: text('preference').notNull().default('none'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('uq_chat_notification_prefs_user_session').on(table.userId, table.sessionId),
// sql.raw per value — an interpolated string binds as a parameter, which Postgres refuses inside a
// CHECK and breaks push for the whole schema. See src/databases/CLAUDE.md.
check(