From 1ddd878431839d81c523d6ae506f54e4eba41642 Mon Sep 17 00:00:00 2001 From: BaoVu2k4 Date: Tue, 25 Aug 2026 11:55:13 +0700 Subject: [PATCH] Add patient personalization: profile saved once, reused every chat turn --- .../migrations/002_patient_profile.sql | 9 + apps/auth-service/src/auth/auth.controller.ts | 26 ++- apps/auth-service/src/auth/auth.service.ts | 71 +++++++- .../src/auth/dto/patient-profile.dto.ts | 29 ++++ .../auth-service/test/patient-profile.spec.ts | 99 +++++++++++ apps/web/app/_components/AccountMenu.tsx | 4 +- apps/web/app/_components/ChatPanel.tsx | 41 ++++- apps/web/app/_components/ProfileForm.tsx | 154 ++++++++++++++++++ apps/web/app/api/patient-profile/route.ts | 69 ++++++++ apps/web/app/profile/page.tsx | 11 ++ packages/api-client/src/auth.ts | 27 ++- packages/shared-types/src/dto/auth.ts | 14 ++ 12 files changed, 548 insertions(+), 6 deletions(-) create mode 100644 apps/auth-service/migrations/002_patient_profile.sql create mode 100644 apps/auth-service/src/auth/dto/patient-profile.dto.ts create mode 100644 apps/auth-service/test/patient-profile.spec.ts create mode 100644 apps/web/app/_components/ProfileForm.tsx create mode 100644 apps/web/app/api/patient-profile/route.ts create mode 100644 apps/web/app/profile/page.tsx diff --git a/apps/auth-service/migrations/002_patient_profile.sql b/apps/auth-service/migrations/002_patient_profile.sql new file mode 100644 index 0000000..36ebffe --- /dev/null +++ b/apps/auth-service/migrations/002_patient_profile.sql @@ -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() +); diff --git a/apps/auth-service/src/auth/auth.controller.ts b/apps/auth-service/src/auth/auth.controller.ts index 1013cb2..0643227 100644 --- a/apps/auth-service/src/auth/auth.controller.ts +++ b/apps/auth-service/src/auth/auth.controller.ts @@ -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 { + 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 { + const payload = (request as Request & { user: JwtPayload }).user; + return this.auth.savePatientProfile(payload.sub, body); + } } diff --git a/apps/auth-service/src/auth/auth.service.ts b/apps/auth-service/src/auth/auth.service.ts index 0050381..b65991e 100644 --- a/apps/auth-service/src/auth/auth.service.ts +++ b/apps/auth-service/src/auth/auth.service.ts @@ -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 { + const result = await this.pool.query( + `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 { + 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); + } } diff --git a/apps/auth-service/src/auth/dto/patient-profile.dto.ts b/apps/auth-service/src/auth/dto/patient-profile.dto.ts new file mode 100644 index 0000000..296b3e8 --- /dev/null +++ b/apps/auth-service/src/auth/dto/patient-profile.dto.ts @@ -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; +} diff --git a/apps/auth-service/test/patient-profile.spec.ts b/apps/auth-service/test/patient-profile.spec.ts new file mode 100644 index 0000000..516401e --- /dev/null +++ b/apps/auth-service/test/patient-profile.spec.ts @@ -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"); + }); +}); diff --git a/apps/web/app/_components/AccountMenu.tsx b/apps/web/app/_components/AccountMenu.tsx index 9a1635c..05c661d 100644 --- a/apps/web/app/_components/AccountMenu.tsx +++ b/apps/web/app/_components/AccountMenu.tsx @@ -39,7 +39,9 @@ export function AccountMenu() { return (
- {user.username} + + {user.username} + + + Về trang tra cứu + +
+ + + ); +} diff --git a/apps/web/app/api/patient-profile/route.ts b/apps/web/app/api/patient-profile/route.ts new file mode 100644 index 0000000..f02f9fb --- /dev/null +++ b/apps/web/app/api/patient-profile/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import type { PatientProfile, PatientProfileUpdate } from "@duoc-thu/shared-types"; +import { SESSION_COOKIE } from "../auth/session"; + +const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000"; + +export async function GET() { + const token = cookies().get(SESSION_COOKIE)?.value; + if (!token) { + return NextResponse.json({ error: "not_authenticated" }, { status: 401 }); + } + + let upstream: Response; + try { + upstream = await fetch(`${GATEWAY_URL}/auth/patient-profile`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + } catch { + return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 }); + } + + if (!upstream.ok) { + return NextResponse.json({ error: "not_authenticated" }, { status: 401 }); + } + + const profile = (await upstream.json()) as PatientProfile; + return NextResponse.json(profile); +} + +export async function PUT(request: Request) { + const token = cookies().get(SESSION_COOKIE)?.value; + if (!token) { + return NextResponse.json({ error: "not_authenticated" }, { status: 401 }); + } + + let body: PatientProfileUpdate; + try { + body = (await request.json()) as PatientProfileUpdate; + } catch { + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + } + + let upstream: Response; + try { + upstream = await fetch(`${GATEWAY_URL}/auth/patient-profile`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache: "no-store", + }); + } catch { + return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 }); + } + + if (!upstream.ok) { + return NextResponse.json( + { error: "save_failed" }, + { status: upstream.status === 401 ? 401 : 502 } + ); + } + + const profile = (await upstream.json()) as PatientProfile; + return NextResponse.json(profile); +} diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx new file mode 100644 index 0000000..a88845e --- /dev/null +++ b/apps/web/app/profile/page.tsx @@ -0,0 +1,11 @@ +import { ProfileForm } from "../_components/ProfileForm"; + +/** + * Only reachable by a logged-in account (linked from `AccountMenu`, never + * shown to an anonymous visitor). Saving a profile here is what lets the + * chat composer stop asking age/weight every turn for that account — see + * `ChatPanel.tsx`'s use of `getPatientProfile`. + */ +export default function ProfilePage() { + return ; +} diff --git a/packages/api-client/src/auth.ts b/packages/api-client/src/auth.ts index 6a756e3..e923fb9 100644 --- a/packages/api-client/src/auth.ts +++ b/packages/api-client/src/auth.ts @@ -1,4 +1,9 @@ -import type { AuthUser, LoginRequest } from "@duoc-thu/shared-types"; +import type { + AuthUser, + LoginRequest, + PatientProfile, + PatientProfileUpdate, +} from "@duoc-thu/shared-types"; export class AuthError extends Error { constructor(readonly status: number) { @@ -34,3 +39,23 @@ export async function me(): Promise { if (!response.ok) throw new AuthError(response.status); return (await response.json()) as AuthUser; } + +/** Returns `null` on 401, same "not logged in is not an error" rule as `me`. */ +export async function getPatientProfile(): Promise { + const response = await fetch("/api/patient-profile"); + if (response.status === 401) return null; + if (!response.ok) throw new AuthError(response.status); + return (await response.json()) as PatientProfile; +} + +export async function savePatientProfile( + update: PatientProfileUpdate +): Promise { + const response = await fetch("/api/patient-profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(update), + }); + if (!response.ok) throw new AuthError(response.status); + return (await response.json()) as PatientProfile; +} diff --git a/packages/shared-types/src/dto/auth.ts b/packages/shared-types/src/dto/auth.ts index 8f81742..47c3eae 100644 --- a/packages/shared-types/src/dto/auth.ts +++ b/packages/shared-types/src/dto/auth.ts @@ -23,3 +23,17 @@ export interface RegisterRequest { username: string; password: string; } + +/** Per-user clinical context, set once from the account profile page and + * reused across chat turns so a logged-in doctor isn't re-asked age/weight + * on every dosing question. All fields optional/nullable — an anonymous + * user or a logged-in user with no saved profile is entirely unaffected. */ +export interface PatientProfile { + ageText: string | null; + weightKg: number | null; + renalFunction: string | null; + hepaticFunction: string | null; + knownAllergies: string | null; +} + +export type PatientProfileUpdate = Partial;