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,
|
"when": 1780481600000,
|
||||||
"tag": "0011_conversation_metadata",
|
"tag": "0011_conversation_metadata",
|
||||||
"breakpoints": true
|
"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() {
|
export async function ensureRuntimeSchema() {
|
||||||
await ensureGrowConversationsMetadataColumn();
|
await ensureGrowConversationsMetadataColumn();
|
||||||
|
await ensureSystemNotificationsTables();
|
||||||
log.info("runtime schema ensured");
|
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 GrowEventRow = typeof growEvents.$inferSelect;
|
||||||
export type NewGrowEvent = typeof growEvents.$inferInsert;
|
export type NewGrowEvent = typeof growEvents.$inferInsert;
|
||||||
export type MissionActionRow = typeof missionActions.$inferSelect;
|
export type MissionActionRow = typeof missionActions.$inferSelect;
|
||||||
@@ -598,3 +636,6 @@ export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
|
|||||||
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
|
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
|
||||||
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
|
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
|
||||||
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;
|
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,224 @@
|
|||||||
|
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();
|
||||||
|
const current = await getSystemNotificationPreferences(userId);
|
||||||
|
|
||||||
|
for (const [kind, value] of Object.entries(preferences)) {
|
||||||
|
if (!isSystemNotificationKind(kind)) continue;
|
||||||
|
const next = {
|
||||||
|
inApp: value?.inApp ?? current[kind].inApp,
|
||||||
|
email: value?.email ?? current[kind].email,
|
||||||
|
};
|
||||||
|
await db
|
||||||
|
.insert(systemNotificationPreferences)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
kind,
|
||||||
|
inApp: next.inApp,
|
||||||
|
email: next.email,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [systemNotificationPreferences.userId, systemNotificationPreferences.kind],
|
||||||
|
set: {
|
||||||
|
inApp: next.inApp,
|
||||||
|
email: next.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 { seedDemoHome } from "../home/seed-demo-home.js";
|
||||||
import { getRequestUserProfile } from "../services/user-context.js";
|
import { getRequestUserProfile } from "../services/user-context.js";
|
||||||
import { log } from "../log.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) {
|
function canSeedDemo(userId: string) {
|
||||||
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
|
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
|
||||||
@@ -44,6 +53,32 @@ export function homeRoutes() {
|
|||||||
return c.json({ ok: true });
|
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.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
|
||||||
|
|
||||||
app.post("/demo/seed", async (c) => {
|
app.post("/demo/seed", async (c) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user