feat: add system configuration platform
This commit is contained in:
@@ -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>): 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<AppData> {
|
||||
try {
|
||||
const raw = await fs.readFile(DATA_FILE, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<AppData>;
|
||||
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<string | null>`(
|
||||
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<string, { total: number; occupied: number }>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
throw new Error("writeData is not available after PostgreSQL migration");
|
||||
}
|
||||
|
||||
export async function updateData<T>(mutator: (data: AppData) => T): Promise<T> {
|
||||
const data = await readData();
|
||||
const result = mutator(data);
|
||||
await writeData(data);
|
||||
return result;
|
||||
export async function updateData<T>(): Promise<T> {
|
||||
throw new Error("updateData is not available after PostgreSQL migration");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user