feat: doctor reply + diagnosis + tracking code per health request
CI/CD / CI · dotnet build (push) Successful in 45s
CI/CD / Deploy · drsousan (push) Successful in 28s

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:
soroush.asadi
2026-06-02 22:03:00 +03:30
parent 1e51df406b
commit 22d0ecb330
4 changed files with 187 additions and 47 deletions
+64 -13
View File
@@ -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;