backend: per-user settings store (/api/settings) + enable account deletion

- user_settings table (userId PK -> user, jsonb preferences) + migration
- GET/PUT /api/settings (requireAuth, user-scoped; zod-validated flat map)
- Better Auth user.deleteUser enabled so users can delete their own account

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-10 20:05:34 +03:00
parent da245b8758
commit 50fbb6cd40
9 changed files with 2636 additions and 0 deletions
@@ -0,0 +1,7 @@
CREATE TABLE "user_settings" (
"user_id" text PRIMARY KEY NOT NULL,
"preferences" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1781022080228,
"tag": "0009_melted_puck",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1781111039837,
"tag": "0010_eager_hellfire_club",
"breakpoints": true
}
]
}
+8
View File
@@ -43,6 +43,14 @@ export const auth = betterAuth({
},
},
user: {
// Lets a signed-in user delete their own account from Settings. The
// frontend confirms with the user's password (authClient.deleteUser).
deleteUser: {
enabled: true,
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
+1
View File
@@ -7,3 +7,4 @@ export * from "./tasks.js";
export * from "./activity.js";
export * from "./messaging.js";
export * from "./notifications.js";
export * from "./settings.js";
+20
View File
@@ -0,0 +1,20 @@
import { jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { user } from "./auth.js";
// Per-user preferences (notification toggles, profile extras). One row per
// user, keyed by the Better Auth user id; the payload is a flat JSONB map so
// the frontend can add settings without a migration.
export const userSettings = pgTable("user_settings", {
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
preferences: jsonb("preferences")
.$type<Record<string, boolean | string>>()
.notNull()
.default({}),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+3
View File
@@ -16,6 +16,7 @@ import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
import { prescriptionsRouter } from "./routes/prescriptions.js";
import { settingsRouter } from "./routes/settings.js";
import { staffRouter } from "./routes/staff.js";
import { tasksRouter } from "./routes/tasks.js";
@@ -64,6 +65,7 @@ app.use("/api/activity", activityRouter);
app.use("/api/analytics", analyticsRouter);
app.use("/api/conversations", conversationsRouter);
app.use("/api/notifications", notificationsRouter);
app.use("/api/settings", settingsRouter);
app.use(notFound);
app.use(errorHandler);
@@ -85,4 +87,5 @@ server.listen(env.PORT, () => {
console.log(` • stats: /api/analytics`);
console.log(` • messages: /api/conversations (+ Socket.io)`);
console.log(` • notifs: /api/notifications`);
console.log(` • settings: /api/settings`);
});
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
// Payload accepted by PUT /api/settings: a flat map of preference keys to
// booleans or short strings. Capped so the JSONB column can't grow unbounded.
export const settingsInputSchema = z.object({
preferences: z
.record(
z.string().min(1).max(64),
z.union([z.boolean(), z.string().max(500)]),
)
.refine((prefs) => Object.keys(prefs).length <= 100, {
message: "Too many preference keys.",
}),
});
export type SettingsInput = z.infer<typeof settingsInputSchema>;
+42
View File
@@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { Router } from "express";
import { db } from "../db/index.js";
import { userSettings } from "../db/schema/settings.js";
import { settingsInputSchema } from "../lib/settings-validation.js";
import { requireAuth } from "../middleware/auth.js";
export const settingsRouter = Router();
// Settings are per-user (not per-clinic), so only authentication is required —
// no active organization or RBAC permission.
settingsRouter.use(requireAuth);
settingsRouter.get("/", async (req, res, next) => {
try {
const rows = await db
.select({ preferences: userSettings.preferences })
.from(userSettings)
.where(eq(userSettings.userId, req.user!.id))
.limit(1);
res.json({ preferences: rows[0]?.preferences ?? {} });
} catch (err) {
next(err);
}
});
settingsRouter.put("/", async (req, res, next) => {
try {
const { preferences } = settingsInputSchema.parse(req.body);
await db
.insert(userSettings)
.values({ userId: req.user!.id, preferences })
.onConflictDoUpdate({
target: userSettings.userId,
set: { preferences, updatedAt: new Date() },
});
res.json({ preferences });
} catch (err) {
next(err);
}
});