379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
import { and, desc, eq, sql } from "drizzle-orm";
|
|
|
|
import { getDatabase } from "@/modules/core/server/db";
|
|
import {
|
|
accounts,
|
|
chronicConditions,
|
|
elders,
|
|
healthAnomalyReviews,
|
|
healthProfiles,
|
|
vitalRecords,
|
|
} from "@/modules/core/server/schema";
|
|
import type {
|
|
ChronicCondition,
|
|
ChronicConditionInput,
|
|
HealthAdminData,
|
|
HealthAnomalyReview,
|
|
HealthProfile,
|
|
HealthProfileInput,
|
|
HealthReviewUpdateInput,
|
|
VitalRecord,
|
|
VitalRecordInput,
|
|
} from "@/modules/health/types";
|
|
|
|
export type MutationFailure = {
|
|
success: false;
|
|
reason: string;
|
|
status: number;
|
|
};
|
|
|
|
type ElderRow = typeof elders.$inferSelect;
|
|
type AccountNameRow = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
export function isMutationFailure(value: unknown): value is MutationFailure {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"success" in value &&
|
|
(value as { success?: unknown }).success === false
|
|
);
|
|
}
|
|
|
|
function iso(value: Date): string {
|
|
return value.toISOString();
|
|
}
|
|
|
|
function optionalIso(value: Date | null): string | undefined {
|
|
return value ? iso(value) : undefined;
|
|
}
|
|
|
|
function buildNameMap(rows: AccountNameRow[]): Map<string, string> {
|
|
return new Map(rows.map((row) => [row.id, row.name]));
|
|
}
|
|
|
|
function toProfile(row: typeof healthProfiles.$inferSelect, elderName: string): HealthProfile {
|
|
return {
|
|
id: row.id,
|
|
organizationId: row.organizationId,
|
|
elderId: row.elderId,
|
|
elderName,
|
|
allergyNotes: row.allergyNotes,
|
|
medicalHistory: row.medicalHistory,
|
|
medicationNotes: row.medicationNotes,
|
|
careRestrictions: row.careRestrictions,
|
|
emergencyNotes: row.emergencyNotes,
|
|
createdAt: iso(row.createdAt),
|
|
updatedAt: iso(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
function toVital(row: typeof vitalRecords.$inferSelect, elderName: string, accountNameById: Map<string, string>): VitalRecord {
|
|
return {
|
|
id: row.id,
|
|
organizationId: row.organizationId,
|
|
elderId: row.elderId,
|
|
elderName,
|
|
recordedAt: iso(row.recordedAt),
|
|
source: row.source,
|
|
systolicBp: row.systolicBp ?? undefined,
|
|
diastolicBp: row.diastolicBp ?? undefined,
|
|
heartRate: row.heartRate ?? undefined,
|
|
temperatureTenths: row.temperatureTenths ?? undefined,
|
|
spo2: row.spo2 ?? undefined,
|
|
bloodGlucoseTenths: row.bloodGlucoseTenths ?? undefined,
|
|
weightTenths: row.weightTenths ?? undefined,
|
|
notes: row.notes,
|
|
createdByAccountId: row.createdByAccountId ?? undefined,
|
|
createdByName: row.createdByAccountId ? accountNameById.get(row.createdByAccountId) : undefined,
|
|
createdAt: iso(row.createdAt),
|
|
updatedAt: iso(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
function toCondition(row: typeof chronicConditions.$inferSelect, elderName: string): ChronicCondition {
|
|
return {
|
|
id: row.id,
|
|
organizationId: row.organizationId,
|
|
elderId: row.elderId,
|
|
elderName,
|
|
name: row.name,
|
|
status: row.status,
|
|
diagnosedAt: optionalIso(row.diagnosedAt),
|
|
treatmentNotes: row.treatmentNotes,
|
|
followUpNotes: row.followUpNotes,
|
|
createdAt: iso(row.createdAt),
|
|
updatedAt: iso(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
function toReview(
|
|
row: typeof healthAnomalyReviews.$inferSelect,
|
|
elderName: string,
|
|
accountNameById: Map<string, string>,
|
|
): HealthAnomalyReview {
|
|
return {
|
|
id: row.id,
|
|
organizationId: row.organizationId,
|
|
elderId: row.elderId,
|
|
elderName,
|
|
vitalRecordId: row.vitalRecordId ?? undefined,
|
|
severity: row.severity,
|
|
status: row.status,
|
|
title: row.title,
|
|
description: row.description,
|
|
reviewedByAccountId: row.reviewedByAccountId ?? undefined,
|
|
reviewedByName: row.reviewedByAccountId ? accountNameById.get(row.reviewedByAccountId) : undefined,
|
|
reviewedAt: optionalIso(row.reviewedAt),
|
|
resolutionNotes: row.resolutionNotes,
|
|
createdAt: iso(row.createdAt),
|
|
updatedAt: iso(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
function getElderName(row: ElderRow | undefined): string {
|
|
return row?.name ?? "";
|
|
}
|
|
|
|
async function findElder(organizationId: string, elderId: string): Promise<ElderRow | undefined> {
|
|
const database = getDatabase();
|
|
const rows = await database
|
|
.select()
|
|
.from(elders)
|
|
.where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId)))
|
|
.limit(1);
|
|
return rows[0];
|
|
}
|
|
|
|
function detectVitalAnomaly(input: VitalRecordInput): { title: string; description: string; severity: "warning" | "critical" } | null {
|
|
if ((input.systolicBp !== undefined && input.systolicBp >= 180) || (input.diastolicBp !== undefined && input.diastolicBp >= 110)) {
|
|
return { title: "血压严重异常", description: "血压达到紧急复核阈值", severity: "critical" };
|
|
}
|
|
|
|
if ((input.systolicBp !== undefined && input.systolicBp >= 150) || (input.diastolicBp !== undefined && input.diastolicBp >= 95)) {
|
|
return { title: "血压异常", description: "血压偏高,需要护理人员复核", severity: "warning" };
|
|
}
|
|
|
|
if (input.temperatureTenths !== undefined && input.temperatureTenths >= 380) {
|
|
return { title: "体温异常", description: "体温偏高,需要复核", severity: "warning" };
|
|
}
|
|
|
|
if (input.spo2 !== undefined && input.spo2 < 92) {
|
|
return { title: "血氧异常", description: "血氧偏低,需要复核", severity: "critical" };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function listHealthAdminData(organizationId: string): Promise<HealthAdminData> {
|
|
const database = getDatabase();
|
|
const todayStart = new Date();
|
|
todayStart.setHours(0, 0, 0, 0);
|
|
|
|
const [elderRows, profileRows, vitalRows, conditionRows, reviewRows, accountRows] = await Promise.all([
|
|
database.select().from(elders).where(eq(elders.organizationId, organizationId)).orderBy(desc(elders.createdAt)),
|
|
database.select().from(healthProfiles).where(eq(healthProfiles.organizationId, organizationId)).orderBy(desc(healthProfiles.updatedAt)),
|
|
database.select().from(vitalRecords).where(eq(vitalRecords.organizationId, organizationId)).orderBy(desc(vitalRecords.recordedAt)).limit(100),
|
|
database
|
|
.select()
|
|
.from(chronicConditions)
|
|
.where(eq(chronicConditions.organizationId, organizationId))
|
|
.orderBy(desc(chronicConditions.updatedAt)),
|
|
database
|
|
.select()
|
|
.from(healthAnomalyReviews)
|
|
.where(eq(healthAnomalyReviews.organizationId, organizationId))
|
|
.orderBy(desc(healthAnomalyReviews.createdAt)),
|
|
database.select({ id: accounts.id, name: accounts.name }).from(accounts),
|
|
]);
|
|
|
|
const elderById = new Map(elderRows.map((elder) => [elder.id, elder]));
|
|
const accountNameById = buildNameMap(accountRows);
|
|
|
|
return {
|
|
elders: elderRows.map((elder) => ({
|
|
id: elder.id,
|
|
name: elder.name,
|
|
careLevel: elder.careLevel,
|
|
status: elder.status,
|
|
})),
|
|
profiles: profileRows.map((profile) => toProfile(profile, getElderName(elderById.get(profile.elderId)))),
|
|
vitals: vitalRows.map((vital) => toVital(vital, getElderName(elderById.get(vital.elderId)), accountNameById)),
|
|
chronicConditions: conditionRows.map((condition) => toCondition(condition, getElderName(elderById.get(condition.elderId)))),
|
|
reviews: reviewRows.map((review) => toReview(review, getElderName(elderById.get(review.elderId)), accountNameById)),
|
|
metrics: {
|
|
eldersWithProfiles: profileRows.length,
|
|
vitalsToday: vitalRows.filter((vital) => vital.recordedAt >= todayStart).length,
|
|
activeChronicConditions: conditionRows.filter((condition) => condition.status === "active").length,
|
|
pendingReviews: reviewRows.filter((review) => review.status === "pending").length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function upsertHealthProfile(input: HealthProfileInput & { elderId: string; organizationId: string }): Promise<HealthProfile | MutationFailure> {
|
|
const elder = await findElder(input.organizationId, input.elderId);
|
|
if (!elder) {
|
|
return { success: false, reason: "老人档案不存在", status: 404 };
|
|
}
|
|
|
|
const database = getDatabase();
|
|
const rows = await database
|
|
.insert(healthProfiles)
|
|
.values({
|
|
organizationId: input.organizationId,
|
|
elderId: input.elderId,
|
|
allergyNotes: input.allergyNotes,
|
|
medicalHistory: input.medicalHistory,
|
|
medicationNotes: input.medicationNotes,
|
|
careRestrictions: input.careRestrictions,
|
|
emergencyNotes: input.emergencyNotes,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [healthProfiles.organizationId, healthProfiles.elderId],
|
|
set: {
|
|
allergyNotes: input.allergyNotes,
|
|
medicalHistory: input.medicalHistory,
|
|
medicationNotes: input.medicationNotes,
|
|
careRestrictions: input.careRestrictions,
|
|
emergencyNotes: input.emergencyNotes,
|
|
updatedAt: sql`NOW()`,
|
|
},
|
|
})
|
|
.returning();
|
|
const profile = rows[0];
|
|
if (!profile) {
|
|
return { success: false, reason: "健康档案保存失败", status: 500 };
|
|
}
|
|
|
|
return toProfile(profile, elder.name);
|
|
}
|
|
|
|
export async function createVitalRecord(input: VitalRecordInput & { accountId: string; organizationId: string }): Promise<
|
|
| {
|
|
vital: VitalRecord;
|
|
review?: HealthAnomalyReview;
|
|
}
|
|
| MutationFailure
|
|
> {
|
|
const elder = await findElder(input.organizationId, input.elderId);
|
|
if (!elder) {
|
|
return { success: false, reason: "老人档案不存在", status: 404 };
|
|
}
|
|
|
|
const database = getDatabase();
|
|
const created = await database.transaction(async (transaction) => {
|
|
const vitalRows = await transaction
|
|
.insert(vitalRecords)
|
|
.values({
|
|
organizationId: input.organizationId,
|
|
elderId: input.elderId,
|
|
recordedAt: input.recordedAt,
|
|
source: input.source,
|
|
systolicBp: input.systolicBp,
|
|
diastolicBp: input.diastolicBp,
|
|
heartRate: input.heartRate,
|
|
temperatureTenths: input.temperatureTenths,
|
|
spo2: input.spo2,
|
|
bloodGlucoseTenths: input.bloodGlucoseTenths,
|
|
weightTenths: input.weightTenths,
|
|
notes: input.notes,
|
|
createdByAccountId: input.accountId,
|
|
})
|
|
.returning();
|
|
const vital = vitalRows[0];
|
|
if (!vital) {
|
|
return null;
|
|
}
|
|
|
|
const anomaly = detectVitalAnomaly(input);
|
|
if (!anomaly) {
|
|
return { vital };
|
|
}
|
|
|
|
const reviewRows = await transaction
|
|
.insert(healthAnomalyReviews)
|
|
.values({
|
|
organizationId: input.organizationId,
|
|
elderId: input.elderId,
|
|
vitalRecordId: vital.id,
|
|
severity: anomaly.severity,
|
|
status: "pending",
|
|
title: anomaly.title,
|
|
description: anomaly.description,
|
|
})
|
|
.returning();
|
|
return { vital, review: reviewRows[0] };
|
|
});
|
|
|
|
if (!created) {
|
|
return { success: false, reason: "生命体征记录创建失败", status: 500 };
|
|
}
|
|
|
|
const accountRows = await database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId));
|
|
const accountNameById = buildNameMap(accountRows);
|
|
|
|
return {
|
|
vital: toVital(created.vital, elder.name, accountNameById),
|
|
review: created.review ? toReview(created.review, elder.name, accountNameById) : undefined,
|
|
};
|
|
}
|
|
|
|
export async function createChronicCondition(
|
|
input: ChronicConditionInput & { organizationId: string },
|
|
): Promise<ChronicCondition | MutationFailure> {
|
|
const elder = await findElder(input.organizationId, input.elderId);
|
|
if (!elder) {
|
|
return { success: false, reason: "老人档案不存在", status: 404 };
|
|
}
|
|
|
|
const database = getDatabase();
|
|
const rows = await database
|
|
.insert(chronicConditions)
|
|
.values({
|
|
organizationId: input.organizationId,
|
|
elderId: input.elderId,
|
|
name: input.name,
|
|
status: input.status,
|
|
diagnosedAt: input.diagnosedAt,
|
|
treatmentNotes: input.treatmentNotes,
|
|
followUpNotes: input.followUpNotes,
|
|
})
|
|
.returning();
|
|
const condition = rows[0];
|
|
if (!condition) {
|
|
return { success: false, reason: "慢病记录创建失败", status: 500 };
|
|
}
|
|
|
|
return toCondition(condition, elder.name);
|
|
}
|
|
|
|
export async function updateHealthReview(input: HealthReviewUpdateInput & { accountId: string; id: string; organizationId: string }): Promise<
|
|
HealthAnomalyReview | MutationFailure
|
|
> {
|
|
const database = getDatabase();
|
|
const rows = await database
|
|
.update(healthAnomalyReviews)
|
|
.set({
|
|
status: input.status,
|
|
resolutionNotes: input.resolutionNotes,
|
|
reviewedByAccountId: input.accountId,
|
|
reviewedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(healthAnomalyReviews.id, input.id), eq(healthAnomalyReviews.organizationId, input.organizationId)))
|
|
.returning();
|
|
const review = rows[0];
|
|
if (!review) {
|
|
return { success: false, reason: "异常复核记录不存在", status: 404 };
|
|
}
|
|
|
|
const [elderRows, accountRows] = await Promise.all([
|
|
database.select().from(elders).where(eq(elders.id, review.elderId)).limit(1),
|
|
database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(eq(accounts.id, input.accountId)),
|
|
]);
|
|
|
|
return toReview(review, getElderName(elderRows[0]), buildNameMap(accountRows));
|
|
}
|