inou/portal/prompts.go

378 lines
10 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)
// Convert to view models
var prompts []PromptView
for _, ap := range apiPrompts {
pv := PromptView{
ID: ap.ID,
Category: 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 category/type
promptMap := make(map[string]PromptView)
for _, p := range prompts {
key := p.Category + "/" + p.Type
promptMap[key] = p
}
// 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
rawEntries, _ := lib.EntryQueryByDate(targetID, startOfDay, endOfDay)
var entries []EntryView
for _, e := range rawEntries {
catName := lib.CategoryName(e.Category)
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"),
}
// Get layout from matching prompt (uses internal category name)
key := catName + "/" + e.Type
if prompt, ok := promptMap[key]; ok {
ev.Question = prompt.Question
ev.PromptID = prompt.ID
// Copy fields from prompt
ev.Fields = make([]PromptField, len(prompt.Fields))
copy(ev.Fields, prompt.Fields)
}
// 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)
}
// Split prompts into due and upcoming
var duePrompts, upcomingPrompts []PromptView
for _, p := range prompts {
if p.IsDue || p.IsFreeform {
duePrompts = append(duePrompts, p)
} else {
upcomingPrompts = append(upcomingPrompts, p)
}
}
// Count due items (excluding freeform)
dueCount := 0
for _, p := range duePrompts {
if !p.IsFreeform {
dueCount++
}
}
// Add prompts and entries to template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.ExecuteTemplate(w, "base.tmpl", struct {
PageData
DuePrompts []PromptView
UpcomingPrompts []PromptView
Entries []EntryView
TargetHex string
DueCount int
}{data, duePrompts, upcomingPrompts, 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")
}