From e3e7b0d8e0c63fa0ee59213b28a33fb257003b2b Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 2 Jul 2026 00:03:27 -0700 Subject: [PATCH] feat: add system configuration platform --- .trellis/spec/backend/deployment.md | 58 + .trellis/spec/backend/index.md | 1 + .trellis/spec/backend/local-json-mvp.md | 30 + app/(app)/app/beds/page.tsx | 245 ++- app/(app)/app/layout.tsx | 2 + app/(app)/app/settings/page.tsx | 10 +- app/(auth)/login/page.tsx | 2 + app/(auth)/register/page.tsx | 2 + app/(auth)/setup/page.tsx | 2 + app/api/admissions/route.ts | 138 ++ app/api/audit-logs/route.ts | 7 +- app/api/auth/bootstrap/route.ts | 16 +- app/api/auth/login/route.ts | 41 +- app/api/auth/register/route.ts | 64 +- app/api/auth/session/route.ts | 5 +- app/api/auth/setup/route.ts | 69 +- app/api/elders/[id]/route.ts | 110 +- app/api/elders/route.ts | 111 +- app/api/facilities/beds/route.ts | 106 + app/api/facilities/rooms/route.ts | 140 ++ app/api/settings/accounts/route.ts | 107 +- app/api/settings/permissions/route.ts | 16 + app/api/settings/roles/route.ts | 108 +- app/api/system/incidents/[id]/route.ts | 85 + app/api/system/status/route.ts | 49 + drizzle.config.ts | 10 + drizzle/0000_bouncy_micromax.sql | 235 +++ drizzle/meta/0000_snapshot.json | 1772 +++++++++++++++++ drizzle/meta/_journal.json | 13 + modules/core/server/api.ts | 21 +- modules/core/server/audit.ts | 57 +- modules/core/server/auth.ts | 426 +++- modules/core/server/db.ts | 37 + modules/core/server/permissions.ts | 263 ++- modules/core/server/schema.ts | 227 +++ modules/core/server/store.ts | 262 ++- modules/core/types.ts | 137 +- modules/elders/types.ts | 30 +- .../settings/components/SettingsOverview.tsx | 180 +- package.json | 10 +- pnpm-lock.yaml | 947 +++++++++ 41 files changed, 5746 insertions(+), 405 deletions(-) create mode 100644 .trellis/spec/backend/deployment.md create mode 100644 app/api/admissions/route.ts create mode 100644 app/api/facilities/beds/route.ts create mode 100644 app/api/facilities/rooms/route.ts create mode 100644 app/api/settings/permissions/route.ts create mode 100644 app/api/system/incidents/[id]/route.ts create mode 100644 app/api/system/status/route.ts create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_bouncy_micromax.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 modules/core/server/db.ts create mode 100644 modules/core/server/schema.ts diff --git a/.trellis/spec/backend/deployment.md b/.trellis/spec/backend/deployment.md new file mode 100644 index 0000000..b1cfb6e --- /dev/null +++ b/.trellis/spec/backend/deployment.md @@ -0,0 +1,58 @@ +# Deployment Targets + +## Scenario: Wulanchabu Server Deployment + +### 1. Scope / Trigger + +- Trigger: the user asks to deploy this project. +- Default target: Wulanchabu server, reached through SSH host alias `wlcb1`. +- This preference is project-specific and should be used for future deployment requests unless the user explicitly names another target. + +### 2. Signatures + +- SSH entrypoint: `ssh wlcb1` +- Repository remote: push code to `origin` before deploying. +- Database migration command: `pnpm db:migrate` +- Production build command: `pnpm build` + +### 3. Contracts + +- Required environment on the server: + - `DATABASE_URL` must point to the production PostgreSQL database. + - Node.js and pnpm must be available. +- Deployment should run from the server-side checkout for this repository. +- Code should be pushed to `origin` before server deployment so `wlcb1` deploys committed source. + +### 4. Validation & Error Matrix + +- Missing `DATABASE_URL` -> deployment is not healthy; setup/login APIs return database configuration failures. +- Migration failure -> stop deployment and report the failing migration output. +- Build failure -> stop deployment and report the failing command output. +- SSH failure -> report that `wlcb1` could not be reached. + +### 5. Good/Base/Bad Cases + +- Good: commit locally, push to `origin`, SSH to `wlcb1`, update checkout, install deps, migrate DB, build, restart app. +- Base: if the server has a project-specific deploy script, run that script after pushing. +- Bad: deploy uncommitted local files, deploy to a different host by default, or skip migrations after schema changes. + +### 6. Tests Required + +- `pnpm lint` +- `pnpm type-check` +- `pnpm build` +- After deploy, verify the app responds and `/api/auth/bootstrap` returns a structured JSON response. + +### 7. Wrong vs Correct + +#### Wrong + +```bash +ssh some-other-host +``` + +#### Correct + +```bash +ssh wlcb1 +``` diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index 0bc3d2d..756b94a 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -21,6 +21,7 @@ | [authentication.md](./authentication.md) | better-auth, sessions, OAuth, protected procedures | Auth-related features | | [logging.md](./logging.md) | Structured logging, Sentry tracing, telemetry | Debugging, observability | | [local-json-mvp.md](./local-json-mvp.md) | Local JSON persistence/session/API contracts before database adoption | Single-repo MVP features without DB/oRPC deps | +| [deployment.md](./deployment.md) | Wulanchabu deployment target and validation contract | Deploying this project | | [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization | | [ai-sdk-integration.md](./ai-sdk-integration.md) | Vercel AI SDK, tool calling, prompt patterns | AI-powered features | | [quality.md](./quality.md) | Pre-commit checklist for backend code | Before committing | diff --git a/.trellis/spec/backend/local-json-mvp.md b/.trellis/spec/backend/local-json-mvp.md index 849f565..76ab43c 100644 --- a/.trellis/spec/backend/local-json-mvp.md +++ b/.trellis/spec/backend/local-json-mvp.md @@ -22,6 +22,8 @@ - Default data file: `.data/teatea.json`. - Override key: `TEATEA_DATA_DIR` points to the directory containing `teatea.json`. - `.data/` must stay ignored; it may contain account hashes and session IDs. +- Routes or layouts that read local JSON state or session cookies must opt out of static prerendering with `export const dynamic = "force-dynamic"`. +- API JSON responses backed by local JSON state or session cookies must include `Cache-Control: no-store`. - API response shape: ```ts @@ -51,6 +53,7 @@ type ApiResult> = - Good: UI submits to Route Handler, Route Handler validates input, calls `requirePermission`, mutates data through `updateData`, writes audit log, returns `ApiResult`. - Base: Server Component reads data directly via `readData` after session/permission checks. - Bad: UI or route handler reads `.data/teatea.json` directly, bypasses `requirePermission`, or stores auth state in localStorage. +- Bad: `/app/*`, `/login`, or `/setup` is prerendered as static content and caches a redirect based on build-time empty data. ### 6. Tests Required @@ -60,6 +63,8 @@ type ApiResult> = - Manual or automated integration assertions: - first setup creates admin and cookie session - protected app route redirects without cookie + - protected app route renders with a valid cookie after setup and does not return a cached `/setup` redirect + - auth/bootstrap/session API responses include `Cache-Control: no-store` - CRUD mutation persists across reload/API list - denied permission returns 403 and writes an audit log @@ -84,3 +89,28 @@ cookieStore.set({ }); ``` +#### Wrong + +```ts +export default async function AppLayout({ children }: AppLayoutProps) { + const bootstrap = await getBootstrapState(); + if (bootstrap.setupRequired) { + redirect("/setup"); + } + return children; +} +``` + +#### Correct + +```ts +export const dynamic = "force-dynamic"; + +export default async function AppLayout({ children }: AppLayoutProps) { + const bootstrap = await getBootstrapState(); + if (bootstrap.setupRequired) { + redirect("/setup"); + } + return children; +} +``` diff --git a/app/(app)/app/beds/page.tsx b/app/(app)/app/beds/page.tsx index 14847e5..c25be01 100644 --- a/app/(app)/app/beds/page.tsx +++ b/app/(app)/app/beds/page.tsx @@ -1,21 +1,234 @@ -import { BedDouble } from "lucide-react"; +import { redirect } from "next/navigation"; +import { BedDouble, DoorOpen, History, Wrench } from "lucide-react"; -import { ModulePage } from "@/modules/shared/components/ModulePage"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { hasPermission } from "@/modules/core/server/permissions"; +import { readData } from "@/modules/core/server/store"; + +export default async function BedsPage(): Promise { + const context = await getCurrentAuthContext(); + if (!context) { + redirect("/login"); + } + + if (!hasPermission(context.permissions, "facility:read")) { + redirect("/app/dashboard"); + } + + const data = await readData(); + const organizationId = context.organization?.id; + const rooms = organizationId ? data.rooms.filter((room) => room.organizationId === organizationId) : data.rooms; + const beds = organizationId ? data.beds.filter((bed) => bed.organizationId === organizationId) : data.beds; + const admissions = organizationId + ? data.admissions.filter((admission) => admission.organizationId === organizationId) + : data.admissions; + + const occupiedBeds = beds.filter((bed) => bed.status === "occupied").length; + const maintenanceBeds = beds.filter((bed) => bed.status === "maintenance").length; + const occupancyRate = beds.length > 0 ? Math.round((occupiedBeds / beds.length) * 100) : 0; -export default function BedsPage(): React.ReactElement { return ( - +
+
+
+ Facility + Admission +

床位房间

+

房间主数据、床位状态、当前占用和入住历史。

+
+
+ +
+ + +
+ 房间 + {rooms.length} +
+
+
+ + +
+ 床位 + {beds.length} +
+
+ +

占用率 {occupancyRate}%

