399 lines
11 KiB
Go
399 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"inou/lib"
|
|
)
|
|
|
|
type PromptField struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
Datatype string `json:"datatype"`
|
|
Min float64 `json:"min,omitempty"`
|
|
Max float64 `json:"max,omitempty"`
|
|
Step float64 `json:"step,omitempty"`
|
|
Unit string `json:"unit,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
MaxLength int `json:"maxlength,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
ScaleLabels []string `json:"scale_labels,omitempty"`
|
|
Value string // filled from last_response
|
|
}
|
|
|
|
type PromptView struct {
|
|
ID string
|
|
Category string
|
|
Type string
|
|
Question string
|
|
Fields []PromptField
|
|
NextAsk int64
|
|
NextAskFormatted string
|
|
IsOverdue bool
|
|
IsDue bool
|
|
IsFreeform bool
|
|
SourceInput string
|
|
LastResponseAt int64
|
|
LastResponseFormatted string
|
|
LastResponseRaw string
|
|
HasResponse bool
|
|
}
|
|
|
|
type EntryView struct {
|
|
ID string
|
|
Category string
|
|
Type string
|
|
Value string
|
|
Question string
|
|
SourceInput string
|
|
Fields []PromptField
|
|
Timestamp int64
|
|
TimeFormatted string
|
|
PromptID string // linked prompt for delete
|
|
}
|
|
|
|
func handlePrompts(w http.ResponseWriter, r *http.Request) {
|
|
p := getLoggedInDossier(r)
|
|
if p == nil {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
if len(parts) < 3 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
targetHex := parts[2]
|
|
targetID := targetHex
|
|
|
|
// Check access
|
|
isSelf := targetID == p.DossierID
|
|
if !isSelf {
|
|
if _, found := getAccess(formatHexID(p.DossierID), targetHex); !found {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
target, _ := lib.DossierGet(nil, targetID) // nil ctx - internal operation
|
|
if target == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
lang := getLang(r)
|
|
showAll := r.URL.Query().Get("all") == "1"
|
|
|
|
// Fetch prompts from API
|
|
url := fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s&all=1", targetHex)
|
|
if showAll {
|
|
url += "&all=1"
|
|
}
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
render(w, r, PageData{Page: "prompts", Lang: lang, Dossier: p, Error: "Failed to load prompts"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var apiPrompts []struct {
|
|
ID string `json:"id"`
|
|
Category string `json:"category"`
|
|
Type string `json:"type"`
|
|
Question string `json:"question"`
|
|
NextAsk int64 `json:"next_ask"`
|
|
InputType string `json:"input_type"`
|
|
InputConfig json.RawMessage `json:"input_config"`
|
|
SourceInput string `json:"source_input"`
|
|
LastResponse json.RawMessage `json:"last_response"`
|
|
LastResponseRaw string `json:"last_response_raw"`
|
|
LastResponseAt int64 `json:"last_response_at"`
|
|
IsDue bool `json:"is_due"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&apiPrompts)
|
|
|
|
// Helper to translate category name
|
|
translateCategory := func(cat string) string {
|
|
switch cat {
|
|
case "supplement": return "Supplement"
|
|
case "medication": return "Medication"
|
|
case "vital": return "Vital"
|
|
case "exercise": return "Exercise"
|
|
case "symptom": return "Symptom"
|
|
case "nutrition": return "Nutrition"
|
|
case "note": return "Note"
|
|
default: return cat
|
|
}
|
|
}
|
|
|
|
// Convert to view models
|
|
var prompts []PromptView
|
|
for _, ap := range apiPrompts {
|
|
pv := PromptView{
|
|
ID: ap.ID,
|
|
Category: translateCategory(ap.Category),
|
|
Type: ap.Type,
|
|
Question: ap.Question,
|
|
NextAsk: ap.NextAsk,
|
|
IsDue: ap.IsDue,
|
|
IsFreeform: ap.InputType == "freeform",
|
|
SourceInput: ap.SourceInput,
|
|
LastResponseAt: ap.LastResponseAt,
|
|
LastResponseRaw: ap.LastResponseRaw,
|
|
HasResponse: ap.LastResponseRaw != "" || ap.LastResponseAt > 0,
|
|
}
|
|
|
|
if ap.NextAsk > 0 {
|
|
pv.NextAskFormatted = formatDueDate(ap.NextAsk)
|
|
// Freeform prompts (like "anything else") are never overdue
|
|
pv.IsOverdue = ap.InputType != "freeform" && ap.NextAsk < time.Now().Unix()
|
|
}
|
|
if ap.LastResponseAt > 0 {
|
|
pv.LastResponseFormatted = time.Unix(ap.LastResponseAt, 0).Format("Jan 2, 3:04 PM")
|
|
}
|
|
|
|
// Parse input_config
|
|
if len(ap.InputConfig) > 0 {
|
|
var config struct {
|
|
Fields []PromptField `json:"fields"`
|
|
}
|
|
if json.Unmarshal(ap.InputConfig, &config) == nil {
|
|
pv.Fields = config.Fields
|
|
}
|
|
}
|
|
|
|
// Fill values from last_response
|
|
log.Printf("DEBUG: q=%s lastResp=%d fields=%d", ap.Question, len(ap.LastResponse), len(pv.Fields))
|
|
if len(ap.LastResponse) > 0 && len(pv.Fields) > 0 {
|
|
var lastResp map[string]interface{}
|
|
if err := json.Unmarshal(ap.LastResponse, &lastResp); err == nil {
|
|
log.Printf("DEBUG: parsed lastResp=%v", lastResp)
|
|
for i := range pv.Fields {
|
|
if v, ok := lastResp[pv.Fields[i].Key]; ok {
|
|
pv.Fields[i].Value = fmt.Sprintf("%v", v)
|
|
log.Printf("DEBUG: set field %s = %v", pv.Fields[i].Key, v)
|
|
}
|
|
}
|
|
} else {
|
|
log.Printf("DEBUG: unmarshal error: %v raw=%s", err, string(ap.LastResponse))
|
|
}
|
|
}
|
|
|
|
prompts = append(prompts, pv)
|
|
}
|
|
|
|
data := PageData{
|
|
Page: "prompts",
|
|
Lang: lang,
|
|
Dossier: p,
|
|
TargetDossier: target,
|
|
}
|
|
data.T = translations[lang]
|
|
|
|
// Build prompt lookup map by prompt ID
|
|
promptMap := make(map[string]PromptView)
|
|
for _, p := range prompts {
|
|
promptMap[p.ID] = p
|
|
log.Printf("DEBUG: Added to promptMap: id=%s, question=%s", p.ID, p.Question)
|
|
}
|
|
|
|
// Fetch today's entries
|
|
now := time.Now()
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
|
|
endOfDay := startOfDay + 86400
|
|
log.Printf("DEBUG: Fetching entries for today: start=%d end=%d now=%d", startOfDay, endOfDay, now.Unix())
|
|
rawEntries, _ := lib.EntryQueryByDate(targetID, startOfDay, endOfDay)
|
|
log.Printf("DEBUG: Found %d entries for today", len(rawEntries))
|
|
|
|
var entries []EntryView
|
|
for _, e := range rawEntries {
|
|
catName := lib.CategoryName(e.Category)
|
|
log.Printf("DEBUG: Processing entry: cat=%d (%s), type=%s, value=%s, search_key=%s", e.Category, catName, e.Type, e.Value, e.SearchKey)
|
|
ev := EntryView{
|
|
ID: formatHexID(e.EntryID),
|
|
Category: lib.CategoryTranslate(e.Category, lang),
|
|
Type: e.Type,
|
|
Value: e.Value,
|
|
Timestamp: e.Timestamp,
|
|
TimeFormatted: time.Unix(e.Timestamp, 0).Format("Jan 2, 3:04 PM"),
|
|
}
|
|
|
|
// Link entry to prompt via SearchKey (foreign key relationship)
|
|
if e.SearchKey != "" {
|
|
log.Printf("DEBUG: Looking for prompt with id=%s", e.SearchKey)
|
|
if prompt, ok := promptMap[e.SearchKey]; ok {
|
|
log.Printf("DEBUG: Found matching prompt: question=%s, fields=%d", prompt.Question, len(prompt.Fields))
|
|
ev.Question = prompt.Question
|
|
ev.PromptID = prompt.ID
|
|
// Copy fields from prompt
|
|
ev.Fields = make([]PromptField, len(prompt.Fields))
|
|
copy(ev.Fields, prompt.Fields)
|
|
} else {
|
|
log.Printf("DEBUG: No prompt found with id=%s", e.SearchKey)
|
|
}
|
|
} else {
|
|
log.Printf("DEBUG: Entry has no search_key (may be freeform/legacy entry)")
|
|
}
|
|
|
|
// Extract source_input and values from Data JSON
|
|
if e.Data != "" {
|
|
var dataMap struct {
|
|
SourceInput string `json:"source_input"`
|
|
Values json.RawMessage `json:"values"`
|
|
}
|
|
if json.Unmarshal([]byte(e.Data), &dataMap) == nil {
|
|
ev.SourceInput = dataMap.SourceInput
|
|
// Fill field values
|
|
var values map[string]interface{}
|
|
if json.Unmarshal(dataMap.Values, &values) == nil {
|
|
for i := range ev.Fields {
|
|
if v, ok := values[ev.Fields[i].Key]; ok {
|
|
ev.Fields[i].Value = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
entries = append(entries, ev)
|
|
}
|
|
log.Printf("DEBUG: Built %d entry views for display", len(entries))
|
|
|
|
// Split prompts into regular and freeform
|
|
var allPrompts, freeformPrompts []PromptView
|
|
dueCount := 0
|
|
for _, p := range prompts {
|
|
if p.IsFreeform {
|
|
freeformPrompts = append(freeformPrompts, p)
|
|
} else {
|
|
allPrompts = append(allPrompts, p)
|
|
if p.IsDue {
|
|
dueCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add prompts and entries to template
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.ExecuteTemplate(w, "base.tmpl", struct {
|
|
PageData
|
|
AllPrompts []PromptView
|
|
FreeformPrompts []PromptView
|
|
Entries []EntryView
|
|
TargetHex string
|
|
DueCount int
|
|
}{data, allPrompts, freeformPrompts, entries, targetHex, dueCount})
|
|
}
|
|
|
|
func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
p := getLoggedInDossier(r)
|
|
if p == nil {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
if len(parts) < 4 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
targetHex := parts[2]
|
|
|
|
r.ParseForm()
|
|
promptID := r.FormValue("prompt_id")
|
|
action := r.FormValue("action")
|
|
|
|
// Build response JSON from form fields
|
|
response := make(map[string]interface{})
|
|
var responseRaw string
|
|
for key, values := range r.Form {
|
|
if strings.HasPrefix(key, "field_") && len(values) > 0 {
|
|
fieldKey := strings.TrimPrefix(key, "field_")
|
|
response[fieldKey] = values[0]
|
|
if responseRaw != "" {
|
|
responseRaw += ", "
|
|
}
|
|
responseRaw += values[0]
|
|
}
|
|
}
|
|
if raw := r.FormValue("response_raw"); raw != "" {
|
|
responseRaw = raw
|
|
}
|
|
|
|
responseJSON, _ := json.Marshal(response)
|
|
|
|
// Call API
|
|
reqBody := map[string]string{
|
|
"prompt_id": promptID,
|
|
"response": string(responseJSON),
|
|
"response_raw": responseRaw,
|
|
"action": action,
|
|
}
|
|
reqJSON, _ := json.Marshal(reqBody)
|
|
|
|
resp, err := http.Post("http://localhost:8082/api/prompts/respond", "application/json", strings.NewReader(string(reqJSON)))
|
|
if err != nil {
|
|
http.Redirect(w, r, fmt.Sprintf("/dossier/%s/prompts?error=1", targetHex), http.StatusSeeOther)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// For AJAX requests, return the API response directly
|
|
if r.Header.Get("Accept") == "application/json" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
io.Copy(w, resp.Body)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/dossier/%s/prompts", targetHex), http.StatusSeeOther)
|
|
}
|
|
|
|
// formatDueDate returns a human-readable due date
|
|
func formatDueDate(ts int64) string {
|
|
now := time.Now()
|
|
due := time.Unix(ts, 0)
|
|
|
|
// If in the past, show overdue duration
|
|
if due.Before(now) {
|
|
diff := now.Sub(due)
|
|
if diff < time.Hour {
|
|
return "now"
|
|
} else if diff < 2*time.Hour {
|
|
return "1 hour overdue"
|
|
} else if diff < 24*time.Hour {
|
|
return fmt.Sprintf("%d hours overdue", int(diff.Hours()))
|
|
} else if diff < 48*time.Hour {
|
|
return "overdue since yesterday"
|
|
} else {
|
|
return fmt.Sprintf("overdue since %s", due.Format("Jan 2"))
|
|
}
|
|
}
|
|
|
|
// If today
|
|
if due.Year() == now.Year() && due.YearDay() == now.YearDay() {
|
|
return "today " + due.Format("3:04 PM")
|
|
}
|
|
|
|
// If tomorrow
|
|
tomorrow := now.AddDate(0, 0, 1)
|
|
if due.Year() == tomorrow.Year() && due.YearDay() == tomorrow.YearDay() {
|
|
return "tomorrow " + due.Format("3:04 PM")
|
|
}
|
|
|
|
// Otherwise show date
|
|
return due.Format("Jan 2, 3:04 PM")
|
|
}
|