feat: back system notification bell with database

This commit is contained in:
dv
2026-07-06 01:40:35 +05:30
parent 9062ce9f22
commit 12b97848b0
6 changed files with 369 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS "system_notifications" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"title" text NOT NULL,
"sub" text NOT NULL,
"when_label" text NOT NULL,
"href" text,
"source" text DEFAULT 'system' NOT NULL,
"source_ref" jsonb DEFAULT '{}'::jsonb NOT NULL,
"read_at" timestamp with time zone,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "system_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "system_notifications_kind_check" CHECK ("kind" IN ('session', 'billing', 'security', 'feature', 'account'))
);
CREATE INDEX IF NOT EXISTS "system_notifications_user_idx" ON "system_notifications" USING btree ("user_id","read_at","occurred_at");
CREATE INDEX IF NOT EXISTS "system_notifications_kind_idx" ON "system_notifications" USING btree ("user_id","kind","occurred_at");
CREATE TABLE IF NOT EXISTS "system_notification_preferences" (
"user_id" text NOT NULL,
"kind" text NOT NULL,
"in_app" boolean DEFAULT true NOT NULL,
"email" boolean DEFAULT true NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "system_notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "system_notification_preferences_pk" PRIMARY KEY("user_id","kind"),
CONSTRAINT "system_notification_preferences_kind_check" CHECK ("kind" IN ('session', 'billing', 'security', 'feature', 'account'))
);
CREATE INDEX IF NOT EXISTS "system_notification_preferences_user_idx" ON "system_notification_preferences" USING btree ("user_id");
+7
View File
@@ -85,6 +85,13 @@
"when": 1780481600000,
"tag": "0011_conversation_metadata",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780481700000,
"tag": "0012_system_notifications",
"breakpoints": true
}
]
}
+34
View File
@@ -8,7 +8,41 @@ async function ensureGrowConversationsMetadataColumn() {
`);
}
async function ensureSystemNotificationsTables() {
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notifications (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
kind text NOT NULL CHECK (kind IN ('session', 'billing', 'security', 'feature', 'account')),
title text NOT NULL,
sub text NOT NULL,
when_label text NOT NULL,
href text,
source text NOT NULL DEFAULT 'system',
source_ref jsonb NOT NULL DEFAULT '{}'::jsonb,
read_at timestamp with time zone,
occurred_at timestamp with time zone NOT NULL DEFAULT now(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
)
`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notifications_user_idx ON system_notifications (user_id, read_at, occurred_at)`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notifications_kind_idx ON system_notifications (user_id, kind, occurred_at)`);
await db.execute(`
CREATE TABLE IF NOT EXISTS system_notification_preferences (
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
kind text NOT NULL CHECK (kind IN ('session', 'billing', 'security', 'feature', 'account')),
in_app boolean NOT NULL DEFAULT true,
email boolean NOT NULL DEFAULT true,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, kind)
)
`);
await db.execute(`CREATE INDEX IF NOT EXISTS system_notification_preferences_user_idx ON system_notification_preferences (user_id)`);
}
export async function ensureRuntimeSchema() {
await ensureGrowConversationsMetadataColumn();
await ensureSystemNotificationsTables();
log.info("runtime schema ensured");
}
+41
View File
@@ -589,6 +589,44 @@ export const growHomeNotifications = pgTable(
}),
);
export const systemNotifications = pgTable(
"system_notifications",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
title: text("title").notNull(),
sub: text("sub").notNull(),
whenLabel: text("when_label").notNull(),
href: text("href"),
source: text("source").notNull().default("system"),
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
readAt: timestamp("read_at", { withTimezone: true }),
occurredAt: timestamp("occurred_at", { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("system_notifications_user_idx").on(t.userId, t.readAt, t.occurredAt),
kindIdx: index("system_notifications_kind_idx").on(t.userId, t.kind, t.occurredAt),
}),
);
export const systemNotificationPreferences = pgTable(
"system_notification_preferences",
{
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["session", "billing", "security", "feature", "account"] }).notNull(),
inApp: boolean("in_app").notNull().default(true),
email: boolean("email").notNull().default(true),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.kind] }),
userIdx: index("system_notification_preferences_user_idx").on(t.userId),
}),
);
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type MissionActionRow = typeof missionActions.$inferSelect;
@@ -598,3 +636,6 @@ export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
export type SystemNotificationRow = typeof systemNotifications.$inferSelect;
export type NewSystemNotification = typeof systemNotifications.$inferInsert;
export type SystemNotificationPreferenceRow = typeof systemNotificationPreferences.$inferSelect;
+219
View File
@@ -0,0 +1,219 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import {
systemNotificationPreferences,
systemNotifications,
type SystemNotificationPreferenceRow,
type SystemNotificationRow,
} from "../db/schema.js";
export const SYSTEM_NOTIFICATION_KINDS = ["session", "billing", "security", "feature", "account"] as const;
export type SystemNotificationKind = typeof SYSTEM_NOTIFICATION_KINDS[number];
export type SystemNotificationPreference = { inApp: boolean; email: boolean };
export type SystemNotificationPreferences = Record<SystemNotificationKind, SystemNotificationPreference>;
export type PublicSystemNotification = {
id: string;
kind: SystemNotificationKind;
title: string;
sub: string;
when: string;
read: boolean;
href?: string;
occurredAt: string;
};
const DEFAULT_PREF: SystemNotificationPreference = { inApp: true, email: true };
export const DEFAULT_SYSTEM_NOTIFICATION_PREFERENCES = Object.fromEntries(
SYSTEM_NOTIFICATION_KINDS.map((kind) => [kind, { ...DEFAULT_PREF }]),
) as SystemNotificationPreferences;
type SeedSystemNotification = {
id: string;
kind: SystemNotificationKind;
title: string;
sub: string;
whenLabel: string;
href?: string;
source: string;
sourceRef?: Record<string, unknown>;
};
function seedRows(userId: string): SeedSystemNotification[] {
return [
{
id: `system:${userId}:session-current-device`,
kind: "session",
title: "Session active on this device",
sub: "You are signed in securely on the current browser.",
whenLabel: "Now",
href: "/settings",
source: "auth",
},
{
id: `system:${userId}:security-profile-review`,
kind: "security",
title: "Security review available",
sub: "Review connected sources and account access from settings.",
whenLabel: "Today",
href: "/settings",
source: "security",
},
{
id: `system:${userId}:feature-dashboard-refresh`,
kind: "feature",
title: "Dashboard experience updated",
sub: "The home dashboard now separates agent inbox items from account notifications.",
whenLabel: "Today",
href: "/",
source: "release",
},
{
id: `system:${userId}:account-preferences-ready`,
kind: "account",
title: "Notification preferences are ready",
sub: "Control in-app and email alerts by category in Settings.",
whenLabel: "Today",
href: "/settings",
source: "account",
},
];
}
function isSystemNotificationKind(value: string): value is SystemNotificationKind {
return (SYSTEM_NOTIFICATION_KINDS as readonly string[]).includes(value);
}
function normalizePreferences(rows: SystemNotificationPreferenceRow[]): SystemNotificationPreferences {
const preferences = { ...DEFAULT_SYSTEM_NOTIFICATION_PREFERENCES };
for (const row of rows) {
if (!isSystemNotificationKind(row.kind)) continue;
preferences[row.kind] = { inApp: row.inApp, email: row.email };
}
return preferences;
}
function publicNotification(row: SystemNotificationRow): PublicSystemNotification {
return {
id: row.id,
kind: row.kind as SystemNotificationKind,
title: row.title,
sub: row.sub,
when: row.whenLabel,
read: Boolean(row.readAt),
href: row.href ?? undefined,
occurredAt: row.occurredAt.toISOString(),
};
}
export async function ensureDefaultSystemNotifications(userId: string) {
const now = new Date();
const rows = seedRows(userId).map((item) => ({
...item,
userId,
sourceRef: item.sourceRef ?? {},
updatedAt: now,
}));
await db
.insert(systemNotifications)
.values(rows)
.onConflictDoUpdate({
target: systemNotifications.id,
set: {
title: sql`excluded.title`,
sub: sql`excluded.sub`,
whenLabel: sql`excluded.when_label`,
href: sql`excluded.href`,
source: sql`excluded.source`,
sourceRef: sql`excluded.source_ref`,
updatedAt: now,
},
});
}
export async function ensureSystemNotificationPreferences(userId: string) {
const now = new Date();
await db
.insert(systemNotificationPreferences)
.values(SYSTEM_NOTIFICATION_KINDS.map((kind) => ({ userId, kind, ...DEFAULT_PREF, updatedAt: now })))
.onConflictDoNothing();
}
export async function getSystemNotificationPreferences(userId: string) {
await ensureSystemNotificationPreferences(userId);
const rows = await db
.select()
.from(systemNotificationPreferences)
.where(eq(systemNotificationPreferences.userId, userId));
return normalizePreferences(rows);
}
export async function updateSystemNotificationPreferences(userId: string, preferences: Partial<Record<SystemNotificationKind, Partial<SystemNotificationPreference>>>) {
await ensureSystemNotificationPreferences(userId);
const now = new Date();
for (const [kind, value] of Object.entries(preferences)) {
if (!isSystemNotificationKind(kind)) continue;
await db
.insert(systemNotificationPreferences)
.values({
userId,
kind,
inApp: value?.inApp ?? DEFAULT_PREF.inApp,
email: value?.email ?? DEFAULT_PREF.email,
updatedAt: now,
})
.onConflictDoUpdate({
target: [systemNotificationPreferences.userId, systemNotificationPreferences.kind],
set: {
inApp: value?.inApp ?? DEFAULT_PREF.inApp,
email: value?.email ?? DEFAULT_PREF.email,
updatedAt: now,
},
});
}
return getSystemNotificationPreferences(userId);
}
export async function listSystemNotifications(userId: string) {
await ensureDefaultSystemNotifications(userId);
const preferences = await getSystemNotificationPreferences(userId);
const enabledKinds = SYSTEM_NOTIFICATION_KINDS.filter((kind) => preferences[kind].inApp);
if (!enabledKinds.length) {
return { notifications: [], preferences };
}
const rows = await db
.select()
.from(systemNotifications)
.where(and(eq(systemNotifications.userId, userId), inArray(systemNotifications.kind, enabledKinds)))
.orderBy(desc(systemNotifications.occurredAt), desc(systemNotifications.createdAt))
.limit(50);
return {
notifications: rows.map(publicNotification),
preferences,
};
}
export async function markSystemNotificationRead(userId: string, notificationId: string) {
const [row] = await db
.update(systemNotifications)
.set({ readAt: new Date(), updatedAt: new Date() })
.where(and(eq(systemNotifications.userId, userId), eq(systemNotifications.id, notificationId)))
.returning();
return row ? publicNotification(row) : null;
}
export async function markAllSystemNotificationsRead(userId: string) {
await db
.update(systemNotifications)
.set({ readAt: new Date(), updatedAt: new Date() })
.where(and(eq(systemNotifications.userId, userId), sql`${systemNotifications.readAt} is null`));
return listSystemNotifications(userId);
}
+35
View File
@@ -6,6 +6,15 @@ import { HomeFeedAgentError } from "../home/home-feed-agent.js";
import { seedDemoHome } from "../home/seed-demo-home.js";
import { getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
import {
getSystemNotificationPreferences,
listSystemNotifications,
markAllSystemNotificationsRead,
markSystemNotificationRead,
updateSystemNotificationPreferences,
type SystemNotificationKind,
type SystemNotificationPreference,
} from "../notifications/system-notifications.js";
function canSeedDemo(userId: string) {
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
@@ -44,6 +53,32 @@ export function homeRoutes() {
return c.json({ ok: true });
});
app.get("/system-notifications", async (c) => {
return c.json(await listSystemNotifications(c.get("userId")));
});
app.post("/system-notifications/:id/read", async (c) => {
const notification = await markSystemNotificationRead(c.get("userId"), c.req.param("id"));
if (!notification) return c.json({ error: "notification_not_found" }, 404);
return c.json({ notification });
});
app.post("/system-notifications/read-all", async (c) => {
return c.json(await markAllSystemNotificationsRead(c.get("userId")));
});
app.get("/system-notifications/preferences", async (c) => {
return c.json({ preferences: await getSystemNotificationPreferences(c.get("userId")) });
});
app.put("/system-notifications/preferences", async (c) => {
const body = await c.req.json().catch(() => ({}));
const preferences = typeof body === "object" && body !== null && "preferences" in body
? (body as { preferences?: Partial<Record<SystemNotificationKind, Partial<SystemNotificationPreference>>> }).preferences ?? {}
: {};
return c.json({ preferences: await updateSystemNotificationPreferences(c.get("userId"), preferences) });
});
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
app.post("/demo/seed", async (c) => {