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");
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,9 @@ export function AccountMenu() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary">
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
<span>{user.username}</span>
|
||||
<Link href="/profile" className="hover:underline" title="Hồ sơ bệnh nhân">
|
||||
{user.username}
|
||||
</Link>
|
||||
<button
|
||||
aria-label="Đăng xuất"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
SendMessageResponse,
|
||||
} from "@duoc-thu/shared-types";
|
||||
import { ChatBubble, CitationBeamOverlay, useTheme } from "@duoc-thu/ui";
|
||||
import type { PatientProfile } from "@duoc-thu/shared-types";
|
||||
import { getPatientProfile } from "@duoc-thu/api-client";
|
||||
import { Composer } from "./Composer";
|
||||
import { AnswerFeedback } from "./AnswerFeedback";
|
||||
import {
|
||||
@@ -54,6 +56,24 @@ interface SectionTextResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Prepends a short, natural Vietnamese clause carrying whatever the saved
|
||||
* profile has (only the fields actually filled in — an empty profile or a
|
||||
* partially-filled one changes nothing it doesn't have data for). Sent on
|
||||
* every turn, not just the first: the understanding model's own multi-turn
|
||||
* merge already treats a repeated fact as a no-op, so there's no need to
|
||||
* track "did we already say this in this conversation" here. */
|
||||
function withPatientContext(userText: string, profile: PatientProfile | null): string {
|
||||
if (!profile) return userText;
|
||||
const parts: string[] = [];
|
||||
if (profile.ageText) parts.push(profile.ageText);
|
||||
if (profile.weightKg != null) parts.push(`${profile.weightKg} kg`);
|
||||
if (profile.renalFunction) parts.push(`thận: ${profile.renalFunction}`);
|
||||
if (profile.hepaticFunction) parts.push(`gan: ${profile.hepaticFunction}`);
|
||||
if (profile.knownAllergies) parts.push(`dị ứng: ${profile.knownAllergies}`);
|
||||
if (parts.length === 0) return userText;
|
||||
return `Bệnh nhân ${parts.join(", ")}. ${userText}`;
|
||||
}
|
||||
|
||||
const MONOGRAPH_DISCLAIMER =
|
||||
"Nội dung nguyên văn được lấy từ Dược thư Quốc gia Việt Nam 2018, phục vụ tra cứu chuyên môn và không thay thế chỉ định của bác sĩ hoặc dược sĩ lâm sàng.";
|
||||
|
||||
@@ -132,6 +152,20 @@ export function ChatPanel({
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const initialQuerySentRef = useRef<number | undefined>(undefined);
|
||||
const stopRequestedRef = useRef(false);
|
||||
// null = anonymous or no saved profile — never touches the outgoing query.
|
||||
// Fetched once; `getPatientProfile()` itself returns null on a 401, so an
|
||||
// anonymous visitor never even attempts an authenticated call more than once.
|
||||
const patientProfileRef = useRef<PatientProfile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPatientProfile()
|
||||
.then((profile) => {
|
||||
patientProfileRef.current = profile;
|
||||
})
|
||||
.catch(() => {
|
||||
patientProfileRef.current = null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -172,7 +206,12 @@ export function ChatPanel({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content: userText,
|
||||
// Prepends saved patient context (age/weight/renal/hepatic/allergy)
|
||||
// to what's actually sent, never to what's shown in the chat
|
||||
// bubble above — the LLM understanding step already extracts these
|
||||
// fields from free text every turn (see rag/understanding.py), so
|
||||
// this reuses that exact path instead of adding a second one.
|
||||
content: withPatientContext(userText, patientProfileRef.current),
|
||||
conversationId: sessionId,
|
||||
responseMode,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { PatientProfile } from "@duoc-thu/shared-types";
|
||||
import { getPatientProfile, savePatientProfile } from "@duoc-thu/api-client";
|
||||
|
||||
const EMPTY: PatientProfile = {
|
||||
ageText: null,
|
||||
weightKg: null,
|
||||
renalFunction: null,
|
||||
hepaticFunction: null,
|
||||
knownAllergies: null,
|
||||
};
|
||||
|
||||
/** Optional per-account clinical context. Nothing here is required — an
|
||||
* empty profile behaves exactly like no profile: the chatbot keeps asking
|
||||
* age/weight per turn, same as an anonymous session. Only a logged-in user
|
||||
* ever sees this page (`AccountMenu` is the only link to it). */
|
||||
export function ProfileForm() {
|
||||
const [profile, setProfile] = useState<PatientProfile>(EMPTY);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPatientProfile()
|
||||
.then((p) => p && setProfile(p))
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await savePatientProfile(profile);
|
||||
setProfile(result);
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setError("Không lưu được lúc này. Vui lòng thử lại.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm space-y-4 rounded-2xl border border-border-subtle bg-surface p-6 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-txt-primary">
|
||||
Hồ sơ bệnh nhân
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-txt-secondary">
|
||||
Điền sẵn để không phải nhắc lại tuổi/cân nặng mỗi lượt hỏi. Bỏ
|
||||
trống mục nào thì chatbot vẫn hỏi lại mục đó như bình thường.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-semibold text-txt-secondary">
|
||||
Tuổi
|
||||
<input
|
||||
type="text"
|
||||
value={profile.ageText ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, ageText: e.target.value })}
|
||||
placeholder="VD: 8 tuổi, sơ sinh 2 tháng"
|
||||
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-txt-secondary">
|
||||
Cân nặng (kg)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={500}
|
||||
value={profile.weightKg ?? ""}
|
||||
onChange={(e) =>
|
||||
setProfile({
|
||||
...profile,
|
||||
weightKg: e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-txt-secondary">
|
||||
Chức năng thận
|
||||
<input
|
||||
type="text"
|
||||
value={profile.renalFunction ?? ""}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, renalFunction: e.target.value })
|
||||
}
|
||||
placeholder="VD: suy thận độ 2, Clcr 45 ml/phút"
|
||||
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-txt-secondary">
|
||||
Chức năng gan
|
||||
<input
|
||||
type="text"
|
||||
value={profile.hepaticFunction ?? ""}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, hepaticFunction: e.target.value })
|
||||
}
|
||||
placeholder="VD: suy gan nhẹ"
|
||||
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-semibold text-txt-secondary">
|
||||
Dị ứng đã biết
|
||||
<input
|
||||
type="text"
|
||||
value={profile.knownAllergies ?? ""}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, knownAllergies: e.target.value })
|
||||
}
|
||||
placeholder="VD: dị ứng penicillin"
|
||||
className="mt-1 w-full rounded-lg border border-border-subtle bg-surface-hover px-3 py-2 text-sm text-txt-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{saved && !error && (
|
||||
<p className="text-xs text-emerald-600">Đã lưu.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-lg bg-accent-primary px-4 py-2 text-sm font-semibold text-txt-inverse disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Đang lưu..." : "Lưu"}
|
||||
</button>
|
||||
<Link href="/" className="text-xs text-txt-secondary hover:underline">
|
||||
Về trang tra cứu
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <ProfileForm />;
|
||||
}
|
||||
@@ -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<AuthUser | null> {
|
||||
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<PatientProfile | null> {
|
||||
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<PatientProfile> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<PatientProfile>;
|
||||
|
||||
Reference in New Issue
Block a user