feat: doctor reply + diagnosis + tracking code per health request
Backend:
- HealthRequest model: TrackingCode (DR-XXXXXX), Diagnosis,
DoctorReply, RepliedAt fields
- Runtime migration: ALTER TABLE adds 4 new columns to existing DB
- POST /api/health-request: auto-generates tracking code, returns it
- PUT /api/health-requests/{id}/reply: doctor sets diagnosis + reply
- GET /api/health-request/track/{code}: public lookup by tracking code
- GET /api/health-requests?phone=: filter history by phone number
Admin panel:
- Request table shows tracking code column (gold badge)
- Detail modal (680px): tracking code header, patient info, full message
- Previous doctor reply shown in green box if exists
- Reply form: diagnosis input + textarea for doctor message
- History panel: all requests from same phone, click to switch
- 'پاسخ / مشاهده' button opens reply modal directly
Frontend:
- After form submit: shows tracking code in green box to user
(format: DR-XXXXXX, stays visible 8 seconds)
- Box auto-hides and form resets after timeout
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+64
-13
@@ -131,17 +131,32 @@ await using (var scope = app.Services.CreateAsyncScope())
|
||||
try {
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS "HealthRequests" (
|
||||
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"FullName" TEXT NOT NULL DEFAULT '',
|
||||
"PhoneNumber" TEXT NOT NULL DEFAULT '',
|
||||
"Email" TEXT NOT NULL DEFAULT '',
|
||||
"Message" TEXT NOT NULL DEFAULT '',
|
||||
"Category" TEXT NOT NULL DEFAULT 'beauty',
|
||||
"IsHandled" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAt" TEXT NOT NULL DEFAULT ''
|
||||
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"TrackingCode" TEXT NOT NULL DEFAULT '',
|
||||
"FullName" TEXT NOT NULL DEFAULT '',
|
||||
"PhoneNumber" TEXT NOT NULL DEFAULT '',
|
||||
"Email" TEXT NOT NULL DEFAULT '',
|
||||
"Message" TEXT NOT NULL DEFAULT '',
|
||||
"Category" TEXT NOT NULL DEFAULT 'beauty',
|
||||
"IsHandled" INTEGER NOT NULL DEFAULT 0,
|
||||
"Diagnosis" TEXT NOT NULL DEFAULT '',
|
||||
"DoctorReply" TEXT NOT NULL DEFAULT '',
|
||||
"RepliedAt" TEXT,
|
||||
"CreatedAt" TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""");
|
||||
} catch { }
|
||||
// Add new columns to existing HealthRequests table (safe migration)
|
||||
foreach (var col in new[] {
|
||||
("TrackingCode", "TEXT NOT NULL DEFAULT ''"),
|
||||
("Diagnosis", "TEXT NOT NULL DEFAULT ''"),
|
||||
("DoctorReply", "TEXT NOT NULL DEFAULT ''"),
|
||||
("RepliedAt", "TEXT") })
|
||||
{
|
||||
try { await db.Database.ExecuteSqlRawAsync(
|
||||
$"ALTER TABLE HealthRequests ADD COLUMN \"{col.Item1}\" {col.Item2}"); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
await SeedAsync(db);
|
||||
}
|
||||
@@ -703,20 +718,47 @@ patientsGroup.MapDelete("/visits/{visitId:int}", async (int visitId, AppDbContex
|
||||
// ── Health Requests (public submit / admin manage) ────────────────────────────
|
||||
app.MapPost("/api/health-request", async (HealthRequest req, AppDbContext db) =>
|
||||
{
|
||||
req.CreatedAt = DateTime.UtcNow;
|
||||
req.IsHandled = false;
|
||||
req.CreatedAt = DateTime.UtcNow;
|
||||
req.IsHandled = false;
|
||||
req.TrackingCode = "DR-" + GenerateTrackingCode();
|
||||
db.HealthRequests.Add(req);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { message = "درخواست شما ثبت شد" });
|
||||
return Results.Ok(new { message = "درخواست شما ثبت شد", trackingCode = req.TrackingCode, id = req.Id });
|
||||
});
|
||||
|
||||
app.MapGet("/api/health-requests", async (bool? handled, AppDbContext db) =>
|
||||
// Public: look up own request by tracking code
|
||||
app.MapGet("/api/health-request/track/{code}", async (string code, AppDbContext db) =>
|
||||
{
|
||||
var r = await db.HealthRequests.FirstOrDefaultAsync(x => x.TrackingCode == code);
|
||||
if (r is null) return Results.NotFound(new { message = "کد رهگیری یافت نشد" });
|
||||
return Results.Ok(new {
|
||||
r.TrackingCode, r.FullName, r.Category, r.Message, r.IsHandled,
|
||||
r.Diagnosis, r.DoctorReply, r.RepliedAt, r.CreatedAt
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/health-requests", async (bool? handled, string? phone, AppDbContext db) =>
|
||||
{
|
||||
var q = db.HealthRequests.AsQueryable();
|
||||
if (handled.HasValue) q = q.Where(r => r.IsHandled == handled);
|
||||
if (handled.HasValue) q = q.Where(r => r.IsHandled == handled);
|
||||
if (!string.IsNullOrEmpty(phone)) q = q.Where(r => r.PhoneNumber == phone);
|
||||
return Results.Ok(await q.OrderByDescending(r => r.CreatedAt).ToListAsync());
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Doctor reply: set diagnosis + reply text + mark handled
|
||||
app.MapPut("/api/health-requests/{id:int}/reply", async (int id, DoctorReplyDto dto, AppDbContext db) =>
|
||||
{
|
||||
var r = await db.HealthRequests.FindAsync(id);
|
||||
if (r is null) return Results.NotFound();
|
||||
r.Diagnosis = dto.Diagnosis ?? r.Diagnosis;
|
||||
r.DoctorReply = dto.DoctorReply ?? r.DoctorReply;
|
||||
r.IsHandled = true;
|
||||
r.RepliedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(r);
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Mark handled without reply
|
||||
app.MapPut("/api/health-requests/{id:int}", async (int id, AppDbContext db) =>
|
||||
{
|
||||
var r = await db.HealthRequests.FindAsync(id);
|
||||
@@ -808,6 +850,15 @@ static string Slugify(string text)
|
||||
return text.Trim('-');
|
||||
}
|
||||
|
||||
static string GenerateTrackingCode()
|
||||
{
|
||||
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
var bytes = new byte[6];
|
||||
rng.GetBytes(bytes);
|
||||
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
|
||||
}
|
||||
|
||||
static int EstimateReadingTime(string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content)) return 1;
|
||||
|
||||
Reference in New Issue
Block a user