+
+
+ 0 ? "border-amber-200 bg-amber-50/70" : undefined}> + +
+ 0 ? "text-amber-700" : undefined}>维修床位 + 0 ? "mt-2 text-3xl text-amber-700" : "mt-2 text-3xl"}> + {maintenanceBeds} + +
+ 0 ? "size-5 text-amber-700" : "size-5 text-primary"} aria-hidden="true" /> +
+
+ + +
+ 入住历史 + {admissions.length} +
+
+
+
+ +
+ + + 房间台账 + + +
+ + + + + + + + + + + + {rooms.map((room) => ( + + + + + + + + ))} + {rooms.length === 0 ? ( + + + + ) : null} + +
房间编号容量床位状态
{room.name}{room.code}{room.capacity} + {room.occupiedBedCount}/{room.bedCount} + + {room.status} +
+ 暂无房间,请通过 /api/facilities/rooms 创建 +
+
+
+
+ + + + 床位状态 + + +
+ + + + + + + + + + + + {beds.map((bed) => ( + + + + + + + + ))} + {beds.length === 0 ? ( + + + + ) : null} + +
房间床位状态当前老人备注
{bed.roomName}{bed.code} + + {bed.status} + + {bed.currentElderName ?? "-"}{bed.notes || "-"}
+ 暂无床位,请通过 /api/facilities/beds 创建 +
+
+
+
+
+ + + + 入住与换床历史 + + +
+ + + + + + + + + + + + + {admissions.map((admission) => ( + + + + + + + + + ))} + {admissions.length === 0 ? ( + + + + ) : null} + +
老人床位状态入住时间结束时间备注
{admission.elderName} + {admission.roomName}-{admission.bedCode} + + {admission.status} + + {new Date(admission.admittedAt).toLocaleString("zh-CN")} + + {admission.dischargedAt ? new Date(admission.dischargedAt).toLocaleString("zh-CN") : "-"} + {admission.notes || "-"}
+ 暂无入住历史 +
+
+
+
+
); } diff --git a/app/(app)/app/layout.tsx b/app/(app)/app/layout.tsx index 069d487..43eeb15 100644 --- a/app/(app)/app/layout.tsx +++ b/app/(app)/app/layout.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { getBootstrapState, getCurrentAuthContext } from "@/modules/core/server/auth"; import { AppShell } from "@/modules/shared/components/AppShell"; +export const dynamic = "force-dynamic"; + type AppLayoutProps = { children: React.ReactNode; }; diff --git a/app/(app)/app/settings/page.tsx b/app/(app)/app/settings/page.tsx index d0cecaa..40b01ae 100644 --- a/app/(app)/app/settings/page.tsx +++ b/app/(app)/app/settings/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth"; -import { hasPermission, ROLE_DEFINITIONS } from "@/modules/core/server/permissions"; +import { getRoleDefinitions, hasPermission } from "@/modules/core/server/permissions"; import { readData } from "@/modules/core/server/store"; import { SettingsOverview } from "@/modules/settings/components/SettingsOverview"; @@ -11,16 +11,20 @@ export default async function SettingsPage(): Promise { redirect("/login"); } - if (!hasPermission(context.account.role, "account:read")) { + if (!hasPermission(context.permissions, "account:read")) { redirect("/app/dashboard"); } const data = await readData(); + const roles = await getRoleDefinitions(context.organization?.id); return ( toPublicAccount(account))} - roles={ROLE_DEFINITIONS} + roles={roles} auditLogs={data.auditLogs.slice(0, 100)} + organizations={data.organizations} + memberships={data.memberships} + incidents={data.incidents} /> ); } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 7580ea0..d68e167 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,5 +1,7 @@ import { AuthPanel } from "@/modules/auth/components/AuthPanel"; +export const dynamic = "force-dynamic"; + export default function LoginPage(): React.ReactElement { return ; } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 5f50de2..9ad3f66 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,5 +1,7 @@ import { AuthPanel } from "@/modules/auth/components/AuthPanel"; +export const dynamic = "force-dynamic"; + export default function RegisterPage(): React.ReactElement { return ; } diff --git a/app/(auth)/setup/page.tsx b/app/(auth)/setup/page.tsx index de30506..fd2dfdb 100644 --- a/app/(auth)/setup/page.tsx +++ b/app/(auth)/setup/page.tsx @@ -1,5 +1,7 @@ import { AuthPanel } from "@/modules/auth/components/AuthPanel"; +export const dynamic = "force-dynamic"; + export default function SetupPage(): React.ReactElement { return ; } diff --git a/app/api/admissions/route.ts b/app/api/admissions/route.ts new file mode 100644 index 0000000..358b49a --- /dev/null +++ b/app/api/admissions/route.ts @@ -0,0 +1,138 @@ +import { and, eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { admissions, beds, elders } from "@/modules/core/server/schema"; +import { readData } from "@/modules/core/server/store"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export async function GET(): Promise { + const auth = await requirePermission("admission:read", { + action: "admission.list", + targetType: "admission", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + const organizationId = auth.context.organization?.id; + return jsonSuccess("入住记录已加载", { + admissions: organizationId + ? data.admissions.filter((admission) => admission.organizationId === organizationId) + : data.admissions, + }); +} + +export async function POST(request: Request): Promise { + const auth = await requirePermission("admission:manage", { + action: "admission.create", + targetType: "admission", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后办理入住", 400); + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const elderId = readString(body, "elderId"); + const bedId = readString(body, "bedId"); + if (!elderId || !bedId) { + return jsonFailure("老人和床位不能为空"); + } + + const database = getDatabase(); + const admission = await database.transaction(async (transaction) => { + const elderRows = await transaction + .select() + .from(elders) + .where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId))) + .limit(1); + const elder = elderRows[0]; + if (!elder) { + throw new Error("老人档案不存在"); + } + + const bedRows = await transaction + .select() + .from(beds) + .where(and(eq(beds.id, bedId), eq(beds.organizationId, organizationId))) + .limit(1); + const bed = bedRows[0]; + if (!bed) { + throw new Error("床位不存在"); + } + if (bed.status !== "available") { + throw new Error("床位不可分配"); + } + + const activeAdmissions = await transaction + .select() + .from(admissions) + .where(and(eq(admissions.elderId, elderId), eq(admissions.status, "active"))); + await Promise.all( + activeAdmissions.map((activeAdmission) => + transaction + .update(admissions) + .set({ status: "transferred", dischargedAt: new Date(), updatedAt: new Date() }) + .where(eq(admissions.id, activeAdmission.id)), + ), + ); + await Promise.all( + activeAdmissions.map((activeAdmission) => + transaction.update(beds).set({ status: "available", updatedAt: new Date() }).where(eq(beds.id, activeAdmission.bedId)), + ), + ); + + const rows = await transaction + .insert(admissions) + .values({ + organizationId, + elderId, + bedId, + status: "active", + notes: readString(body, "notes"), + }) + .returning(); + const row = rows[0]; + if (!row) { + throw new Error("入住记录创建失败"); + } + + await transaction.update(beds).set({ status: "occupied", updatedAt: new Date() }).where(eq(beds.id, bedId)); + await transaction.update(elders).set({ status: "active", updatedAt: new Date() }).where(eq(elders.id, elderId)); + return row; + }); + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "admission.create", + targetType: "admission", + targetId: admission.id, + result: "success", + reason: "办理入住/换床", + }); + + return jsonSuccess("入住记录已创建", { admission }, 201); +} diff --git a/app/api/audit-logs/route.ts b/app/api/audit-logs/route.ts index 7ad6586..0db3e23 100644 --- a/app/api/audit-logs/route.ts +++ b/app/api/audit-logs/route.ts @@ -1,6 +1,6 @@ import { jsonSuccess } from "@/modules/core/server/api"; +import { listAuditLogs } from "@/modules/core/server/audit"; import { requirePermission } from "@/modules/core/server/auth"; -import { readData } from "@/modules/core/server/store"; export async function GET(): Promise { const auth = await requirePermission("audit:read", { @@ -12,9 +12,8 @@ export async function GET(): Promise { return auth.response; } - const data = await readData(); + const auditLogs = await listAuditLogs({ organizationId: auth.context.organization?.id, limit: 100 }); return jsonSuccess("审计日志已加载", { - auditLogs: data.auditLogs.slice(0, 100), + auditLogs, }); } - diff --git a/app/api/auth/bootstrap/route.ts b/app/api/auth/bootstrap/route.ts index 8531863..917ee53 100644 --- a/app/api/auth/bootstrap/route.ts +++ b/app/api/auth/bootstrap/route.ts @@ -1,8 +1,16 @@ import { getBootstrapState } from "@/modules/core/server/auth"; -import { jsonSuccess } from "@/modules/core/server/api"; +import { jsonFailure, jsonSuccess } from "@/modules/core/server/api"; +import { ensureSystemDefaults } from "@/modules/core/server/permissions"; + +export const dynamic = "force-dynamic"; export async function GET(): Promise { - const state = await getBootstrapState(); - return jsonSuccess("Bootstrap state loaded", state); + try { + await ensureSystemDefaults(); + const state = await getBootstrapState(); + return jsonSuccess("Bootstrap state loaded", state); + } catch (error) { + const reason = error instanceof Error ? error.message : "系统初始化状态读取失败"; + return jsonFailure(reason, 503); + } } - diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 6379358..2c36a0b 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,7 +1,6 @@ -import { createSessionRecord, normalizeEmail, setSessionCookie, toPublicAccount, verifyPassword } from "@/modules/core/server/auth"; +import { loginWithPassword, setSessionCookie } from "@/modules/core/server/auth"; import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; -import { updateData } from "@/modules/core/server/store"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -18,45 +17,25 @@ export async function POST(request: Request): Promise { return jsonFailure("请求数据格式无效"); } - const email = normalizeEmail(readString(body, "email")); + const email = readString(body, "email"); const password = readString(body, "password"); if (!email || !password) { return jsonFailure("邮箱和密码不能为空"); } - const result = await updateData((data) => { - const account = data.accounts.find((item) => item.email === email); - if (!account || account.status !== "active" || !verifyPassword(password, account)) { - return null; - } - - const session = createSessionRecord(account.id); - data.sessions.push(session); - return { account, session }; - }); - - if (!result) { + try { + const result = await loginWithPassword({ email, password }); + await setSessionCookie(result.session.id); + return jsonSuccess("登录成功", { account: result.account }); + } catch (error) { await recordAuditLog({ - action: "auth.login", + action: "account.login", targetType: "account", result: "failure", reason: `登录失败:${email}`, }); - return jsonFailure("账号或密码错误", 401); + const reason = error instanceof Error ? error.message : "登录失败"; + return jsonFailure(reason, 401); } - - await setSessionCookie(result.session.id); - const account = toPublicAccount(result.account); - await recordAuditLog({ - actor: account, - action: "auth.login", - targetType: "account", - targetId: account.id, - result: "success", - reason: "登录成功", - }); - - return jsonSuccess("登录成功", { account }); } - diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index d659f1b..46ef8ac 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,7 +1,5 @@ -import { createAccountRecord, createSessionRecord, normalizeEmail, setSessionCookie } from "@/modules/core/server/auth"; +import { createRegistration, setSessionCookie } from "@/modules/core/server/auth"; import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; -import { recordAuditLog } from "@/modules/core/server/audit"; -import { updateData } from "@/modules/core/server/store"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -21,6 +19,7 @@ export async function POST(request: Request): Promise { const name = readString(body, "name"); const email = readString(body, "email"); const password = readString(body, "password"); + const organizationId = readString(body, "organizationId"); if (!name || !email || !password) { return jsonFailure("姓名、邮箱和密码不能为空"); @@ -30,59 +29,18 @@ export async function POST(request: Request): Promise { return jsonFailure("密码至少需要6位"); } - const normalizedEmail = normalizeEmail(email); - const created = await updateData((data) => { - const exists = data.accounts.some((account) => account.email === normalizedEmail); - if (exists) { - return null; - } - - const account = createAccountRecord({ + try { + const created = await createRegistration({ name, email, password, - role: "caregiver", + organizationId: organizationId || undefined, }); - const session = createSessionRecord(account.id); - - data.accounts.push(account); - data.sessions.push(session); - - return { account, session }; - }); - - if (!created) { - return jsonFailure("账号已存在", 409); + await setSessionCookie(created.session.id); + return jsonSuccess("账号已创建", { account: created.account }); + } catch (error) { + const reason = error instanceof Error ? error.message : "账号创建失败"; + const status = reason === "账号已存在" ? 409 : 500; + return jsonFailure(reason, status); } - - await setSessionCookie(created.session.id); - await recordAuditLog({ - actor: { - id: created.account.id, - name: created.account.name, - email: created.account.email, - role: created.account.role, - status: created.account.status, - createdAt: created.account.createdAt, - updatedAt: created.account.updatedAt, - }, - action: "account.register", - targetType: "account", - targetId: created.account.id, - result: "success", - reason: "创建照护人员账号", - }); - - return jsonSuccess("账号已创建", { - account: { - id: created.account.id, - name: created.account.name, - email: created.account.email, - role: created.account.role, - status: created.account.status, - createdAt: created.account.createdAt, - updatedAt: created.account.updatedAt, - }, - }); } - diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 97881e9..41d7e1d 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -1,12 +1,15 @@ import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { jsonSuccess } from "@/modules/core/server/api"; +export const dynamic = "force-dynamic"; + export async function GET(): Promise { const context = await getCurrentAuthContext(); return jsonSuccess("Session loaded", { account: context?.account ?? null, + organization: context?.organization ?? null, + membership: context?.membership ?? null, permissions: context?.permissions ?? [], }); } - diff --git a/app/api/auth/setup/route.ts b/app/api/auth/setup/route.ts index c6a8b85..51079dc 100644 --- a/app/api/auth/setup/route.ts +++ b/app/api/auth/setup/route.ts @@ -1,7 +1,6 @@ -import { createAccountRecord, createSessionRecord, setSessionCookie } from "@/modules/core/server/auth"; +import { setSessionCookie, setupFirstPlatformAdmin } from "@/modules/core/server/auth"; import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; -import { recordAuditLog } from "@/modules/core/server/audit"; -import { updateData } from "@/modules/core/server/store"; +import { ensureSystemDefaults } from "@/modules/core/server/permissions"; function readString(source: Record, key: string): string { const value = source[key]; @@ -31,60 +30,14 @@ export async function POST(request: Request): Promise { return jsonFailure("密码至少需要6位"); } - const created = await updateData((data) => { - if (data.accounts.length > 0) { - return null; - } - - const account = createAccountRecord({ - name, - email, - password, - role: "admin", - organization, - }); - const session = createSessionRecord(account.id); - - data.accounts.push(account); - data.sessions.push(session); - - return { account, session }; - }); - - if (!created) { - return jsonFailure("系统已完成初始化", 409); + try { + await ensureSystemDefaults(); + const created = await setupFirstPlatformAdmin({ organization, name, email, password }); + await setSessionCookie(created.session.id); + return jsonSuccess("初始化完成", { account: created.account }); + } catch (error) { + const reason = error instanceof Error ? error.message : "初始化失败"; + const status = reason === "系统已完成初始化" ? 409 : 500; + return jsonFailure(reason, status); } - - await setSessionCookie(created.session.id); - await recordAuditLog({ - actor: { - id: created.account.id, - name: created.account.name, - email: created.account.email, - role: created.account.role, - organization: created.account.organization, - status: created.account.status, - createdAt: created.account.createdAt, - updatedAt: created.account.updatedAt, - }, - action: "account.setup", - targetType: "account", - targetId: created.account.id, - result: "success", - reason: "初始化管理员账号", - }); - - return jsonSuccess("初始化完成", { - account: { - id: created.account.id, - name: created.account.name, - email: created.account.email, - role: created.account.role, - organization: created.account.organization, - status: created.account.status, - createdAt: created.account.createdAt, - updatedAt: created.account.updatedAt, - }, - }); } - diff --git a/app/api/elders/[id]/route.ts b/app/api/elders/[id]/route.ts index 01e4b80..1641546 100644 --- a/app/api/elders/[id]/route.ts +++ b/app/api/elders/[id]/route.ts @@ -1,8 +1,11 @@ -import { validateElderInput } from "@/modules/elders/types"; +import { and, eq } from "drizzle-orm"; + import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; import { requirePermission } from "@/modules/core/server/auth"; -import { updateData } from "@/modules/core/server/store"; +import { getDatabase } from "@/modules/core/server/db"; +import { admissions, beds, elders } from "@/modules/core/server/schema"; +import { validateElderInput } from "@/modules/elders/types"; type RouteContext = { params: Promise<{ @@ -22,41 +25,66 @@ export async function PATCH(request: Request, context: RouteContext): Promise { - const elder = data.elders.find((item) => item.id === id); - if (!elder) { - return null; - } + const database = getDatabase(); + const updated = await database + .update(elders) + .set({ + name: input.data.name, + gender: input.data.gender, + age: input.data.age, + careLevel: input.data.careLevel, + status: input.data.status, + primaryContact: input.data.primaryContact, + phone: input.data.phone, + medicalNotes: input.data.medicalNotes, + updatedAt: new Date(), + }) + .where(and(eq(elders.id, id), eq(elders.organizationId, organizationId))) + .returning(); + const elder = updated[0]; - const nextElder = { - ...elder, - ...input.data, - updatedAt: new Date().toISOString(), - }; - - data.elders = data.elders.map((item) => (item.id === id ? nextElder : item)); - return nextElder; - }); - - if (!updated) { + if (!elder) { return jsonFailure("老人档案不存在", 404); } await recordAuditLog({ actor: auth.context.account, + organizationId, action: "elder.update", targetType: "elder", - targetId: updated.id, + targetId: elder.id, result: "success", - reason: `更新老人档案:${updated.name}`, + reason: `更新老人档案:${elder.name}`, }); - return jsonSuccess("老人档案已更新", { elder: updated }); + return jsonSuccess("老人档案已更新", { + elder: { + id: elder.id, + organizationId: elder.organizationId, + name: elder.name, + gender: elder.gender, + age: elder.age, + careLevel: elder.careLevel, + room: "", + bed: "", + status: elder.status, + primaryContact: elder.primaryContact, + phone: elder.phone, + medicalNotes: elder.medicalNotes, + createdAt: elder.createdAt.toISOString(), + updatedAt: elder.updatedAt.toISOString(), + }, + }); } export async function DELETE(_request: Request, context: RouteContext): Promise { @@ -71,13 +99,43 @@ export async function DELETE(_request: Request, context: RouteContext): Promise< return auth.response; } - const deleted = await updateData((data) => { - const elder = data.elders.find((item) => item.id === id); + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后删除老人档案", 400); + } + + const database = getDatabase(); + const deleted = await database.transaction(async (transaction) => { + const elderRows = await transaction + .select() + .from(elders) + .where(and(eq(elders.id, id), eq(elders.organizationId, organizationId))) + .limit(1); + const elder = elderRows[0]; if (!elder) { return null; } - data.elders = data.elders.filter((item) => item.id !== id); + const activeAdmissions = await transaction + .select() + .from(admissions) + .where(and(eq(admissions.elderId, id), eq(admissions.status, "active"))); + + await Promise.all( + activeAdmissions.map((admission) => + transaction + .update(admissions) + .set({ status: "discharged", dischargedAt: new Date(), updatedAt: new Date() }) + .where(eq(admissions.id, admission.id)), + ), + ); + await Promise.all( + activeAdmissions.map((admission) => + transaction.update(beds).set({ status: "available", updatedAt: new Date() }).where(eq(beds.id, admission.bedId)), + ), + ); + + await transaction.delete(elders).where(eq(elders.id, id)); return elder; }); @@ -87,6 +145,7 @@ export async function DELETE(_request: Request, context: RouteContext): Promise< await recordAuditLog({ actor: auth.context.account, + organizationId, action: "elder.delete", targetType: "elder", targetId: deleted.id, @@ -94,6 +153,5 @@ export async function DELETE(_request: Request, context: RouteContext): Promise< reason: `删除老人档案:${deleted.name}`, }); - return jsonSuccess("老人档案已删除", { elder: deleted }); + return jsonSuccess("老人档案已删除", { elder: { id: deleted.id, name: deleted.name } }); } - diff --git a/app/api/elders/route.ts b/app/api/elders/route.ts index c5fbc76..e5f1f86 100644 --- a/app/api/elders/route.ts +++ b/app/api/elders/route.ts @@ -1,11 +1,34 @@ import { randomUUID } from "node:crypto"; -import type { Elder } from "@/modules/elders/types"; -import { validateElderInput } from "@/modules/elders/types"; +import { and, desc, eq } from "drizzle-orm"; + import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; import { requirePermission } from "@/modules/core/server/auth"; -import { readData, updateData } from "@/modules/core/server/store"; +import { getDatabase } from "@/modules/core/server/db"; +import { admissions, beds, elders } from "@/modules/core/server/schema"; +import { validateElderInput } from "@/modules/elders/types"; +import type { Elder } from "@/modules/elders/types"; + +function toElder(row: typeof elders.$inferSelect, bedLabel?: { room: string; bed: string; bedId: string }): Elder { + return { + id: row.id, + organizationId: row.organizationId, + name: row.name, + gender: row.gender, + age: row.age, + careLevel: row.careLevel, + room: bedLabel?.room ?? "", + bed: bedLabel?.bed ?? "", + bedId: bedLabel?.bedId, + status: row.status, + primaryContact: row.primaryContact, + phone: row.phone, + medicalNotes: row.medicalNotes, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} export async function GET(): Promise { const auth = await requirePermission("elder:read", { @@ -17,8 +40,19 @@ export async function GET(): Promise { return auth.response; } - const data = await readData(); - return jsonSuccess("老人档案已加载", { elders: data.elders }); + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后查看老人档案", 400); + } + + const database = getDatabase(); + const rows = await database + .select() + .from(elders) + .where(eq(elders.organizationId, organizationId)) + .orderBy(desc(elders.createdAt)); + + return jsonSuccess("老人档案已加载", { elders: rows.map((row) => toElder(row)) }); } export async function POST(request: Request): Promise { @@ -31,24 +65,72 @@ export async function POST(request: Request): Promise { return auth.response; } + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后新增老人档案", 400); + } + const input = validateElderInput(await readJsonBody(request)); if (!input.success) { return jsonFailure(input.reason); } - const now = new Date().toISOString(); - const elder: Elder = { - ...input.data, - id: randomUUID(), - createdAt: now, - updatedAt: now, - }; + const database = getDatabase(); + const created = await database.transaction(async (transaction) => { + const elderId = randomUUID(); + const elderRows = await transaction + .insert(elders) + .values({ + id: elderId, + organizationId, + name: input.data.name, + gender: input.data.gender, + age: input.data.age, + careLevel: input.data.careLevel, + status: input.data.status, + primaryContact: input.data.primaryContact, + phone: input.data.phone, + medicalNotes: input.data.medicalNotes, + }) + .returning(); + const elder = elderRows[0]; + if (!elder) { + throw new Error("老人档案创建失败"); + } - await updateData((data) => { - data.elders = [elder, ...data.elders]; + if (input.data.bedId) { + const bedRows = await transaction + .select() + .from(beds) + .where(and(eq(beds.id, input.data.bedId), eq(beds.organizationId, organizationId))) + .limit(1); + const bed = bedRows[0]; + if (!bed) { + throw new Error("床位不存在"); + } + if (bed.status !== "available") { + throw new Error("床位不可分配"); + } + + await transaction + .insert(admissions) + .values({ + organizationId, + elderId: elder.id, + bedId: bed.id, + status: "active", + notes: "新增档案时办理入住", + }); + await transaction.update(beds).set({ status: "occupied", updatedAt: new Date() }).where(eq(beds.id, bed.id)); + } + + return elder; }); + + const elder = toElder(created); await recordAuditLog({ actor: auth.context.account, + organizationId, action: "elder.create", targetType: "elder", targetId: elder.id, @@ -58,4 +140,3 @@ export async function POST(request: Request): Promise { return jsonSuccess("老人档案已创建", { elder }, 201); } - diff --git a/app/api/facilities/beds/route.ts b/app/api/facilities/beds/route.ts new file mode 100644 index 0000000..8de0d56 --- /dev/null +++ b/app/api/facilities/beds/route.ts @@ -0,0 +1,106 @@ +import { and, eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { beds, rooms } from "@/modules/core/server/schema"; +import { readData } from "@/modules/core/server/store"; +import type { BedStatus } from "@/modules/core/types"; + +const BED_STATUS_VALUES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readBedStatus(value: string): BedStatus { + return BED_STATUS_VALUES.find((status) => status === value) ?? "available"; +} + +export async function GET(): Promise { + const auth = await requirePermission("facility:read", { + action: "bed.list", + targetType: "bed", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + const organizationId = auth.context.organization?.id; + return jsonSuccess("床位列表已加载", { + beds: organizationId ? data.beds.filter((bed) => bed.organizationId === organizationId) : data.beds, + }); +} + +export async function POST(request: Request): Promise { + const auth = await requirePermission("facility:manage", { + action: "bed.create", + targetType: "bed", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后维护床位", 400); + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const roomId = readString(body, "roomId"); + const code = readString(body, "code"); + if (!roomId || !code) { + return jsonFailure("房间和床位编号不能为空"); + } + + const database = getDatabase(); + const roomRows = await database + .select() + .from(rooms) + .where(and(eq(rooms.id, roomId), eq(rooms.organizationId, organizationId))) + .limit(1); + const room = roomRows[0]; + if (!room) { + return jsonFailure("房间不存在", 404); + } + + const createdRows = await database + .insert(beds) + .values({ + organizationId, + roomId, + code, + status: readBedStatus(readString(body, "status")), + notes: readString(body, "notes"), + }) + .returning(); + const bed = createdRows[0]; + if (!bed) { + return jsonFailure("床位创建失败", 500); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "bed.create", + targetType: "bed", + targetId: bed.id, + result: "success", + reason: `新增床位:${room.name}-${bed.code}`, + }); + + return jsonSuccess("床位已创建", { bed }, 201); +} diff --git a/app/api/facilities/rooms/route.ts b/app/api/facilities/rooms/route.ts new file mode 100644 index 0000000..2d0f4ca --- /dev/null +++ b/app/api/facilities/rooms/route.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { campuses, buildings, floors, rooms } from "@/modules/core/server/schema"; +import { readData } from "@/modules/core/server/store"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readPositiveNumber(source: Record, key: string, fallback: number): number { + const value = source[key]; + const numeric = typeof value === "number" ? value : Number(value); + return Number.isInteger(numeric) && numeric > 0 ? numeric : fallback; +} + +export async function GET(): Promise { + const auth = await requirePermission("facility:read", { + action: "room.list", + targetType: "room", + }); + + if (!auth.success) { + return auth.response; + } + + const data = await readData(); + const organizationId = auth.context.organization?.id; + return jsonSuccess("房间列表已加载", { + rooms: organizationId ? data.rooms.filter((room) => room.organizationId === organizationId) : data.rooms, + }); +} + +export async function POST(request: Request): Promise { + const auth = await requirePermission("facility:manage", { + action: "room.create", + targetType: "room", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后维护房间", 400); + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const roomName = readString(body, "name"); + const roomCode = readString(body, "code"); + if (!roomName || !roomCode) { + return jsonFailure("房间名称和编号不能为空"); + } + + const database = getDatabase(); + const createdRows = await database.transaction(async (transaction) => { + const campusRows = await transaction.select().from(campuses).where(eq(campuses.organizationId, organizationId)).limit(1); + const campus = + campusRows[0] ?? + ( + await transaction + .insert(campuses) + .values({ organizationId, name: "默认院区", address: "" }) + .returning() + )[0]; + if (!campus) { + throw new Error("院区初始化失败"); + } + + const buildingRows = await transaction.select().from(buildings).where(eq(buildings.campusId, campus.id)).limit(1); + const building = + buildingRows[0] ?? + ( + await transaction + .insert(buildings) + .values({ organizationId, campusId: campus.id, name: "默认楼栋" }) + .returning() + )[0]; + if (!building) { + throw new Error("楼栋初始化失败"); + } + + const floorRows = await transaction.select().from(floors).where(eq(floors.buildingId, building.id)).limit(1); + const floor = + floorRows[0] ?? + ( + await transaction + .insert(floors) + .values({ organizationId, buildingId: building.id, name: "默认楼层", level: 1 }) + .returning() + )[0]; + if (!floor) { + throw new Error("楼层初始化失败"); + } + + return await transaction + .insert(rooms) + .values({ + id: randomUUID(), + organizationId, + floorId: floor.id, + name: roomName, + code: roomCode, + capacity: readPositiveNumber(body, "capacity", 1), + }) + .returning(); + }); + + const room = createdRows[0]; + if (!room) { + return jsonFailure("房间创建失败", 500); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "room.create", + targetType: "room", + targetId: room.id, + result: "success", + reason: `新增房间:${room.name}`, + }); + + return jsonSuccess("房间已创建", { room }, 201); +} diff --git a/app/api/settings/accounts/route.ts b/app/api/settings/accounts/route.ts index b6d1345..3add8bc 100644 --- a/app/api/settings/accounts/route.ts +++ b/app/api/settings/accounts/route.ts @@ -1,7 +1,22 @@ +import { and, eq } from "drizzle-orm"; + import { jsonSuccess } from "@/modules/core/server/api"; -import { requirePermission, toPublicAccount } from "@/modules/core/server/auth"; +import { hashPassword, normalizeEmail, requirePermission, toPublicAccount } from "@/modules/core/server/auth"; +import { jsonFailure, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { getDatabase } from "@/modules/core/server/db"; +import { accounts, memberships, organizations, roles } from "@/modules/core/server/schema"; import { readData } from "@/modules/core/server/store"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + export async function GET(): Promise { const auth = await requirePermission("account:read", { action: "account.list", @@ -18,3 +33,93 @@ export async function GET(): Promise { }); } +export async function POST(request: Request): Promise { + const auth = await requirePermission("account:manage", { + action: "account.create", + targetType: "account", + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const name = readString(body, "name"); + const email = readString(body, "email"); + const password = readString(body, "password"); + const roleId = readString(body, "roleId"); + const organizationId = readString(body, "organizationId") || auth.context.organization?.id; + + if (!name || !email || !password || !roleId || !organizationId) { + return jsonFailure("姓名、邮箱、密码、机构和角色不能为空"); + } + + const database = getDatabase(); + const passwordHash = hashPassword(password); + const created = await database.transaction(async (transaction) => { + const organizationRows = await transaction.select().from(organizations).where(eq(organizations.id, organizationId)).limit(1); + const organization = organizationRows[0]; + if (!organization) { + throw new Error("机构不存在"); + } + + const roleRows = await transaction + .select() + .from(roles) + .where(and(eq(roles.id, roleId), eq(roles.organizationId, organizationId))) + .limit(1); + const role = roleRows[0]; + if (!role) { + throw new Error("角色不存在"); + } + + const accountRows = await transaction + .insert(accounts) + .values({ + name, + email: normalizeEmail(email), + passwordHash: passwordHash.hash, + passwordSalt: passwordHash.salt, + status: "active", + }) + .returning(); + const account = accountRows[0]; + if (!account) { + throw new Error("账号创建失败"); + } + + await transaction.insert(memberships).values({ + accountId: account.id, + organizationId, + roleId, + status: "active", + }); + + return { account, organization, role }; + }); + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "account.create", + targetType: "account", + targetId: created.account.id, + result: "success", + reason: `创建机构用户:${created.account.email}`, + }); + + return jsonSuccess("账号已创建", { + account: toPublicAccount(created.account, created.role.key, { + id: created.organization.id, + name: created.organization.name, + slug: created.organization.slug, + status: created.organization.status, + createdAt: created.organization.createdAt.toISOString(), + updatedAt: created.organization.updatedAt.toISOString(), + }), + }, 201); +} diff --git a/app/api/settings/permissions/route.ts b/app/api/settings/permissions/route.ts new file mode 100644 index 0000000..ca8fb94 --- /dev/null +++ b/app/api/settings/permissions/route.ts @@ -0,0 +1,16 @@ +import { jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission } from "@/modules/core/server/auth"; +import { PERMISSION_DEFINITIONS } from "@/modules/core/server/permissions"; + +export async function GET(): Promise { + const auth = await requirePermission("permission:read", { + action: "permission.list", + targetType: "permission", + }); + + if (!auth.success) { + return auth.response; + } + + return jsonSuccess("权限点已加载", { permissions: PERMISSION_DEFINITIONS }); +} diff --git a/app/api/settings/roles/route.ts b/app/api/settings/roles/route.ts index c117a50..6ebacbd 100644 --- a/app/api/settings/roles/route.ts +++ b/app/api/settings/roles/route.ts @@ -1,9 +1,32 @@ import { jsonSuccess } from "@/modules/core/server/api"; +import { jsonFailure, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; import { requirePermission } from "@/modules/core/server/auth"; -import { ROLE_DEFINITIONS } from "@/modules/core/server/permissions"; +import { getDatabase } from "@/modules/core/server/db"; +import { getRoleDefinitions } from "@/modules/core/server/permissions"; +import { rolePermissions, roles } from "@/modules/core/server/schema"; +import type { Permission } from "@/modules/core/types"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function readPermissionArray(source: Record): Permission[] { + const value = source.permissions; + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is Permission => typeof item === "string"); +} export async function GET(): Promise { - const auth = await requirePermission("account:read", { + const auth = await requirePermission("role:read", { action: "role.list", targetType: "role", }); @@ -12,6 +35,85 @@ export async function GET(): Promise { return auth.response; } - return jsonSuccess("角色列表已加载", { roles: ROLE_DEFINITIONS }); + const roles = await getRoleDefinitions(auth.context.organization?.id); + return jsonSuccess("角色列表已加载", { roles }); } +export async function POST(request: Request): Promise { + const auth = await requirePermission("role:manage", { + action: "role.create", + targetType: "role", + }); + + if (!auth.success) { + return auth.response; + } + + const organizationId = auth.context.organization?.id; + if (!organizationId) { + return jsonFailure("请选择机构后创建角色", 400); + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const label = readString(body, "label"); + const key = readString(body, "key") || label.toLowerCase().replace(/[^a-z0-9]+/g, "_"); + const description = readString(body, "description"); + const permissions = readPermissionArray(body); + + if (!label || !key) { + return jsonFailure("角色名称不能为空"); + } + + const database = getDatabase(); + const created = await database.transaction(async (transaction) => { + const rows = await transaction + .insert(roles) + .values({ + key, + organizationId, + scope: "organization", + label, + description: description || "机构自定义角色", + isSystem: false, + isEnabled: true, + }) + .returning(); + const role = rows[0]; + if (!role) { + throw new Error("角色创建失败"); + } + + if (permissions.length > 0) { + await transaction.insert(rolePermissions).values( + permissions.map((permissionId) => ({ + roleId: role.id, + permissionId, + })), + ); + } + + return role; + }); + + await recordAuditLog({ + actor: auth.context.account, + organizationId, + action: "role.create", + targetType: "role", + targetId: created.id, + result: "success", + reason: `创建自定义角色:${created.label}`, + }); + + const roleRows = await getRoleDefinitions(organizationId); + const role = roleRows.find((item) => item.id === created.id); + if (!role) { + return jsonFailure("角色创建后读取失败", 500); + } + + return jsonSuccess("角色已创建", { role }, 201); +} diff --git a/app/api/system/incidents/[id]/route.ts b/app/api/system/incidents/[id]/route.ts new file mode 100644 index 0000000..0d0c3f3 --- /dev/null +++ b/app/api/system/incidents/[id]/route.ts @@ -0,0 +1,85 @@ +import { and, eq } from "drizzle-orm"; + +import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api"; +import { recordAuditLog } from "@/modules/core/server/audit"; +import { requirePermission } from "@/modules/core/server/auth"; +import { getDatabase } from "@/modules/core/server/db"; +import { systemIncidents } from "@/modules/core/server/schema"; +import type { IncidentStatus } from "@/modules/core/types"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +const INCIDENT_STATUSES: IncidentStatus[] = ["open", "acknowledged", "resolved", "closed"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readStatus(value: unknown): IncidentStatus | null { + if (typeof value !== "string") { + return null; + } + + return INCIDENT_STATUSES.find((status) => status === value) ?? null; +} + +export async function PATCH(request: Request, context: RouteContext): Promise { + const { id } = await context.params; + const auth = await requirePermission("incident:manage", { + action: "incident.update", + targetType: "incident", + targetId: id, + }); + + if (!auth.success) { + return auth.response; + } + + const body = await readJsonBody(request); + if (!isRecord(body)) { + return jsonFailure("请求数据格式无效"); + } + + const status = readStatus(body.status); + if (!status) { + return jsonFailure("故障状态无效"); + } + + const database = getDatabase(); + const organizationId = auth.context.organization?.id; + const updateValues = { + status, + updatedAt: new Date(), + acknowledgedByAccountId: status === "acknowledged" ? auth.context.account.id : undefined, + acknowledgedAt: status === "acknowledged" ? new Date() : undefined, + resolvedByAccountId: status === "resolved" || status === "closed" ? auth.context.account.id : undefined, + resolvedAt: status === "resolved" || status === "closed" ? new Date() : undefined, + }; + const rows = organizationId + ? await database + .update(systemIncidents) + .set(updateValues) + .where(and(eq(systemIncidents.id, id), eq(systemIncidents.organizationId, organizationId))) + .returning() + : await database.update(systemIncidents).set(updateValues).where(eq(systemIncidents.id, id)).returning(); + const incident = rows[0]; + if (!incident) { + return jsonFailure("故障事件不存在", 404); + } + + await recordAuditLog({ + actor: auth.context.account, + organizationId: incident.organizationId ?? undefined, + action: "incident.update", + targetType: "incident", + targetId: incident.id, + result: "success", + reason: `故障状态更新为:${status}`, + }); + + return jsonSuccess("故障事件已更新", { incident }); +} diff --git a/app/api/system/status/route.ts b/app/api/system/status/route.ts new file mode 100644 index 0000000..70a3a93 --- /dev/null +++ b/app/api/system/status/route.ts @@ -0,0 +1,49 @@ +import { desc } from "drizzle-orm"; + +import { jsonSuccess } from "@/modules/core/server/api"; +import { requirePermission } from "@/modules/core/server/auth"; +import { checkDatabaseConnection, getDatabase } from "@/modules/core/server/db"; +import { accounts, beds, elders, organizations, systemIncidents } from "@/modules/core/server/schema"; + +export async function GET(): Promise { + const auth = await requirePermission("incident:read", { + action: "system.status", + targetType: "systemStatus", + }); + + if (!auth.success) { + return auth.response; + } + + const health = await checkDatabaseConnection(); + const database = getDatabase(); + const [organizationRows, accountRows, elderRows, bedRows, incidentRows] = await Promise.all([ + database.select({ id: organizations.id }).from(organizations), + database.select({ id: accounts.id }).from(accounts), + database.select({ id: elders.id }).from(elders), + database.select({ id: beds.id }).from(beds), + database.select().from(systemIncidents).orderBy(desc(systemIncidents.createdAt)).limit(20), + ]); + + return jsonSuccess("系统运行状态已加载", { + health, + metrics: { + organizations: organizationRows.length, + accounts: accountRows.length, + elders: elderRows.length, + beds: bedRows.length, + incidents: incidentRows.length, + }, + incidents: incidentRows.map((incident) => ({ + id: incident.id, + organizationId: incident.organizationId, + severity: incident.severity, + status: incident.status, + title: incident.title, + description: incident.description, + source: incident.source, + createdAt: incident.createdAt.toISOString(), + updatedAt: incident.updatedAt.toISOString(), + })), + }); +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..301d19e --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./modules/core/server/schema.ts", + out: "./drizzle", + dbCredentials: { + url: process.env.DATABASE_URL ?? "", + }, +}); diff --git a/drizzle/0000_bouncy_micromax.sql b/drizzle/0000_bouncy_micromax.sql new file mode 100644 index 0000000..c972d77 --- /dev/null +++ b/drizzle/0000_bouncy_micromax.sql @@ -0,0 +1,235 @@ +CREATE TYPE "public"."account_status" AS ENUM('active', 'disabled', 'pending');--> statement-breakpoint +CREATE TYPE "public"."admission_status" AS ENUM('active', 'transferred', 'discharged');--> statement-breakpoint +CREATE TYPE "public"."audit_result" AS ENUM('success', 'denied', 'failure');--> statement-breakpoint +CREATE TYPE "public"."bed_status" AS ENUM('available', 'occupied', 'maintenance', 'disabled');--> statement-breakpoint +CREATE TYPE "public"."care_level" AS ENUM('self-care', 'semi-assisted', 'assisted', 'intensive');--> statement-breakpoint +CREATE TYPE "public"."elder_status" AS ENUM('active', 'pending', 'discharged');--> statement-breakpoint +CREATE TYPE "public"."gender" AS ENUM('male', 'female', 'other');--> statement-breakpoint +CREATE TYPE "public"."incident_severity" AS ENUM('info', 'warning', 'critical');--> statement-breakpoint +CREATE TYPE "public"."incident_status" AS ENUM('open', 'acknowledged', 'resolved', 'closed');--> statement-breakpoint +CREATE TYPE "public"."join_request_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint +CREATE TYPE "public"."membership_status" AS ENUM('active', 'disabled', 'pending');--> statement-breakpoint +CREATE TYPE "public"."organization_status" AS ENUM('active', 'disabled');--> statement-breakpoint +CREATE TYPE "public"."role_scope" AS ENUM('platform', 'organization');--> statement-breakpoint +CREATE TABLE "accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "platform_role_id" uuid, + "name" text NOT NULL, + "email" text NOT NULL, + "password_hash" text NOT NULL, + "password_salt" text NOT NULL, + "status" "account_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "admissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "elder_id" uuid NOT NULL, + "bed_id" uuid NOT NULL, + "status" "admission_status" DEFAULT 'active' NOT NULL, + "admitted_at" timestamp with time zone DEFAULT now() NOT NULL, + "discharged_at" timestamp with time zone, + "notes" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid, + "timestamp" timestamp with time zone DEFAULT now() NOT NULL, + "actor_account_id" uuid, + "actor_email" text, + "action" text NOT NULL, + "target_type" text NOT NULL, + "target_id" text, + "result" "audit_result" NOT NULL, + "reason" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "beds" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "room_id" uuid NOT NULL, + "code" text NOT NULL, + "status" "bed_status" DEFAULT 'available' NOT NULL, + "notes" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "buildings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "campus_id" uuid NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "campuses" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "name" text NOT NULL, + "address" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "elders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "name" text NOT NULL, + "gender" "gender" NOT NULL, + "age" integer NOT NULL, + "care_level" "care_level" NOT NULL, + "status" "elder_status" DEFAULT 'pending' NOT NULL, + "primary_contact" text NOT NULL, + "phone" text NOT NULL, + "medical_notes" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "floors" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "building_id" uuid NOT NULL, + "name" text NOT NULL, + "level" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "join_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "organization_id" uuid NOT NULL, + "status" "join_request_status" DEFAULT 'pending' NOT NULL, + "reason" text DEFAULT '' NOT NULL, + "reviewed_by_account_id" uuid, + "reviewed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "memberships" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "organization_id" uuid NOT NULL, + "role_id" uuid NOT NULL, + "status" "membership_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organizations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "status" "organization_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "permissions" ( + "id" text PRIMARY KEY NOT NULL, + "label" text NOT NULL, + "category" text NOT NULL, + "description" text NOT NULL, + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "role_permissions" ( + "role_id" uuid NOT NULL, + "permission_id" text NOT NULL, + CONSTRAINT "role_permissions_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id") +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "organization_id" uuid, + "scope" "role_scope" NOT NULL, + "label" text NOT NULL, + "description" text NOT NULL, + "is_system" boolean DEFAULT false NOT NULL, + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rooms" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "floor_id" uuid NOT NULL, + "name" text NOT NULL, + "code" text NOT NULL, + "capacity" integer DEFAULT 1 NOT NULL, + "status" "organization_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "active_organization_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "system_incidents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid, + "severity" "incident_severity" NOT NULL, + "status" "incident_status" DEFAULT 'open' NOT NULL, + "title" text NOT NULL, + "description" text NOT NULL, + "source" text NOT NULL, + "acknowledged_by_account_id" uuid, + "acknowledged_at" timestamp with time zone, + "resolved_by_account_id" uuid, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_platform_role_id_roles_id_fk" FOREIGN KEY ("platform_role_id") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "admissions" ADD CONSTRAINT "admissions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "admissions" ADD CONSTRAINT "admissions_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "admissions" ADD CONSTRAINT "admissions_bed_id_beds_id_fk" FOREIGN KEY ("bed_id") REFERENCES "public"."beds"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_account_id_accounts_id_fk" FOREIGN KEY ("actor_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "beds" ADD CONSTRAINT "beds_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "beds" ADD CONSTRAINT "beds_room_id_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "buildings" ADD CONSTRAINT "buildings_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "buildings" ADD CONSTRAINT "buildings_campus_id_campuses_id_fk" FOREIGN KEY ("campus_id") REFERENCES "public"."campuses"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "campuses" ADD CONSTRAINT "campuses_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "elders" ADD CONSTRAINT "elders_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "floors" ADD CONSTRAINT "floors_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "floors" ADD CONSTRAINT "floors_building_id_buildings_id_fk" FOREIGN KEY ("building_id") REFERENCES "public"."buildings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "join_requests" ADD CONSTRAINT "join_requests_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "join_requests" ADD CONSTRAINT "join_requests_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "join_requests" ADD CONSTRAINT "join_requests_reviewed_by_account_id_accounts_id_fk" FOREIGN KEY ("reviewed_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_permissions_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "roles" ADD CONSTRAINT "roles_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rooms" ADD CONSTRAINT "rooms_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rooms" ADD CONSTRAINT "rooms_floor_id_floors_id_fk" FOREIGN KEY ("floor_id") REFERENCES "public"."floors"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_active_organization_id_organizations_id_fk" FOREIGN KEY ("active_organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "system_incidents" ADD CONSTRAINT "system_incidents_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "system_incidents" ADD CONSTRAINT "system_incidents_acknowledged_by_account_id_accounts_id_fk" FOREIGN KEY ("acknowledged_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "system_incidents" ADD CONSTRAINT "system_incidents_resolved_by_account_id_accounts_id_fk" FOREIGN KEY ("resolved_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "accounts_email_unique" ON "accounts" USING btree ("email");--> statement-breakpoint +CREATE UNIQUE INDEX "beds_code_room_unique" ON "beds" USING btree ("room_id","code");--> statement-breakpoint +CREATE UNIQUE INDEX "memberships_account_organization_unique" ON "memberships" USING btree ("account_id","organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "organizations_slug_unique" ON "organizations" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX "roles_key_organization_unique" ON "roles" USING btree ("key","organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "rooms_code_organization_unique" ON "rooms" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..c27992c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1772 @@ +{ + "id": "eef2df79-ffc1-4c17-b07c-1f58053cfdcc", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "platform_role_id": { + "name": "platform_role_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_salt": { + "name": "password_salt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_unique": { + "name": "accounts_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_platform_role_id_roles_id_fk": { + "name": "accounts_platform_role_id_roles_id_fk", + "tableFrom": "accounts", + "tableTo": "roles", + "columnsFrom": [ + "platform_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admissions": { + "name": "admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "elder_id": { + "name": "elder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bed_id": { + "name": "bed_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "admission_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "discharged_at": { + "name": "discharged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admissions_organization_id_organizations_id_fk": { + "name": "admissions_organization_id_organizations_id_fk", + "tableFrom": "admissions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admissions_elder_id_elders_id_fk": { + "name": "admissions_elder_id_elders_id_fk", + "tableFrom": "admissions", + "tableTo": "elders", + "columnsFrom": [ + "elder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admissions_bed_id_beds_id_fk": { + "name": "admissions_bed_id_beds_id_fk", + "tableFrom": "admissions", + "tableTo": "beds", + "columnsFrom": [ + "bed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "audit_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_account_id_accounts_id_fk": { + "name": "audit_logs_actor_account_id_accounts_id_fk", + "tableFrom": "audit_logs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beds": { + "name": "beds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "bed_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "beds_code_room_unique": { + "name": "beds_code_room_unique", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beds_organization_id_organizations_id_fk": { + "name": "beds_organization_id_organizations_id_fk", + "tableFrom": "beds", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "beds_room_id_rooms_id_fk": { + "name": "beds_room_id_rooms_id_fk", + "tableFrom": "beds", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.buildings": { + "name": "buildings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "campus_id": { + "name": "campus_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_organization_id_organizations_id_fk": { + "name": "buildings_organization_id_organizations_id_fk", + "tableFrom": "buildings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "buildings_campus_id_campuses_id_fk": { + "name": "buildings_campus_id_campuses_id_fk", + "tableFrom": "buildings", + "tableTo": "campuses", + "columnsFrom": [ + "campus_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campuses": { + "name": "campuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campuses_organization_id_organizations_id_fk": { + "name": "campuses_organization_id_organizations_id_fk", + "tableFrom": "campuses", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.elders": { + "name": "elders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "care_level": { + "name": "care_level", + "type": "care_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "elder_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "primary_contact": { + "name": "primary_contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medical_notes": { + "name": "medical_notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "elders_organization_id_organizations_id_fk": { + "name": "elders_organization_id_organizations_id_fk", + "tableFrom": "elders", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.floors": { + "name": "floors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "building_id": { + "name": "building_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "floors_organization_id_organizations_id_fk": { + "name": "floors_organization_id_organizations_id_fk", + "tableFrom": "floors", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "join_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reviewed_by_account_id": { + "name": "reviewed_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "join_requests_account_id_accounts_id_fk": { + "name": "join_requests_account_id_accounts_id_fk", + "tableFrom": "join_requests", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_organization_id_organizations_id_fk": { + "name": "join_requests_organization_id_organizations_id_fk", + "tableFrom": "join_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "join_requests_reviewed_by_account_id_accounts_id_fk": { + "name": "join_requests_reviewed_by_account_id_accounts_id_fk", + "tableFrom": "join_requests", + "tableTo": "accounts", + "columnsFrom": [ + "reviewed_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "membership_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_account_organization_unique": { + "name": "memberships_account_organization_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_role_id_roles_id_fk": { + "name": "memberships_role_id_roles_id_fk", + "tableFrom": "memberships", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "organization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "role_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_key_organization_unique": { + "name": "roles_key_organization_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_organization_id_organizations_id_fk": { + "name": "roles_organization_id_organizations_id_fk", + "tableFrom": "roles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "floor_id": { + "name": "floor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "organization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rooms_code_organization_unique": { + "name": "rooms_code_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rooms_organization_id_organizations_id_fk": { + "name": "rooms_organization_id_organizations_id_fk", + "tableFrom": "rooms", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_account_id_accounts_id_fk": { + "name": "sessions_account_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_active_organization_id_organizations_id_fk": { + "name": "sessions_active_organization_id_organizations_id_fk", + "tableFrom": "sessions", + "tableTo": "organizations", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_incidents": { + "name": "system_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "incident_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "incident_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acknowledged_by_account_id": { + "name": "acknowledged_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_account_id": { + "name": "resolved_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_incidents_organization_id_organizations_id_fk": { + "name": "system_incidents_organization_id_organizations_id_fk", + "tableFrom": "system_incidents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_incidents_acknowledged_by_account_id_accounts_id_fk": { + "name": "system_incidents_acknowledged_by_account_id_accounts_id_fk", + "tableFrom": "system_incidents", + "tableTo": "accounts", + "columnsFrom": [ + "acknowledged_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "system_incidents_resolved_by_account_id_accounts_id_fk": { + "name": "system_incidents_resolved_by_account_id_accounts_id_fk", + "tableFrom": "system_incidents", + "tableTo": "accounts", + "columnsFrom": [ + "resolved_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_status": { + "name": "account_status", + "schema": "public", + "values": [ + "active", + "disabled", + "pending" + ] + }, + "public.admission_status": { + "name": "admission_status", + "schema": "public", + "values": [ + "active", + "transferred", + "discharged" + ] + }, + "public.audit_result": { + "name": "audit_result", + "schema": "public", + "values": [ + "success", + "denied", + "failure" + ] + }, + "public.bed_status": { + "name": "bed_status", + "schema": "public", + "values": [ + "available", + "occupied", + "maintenance", + "disabled" + ] + }, + "public.care_level": { + "name": "care_level", + "schema": "public", + "values": [ + "self-care", + "semi-assisted", + "assisted", + "intensive" + ] + }, + "public.elder_status": { + "name": "elder_status", + "schema": "public", + "values": [ + "active", + "pending", + "discharged" + ] + }, + "public.gender": { + "name": "gender", + "schema": "public", + "values": [ + "male", + "female", + "other" + ] + }, + "public.incident_severity": { + "name": "incident_severity", + "schema": "public", + "values": [ + "info", + "warning", + "critical" + ] + }, + "public.incident_status": { + "name": "incident_status", + "schema": "public", + "values": [ + "open", + "acknowledged", + "resolved", + "closed" + ] + }, + "public.join_request_status": { + "name": "join_request_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.membership_status": { + "name": "membership_status", + "schema": "public", + "values": [ + "active", + "disabled", + "pending" + ] + }, + "public.organization_status": { + "name": "organization_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.role_scope": { + "name": "role_scope", + "schema": "public", + "values": [ + "platform", + "organization" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..026b6c7 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1782975351852, + "tag": "0000_bouncy_micromax", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/modules/core/server/api.ts b/modules/core/server/api.ts index 8bbd76f..5a6b05e 100644 --- a/modules/core/server/api.ts +++ b/modules/core/server/api.ts @@ -15,11 +15,27 @@ export function jsonSuccess>( payload: T, status = 200, ): Response { - return Response.json({ success: true, reason, ...payload }, { status }); + return Response.json( + { success: true, reason, ...payload }, + { + status, + headers: { + "Cache-Control": "no-store", + }, + }, + ); } export function jsonFailure(reason: string, status = 400): Response { - return Response.json({ success: false, reason }, { status }); + return Response.json( + { success: false, reason }, + { + status, + headers: { + "Cache-Control": "no-store", + }, + }, + ); } export async function readJsonBody(request: Request): Promise { @@ -29,4 +45,3 @@ export async function readJsonBody(request: Request): Promise { return null; } } - diff --git a/modules/core/server/audit.ts b/modules/core/server/audit.ts index becd3c2..b948b44 100644 --- a/modules/core/server/audit.ts +++ b/modules/core/server/audit.ts @@ -1,10 +1,12 @@ -import { randomUUID } from "node:crypto"; +import { desc, eq } from "drizzle-orm"; +import { getDatabase } from "@/modules/core/server/db"; +import { auditLogs } from "@/modules/core/server/schema"; import type { AuditLog, AuditResult, PublicAccount } from "@/modules/core/types"; -import { updateData } from "@/modules/core/server/store"; type AuditInput = { actor?: PublicAccount; + organizationId?: string; action: string; targetType: string; targetId?: string; @@ -12,13 +14,27 @@ type AuditInput = { reason: string; }; -export async function recordAuditLog(input: AuditInput): Promise { - const timestamp = new Date().toISOString(); +function toAuditLog(row: typeof auditLogs.$inferSelect): AuditLog { + return { + id: row.id, + organizationId: row.organizationId ?? undefined, + timestamp: row.timestamp.toISOString(), + actorAccountId: row.actorAccountId ?? undefined, + actorEmail: row.actorEmail ?? undefined, + action: row.action, + targetType: row.targetType, + targetId: row.targetId ?? undefined, + result: row.result, + reason: row.reason, + }; +} - return await updateData((data) => { - const nextLog: AuditLog = { - id: randomUUID(), - timestamp, +export async function recordAuditLog(input: AuditInput): Promise { + const database = getDatabase(); + const rows = await database + .insert(auditLogs) + .values({ + organizationId: input.organizationId, actorAccountId: input.actor?.id, actorEmail: input.actor?.email, action: input.action, @@ -26,10 +42,27 @@ export async function recordAuditLog(input: AuditInput): Promise { targetId: input.targetId, result: input.result, reason: input.reason, - }; + }) + .returning(); + const row = rows[0]; + if (!row) { + throw new Error("审计日志写入失败"); + } - data.auditLogs = [nextLog, ...data.auditLogs].slice(0, 500); - return nextLog; - }); + return toAuditLog(row); } +export async function listAuditLogs(params: { organizationId?: string; limit?: number }): Promise { + const database = getDatabase(); + const limit = params.limit ?? 100; + const rows = params.organizationId + ? await database + .select() + .from(auditLogs) + .where(eq(auditLogs.organizationId, params.organizationId)) + .orderBy(desc(auditLogs.timestamp)) + .limit(limit) + : await database.select().from(auditLogs).orderBy(desc(auditLogs.timestamp)).limit(limit); + + return rows.map(toAuditLog); +} diff --git a/modules/core/server/auth.ts b/modules/core/server/auth.ts index 01d0ec5..dff25b2 100644 --- a/modules/core/server/auth.ts +++ b/modules/core/server/auth.ts @@ -1,30 +1,83 @@ -import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto"; +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; import { cookies } from "next/headers"; -import type { Account, AuthContext, Permission, PublicAccount, RoleId, Session } from "@/modules/core/types"; import { jsonFailure } from "@/modules/core/server/api"; import { recordAuditLog } from "@/modules/core/server/audit"; -import { getPermissionsForRole, hasPermission } from "@/modules/core/server/permissions"; -import { readData, updateData } from "@/modules/core/server/store"; +import { getDatabase } from "@/modules/core/server/db"; +import { getPermissionsForRole, hasPermission, seedOrganizationRoles } from "@/modules/core/server/permissions"; +import { accounts, joinRequests, memberships, organizations, roles, sessions } from "@/modules/core/server/schema"; +import type { Account, AuthContext, Membership, Organization, Permission, PublicAccount, Session } from "@/modules/core/types"; const SESSION_COOKIE = "teatea_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +type AccountRow = typeof accounts.$inferSelect; +type OrganizationRow = typeof organizations.$inferSelect; +type MembershipRow = typeof memberships.$inferSelect; +type RoleRow = typeof roles.$inferSelect; +type SessionRow = typeof sessions.$inferSelect; + export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } -export function toPublicAccount(account: Account): PublicAccount { +function toIsoString(value: Date): string { + return value.toISOString(); +} + +function toOrganization(row: OrganizationRow): Organization { + return { + id: row.id, + name: row.name, + slug: row.slug, + status: row.status, + createdAt: toIsoString(row.createdAt), + updatedAt: toIsoString(row.updatedAt), + }; +} + +function toSession(row: SessionRow): Session { + return { + id: row.id, + accountId: row.accountId, + activeOrganizationId: row.activeOrganizationId ?? undefined, + createdAt: toIsoString(row.createdAt), + expiresAt: toIsoString(row.expiresAt), + }; +} + +function toMembership(row: MembershipRow, role: RoleRow): Membership { + return { + id: row.id, + accountId: row.accountId, + organizationId: row.organizationId, + roleId: row.roleId, + roleKey: role.key, + roleLabel: role.label, + status: row.status, + createdAt: toIsoString(row.createdAt), + updatedAt: toIsoString(row.updatedAt), + }; +} + +export function toPublicAccount(account: AccountRow | Account, roleKey?: string, organization?: Organization): PublicAccount { + const fallbackRole = "role" in account ? account.role : "viewer"; + const fallbackOrganization = "organization" in account ? account.organization : undefined; + const fallbackOrganizationId = "organizationId" in account ? account.organizationId : undefined; + return { id: account.id, + platformRoleId: "platformRoleId" in account ? account.platformRoleId ?? undefined : undefined, name: account.name, email: account.email, - role: account.role, - organization: account.organization, + role: roleKey ?? fallbackRole, + organization: organization?.name ?? fallbackOrganization, + organizationId: organization?.id ?? fallbackOrganizationId, status: account.status, - createdAt: account.createdAt, - updatedAt: account.updatedAt, + createdAt: typeof account.createdAt === "string" ? account.createdAt : toIsoString(account.createdAt), + updatedAt: typeof account.updatedAt === "string" ? account.updatedAt : toIsoString(account.updatedAt), }; } @@ -38,7 +91,7 @@ export function hashPassword(password: string, salt = randomBytes(16).toString(" }; } -export function verifyPassword(password: string, account: Account): boolean { +export function verifyPassword(password: string, account: Pick): boolean { const candidate = Buffer.from(hashPassword(password, account.passwordSalt).hash, "hex"); const stored = Buffer.from(account.passwordHash, "hex"); @@ -49,40 +102,14 @@ export function verifyPassword(password: string, account: Account): boolean { return timingSafeEqual(candidate, stored); } -export function createAccountRecord(input: { - name: string; - email: string; - password: string; - role: RoleId; - organization?: string; -}): Account { - const now = new Date().toISOString(); - const password = hashPassword(input.password); +function createSlug(name: string): string { + const base = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); - return { - id: randomUUID(), - name: input.name.trim(), - email: normalizeEmail(input.email), - role: input.role, - organization: input.organization?.trim(), - passwordHash: password.hash, - passwordSalt: password.salt, - status: "active", - createdAt: now, - updatedAt: now, - }; -} - -export function createSessionRecord(accountId: string): Session { - const now = new Date(); - const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_SECONDS * 1000); - - return { - id: randomUUID(), - accountId, - createdAt: now.toISOString(), - expiresAt: expiresAt.toISOString(), - }; + return base.length > 0 ? base : `org-${Date.now()}`; } export async function setSessionCookie(sessionId: string): Promise { @@ -103,8 +130,255 @@ export async function clearSessionCookie(): Promise { } export async function getBootstrapState(): Promise<{ setupRequired: boolean }> { - const data = await readData(); - return { setupRequired: data.accounts.length === 0 }; + const database = getDatabase(); + const existingAccounts = await database.select({ id: accounts.id }).from(accounts).limit(1); + return { setupRequired: existingAccounts.length === 0 }; +} + +export async function setupFirstPlatformAdmin(input: { + organization: string; + name: string; + email: string; + password: string; +}): Promise<{ account: PublicAccount; session: Session }> { + const database = getDatabase(); + const password = hashPassword(input.password); + const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); + + const created = await database.transaction(async (transaction) => { + const existingAccounts = await transaction.select({ id: accounts.id }).from(accounts).limit(1); + if (existingAccounts.length > 0) { + return null; + } + + const platformRoleRows = await transaction + .select() + .from(roles) + .where(and(eq(roles.key, "platform_admin"), isNull(roles.organizationId))) + .limit(1); + const platformRole = platformRoleRows[0]; + if (!platformRole) { + throw new Error("平台超管角色未初始化,请先运行数据库初始化"); + } + + const organizationRows = await transaction + .insert(organizations) + .values({ + name: input.organization.trim(), + slug: createSlug(input.organization), + }) + .returning(); + const organization = organizationRows[0]; + if (!organization) { + throw new Error("机构初始化失败"); + } + + const accountRows = await transaction + .insert(accounts) + .values({ + platformRoleId: platformRole.id, + name: input.name.trim(), + email: normalizeEmail(input.email), + passwordHash: password.hash, + passwordSalt: password.salt, + status: "active", + }) + .returning(); + const account = accountRows[0]; + if (!account) { + throw new Error("账号初始化失败"); + } + + const sessionRows = await transaction + .insert(sessions) + .values({ + accountId: account.id, + activeOrganizationId: organization.id, + expiresAt, + }) + .returning(); + const session = sessionRows[0]; + if (!session) { + throw new Error("会话初始化失败"); + } + + return { account, organization, platformRole, session }; + }); + + if (!created) { + throw new Error("系统已完成初始化"); + } + + await seedOrganizationRoles(created.organization.id); + const databaseAfterSeed = getDatabase(); + const orgAdminRows = await databaseAfterSeed + .select() + .from(roles) + .where(and(eq(roles.organizationId, created.organization.id), eq(roles.key, "org_admin"))) + .limit(1); + const orgAdminRole = orgAdminRows[0]; + if (orgAdminRole) { + await databaseAfterSeed + .insert(memberships) + .values({ + accountId: created.account.id, + organizationId: created.organization.id, + roleId: orgAdminRole.id, + status: "active", + }) + .onConflictDoNothing(); + } + + const organization = toOrganization(created.organization); + const publicAccount = toPublicAccount(created.account, created.platformRole.key, organization); + const session = toSession(created.session); + + await recordAuditLog({ + actor: publicAccount, + organizationId: organization.id, + action: "account.setup", + targetType: "account", + targetId: publicAccount.id, + result: "success", + reason: "初始化平台超管与首个机构", + }); + + return { account: publicAccount, session }; +} + +export async function createRegistration(input: { + name: string; + email: string; + password: string; + organizationId?: string; +}): Promise<{ account: PublicAccount; session: Session }> { + const database = getDatabase(); + const password = hashPassword(input.password); + const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); + + const created = await database.transaction(async (transaction) => { + const normalizedEmail = normalizeEmail(input.email); + const existing = await transaction.select({ id: accounts.id }).from(accounts).where(eq(accounts.email, normalizedEmail)); + if (existing.length > 0) { + return null; + } + + const accountRows = await transaction + .insert(accounts) + .values({ + name: input.name.trim(), + email: normalizedEmail, + passwordHash: password.hash, + passwordSalt: password.salt, + status: input.organizationId ? "pending" : "active", + }) + .returning(); + const account = accountRows[0]; + if (!account) { + throw new Error("账号创建失败"); + } + + const sessionRows = await transaction + .insert(sessions) + .values({ + accountId: account.id, + activeOrganizationId: input.organizationId, + expiresAt, + }) + .returning(); + const session = sessionRows[0]; + if (!session) { + throw new Error("会话创建失败"); + } + + if (input.organizationId) { + await transaction.insert(joinRequests).values({ + accountId: account.id, + organizationId: input.organizationId, + status: "pending", + reason: "公开注册申请加入机构", + }); + } + + return { account, session }; + }); + + if (!created) { + throw new Error("账号已存在"); + } + + const publicAccount = toPublicAccount(created.account); + const session = toSession(created.session); + await recordAuditLog({ + actor: publicAccount, + organizationId: input.organizationId, + action: "account.register", + targetType: "account", + targetId: publicAccount.id, + result: "success", + reason: input.organizationId ? "注册账号并申请加入机构" : "注册独立账号", + }); + + return { account: publicAccount, session }; +} + +export async function loginWithPassword(input: { + email: string; + password: string; +}): Promise<{ account: PublicAccount; session: Session }> { + const database = getDatabase(); + const accountRows = await database + .select() + .from(accounts) + .where(eq(accounts.email, normalizeEmail(input.email))) + .limit(1); + const account = accountRows[0]; + + if (!account || account.status === "disabled" || !verifyPassword(input.password, account)) { + throw new Error("邮箱或密码错误"); + } + + const membershipRows = await database + .select({ membership: memberships, organization: organizations, role: roles }) + .from(memberships) + .innerJoin(organizations, eq(organizations.id, memberships.organizationId)) + .innerJoin(roles, eq(roles.id, memberships.roleId)) + .where(and(eq(memberships.accountId, account.id), eq(memberships.status, "active"))) + .limit(1); + const membership = membershipRows[0]; + const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000); + const sessionRows = await database + .insert(sessions) + .values({ + accountId: account.id, + activeOrganizationId: membership?.organization.id, + expiresAt, + }) + .returning(); + const sessionRow = sessionRows[0]; + if (!sessionRow) { + throw new Error("会话创建失败"); + } + + const platformRoleRows = account.platformRoleId + ? await database.select().from(roles).where(eq(roles.id, account.platformRoleId)).limit(1) + : []; + const platformRole = platformRoleRows[0]; + const organization = membership ? toOrganization(membership.organization) : undefined; + const publicAccount = toPublicAccount(account, platformRole?.key ?? membership?.role.key, organization); + const session = toSession(sessionRow); + + await recordAuditLog({ + actor: publicAccount, + organizationId: organization?.id, + action: "account.login", + targetType: "account", + targetId: account.id, + result: "success", + reason: "账号登录", + }); + + return { account: publicAccount, session }; } export async function getCurrentAuthContext(): Promise { @@ -115,20 +389,54 @@ export async function getCurrentAuthContext(): Promise { return null; } - const data = await readData(); - const session = data.sessions.find((item) => item.id === sessionId); - if (!session || Date.parse(session.expiresAt) <= Date.now()) { + const database = getDatabase(); + const rows = await database + .select({ session: sessions, account: accounts }) + .from(sessions) + .innerJoin(accounts, eq(accounts.id, sessions.accountId)) + .where(and(eq(sessions.id, sessionId), gt(sessions.expiresAt, new Date()))) + .limit(1); + const row = rows[0]; + if (!row || row.account.status === "disabled") { return null; } - const account = data.accounts.find((item) => item.id === session.accountId); - if (!account || account.status !== "active") { - return null; - } + const session = toSession(row.session); + const platformRoleRows = row.account.platformRoleId + ? await database.select().from(roles).where(eq(roles.id, row.account.platformRoleId)).limit(1) + : []; + const platformRole = platformRoleRows[0]; + const organizationRows = row.session.activeOrganizationId + ? await database.select().from(organizations).where(eq(organizations.id, row.session.activeOrganizationId)).limit(1) + : []; + const organizationRow = organizationRows[0]; + const membershipRows = organizationRow + ? await database + .select({ membership: memberships, role: roles }) + .from(memberships) + .innerJoin(roles, eq(roles.id, memberships.roleId)) + .where( + and( + eq(memberships.accountId, row.account.id), + eq(memberships.organizationId, organizationRow.id), + eq(memberships.status, "active"), + ), + ) + .limit(1) + : []; + const membershipRow = membershipRows[0]; + + const platformPermissions = platformRole ? await getPermissionsForRole(platformRole.id) : []; + const organizationPermissions = membershipRow ? await getPermissionsForRole(membershipRow.role.id) : []; + const permissions = [...new Set([...platformPermissions, ...organizationPermissions])]; + const organization = organizationRow ? toOrganization(organizationRow) : undefined; + const membership = membershipRow ? toMembership(membershipRow.membership, membershipRow.role) : undefined; return { - account: toPublicAccount(account), - permissions: getPermissionsForRole(account.role), + account: toPublicAccount(row.account, platformRole?.key ?? membership?.roleKey, organization), + organization, + membership, + permissions, session, }; } @@ -139,9 +447,8 @@ export async function removeCurrentSession(): Promise { const sessionId = cookieStore.get(SESSION_COOKIE)?.value; if (sessionId) { - await updateData((data) => { - data.sessions = data.sessions.filter((item) => item.id !== sessionId); - }); + const database = getDatabase(); + await database.delete(sessions).where(eq(sessions.id, sessionId)); } await clearSessionCookie(); @@ -162,6 +469,7 @@ type DeniedAuditContext = { action: string; targetType: string; targetId?: string; + organizationId?: string; }; export async function requirePermission( @@ -172,6 +480,7 @@ export async function requirePermission( if (!context) { await recordAuditLog({ + organizationId: auditContext.organizationId, action: auditContext.action, targetType: auditContext.targetType, targetId: auditContext.targetId, @@ -185,9 +494,10 @@ export async function requirePermission( }; } - if (!hasPermission(context.account.role, permission)) { + if (!hasPermission(context.permissions, permission)) { await recordAuditLog({ actor: context.account, + organizationId: auditContext.organizationId ?? context.organization?.id, action: auditContext.action, targetType: auditContext.targetType, targetId: auditContext.targetId, diff --git a/modules/core/server/db.ts b/modules/core/server/db.ts new file mode 100644 index 0000000..074a3f5 --- /dev/null +++ b/modules/core/server/db.ts @@ -0,0 +1,37 @@ +import "server-only"; + +import { sql } from "drizzle-orm"; +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import * as schema from "@/modules/core/server/schema"; + +export type AppDatabase = PostgresJsDatabase; + +let cachedDatabase: AppDatabase | null = null; +let cachedClient: postgres.Sql | null = null; + +export function getDatabase(): AppDatabase { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error("DATABASE_URL is required for database-backed system configuration"); + } + + if (!cachedDatabase) { + cachedClient = postgres(databaseUrl, { max: 1 }); + cachedDatabase = drizzle(cachedClient, { schema }); + } + + return cachedDatabase; +} + +export async function checkDatabaseConnection(): Promise<{ ok: boolean; reason: string }> { + try { + const database = getDatabase(); + await database.execute(sql`select 1`); + return { ok: true, reason: "数据库连接正常" }; + } catch (error) { + const reason = error instanceof Error ? error.message : "数据库连接失败"; + return { ok: false, reason }; + } +} diff --git a/modules/core/server/permissions.ts b/modules/core/server/permissions.ts index 3fdf086..38cb861 100644 --- a/modules/core/server/permissions.ts +++ b/modules/core/server/permissions.ts @@ -1,15 +1,97 @@ -import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types"; -import { ROLE_LABELS } from "@/modules/core/types"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; -export const ROLE_DEFINITIONS: RoleDefinition[] = [ +import { getDatabase } from "@/modules/core/server/db"; +import { permissions, rolePermissions, roles } from "@/modules/core/server/schema"; +import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types"; +import { PERMISSIONS, ROLE_LABELS } from "@/modules/core/types"; + +type PermissionDefinition = { + id: Permission; + label: string; + category: string; + description: string; +}; + +export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ + { id: "platform:manage", label: "平台管理", category: "平台", description: "管理平台级配置和跨机构操作。" }, + { id: "organization:read", label: "查看机构", category: "机构", description: "查看机构、院区和租户信息。" }, + { id: "organization:manage", label: "管理机构", category: "机构", description: "创建、编辑和停用机构。" }, + { id: "account:read", label: "查看用户", category: "用户", description: "查看账号、成员和申请。" }, + { id: "account:manage", label: "管理用户", category: "用户", description: "创建、审批、启停和分配用户。" }, + { id: "role:read", label: "查看角色", category: "权限", description: "查看角色和权限矩阵。" }, + { id: "role:manage", label: "管理角色", category: "权限", description: "创建自定义角色并配置权限组合。" }, + { id: "permission:read", label: "查看权限", category: "权限", description: "查看系统注册权限点。" }, + { id: "audit:read", label: "查看审计", category: "审计", description: "查看关键操作审计日志。" }, + { id: "incident:read", label: "查看故障", category: "运维", description: "查看系统健康和故障中心。" }, + { id: "incident:manage", label: "处理故障", category: "运维", description: "确认、处理和关闭故障事件。" }, + { id: "facility:read", label: "查看房间床位", category: "床位", description: "查看房间、床位和占用状态。" }, + { id: "facility:manage", label: "管理房间床位", category: "床位", description: "维护院区、房间、床位和床位状态。" }, + { id: "admission:read", label: "查看入住", category: "入住", description: "查看入住、换床、退住历史。" }, + { id: "admission:manage", label: "管理入住", category: "入住", description: "办理入住、换床和退住。" }, + { id: "elder:read", label: "查看老人档案", category: "老人", description: "查看老人基础档案。" }, + { id: "elder:create", label: "新增老人档案", category: "老人", description: "创建老人档案。" }, + { id: "elder:update", label: "更新老人档案", category: "老人", description: "更新老人档案和照护信息。" }, + { id: "elder:delete", label: "删除老人档案", category: "老人", description: "删除老人档案。" }, +]; + +type SeedRole = { + key: RoleId; + scope: "platform" | "organization"; + description: string; + permissions: Permission[]; +}; + +export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [ { - id: "admin", - label: ROLE_LABELS.admin, - description: "管理账号、权限、审计日志与全部业务数据。", + key: "platform_admin", + scope: "platform", + description: "平台最高权限,管理所有机构、角色、审计和故障中心。", + permissions: [...PERMISSIONS], + }, + { + key: "platform_operator", + scope: "platform", + description: "平台运营角色,管理机构和跨机构用户配置。", permissions: [ + "organization:read", + "organization:manage", "account:read", "account:manage", + "role:read", + "permission:read", "audit:read", + "incident:read", + ], + }, + { + key: "platform_auditor", + scope: "platform", + description: "平台审计角色,只读查看机构、审计和故障。", + permissions: ["organization:read", "account:read", "role:read", "permission:read", "audit:read", "incident:read"], + }, + { + key: "platform_ops", + scope: "platform", + description: "平台运维角色,处理系统故障和运行状态。", + permissions: ["organization:read", "audit:read", "incident:read", "incident:manage"], + }, + { + key: "org_admin", + scope: "organization", + description: "机构管理员,管理本机构用户、角色、房间床位、入住和审计。", + permissions: [ + "organization:read", + "account:read", + "account:manage", + "role:read", + "role:manage", + "permission:read", + "audit:read", + "incident:read", + "facility:read", + "facility:manage", + "admission:read", + "admission:manage", "elder:read", "elder:create", "elder:update", @@ -17,43 +99,160 @@ export const ROLE_DEFINITIONS: RoleDefinition[] = [ ], }, { - id: "manager", - label: ROLE_LABELS.manager, - description: "处理运营业务,可查看账号与审计日志,不管理账号角色。", - permissions: ["account:read", "audit:read", "elder:read", "elder:create", "elder:update", "elder:delete"], + key: "manager", + scope: "organization", + description: "运营主管,处理老人档案、床位入住和审计查看。", + permissions: [ + "account:read", + "role:read", + "audit:read", + "facility:read", + "facility:manage", + "admission:read", + "admission:manage", + "elder:read", + "elder:create", + "elder:update", + "elder:delete", + ], }, { - id: "caregiver", - label: ROLE_LABELS.caregiver, - description: "查看老人档案并维护照护相关信息。", - permissions: ["elder:read", "elder:update"], + key: "caregiver", + scope: "organization", + description: "照护人员,查看床位和维护老人照护信息。", + permissions: ["facility:read", "admission:read", "elder:read", "elder:update"], }, { - id: "viewer", - label: ROLE_LABELS.viewer, - description: "只读查看老人档案。", - permissions: ["elder:read"], + key: "viewer", + scope: "organization", + description: "只读访客,查看老人、床位和入住信息。", + permissions: ["facility:read", "admission:read", "elder:read"], }, ]; -export function getRoleDefinition(role: RoleId): RoleDefinition { - const roleDefinition = ROLE_DEFINITIONS.find((item) => item.id === role); - if (roleDefinition) { - return roleDefinition; +export async function ensureSystemDefaults(): Promise { + const database = getDatabase(); + + await database + .insert(permissions) + .values(PERMISSION_DEFINITIONS) + .onConflictDoUpdate({ + target: permissions.id, + set: { + label: permissions.label, + category: permissions.category, + description: permissions.description, + isEnabled: true, + }, + }); + + const existingRoles = await database.select().from(roles).where(isNull(roles.organizationId)); + const existingRoleKeys = new Set(existingRoles.map((role) => role.key)); + const seedRoles = SYSTEM_ROLE_DEFINITIONS.filter((role) => role.scope === "platform" && !existingRoleKeys.has(role.key)); + + if (seedRoles.length > 0) { + await database.insert(roles).values( + seedRoles.map((role) => ({ + key: role.key, + scope: role.scope, + label: ROLE_LABELS[role.key], + description: role.description, + isSystem: true, + isEnabled: true, + })), + ); } - return { - id: "viewer", - label: ROLE_LABELS.viewer, - description: "只读查看老人档案。", - permissions: ["elder:read"], - }; + const platformRoles = await database.select().from(roles).where(isNull(roles.organizationId)); + const inserts = platformRoles.flatMap((role) => { + const definition = SYSTEM_ROLE_DEFINITIONS.find((item) => item.key === role.key); + if (!definition) { + return []; + } + + return definition.permissions.map((permissionId) => ({ + roleId: role.id, + permissionId, + })); + }); + + if (inserts.length > 0) { + await database.insert(rolePermissions).values(inserts).onConflictDoNothing(); + } } -export function getPermissionsForRole(role: RoleId): Permission[] { - return [...getRoleDefinition(role).permissions]; +export async function seedOrganizationRoles(organizationId: string): Promise { + const database = getDatabase(); + const organizationRoles = SYSTEM_ROLE_DEFINITIONS.filter((role) => role.scope === "organization"); + + await database + .insert(roles) + .values( + organizationRoles.map((role) => ({ + key: role.key, + organizationId, + scope: role.scope, + label: ROLE_LABELS[role.key], + description: role.description, + isSystem: true, + isEnabled: true, + })), + ) + .onConflictDoNothing(); + + const seededRoles = await database + .select() + .from(roles) + .where(and(eq(roles.organizationId, organizationId), inArray(roles.key, organizationRoles.map((role) => role.key)))); + + const inserts = seededRoles.flatMap((role) => { + const definition = SYSTEM_ROLE_DEFINITIONS.find((item) => item.key === role.key); + if (!definition) { + return []; + } + + return definition.permissions.map((permissionId) => ({ roleId: role.id, permissionId })); + }); + + if (inserts.length > 0) { + await database.insert(rolePermissions).values(inserts).onConflictDoNothing(); + } } -export function hasPermission(role: RoleId, permission: Permission): boolean { - return getRoleDefinition(role).permissions.includes(permission); +export async function getRoleDefinitions(organizationId?: string): Promise { + const database = getDatabase(); + const rows = await database + .select() + .from(roles) + .where(organizationId ? or(isNull(roles.organizationId), eq(roles.organizationId, organizationId)) : isNull(roles.organizationId)); + + const roleIds = rows.map((role) => role.id); + const permissionRows = + roleIds.length > 0 + ? await database.select().from(rolePermissions).where(inArray(rolePermissions.roleId, roleIds)) + : []; + + return rows.map((role) => ({ + id: role.id, + key: role.key, + label: role.label, + description: role.description, + scope: role.scope, + organizationId: role.organizationId ?? undefined, + isSystem: role.isSystem, + isEnabled: role.isEnabled, + permissions: permissionRows + .filter((permission) => permission.roleId === role.id) + .map((permission) => permission.permissionId as Permission), + })); +} + +export async function getPermissionsForRole(roleId: string): Promise { + const database = getDatabase(); + const rows = await database.select().from(rolePermissions).where(eq(rolePermissions.roleId, roleId)); + return rows.map((row) => row.permissionId as Permission); +} + +export function hasPermission(permissionsList: readonly Permission[], permission: Permission): boolean { + return permissionsList.includes(permission); } diff --git a/modules/core/server/schema.ts b/modules/core/server/schema.ts new file mode 100644 index 0000000..309d070 --- /dev/null +++ b/modules/core/server/schema.ts @@ -0,0 +1,227 @@ +import { + boolean, + integer, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +export const accountStatusEnum = pgEnum("account_status", ["active", "disabled", "pending"]); +export const organizationStatusEnum = pgEnum("organization_status", ["active", "disabled"]); +export const roleScopeEnum = pgEnum("role_scope", ["platform", "organization"]); +export const membershipStatusEnum = pgEnum("membership_status", ["active", "disabled", "pending"]); +export const bedStatusEnum = pgEnum("bed_status", ["available", "occupied", "maintenance", "disabled"]); +export const elderStatusEnum = pgEnum("elder_status", ["active", "pending", "discharged"]); +export const careLevelEnum = pgEnum("care_level", ["self-care", "semi-assisted", "assisted", "intensive"]); +export const genderEnum = pgEnum("gender", ["male", "female", "other"]); +export const admissionStatusEnum = pgEnum("admission_status", ["active", "transferred", "discharged"]); +export const auditResultEnum = pgEnum("audit_result", ["success", "denied", "failure"]); +export const incidentStatusEnum = pgEnum("incident_status", ["open", "acknowledged", "resolved", "closed"]); +export const incidentSeverityEnum = pgEnum("incident_severity", ["info", "warning", "critical"]); +export const joinRequestStatusEnum = pgEnum("join_request_status", ["pending", "approved", "rejected"]); + +export const organizations = pgTable("organizations", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + slug: text("slug").notNull(), + status: organizationStatusEnum("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + slugUnique: uniqueIndex("organizations_slug_unique").on(table.slug), +})); + +export const permissions = pgTable("permissions", { + id: text("id").primaryKey(), + label: text("label").notNull(), + category: text("category").notNull(), + description: text("description").notNull(), + isEnabled: boolean("is_enabled").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const roles = pgTable("roles", { + id: uuid("id").primaryKey().defaultRandom(), + key: text("key").notNull(), + organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }), + scope: roleScopeEnum("scope").notNull(), + label: text("label").notNull(), + description: text("description").notNull(), + isSystem: boolean("is_system").notNull().default(false), + isEnabled: boolean("is_enabled").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + roleKeyUnique: uniqueIndex("roles_key_organization_unique").on(table.key, table.organizationId), +})); + +export const rolePermissions = pgTable("role_permissions", { + roleId: uuid("role_id").notNull().references(() => roles.id, { onDelete: "cascade" }), + permissionId: text("permission_id").notNull().references(() => permissions.id, { onDelete: "cascade" }), +}, (table) => ({ + pk: primaryKey({ columns: [table.roleId, table.permissionId] }), +})); + +export const accounts = pgTable("accounts", { + id: uuid("id").primaryKey().defaultRandom(), + platformRoleId: uuid("platform_role_id").references(() => roles.id), + name: text("name").notNull(), + email: text("email").notNull(), + passwordHash: text("password_hash").notNull(), + passwordSalt: text("password_salt").notNull(), + status: accountStatusEnum("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + emailUnique: uniqueIndex("accounts_email_unique").on(table.email), +})); + +export const memberships = pgTable("memberships", { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull().references(() => accounts.id, { onDelete: "cascade" }), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + roleId: uuid("role_id").notNull().references(() => roles.id), + status: membershipStatusEnum("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + accountOrganizationUnique: uniqueIndex("memberships_account_organization_unique").on( + table.accountId, + table.organizationId, + ), +})); + +export const sessions = pgTable("sessions", { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull().references(() => accounts.id, { onDelete: "cascade" }), + activeOrganizationId: uuid("active_organization_id").references(() => organizations.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), +}); + +export const joinRequests = pgTable("join_requests", { + id: uuid("id").primaryKey().defaultRandom(), + accountId: uuid("account_id").notNull().references(() => accounts.id, { onDelete: "cascade" }), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + status: joinRequestStatusEnum("status").notNull().default("pending"), + reason: text("reason").notNull().default(""), + reviewedByAccountId: uuid("reviewed_by_account_id").references(() => accounts.id), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const campuses = pgTable("campuses", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + address: text("address").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const buildings = pgTable("buildings", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + campusId: uuid("campus_id").notNull().references(() => campuses.id, { onDelete: "cascade" }), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const floors = pgTable("floors", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + buildingId: uuid("building_id").notNull().references(() => buildings.id, { onDelete: "cascade" }), + name: text("name").notNull(), + level: integer("level").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const rooms = pgTable("rooms", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + floorId: uuid("floor_id").notNull().references(() => floors.id, { onDelete: "cascade" }), + name: text("name").notNull(), + code: text("code").notNull(), + capacity: integer("capacity").notNull().default(1), + status: organizationStatusEnum("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + roomCodeUnique: uniqueIndex("rooms_code_organization_unique").on(table.organizationId, table.code), +})); + +export const beds = pgTable("beds", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + roomId: uuid("room_id").notNull().references(() => rooms.id, { onDelete: "cascade" }), + code: text("code").notNull(), + status: bedStatusEnum("status").notNull().default("available"), + notes: text("notes").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + bedCodeUnique: uniqueIndex("beds_code_room_unique").on(table.roomId, table.code), +})); + +export const elders = pgTable("elders", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + gender: genderEnum("gender").notNull(), + age: integer("age").notNull(), + careLevel: careLevelEnum("care_level").notNull(), + status: elderStatusEnum("status").notNull().default("pending"), + primaryContact: text("primary_contact").notNull(), + phone: text("phone").notNull(), + medicalNotes: text("medical_notes").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const admissions = pgTable("admissions", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }), + bedId: uuid("bed_id").notNull().references(() => beds.id), + status: admissionStatusEnum("status").notNull().default("active"), + admittedAt: timestamp("admitted_at", { withTimezone: true }).notNull().defaultNow(), + dischargedAt: timestamp("discharged_at", { withTimezone: true }), + notes: text("notes").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const auditLogs = pgTable("audit_logs", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }), + timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(), + actorAccountId: uuid("actor_account_id").references(() => accounts.id), + actorEmail: text("actor_email"), + action: text("action").notNull(), + targetType: text("target_type").notNull(), + targetId: text("target_id"), + result: auditResultEnum("result").notNull(), + reason: text("reason").notNull(), +}); + +export const systemIncidents = pgTable("system_incidents", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }), + severity: incidentSeverityEnum("severity").notNull(), + status: incidentStatusEnum("status").notNull().default("open"), + title: text("title").notNull(), + description: text("description").notNull(), + source: text("source").notNull(), + acknowledgedByAccountId: uuid("acknowledged_by_account_id").references(() => accounts.id), + acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }), + resolvedByAccountId: uuid("resolved_by_account_id").references(() => accounts.id), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/modules/core/server/store.ts b/modules/core/server/store.ts index 2735215..9d93b64 100644 --- a/modules/core/server/store.ts +++ b/modules/core/server/store.ts @@ -1,63 +1,223 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; +import { desc, eq, sql } from "drizzle-orm"; -import type { AppData } from "@/modules/core/types"; +import { listAuditLogs } from "@/modules/core/server/audit"; +import { getDatabase } from "@/modules/core/server/db"; +import { + accounts, + admissions, + beds, + elders, + memberships, + organizations, + roles, + rooms, + sessions, + systemIncidents, +} from "@/modules/core/server/schema"; +import type { Account, Admission, AppData, FacilityBed, Membership, Organization, Room, Session, SystemIncident } from "@/modules/core/types"; +import type { Elder } from "@/modules/elders/types"; -const DATA_DIR = process.env.TEATEA_DATA_DIR ?? path.join(process.cwd(), ".data"); -const DATA_FILE = path.join(DATA_DIR, "teatea.json"); - -function createEmptyData(): AppData { - return { - accounts: [], - sessions: [], - elders: [], - auditLogs: [], - }; -} - -function normalizeData(data: Partial): AppData { - return { - accounts: Array.isArray(data.accounts) ? data.accounts : [], - sessions: Array.isArray(data.sessions) ? data.sessions : [], - elders: Array.isArray(data.elders) ? data.elders : [], - auditLogs: Array.isArray(data.auditLogs) ? data.auditLogs : [], - }; -} - -function isMissingFileError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === "ENOENT" - ); +function iso(value: Date): string { + return value.toISOString(); } export async function readData(): Promise { - try { - const raw = await fs.readFile(DATA_FILE, "utf8"); - const parsed = JSON.parse(raw) as Partial; - return normalizeData(parsed); - } catch (error) { - if (isMissingFileError(error)) { - return createEmptyData(); + const database = getDatabase(); + const [ + organizationRows, + accountRows, + membershipRows, + sessionRows, + elderRows, + roomRows, + bedRows, + admissionRows, + auditLogRows, + incidentRows, + ] = await Promise.all([ + database.select().from(organizations).orderBy(desc(organizations.createdAt)), + database.select().from(accounts).orderBy(desc(accounts.createdAt)), + database.select({ membership: memberships, role: roles }).from(memberships).innerJoin(roles, eq(roles.id, memberships.roleId)), + database.select().from(sessions).orderBy(desc(sessions.createdAt)), + database.select().from(elders).orderBy(desc(elders.createdAt)), + database.select().from(rooms).orderBy(desc(rooms.createdAt)), + database + .select({ + bed: beds, + roomName: rooms.name, + currentElderName: sql`( + select ${elders.name} + from ${admissions} + inner join ${elders} on ${elders.id} = ${admissions.elderId} + where ${admissions.bedId} = ${beds.id} and ${admissions.status} = 'active' + limit 1 + )`, + }) + .from(beds) + .innerJoin(rooms, eq(rooms.id, beds.roomId)) + .orderBy(desc(beds.createdAt)), + database + .select({ + admission: admissions, + elderName: elders.name, + bedCode: beds.code, + roomName: rooms.name, + }) + .from(admissions) + .innerJoin(elders, eq(elders.id, admissions.elderId)) + .innerJoin(beds, eq(beds.id, admissions.bedId)) + .innerJoin(rooms, eq(rooms.id, beds.roomId)) + .orderBy(desc(admissions.admittedAt)), + listAuditLogs({ limit: 100 }), + database.select().from(systemIncidents).orderBy(desc(systemIncidents.createdAt)), + ]); + + const roleById = new Map(membershipRows.map((row) => [row.membership.id, row.role])); + const bedCounts = new Map(); + for (const row of bedRows) { + const current = bedCounts.get(row.bed.roomId) ?? { total: 0, occupied: 0 }; + current.total += 1; + if (row.bed.status === "occupied") { + current.occupied += 1; } - - throw error; + bedCounts.set(row.bed.roomId, current); } + + const organizationsData: Organization[] = organizationRows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + status: row.status, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + })); + + const accountsData: Account[] = accountRows.map((row) => ({ + id: row.id, + platformRoleId: row.platformRoleId ?? undefined, + name: row.name, + email: row.email, + role: row.platformRoleId ?? "viewer", + passwordHash: row.passwordHash, + passwordSalt: row.passwordSalt, + status: row.status, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + })); + + const membershipsData: Membership[] = membershipRows.map((row) => { + const role = roleById.get(row.membership.id) ?? row.role; + return { + id: row.membership.id, + accountId: row.membership.accountId, + organizationId: row.membership.organizationId, + roleId: row.membership.roleId, + roleKey: role.key, + roleLabel: role.label, + status: row.membership.status, + createdAt: iso(row.membership.createdAt), + updatedAt: iso(row.membership.updatedAt), + }; + }); + + const sessionsData: Session[] = sessionRows.map((row) => ({ + id: row.id, + accountId: row.accountId, + activeOrganizationId: row.activeOrganizationId ?? undefined, + createdAt: iso(row.createdAt), + expiresAt: iso(row.expiresAt), + })); + + const eldersData: Elder[] = elderRows.map((row) => ({ + id: row.id, + organizationId: row.organizationId, + name: row.name, + gender: row.gender, + age: row.age, + careLevel: row.careLevel, + room: "", + bed: "", + status: row.status, + primaryContact: row.primaryContact, + phone: row.phone, + medicalNotes: row.medicalNotes, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + })); + + const roomsData: Room[] = roomRows.map((row) => { + const counts = bedCounts.get(row.id) ?? { total: 0, occupied: 0 }; + return { + id: row.id, + organizationId: row.organizationId, + name: row.name, + code: row.code, + capacity: row.capacity, + status: row.status, + bedCount: counts.total, + occupiedBedCount: counts.occupied, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; + }); + + const bedsData: FacilityBed[] = bedRows.map((row) => ({ + id: row.bed.id, + organizationId: row.bed.organizationId, + roomId: row.bed.roomId, + roomName: row.roomName, + code: row.bed.code, + status: row.bed.status, + notes: row.bed.notes, + currentElderName: row.currentElderName ?? undefined, + createdAt: iso(row.bed.createdAt), + updatedAt: iso(row.bed.updatedAt), + })); + + const admissionsData: Admission[] = admissionRows.map((row) => ({ + id: row.admission.id, + organizationId: row.admission.organizationId, + elderId: row.admission.elderId, + elderName: row.elderName, + bedId: row.admission.bedId, + bedCode: row.bedCode, + roomName: row.roomName, + status: row.admission.status, + admittedAt: iso(row.admission.admittedAt), + dischargedAt: row.admission.dischargedAt ? iso(row.admission.dischargedAt) : undefined, + notes: row.admission.notes, + })); + + const incidentsData: SystemIncident[] = incidentRows.map((row) => ({ + id: row.id, + organizationId: row.organizationId ?? undefined, + severity: row.severity, + status: row.status, + title: row.title, + description: row.description, + source: row.source, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + })); + + return { + organizations: organizationsData, + accounts: accountsData, + memberships: membershipsData, + sessions: sessionsData, + elders: eldersData, + rooms: roomsData, + beds: bedsData, + admissions: admissionsData, + auditLogs: auditLogRows, + incidents: incidentsData, + }; } -export async function writeData(data: AppData): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }); - const temporaryFile = `${DATA_FILE}.tmp`; - await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2), "utf8"); - await fs.rename(temporaryFile, DATA_FILE); +export async function writeData(): Promise { + throw new Error("writeData is not available after PostgreSQL migration"); } -export async function updateData(mutator: (data: AppData) => T): Promise { - const data = await readData(); - const result = mutator(data); - await writeData(data); - return result; +export async function updateData(): Promise { + throw new Error("updateData is not available after PostgreSQL migration"); } - diff --git a/modules/core/types.ts b/modules/core/types.ts index e099210..6f406a6 100644 --- a/modules/core/types.ts +++ b/modules/core/types.ts @@ -1,19 +1,44 @@ import type { Elder } from "@/modules/elders/types"; -export const ROLE_IDS = ["admin", "manager", "caregiver", "viewer"] as const; +export const ROLE_IDS = [ + "platform_admin", + "platform_operator", + "platform_auditor", + "platform_ops", + "org_admin", + "manager", + "caregiver", + "viewer", +] as const; export type RoleId = (typeof ROLE_IDS)[number]; export const ROLE_LABELS: Record = { - admin: "系统管理员", + platform_admin: "平台超管", + platform_operator: "平台运营", + platform_auditor: "平台审计", + platform_ops: "平台运维", + org_admin: "机构管理员", manager: "运营主管", caregiver: "照护人员", viewer: "只读访客", }; export const PERMISSIONS = [ + "platform:manage", + "organization:read", + "organization:manage", "account:read", "account:manage", + "role:read", + "role:manage", + "permission:read", "audit:read", + "incident:read", + "incident:manage", + "facility:read", + "facility:manage", + "admission:read", + "admission:manage", "elder:read", "elder:create", "elder:update", @@ -23,20 +48,52 @@ export const PERMISSIONS = [ export type Permission = (typeof PERMISSIONS)[number]; export type RoleDefinition = { - id: RoleId; + id: string; + key: RoleId | string; label: string; description: string; + scope: "platform" | "organization"; + organizationId?: string; + isSystem: boolean; + isEnabled: boolean; permissions: Permission[]; }; -export type AccountStatus = "active" | "disabled"; +export type AccountStatus = "active" | "disabled" | "pending"; + +export type OrganizationStatus = "active" | "disabled"; + +export type Organization = { + id: string; + name: string; + slug: string; + status: OrganizationStatus; + createdAt: string; + updatedAt: string; +}; + +export type MembershipStatus = "active" | "disabled" | "pending"; + +export type Membership = { + id: string; + accountId: string; + organizationId: string; + roleId: string; + roleKey: string; + roleLabel: string; + status: MembershipStatus; + createdAt: string; + updatedAt: string; +}; export type Account = { id: string; + platformRoleId?: string; name: string; email: string; - role: RoleId; + role: RoleId | string; organization?: string; + organizationId?: string; passwordHash: string; passwordSalt: string; status: AccountStatus; @@ -49,6 +106,7 @@ export type PublicAccount = Omit; export type Session = { id: string; accountId: string; + activeOrganizationId?: string; createdAt: string; expiresAt: string; }; @@ -57,6 +115,7 @@ export type AuditResult = "success" | "denied" | "failure"; export type AuditLog = { id: string; + organizationId?: string; timestamp: string; actorAccountId?: string; actorEmail?: string; @@ -67,16 +126,82 @@ export type AuditLog = { reason: string; }; +export type BedStatus = "available" | "occupied" | "maintenance" | "disabled"; + +export type FacilityBed = { + id: string; + organizationId: string; + roomId: string; + roomName: string; + code: string; + status: BedStatus; + notes: string; + currentElderName?: string; + createdAt: string; + updatedAt: string; +}; + +export type Room = { + id: string; + organizationId: string; + name: string; + code: string; + capacity: number; + status: OrganizationStatus; + bedCount: number; + occupiedBedCount: number; + createdAt: string; + updatedAt: string; +}; + +export type AdmissionStatus = "active" | "transferred" | "discharged"; + +export type Admission = { + id: string; + organizationId: string; + elderId: string; + elderName: string; + bedId: string; + bedCode: string; + roomName: string; + status: AdmissionStatus; + admittedAt: string; + dischargedAt?: string; + notes: string; +}; + +export type IncidentSeverity = "info" | "warning" | "critical"; +export type IncidentStatus = "open" | "acknowledged" | "resolved" | "closed"; + +export type SystemIncident = { + id: string; + organizationId?: string; + severity: IncidentSeverity; + status: IncidentStatus; + title: string; + description: string; + source: string; + createdAt: string; + updatedAt: string; +}; + export type AppData = { + organizations: Organization[]; accounts: Account[]; + memberships: Membership[]; sessions: Session[]; elders: Elder[]; + rooms: Room[]; + beds: FacilityBed[]; + admissions: Admission[]; auditLogs: AuditLog[]; + incidents: SystemIncident[]; }; export type AuthContext = { account: PublicAccount; + organization?: Organization; + membership?: Membership; permissions: Permission[]; session: Session; }; - diff --git a/modules/elders/types.ts b/modules/elders/types.ts index 010d959..faf2bbf 100644 --- a/modules/elders/types.ts +++ b/modules/elders/types.ts @@ -28,12 +28,14 @@ export const ELDER_STATUS_LABELS: Record = { export type Elder = { id: string; + organizationId?: string; name: string; gender: Gender; age: number; careLevel: CareLevel; room: string; bed: string; + bedId?: string; status: ElderStatus; primaryContact: string; phone: string; @@ -73,6 +75,16 @@ function readRequiredString( return { success: true, data: value.trim() }; } +function readOptionalString(source: Record, key: string): string | undefined { + const value = source[key]; + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function readEnum( source: Record, key: keyof ElderInput, @@ -128,15 +140,9 @@ export function validateElderInput(value: unknown): ValidationResult return careLevel; } - const room = readRequiredString(value, "room", "房间"); - if (!room.success) { - return room; - } - - const bed = readRequiredString(value, "bed", "床位"); - if (!bed.success) { - return bed; - } + const room = readOptionalString(value, "room") ?? ""; + const bed = readOptionalString(value, "bed") ?? ""; + const bedId = readOptionalString(value, "bedId"); const status = readEnum(value, "status", ELDER_STATUS_VALUES, "状态"); if (!status.success) { @@ -163,8 +169,9 @@ export function validateElderInput(value: unknown): ValidationResult gender: gender.data, age: age.data, careLevel: careLevel.data, - room: room.data, - bed: bed.data, + room, + bed, + bedId, status: status.data, primaryContact: primaryContact.data, phone: phone.data, @@ -172,4 +179,3 @@ export function validateElderInput(value: unknown): ValidationResult }, }; } - diff --git a/modules/settings/components/SettingsOverview.tsx b/modules/settings/components/SettingsOverview.tsx index 8666346..75c5385 100644 --- a/modules/settings/components/SettingsOverview.tsx +++ b/modules/settings/components/SettingsOverview.tsx @@ -1,23 +1,40 @@ -import { Activity, AlertTriangle, ShieldCheck, Users } from "lucide-react"; +import { Activity, Building2, Server, ShieldCheck, Users } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import type { AuditLog, PublicAccount, RoleDefinition } from "@/modules/core/types"; +import type { AuditLog, Membership, Organization, PublicAccount, RoleDefinition, SystemIncident } from "@/modules/core/types"; import { ROLE_LABELS } from "@/modules/core/types"; +import type { RoleId } from "@/modules/core/types"; type SettingsOverviewProps = { accounts: PublicAccount[]; roles: RoleDefinition[]; auditLogs: AuditLog[]; + organizations: Organization[]; + memberships: Membership[]; + incidents: SystemIncident[]; }; +function formatRoleLabel(role: string): string { + if (role in ROLE_LABELS) { + return ROLE_LABELS[role as RoleId]; + } + + return role; +} + export function SettingsOverview({ accounts, roles, auditLogs, + organizations, + memberships, + incidents, }: SettingsOverviewProps): React.ReactElement { const activeAccountCount = accounts.filter((account) => account.status === "active").length; const disabledAccountCount = accounts.length - activeAccountCount; + const activeOrganizationCount = organizations.filter((organization) => organization.status === "active").length; + const openIncidentCount = incidents.filter((incident) => incident.status === "open").length; const deniedAuditCount = auditLogs.filter((log) => log.result === "denied").length; const failureAuditCount = auditLogs.filter((log) => log.result === "failure").length; const successAuditCount = auditLogs.filter((log) => log.result === "success").length; @@ -33,12 +50,24 @@ export function SettingsOverview({ return (
- RBAC + Audit -

权限设置

-

内置角色、账号状态和关键操作审计。

+ System configuration +

系统设置

+

机构、用户、角色、权限、审计和故障中心。

+ + +
+ 机构租户 + {organizations.length} +
+
+ +

启用 {activeOrganizationCount} 个,多租户数据隔离

+
+
@@ -63,16 +92,20 @@ export function SettingsOverview({

累计覆盖 {totalPermissionCount} 项权限

- + 0 ? "border-red-200 bg-red-50/70" : undefined}>
- 红色审计 - {deniedAuditCount + failureAuditCount} + 0 ? "text-red-700" : undefined}>故障事件 + 0 ? "mt-2 text-3xl text-red-700" : "mt-2 text-3xl"}> + {openIncidentCount} +
-
-

拒绝 {deniedAuditCount} 条,失败 {failureAuditCount} 条

+

0 ? "text-sm text-red-700" : "text-sm text-muted-foreground"}> + 总计 {incidents.length} 条,开放 {openIncidentCount} 条 +

@@ -99,6 +132,10 @@ export function SettingsOverview({ 红色:拒绝/失败审计 {deniedAuditCount + failureAuditCount}
+
+ 开放故障 + {openIncidentCount} +
黄色:停用账号 {disabledAccountCount} @@ -137,7 +174,77 @@ export function SettingsOverview({
- 内置角色 + 机构租户 + + +
+ + + + + + + + + + + {organizations.map((organization) => ( + + + + + + + ))} + {organizations.length === 0 ? ( + + + + ) : null} + +
机构Slug状态创建时间
{organization.name}{organization.slug} + + {organization.status === "active" ? "启用" : "停用"} + + + {new Date(organization.createdAt).toLocaleString("zh-CN")} +
+ 暂无机构 +
+
+
+
+ + + + 故障中心 + + + {incidents.slice(0, 6).map((incident) => ( +
+
+

{incident.title}

+ + {incident.status} + +
+

{incident.description}

+

+ {incident.source} / {new Date(incident.createdAt).toLocaleString("zh-CN")} +

+
+ ))} + {incidents.length === 0 ?

暂无故障事件

: null} +
+
+
+ +
+ + + 角色与权限 {roles.map((role) => ( @@ -181,10 +288,10 @@ export function SettingsOverview({

{account.name}

{account.email}

- {ROLE_LABELS[account.role]} + {formatRoleLabel(account.role)} - - {account.status === "active" ? "启用" : "停用"} + + {account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"} @@ -199,6 +306,51 @@ export function SettingsOverview({
+ + + 机构成员关系 + + +
+ + + + + + + + + + + + {memberships.map((membership) => ( + + + + + + + + ))} + {memberships.length === 0 ? ( + + + + ) : null} + +
账号 ID机构 ID角色状态创建时间
{membership.accountId.slice(0, 8)}{membership.organizationId.slice(0, 8)}{membership.roleLabel} + + {membership.status} + + + {new Date(membership.createdAt).toLocaleString("zh-CN")} +
+ 暂无成员关系 +
+
+
+
+ 审计日志 diff --git a/package.json b/package.json index d34f33b..5fe10af 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", "lint": "eslint app components lib modules next.config.ts eslint.config.mjs", "type-check": "tsc --noEmit" }, @@ -13,8 +16,10 @@ "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", "lucide-react": "^0.468.0", "next": "^15.0.0", + "postgres": "^3.4.9", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.0.0" @@ -26,10 +31,11 @@ "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "drizzle-kit": "^0.31.10", "eslint": "^9.0.0", "eslint-config-next": "^15.0.0", "tailwindcss": "^4.0.0", - "typescript-eslint": "^8.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "typescript-eslint": "^8.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7ac282..07fa61d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(postgres@3.4.9) lucide-react: specifier: ^0.468.0 version: 0.468.0(react@19.2.7) next: specifier: ^15.0.0 version: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + postgres: + specifier: ^3.4.9 + version: 3.4.9 react: specifier: ^19.0.0 version: 19.2.7 @@ -51,6 +57,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.17) + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 eslint: specifier: ^9.0.0 version: 9.39.4(jiti@2.7.0) @@ -73,6 +82,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -85,6 +97,458 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -772,6 +1236,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -873,6 +1340,102 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -920,6 +1483,21 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1092,6 +1670,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1588,6 +2171,10 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1709,6 +2296,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -1798,6 +2392,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1876,6 +2475,8 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1897,6 +2498,238 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: eslint: 9.39.4(jiti@2.7.0) @@ -2517,6 +3350,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2615,6 +3450,17 @@ snapshots: dependencies: esutils: 2.0.3 + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.22.4 + + drizzle-orm@0.45.2(postgres@3.4.9): + optionalDependencies: + postgres: 3.4.9 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2739,6 +3585,89 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@4.0.0: {} eslint-config-next@15.5.19(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): @@ -2985,6 +3914,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3477,6 +4409,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -3652,6 +4586,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -3753,6 +4694,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1