Add patient personalization: profile saved once, reused every chat turn

This commit is contained in:
2026-08-25 11:55:13 +07:00
parent 501c226ecd
commit 1ddd878431
12 changed files with 548 additions and 6 deletions
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS patient_profile (
user_id uuid PRIMARY KEY REFERENCES auth_user(id) ON DELETE CASCADE,
age_text text,
weight_kg numeric,
renal_function text,
hepatic_function text,
known_allergies text,
updated_at timestamptz NOT NULL DEFAULT now()
);
+24 -2
View File
@@ -1,8 +1,13 @@
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import type { AuthUser, LoginResponse } from "@duoc-thu/shared-types";
import { Body, Controller, Get, Post, Put, Req, UseGuards } from "@nestjs/common";
import type {
AuthUser,
LoginResponse,
PatientProfile,
} from "@duoc-thu/shared-types";
import type { Request } from "express";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { PatientProfileDto } from "./dto/patient-profile.dto";
import { RegisterDto } from "./dto/register.dto";
import { JwtAuthGuard, JwtPayload } from "./jwt.guard";
@@ -26,4 +31,21 @@ export class AuthController {
const payload = (request as Request & { user: JwtPayload }).user;
return { username: payload.username, role: payload.role };
}
@UseGuards(JwtAuthGuard)
@Get("patient-profile")
getPatientProfile(@Req() request: Request): Promise<PatientProfile> {
const payload = (request as Request & { user: JwtPayload }).user;
return this.auth.getPatientProfile(payload.sub);
}
@UseGuards(JwtAuthGuard)
@Put("patient-profile")
savePatientProfile(
@Req() request: Request,
@Body() body: PatientProfileDto
): Promise<PatientProfile> {
const payload = (request as Request & { user: JwtPayload }).user;
return this.auth.savePatientProfile(payload.sub, body);
}
}
+70 -1
View File
@@ -4,7 +4,13 @@ import {
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import type { AuthUser, LoginResponse, UserRole } from "@duoc-thu/shared-types";
import type {
AuthUser,
LoginResponse,
PatientProfile,
PatientProfileUpdate,
UserRole,
} from "@duoc-thu/shared-types";
import * as bcrypt from "bcrypt";
import { Pool } from "pg";
@@ -17,6 +23,22 @@ interface UserRow {
role: UserRole;
}
interface PatientProfileRow {
age_text: string | null;
weight_kg: string | null; // numeric comes back as string from `pg`
renal_function: string | null;
hepatic_function: string | null;
known_allergies: string | null;
}
const EMPTY_PROFILE: PatientProfile = {
ageText: null,
weightKg: null,
renalFunction: null,
hepaticFunction: null,
knownAllergies: null,
};
@Injectable()
export class AuthService {
constructor(
@@ -67,4 +89,51 @@ export class AuthService {
});
return { user, token };
}
async getPatientProfile(userId: string): Promise<PatientProfile> {
const result = await this.pool.query<PatientProfileRow>(
`SELECT age_text, weight_kg, renal_function, hepatic_function, known_allergies
FROM patient_profile WHERE user_id = $1`,
[userId]
);
const row = result.rows[0];
if (!row) return EMPTY_PROFILE;
return {
ageText: row.age_text,
weightKg: row.weight_kg === null ? null : Number(row.weight_kg),
renalFunction: row.renal_function,
hepaticFunction: row.hepatic_function,
knownAllergies: row.known_allergies,
};
}
// Upsert on the single row per user — a doctor has at most one saved
// profile, and `PUT` is expected to be called with the full form each
// time (undefined fields here keep whatever was already stored, since a
// partial `PatientProfileDto` only sends the keys the form actually has).
async savePatientProfile(
userId: string,
update: PatientProfileUpdate
): Promise<PatientProfile> {
await this.pool.query(
`INSERT INTO patient_profile (user_id, age_text, weight_kg, renal_function, hepatic_function, known_allergies, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (user_id) DO UPDATE SET
age_text = COALESCE($2, patient_profile.age_text),
weight_kg = COALESCE($3, patient_profile.weight_kg),
renal_function = COALESCE($4, patient_profile.renal_function),
hepatic_function = COALESCE($5, patient_profile.hepatic_function),
known_allergies = COALESCE($6, patient_profile.known_allergies),
updated_at = now()`,
[
userId,
update.ageText ?? null,
update.weightKg ?? null,
update.renalFunction ?? null,
update.hepaticFunction ?? null,
update.knownAllergies ?? null,
]
);
return this.getPatientProfile(userId);
}
}
@@ -0,0 +1,29 @@
import { IsNumber, IsOptional, IsString, Length, Max, Min } from "class-validator";
export class PatientProfileDto {
@IsOptional()
@IsString()
@Length(0, 64)
ageText?: string | null;
@IsOptional()
@IsNumber()
@Min(0)
@Max(500)
weightKg?: number | null;
@IsOptional()
@IsString()
@Length(0, 200)
renalFunction?: string | null;
@IsOptional()
@IsString()
@Length(0, 200)
hepaticFunction?: string | null;
@IsOptional()
@IsString()
@Length(0, 500)
knownAllergies?: string | null;
}
@@ -0,0 +1,99 @@
import "reflect-metadata";
import { Test } from "@nestjs/testing";
import { JwtService } from "@nestjs/jwt";
import { Pool } from "pg";
import { AuthController } from "../src/auth/auth.controller";
import { AuthService } from "../src/auth/auth.service";
import { JwtAuthGuard } from "../src/auth/jwt.guard";
describe("patient profile", () => {
let controller: AuthController;
let service: AuthService;
let pool: { query: jest.Mock };
beforeEach(async () => {
pool = { query: jest.fn() };
const moduleRef = await Test.createTestingModule({
controllers: [AuthController],
providers: [
AuthService,
JwtAuthGuard,
{ provide: Pool, useValue: pool },
{ provide: JwtService, useValue: { signAsync: jest.fn(), verifyAsync: jest.fn() } },
],
}).compile();
controller = moduleRef.get(AuthController);
service = moduleRef.get(AuthService);
});
it("getPatientProfile returns an all-null profile when no row exists", async () => {
pool.query.mockResolvedValueOnce({ rows: [] });
const profile = await service.getPatientProfile("user-1");
expect(profile).toEqual({
ageText: null,
weightKg: null,
renalFunction: null,
hepaticFunction: null,
knownAllergies: null,
});
});
it("getPatientProfile maps the stored row, converting numeric weight from string", async () => {
pool.query.mockResolvedValueOnce({
rows: [
{
age_text: "8 tuổi",
weight_kg: "25.5",
renal_function: null,
hepatic_function: null,
known_allergies: "penicillin",
},
],
});
const profile = await service.getPatientProfile("user-1");
expect(profile.ageText).toBe("8 tuổi");
expect(profile.weightKg).toBe(25.5);
expect(profile.knownAllergies).toBe("penicillin");
});
it("savePatientProfile upserts then re-reads the row (one INSERT..ON CONFLICT, one SELECT)", async () => {
pool.query.mockResolvedValueOnce({ rows: [] }); // the upsert itself
pool.query.mockResolvedValueOnce({
rows: [
{
age_text: "30 tuổi",
weight_kg: "70",
renal_function: null,
hepatic_function: null,
known_allergies: null,
},
],
});
const result = await service.savePatientProfile("user-1", {
ageText: "30 tuổi",
weightKg: 70,
});
expect(pool.query).toHaveBeenCalledTimes(2);
expect(pool.query.mock.calls[0][0]).toMatch(/ON CONFLICT \(user_id\) DO UPDATE/);
expect(result.ageText).toBe("30 tuổi");
expect(result.weightKg).toBe(70);
});
it("controller.getPatientProfile reads the JWT payload's sub, not a request param", async () => {
const spy = jest
.spyOn(service, "getPatientProfile")
.mockResolvedValue({
ageText: null,
weightKg: null,
renalFunction: null,
hepaticFunction: null,
knownAllergies: null,
});
const request = { user: { sub: "user-42", username: "demo", role: "user" } };
await controller.getPatientProfile(request as never);
expect(spy).toHaveBeenCalledWith("user-42");
});
});