Files
foodster/cmd/foodster/handlers.go
T
Esa Kataja c9a63bdd9e Scaffold the Go app: auth, migrations, bundle import
Bring up the stage 1 skeleton described in the PRD, enough that the app
builds, serves, and can be populated with dishes.

- net/http server with shared-password Basic auth, /healthz outside it,
  graceful shutdown, and TZ-aware calendar days
- SQLite via modernc (pure Go, static binary), opened with WAL and a single
  connection
- migration runner: numbered SQL files embedded and applied once each inside
  a transaction, recorded in schema_migrations
- bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or
  upload a file, get a per-row Finnish report. The same importer is reachable
  as `foodster -import` for repopulating a scratch database
- templ views and hand-written CSS with light-dark() theming; the Datastar
  v1.0.3 client is vendored, since the Go SDK ships no browser asset and a
  CDN would break an offline LAN

Names are normalized to sentence case rather than title case: Finnish
capitalizes only the first word of a phrase, so "Keitetyt perunat" is right
and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match.

Testing is behind make targets rather than ad-hoc commands: `make check` runs
lint, unit tests and scripts/smoke.sh, which exercises auth, static assets
and every import path against a scratch database on a spare port.
2026-09-05 18:15:17 +03:00

92 lines
2.5 KiB
Go

package main
import (
"database/sql"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/a-h/templ"
)
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
// kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20
type app struct {
db *sql.DB
loc *time.Location
}
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
log.Printf("render %s: %v", r.URL.Path, err)
}
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, r, indexPage(today(a.loc)))
}
func (a *app) history(w http.ResponseWriter, r *http.Request) {
render(w, r, historyPage())
}
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
a.renderCatalog(w, r, nil)
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, report *ImportReport) {
var mains, sides int
err := a.db.QueryRow(`
SELECT (SELECT count(*) FROM main_dishes WHERE deleted_at IS NULL),
(SELECT count(*) FROM side_dishes WHERE deleted_at IS NULL)`,
).Scan(&mains, &sides)
if err != nil {
log.Printf("catalog counts: %v", err)
}
render(w, r, catalogPage(mains, sides, report))
}
// importDishes takes a bundle either pasted into the textarea or uploaded as a
// file, and reports row by row what happened (PRD §7.3).
//
// ponytail: a plain multipart form rather than a Datastar round trip. The
// result is a whole-page report, not a fragment, and a form needs no client
// code at all.
func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
if err := r.ParseMultipartForm(maxUpload); err != nil {
a.renderCatalog(w, r, failed("Tiedosto on liian suuri tai vioittunut."))
return
}
var src io.Reader
if file, _, err := r.FormFile("tiedosto"); err == nil {
defer file.Close()
src = file
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
src = strings.NewReader(pasted)
} else {
a.renderCatalog(w, r, failed("Ei tuotavaa: liitä JSON tai valitse tiedosto."))
return
}
report, err := importBundle(a.db, src)
if err != nil {
a.renderCatalog(w, r, failed("JSON ei kelpaa: "+err.Error()))
return
}
a.renderCatalog(w, r, report)
}
// failed builds a report for a whole-request failure, so the view only ever
// has one shape to render.
func failed(note string) *ImportReport {
return &ImportReport{Skipped: 1, Notes: []string{note}}
}