feat: back system notification bell with database
This commit is contained in:
@@ -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");
|
||||
@@ -85,6 +85,13 @@
|
||||
"when": 1780481600000,
|
||||
"tag": "0011_conversation_metadata",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1780481700000,
|
||||
"tag": "0012_system_notifications",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user