Add patient personalization: profile saved once, reused every chat turn
This commit is contained in:
@@ -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()
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user