frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
# Pi agent local state (machine-specific config, memory, tasks)
|
# Pi agent local state (machine-specific config, memory, tasks)
|
||||||
.pi/
|
.pi/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
frontend/where-woof
|
||||||
|
|
||||||
# Editor/OS noise
|
# Editor/OS noise
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
22
Makefile
Normal file
22
Makefile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
DATABASE_URL ?= postgres://wherewoof:ww_dev_2026@192.168.20.13:5434/wherewoof
|
||||||
|
export DATABASE_URL
|
||||||
|
|
||||||
|
.PHONY: db-up seed run build generate psql
|
||||||
|
|
||||||
|
db-up: ## apply db/schema.sql (embedded migrator, no psql needed)
|
||||||
|
cd frontend && go run ./cmd/migrate -schema ../db/schema.sql
|
||||||
|
|
||||||
|
seed: ## insert test tag codes TEST000001..TEST000010
|
||||||
|
cd frontend && go run ./cmd/seed
|
||||||
|
|
||||||
|
run: ## run the web server (port 3020)
|
||||||
|
cd frontend && go run .
|
||||||
|
|
||||||
|
build: ## build linux amd64 binary for .13
|
||||||
|
cd frontend && GOOS=linux GOARCH=amd64 go build -o where-woof .
|
||||||
|
|
||||||
|
generate: ## regenerate sqlc query code
|
||||||
|
cd frontend && sqlc generate
|
||||||
|
|
||||||
|
psql: ## ad-hoc SQL shell into wherewoof-db on .13 (no local psql install)
|
||||||
|
ssh sam@192.168.20.13 docker exec -i wherewoof-db psql -U wherewoof -d wherewoof
|
||||||
39
db/schema.sql
Normal file
39
db/schema.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- WhereWoof canonical schema (Phase 1)
|
||||||
|
-- Source of truth until Laravel takes over migration ownership (Phase 4).
|
||||||
|
-- Applied by: make db-up (frontend/cmd/migrate)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tag_code TEXT NOT NULL UNIQUE, -- printed on QR + NFC, public identifier
|
||||||
|
owner_id BIGINT REFERENCES users(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'unset'
|
||||||
|
CHECK (status IN ('unset', 'active', 'suspended')),
|
||||||
|
item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')),
|
||||||
|
description TEXT,
|
||||||
|
photo_url TEXT,
|
||||||
|
phone TEXT, -- owner contact phone (shown via tel:)
|
||||||
|
address TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scans (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tag_id BIGINT NOT NULL REFERENCES tags(id),
|
||||||
|
scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
location_shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
scanner_phone TEXT,
|
||||||
|
alert_sent BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
46
frontend/cmd/migrate/main.go
Normal file
46
frontend/cmd/migrate/main.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Command migrate applies db/schema.sql to DATABASE_URL.
|
||||||
|
// Usage: go run ./cmd/migrate [-schema db/schema.sql]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
schemaPath := flag.String("schema", "db/schema.sql", "path to schema file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlBytes, err := os.ReadFile(*schemaPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error reading schema:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error connecting:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error applying schema:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("schema applied to", dsn)
|
||||||
|
}
|
||||||
49
frontend/cmd/seed/main.go
Normal file
49
frontend/cmd/seed/main.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Command seed inserts test tag codes TEST000001..TEST000025.
|
||||||
|
// Idempotent: already-existing codes are skipped.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"wherewoof/frontend/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const count = 25
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "connect:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
q := db.New(pool)
|
||||||
|
for i := 1; i <= count; i++ {
|
||||||
|
code := fmt.Sprintf("TEST%06d", i)
|
||||||
|
if _, err := q.InsertTag(ctx, code); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
fmt.Println("skip (exists)", code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "insert", code, ":", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("seeded", code)
|
||||||
|
}
|
||||||
|
fmt.Printf("done: %d test tags available\n", count)
|
||||||
|
}
|
||||||
18
frontend/go.mod
Normal file
18
frontend/go.mod
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module wherewoof/frontend
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/sessions v1.4.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
)
|
||||||
34
frontend/go.sum
Normal file
34
frontend/go.sum
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
18
frontend/internal/auth/passwords.go
Normal file
18
frontend/internal/auth/passwords.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Package auth provides password hashing and session handling for WhereWoof.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
// HashPassword returns a bcrypt hash of the given plaintext password.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword reports whether the plaintext password matches the bcrypt hash.
|
||||||
|
func CheckPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
51
frontend/internal/auth/session.go
Normal file
51
frontend/internal/auth/session.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionName is the cookie name for authenticated sessions.
|
||||||
|
const SessionName = "ww_session"
|
||||||
|
|
||||||
|
// Store is the signed + encrypted cookie store, initialised by InitSessionStore.
|
||||||
|
var Store *sessions.CookieStore
|
||||||
|
|
||||||
|
// InitSessionStore creates the cookie store with the server secret.
|
||||||
|
func InitSessionStore(secret string) {
|
||||||
|
Store = sessions.NewCookieStore([]byte(secret))
|
||||||
|
Store.Options = &sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 30 * 24 * 3600, // 30 days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserID returns the authenticated user id from the request, if any.
|
||||||
|
func GetUserID(r *http.Request) (int64, bool) {
|
||||||
|
if Store == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
s, err := Store.Get(r, SessionName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id, ok := s.Values["user_id"].(int64)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserID records the user id in the session cookie.
|
||||||
|
func SetUserID(w http.ResponseWriter, r *http.Request, id int64) error {
|
||||||
|
s, _ := Store.Get(r, SessionName)
|
||||||
|
s.Values["user_id"] = id
|
||||||
|
return s.Save(r, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear destroys the session (logout).
|
||||||
|
func Clear(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
s, _ := Store.Get(r, SessionName)
|
||||||
|
s.Options.MaxAge = -1
|
||||||
|
return s.Save(r, w)
|
||||||
|
}
|
||||||
32
frontend/internal/db/db.go
Normal file
32
frontend/internal/db/db.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||||
|
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||||
|
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/internal/db/models.go
Normal file
44
frontend/internal/db/models.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scan struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagID int64 `json:"tag_id"`
|
||||||
|
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
|
||||||
|
Lat pgtype.Float8 `json:"lat"`
|
||||||
|
Lng pgtype.Float8 `json:"lng"`
|
||||||
|
LocationShared bool `json:"location_shared"`
|
||||||
|
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||||
|
AlertSent bool `json:"alert_sent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagCode string `json:"tag_code"`
|
||||||
|
OwnerID pgtype.Int8 `json:"owner_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ItemType pgtype.Text `json:"item_type"`
|
||||||
|
Description pgtype.Text `json:"description"`
|
||||||
|
PhotoUrl pgtype.Text `json:"photo_url"`
|
||||||
|
Phone pgtype.Text `json:"phone"`
|
||||||
|
Address pgtype.Text `json:"address"`
|
||||||
|
Notes pgtype.Text `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
Name pgtype.Text `json:"name"`
|
||||||
|
Phone pgtype.Text `json:"phone"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
28
frontend/internal/db/querier.go
Normal file
28
frontend/internal/db/querier.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Querier interface {
|
||||||
|
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
|
||||||
|
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
|
||||||
|
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
|
||||||
|
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||||
|
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
|
||||||
|
GetTagByID(ctx context.Context, id int64) (Tag, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||||
|
GetUserByID(ctx context.Context, id int64) (User, error)
|
||||||
|
InsertTag(ctx context.Context, tagCode string) (Tag, error)
|
||||||
|
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
|
||||||
|
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
|
||||||
|
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Querier = (*Queries)(nil)
|
||||||
47
frontend/internal/db/queries.sql
Normal file
47
frontend/internal/db/queries.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO users (email, password_hash, name, phone)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetUserByEmail :one
|
||||||
|
SELECT * FROM users WHERE email = $1;
|
||||||
|
|
||||||
|
-- name: GetUserByID :one
|
||||||
|
SELECT * FROM users WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListTagsByOwner :many
|
||||||
|
SELECT * FROM tags WHERE owner_id = $1 ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetTagByCode :one
|
||||||
|
SELECT * FROM tags WHERE tag_code = $1;
|
||||||
|
|
||||||
|
-- name: GetTagByID :one
|
||||||
|
SELECT * FROM tags WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: CountTagsByOwner :one
|
||||||
|
SELECT count(*) FROM tags WHERE owner_id = $1;
|
||||||
|
|
||||||
|
-- name: BindTag :one
|
||||||
|
UPDATE tags
|
||||||
|
SET owner_id = $1, updated_at = now()
|
||||||
|
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateTagDetails :one
|
||||||
|
UPDATE tags
|
||||||
|
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ClearTagOwner :one
|
||||||
|
UPDATE tags
|
||||||
|
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetTagStatus :exec
|
||||||
|
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
|
||||||
|
|
||||||
|
-- name: InsertTag :one
|
||||||
|
INSERT INTO tags (tag_code) VALUES ($1)
|
||||||
|
RETURNING *;
|
||||||
319
frontend/internal/db/queries.sql.go
Normal file
319
frontend/internal/db/queries.sql.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: queries.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bindTag = `-- name: BindTag :one
|
||||||
|
UPDATE tags
|
||||||
|
SET owner_id = $1, updated_at = now()
|
||||||
|
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
|
||||||
|
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type BindTagParams struct {
|
||||||
|
OwnerID pgtype.Int8 `json:"owner_id"`
|
||||||
|
TagCode string `json:"tag_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
|
||||||
|
row := q.db.QueryRow(ctx, bindTag, arg.OwnerID, arg.TagCode)
|
||||||
|
var i Tag
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearTagOwner = `-- name: ClearTagOwner :one
|
||||||
|
UPDATE tags
|
||||||
|
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
|
||||||
|
row := q.db.QueryRow(ctx, clearTagOwner, id)
|
||||||
|
var i Tag
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const countTagsByOwner = `-- name: CountTagsByOwner :one
|
||||||
|
SELECT count(*) FROM tags WHERE owner_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countTagsByOwner, ownerID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUser = `-- name: CreateUser :one
|
||||||
|
INSERT INTO users (email, password_hash, name, phone)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, email, password_hash, name, phone, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserParams struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
Name pgtype.Text `json:"name"`
|
||||||
|
Phone pgtype.Text `json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createUser,
|
||||||
|
arg.Email,
|
||||||
|
arg.PasswordHash,
|
||||||
|
arg.Name,
|
||||||
|
arg.Phone,
|
||||||
|
)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.Name,
|
||||||
|
&i.Phone,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTagByCode = `-- name: GetTagByCode :one
|
||||||
|
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE tag_code = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTagByCode, tagCode)
|
||||||
|
var i Tag
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTagByID = `-- name: GetTagByID :one
|
||||||
|
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTagByID, id)
|
||||||
|
var i Tag
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
|
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.Name,
|
||||||
|
&i.Phone,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
|
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.Name,
|
||||||
|
&i.Phone,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTag = `-- name: InsertTag :one
|
||||||
|
INSERT INTO tags (tag_code) VALUES ($1)
|
||||||
|
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertTag, tagCode)
|
||||||
|
var i Tag
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTagsByOwner = `-- name: ListTagsByOwner :many
|
||||||
|
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTagsByOwner, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Tag
|
||||||
|
for rows.Next() {
|
||||||
|
var i Tag
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTagStatus = `-- name: SetTagStatus :exec
|
||||||
|
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetTagStatusParams struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setTagStatus, arg.ID, arg.Status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTagDetails = `-- name: UpdateTagDetails :one
|
||||||
|
UPDATE tags
|
||||||
|
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTagDetailsParams struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ItemType pgtype.Text `json:"item_type"`
|
||||||
|
Description pgtype.Text `json:"description"`
|
||||||
|
PhotoUrl pgtype.Text `json:"photo_url"`
|
||||||
|
Phone pgtype.Text `json:"phone"`
|
||||||
|
Address pgtype.Text `json:"address"`
|
||||||
|
Notes pgtype.Text `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateTagDetails,
|
||||||
|
arg.ID,
|
||||||
|
arg.ItemType,
|
||||||
|
arg.Description,
|
||||||
|
arg.PhotoUrl,
|
||||||
|
arg.Phone,
|
||||||
|
arg.Address,
|
||||||
|
arg.Notes,
|
||||||
|
)
|
||||||
|
var i Tag
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TagCode,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Status,
|
||||||
|
&i.ItemType,
|
||||||
|
&i.Description,
|
||||||
|
&i.PhotoUrl,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Address,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
98
frontend/internal/handlers/auth.go
Normal file
98
frontend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"wherewoof/frontend/internal/auth"
|
||||||
|
"wherewoof/frontend/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) RegisterPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, "register", "Create account", nil, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
|
||||||
|
if email == "" || password == "" {
|
||||||
|
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(password) < 8 {
|
||||||
|
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len([]byte(password)) > 72 {
|
||||||
|
a.render(w, r, "register", "Create account", nil, "Password must be 72 bytes or fewer.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := a.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
Name: pgtype.Text{String: name, Valid: name != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
a.render(w, r, "register", "Create account", nil, "That email is already registered.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetUserID(w, r, user.ID); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, "login", "Log in", nil, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
user, err := a.Queries.GetUserByEmail(r.Context(), email)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !auth.CheckPassword(user.PasswordHash, password) {
|
||||||
|
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetUserID(w, r, user.ID); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = auth.Clear(w, r)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
103
frontend/internal/handlers/handlers.go
Normal file
103
frontend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Package handlers wires templates, auth, and database queries for the
|
||||||
|
// WhereWoof GOAT front-end.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"wherewoof/frontend/internal/auth"
|
||||||
|
"wherewoof/frontend/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Templates maps a page key to its parsed template set (base + page + partials).
|
||||||
|
// Each page is parsed as its own set so the shared "content" block name
|
||||||
|
// doesn't collide across pages.
|
||||||
|
type Templates map[string]*template.Template
|
||||||
|
|
||||||
|
// App holds dependencies shared by all handlers.
|
||||||
|
type App struct {
|
||||||
|
Queries *db.Queries
|
||||||
|
Tpl Templates
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an App with the given query layer and template sets.
|
||||||
|
func New(queries *db.Queries, tpl Templates) *App {
|
||||||
|
return &App{Queries: queries, Tpl: tpl}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageData is the root data passed to the base layout.
|
||||||
|
type PageData struct {
|
||||||
|
CurrentUser *db.User
|
||||||
|
Title string
|
||||||
|
Error string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleCase(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s[:1]) + s[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTemplates parses every page's template set from templates/.
|
||||||
|
func LoadTemplates() (Templates, error) {
|
||||||
|
const dir = "templates"
|
||||||
|
base := dir + "/base.html"
|
||||||
|
pages := map[string][]string{
|
||||||
|
"index": {dir + "/index.html"},
|
||||||
|
"register": {dir + "/register.html"},
|
||||||
|
"login": {dir + "/login.html"},
|
||||||
|
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html"},
|
||||||
|
"edit": {dir + "/tag-edit.html"},
|
||||||
|
"public": {dir + "/tag-public.html"},
|
||||||
|
"notfound": {dir + "/not-found.html"},
|
||||||
|
}
|
||||||
|
funcs := template.FuncMap{"title": titleCase}
|
||||||
|
tpl := make(Templates, len(pages))
|
||||||
|
for name, files := range pages {
|
||||||
|
paths := append([]string{base}, files...)
|
||||||
|
t, err := template.New(name).Funcs(funcs).ParseFiles(paths...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tpl[name] = t
|
||||||
|
}
|
||||||
|
return tpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// render executes the page's base layout with a PageData populated from the
|
||||||
|
// authenticated user (if any).
|
||||||
|
func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, data any, errMsg string) {
|
||||||
|
pd := PageData{Title: title, Data: data, Error: errMsg}
|
||||||
|
if uid, ok := auth.GetUserID(r); ok {
|
||||||
|
if u, err := a.Queries.GetUserByID(r.Context(), uid); err == nil {
|
||||||
|
pd.CurrentUser = &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := a.Tpl[page].ExecuteTemplate(w, "base", pd); err != nil {
|
||||||
|
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPartial executes a named partial (e.g. "account-panel") for HTMX swaps.
|
||||||
|
func (a *App) renderPartial(w http.ResponseWriter, page, partial string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := a.Tpl[page].ExecuteTemplate(w, partial, data); err != nil {
|
||||||
|
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth redirects unauthenticated requests to /login.
|
||||||
|
func (a *App) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := auth.GetUserID(r); !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
frontend/internal/handlers/tag_page.go
Normal file
59
frontend/internal/handlers/tag_page.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"wherewoof/frontend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type publicData struct {
|
||||||
|
ID int64
|
||||||
|
TagCode string
|
||||||
|
Status string
|
||||||
|
ItemType pgtype.Text
|
||||||
|
Description pgtype.Text
|
||||||
|
PhotoUrl pgtype.Text
|
||||||
|
Phone pgtype.Text
|
||||||
|
Address pgtype.Text
|
||||||
|
Notes pgtype.Text
|
||||||
|
IsOwner bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, "index", "Home", nil, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicTag renders the unauthenticated tag page: setup prompt, return details,
|
||||||
|
// or unavailable (suspended / not found).
|
||||||
|
func (a *App) PublicTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("tag_code")
|
||||||
|
tag, err := a.Queries.GetTagByCode(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
a.render(w, r, "notfound", "Tag not found", nil, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pd := publicData{
|
||||||
|
ID: tag.ID,
|
||||||
|
TagCode: tag.TagCode,
|
||||||
|
Status: tag.Status,
|
||||||
|
ItemType: tag.ItemType,
|
||||||
|
Description: tag.Description,
|
||||||
|
PhotoUrl: tag.PhotoUrl,
|
||||||
|
Phone: tag.Phone,
|
||||||
|
Address: tag.Address,
|
||||||
|
Notes: tag.Notes,
|
||||||
|
}
|
||||||
|
if uid, ok := auth.GetUserID(r); ok && tag.OwnerID.Valid && tag.OwnerID.Int64 == uid {
|
||||||
|
pd.IsOwner = true
|
||||||
|
}
|
||||||
|
a.render(w, r, "public", "Found item", pd, "")
|
||||||
|
}
|
||||||
152
frontend/internal/handlers/tags.go
Normal file
152
frontend/internal/handlers/tags.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"wherewoof/frontend/internal/auth"
|
||||||
|
"wherewoof/frontend/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxTagsPerAccount = 20
|
||||||
|
|
||||||
|
type accountData struct {
|
||||||
|
Tags []db.Tag
|
||||||
|
AddError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerID(uid int64) pgtype.Int8 {
|
||||||
|
return pgtype.Int8{Int64: uid, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Account(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, _ := auth.GetUserID(r)
|
||||||
|
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, "account", "My Tags", accountData{Tags: tags}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTag binds a tag code to the current account (HTMX: returns account-panel).
|
||||||
|
func (a *App) AddTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, _ := auth.GetUserID(r)
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(r.FormValue("tag_code")))
|
||||||
|
oid := ownerID(uid)
|
||||||
|
data := accountData{}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
data.AddError = "Enter a tag code."
|
||||||
|
} else {
|
||||||
|
cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cnt >= maxTagsPerAccount {
|
||||||
|
data.AddError = "Limit reached: each account can hold 20 tags."
|
||||||
|
} else {
|
||||||
|
_, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
data.AddError = "Tag not found, or already claimed by another account."
|
||||||
|
} else {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := a.Queries.ListTagsByOwner(r.Context(), oid)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Tags = tags
|
||||||
|
a.renderPartial(w, "account", "account-panel", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) EditTagPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, _ := auth.GetUserID(r)
|
||||||
|
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tag.Status == "suspended" {
|
||||||
|
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, "edit", "Edit tag", tag, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, _ := auth.GetUserID(r)
|
||||||
|
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tag.Status == "suspended" {
|
||||||
|
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := db.UpdateTagDetailsParams{
|
||||||
|
ID: tag.ID,
|
||||||
|
ItemType: textOrNil(strings.TrimSpace(r.FormValue("item_type"))),
|
||||||
|
Description: textOrNil(strings.TrimSpace(r.FormValue("description"))),
|
||||||
|
PhotoUrl: textOrNil(strings.TrimSpace(r.FormValue("photo_url"))),
|
||||||
|
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
|
||||||
|
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
|
||||||
|
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
|
||||||
|
}
|
||||||
|
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTag reverts a tag to unset so it can be re-bound (HTMX: account-panel).
|
||||||
|
func (a *App) DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, _ := auth.GetUserID(r)
|
||||||
|
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.Queries.ClearTagOwner(r.Context(), tag.ID); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderPartial(w, "account", "account-panel", accountData{Tags: tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOwnedTag fetches a tag by path id and verifies it belongs to uid.
|
||||||
|
func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (db.Tag, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return db.Tag{}, false
|
||||||
|
}
|
||||||
|
tag, err := a.Queries.GetTagByID(r.Context(), id)
|
||||||
|
if err != nil || !tag.OwnerID.Valid || tag.OwnerID.Int64 != uid {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return db.Tag{}, false
|
||||||
|
}
|
||||||
|
return tag, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func textOrNil(s string) pgtype.Text {
|
||||||
|
return pgtype.Text{String: s, Valid: s != ""}
|
||||||
|
}
|
||||||
71
frontend/main.go
Normal file
71
frontend/main.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"wherewoof/frontend/internal/auth"
|
||||||
|
"wherewoof/frontend/internal/db"
|
||||||
|
"wherewoof/frontend/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
log.Fatal("DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
secret := os.Getenv("SESSION_SECRET")
|
||||||
|
if secret == "" {
|
||||||
|
log.Fatal("SESSION_SECRET not set")
|
||||||
|
}
|
||||||
|
addr := os.Getenv("ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":3020"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("connect:", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
auth.InitSessionStore(secret)
|
||||||
|
|
||||||
|
tpl, err := handlers.LoadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("templates:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := handlers.New(db.New(pool), tpl)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
mux.HandleFunc("GET /{$}", app.Home)
|
||||||
|
mux.HandleFunc("/{path...}", http.NotFound)
|
||||||
|
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
|
||||||
|
mux.HandleFunc("GET /register", app.RegisterPage)
|
||||||
|
mux.HandleFunc("POST /register", app.Register)
|
||||||
|
mux.HandleFunc("GET /login", app.LoginPage)
|
||||||
|
mux.HandleFunc("POST /login", app.Login)
|
||||||
|
mux.HandleFunc("POST /logout", app.Logout)
|
||||||
|
mux.Handle("GET /account", app.RequireAuth(app.Account))
|
||||||
|
mux.Handle("POST /account/tags", app.RequireAuth(app.AddTag))
|
||||||
|
mux.Handle("GET /account/tags/{id}/edit", app.RequireAuth(app.EditTagPage))
|
||||||
|
mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag))
|
||||||
|
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
log.Println("wherewoof listening on", addr)
|
||||||
|
log.Fatal(srv.ListenAndServe())
|
||||||
|
}
|
||||||
12
frontend/sqlc.yaml
Normal file
12
frontend/sqlc.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "postgresql"
|
||||||
|
queries: "internal/db/queries.sql"
|
||||||
|
schema: "../db/schema.sql"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "db"
|
||||||
|
out: "internal/db"
|
||||||
|
sql_package: "pgx/v5"
|
||||||
|
emit_interface: true
|
||||||
|
emit_json_tags: true
|
||||||
16
frontend/templates/account-panel.html
Normal file
16
frontend/templates/account-panel.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "account-panel"}}
|
||||||
|
<div id="account-panel">
|
||||||
|
{{if .AddError}}<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{{.AddError}}</p>{{end}}
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
|
||||||
|
<form hx-post="/account/tags" hx-target="#account-panel" hx-swap="outerHTML" class="flex gap-2">
|
||||||
|
<input name="tag_code" type="text" placeholder="Enter tag code (e.g. TEST000001)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" required>
|
||||||
|
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Add tag</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tag-list">
|
||||||
|
{{template "tag-list" .Tags}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
7
frontend/templates/account.html
Normal file
7
frontend/templates/account.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">My Tags</h1>
|
||||||
|
<a href="/" class="text-sm text-stone-600 hover:underline">← Home</a>
|
||||||
|
</div>
|
||||||
|
{{template "account-panel" .Data}}
|
||||||
|
{{end}}
|
||||||
35
frontend/templates/base.html
Normal file
35
frontend/templates/base.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "base"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} · WhereWoof</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-full bg-stone-100 text-stone-900 antialiased">
|
||||||
|
<nav class="bg-stone-900 text-white">
|
||||||
|
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
|
||||||
|
<a href="/" class="text-lg font-bold tracking-tight">🐕 WhereWoof</a>
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
|
||||||
|
<a href="/account" class="hover:underline">My Tags</a>
|
||||||
|
<form method="post" action="/logout" class="inline">
|
||||||
|
<button class="hover:underline">Log out</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<a href="/login" class="hover:underline">Log in</a>
|
||||||
|
<a href="/register" class="rounded bg-amber-500 px-3 py-1 font-semibold text-stone-900 hover:bg-amber-400">Register</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
24
frontend/templates/index.html
Normal file
24
frontend/templates/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-2xl text-center">
|
||||||
|
<h1 class="text-4xl font-extrabold tracking-tight">Lost something?<br><span class="text-amber-600">The tag brings it home.</span></h1>
|
||||||
|
<p class="mt-4 text-lg text-stone-600">
|
||||||
|
WhereWoof tags carry the return details for your dog, baggage, skis — anything you care about.
|
||||||
|
A finder scans the QR code or taps the NFC tag and sees exactly how to get it back to you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
|
||||||
|
<div class="text-2xl">🏷️</div>
|
||||||
|
<h2 class="mt-2 font-bold">I have a tag</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Claim it and set up the return details for your item.</p>
|
||||||
|
<a href="/register" class="mt-4 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Set up my tag</a>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
|
||||||
|
<div class="text-2xl">🔍</div>
|
||||||
|
<h2 class="mt-2 font-bold">I found something</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Scan the tag you found — the page shows the owner's return details.</p>
|
||||||
|
<a href="/account" class="mt-4 inline-block rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">My account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
22
frontend/templates/login.html
Normal file
22
frontend/templates/login.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold">Log in</h1>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Welcome back to WhereWoof.</p>
|
||||||
|
|
||||||
|
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/login" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium" for="email">Email</label>
|
||||||
|
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium" for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Log in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center text-sm text-stone-600">New to WhereWoof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
8
frontend/templates/not-found.html
Normal file
8
frontend/templates/not-found.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-md text-center">
|
||||||
|
<div class="text-5xl">🔎</div>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold">Tag not found</h1>
|
||||||
|
<p class="mt-2 text-stone-600">We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.</p>
|
||||||
|
<a href="/" class="mt-6 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Go home</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
26
frontend/templates/register.html
Normal file
26
frontend/templates/register.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold">Create your account</h1>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Register to claim your WhereWoof tag.</p>
|
||||||
|
|
||||||
|
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/register" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium" for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium" for="email">Email</label>
|
||||||
|
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium" for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Create account</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center text-sm text-stone-600">Already have an account? <a href="/login" class="text-amber-600 hover:underline">Log in</a></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
49
frontend/templates/tag-edit.html
Normal file
49
frontend/templates/tag-edit.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-xl">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Edit tag</h1>
|
||||||
|
<a href="/account" class="text-sm text-stone-600 hover:underline">← My Tags</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-mono text-xs text-stone-500">Tag {{.Data.TagCode}}</p>
|
||||||
|
|
||||||
|
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/account/tags/{{.Data.ID}}/edit" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Item type</label>
|
||||||
|
<select name="item_type" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="dog" {{if eq .Data.ItemType.String "dog"}}selected{{end}}>Dog</option>
|
||||||
|
<option value="cat" {{if eq .Data.ItemType.String "cat"}}selected{{end}}>Cat</option>
|
||||||
|
<option value="baggage" {{if eq .Data.ItemType.String "baggage"}}selected{{end}}>Baggage</option>
|
||||||
|
<option value="skis" {{if eq .Data.ItemType.String "skis"}}selected{{end}}>Skis</option>
|
||||||
|
<option value="other" {{if eq .Data.ItemType.String "other"}}selected{{end}}>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Description</label>
|
||||||
|
<textarea name="description" rows="3" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Description.Valid}}{{.Data.Description.String}}{{end}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Photo URL</label>
|
||||||
|
<input name="photo_url" type="url" value="{{if .Data.PhotoUrl.Valid}}{{.Data.PhotoUrl.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="https://…">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
|
||||||
|
<input name="phone" type="tel" value="{{if .Data.Phone.Valid}}{{.Data.Phone.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="+61…">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Address</label>
|
||||||
|
<input name="address" type="text" value="{{if .Data.Address.Valid}}{{.Data.Address.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Notes</label>
|
||||||
|
<textarea name="notes" rows="2" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Notes.Valid}}{{.Data.Notes.String}}{{end}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Save details</button>
|
||||||
|
<a href="/account" class="rounded border border-stone-300 px-4 py-2 text-sm font-medium hover:bg-stone-50">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
37
frontend/templates/tag-list.html
Normal file
37
frontend/templates/tag-list.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{{define "tag-list"}}
|
||||||
|
{{if .}}
|
||||||
|
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="bg-stone-50 text-stone-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 font-medium">Tag</th>
|
||||||
|
<th class="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th class="px-4 py-2 font-medium">Item</th>
|
||||||
|
<th class="px-4 py-2 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .}}
|
||||||
|
<tr class="border-t border-stone-100">
|
||||||
|
<td class="px-4 py-2 font-mono text-xs">{{.TagCode}}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
{{if eq .Status "active"}}<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Active</span>
|
||||||
|
{{else if eq .Status "suspended"}}<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">Suspended</span>
|
||||||
|
{{else}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Unset</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<a href="/account/tags/{{.ID}}/edit" class="text-amber-600 hover:underline">Edit</a>
|
||||||
|
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline">
|
||||||
|
<button class="ml-3 text-red-600 hover:underline">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="mt-6 text-center text-stone-500">No tags yet. Enter your tag code above to claim it.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
64
frontend/templates/tag-public.html
Normal file
64
frontend/templates/tag-public.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
{{if eq .Data.Status "unset"}}
|
||||||
|
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<div class="text-5xl">🏷️</div>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold">This tag isn't set up yet</h1>
|
||||||
|
<p class="mt-2 text-stone-600">This WhereWoof tag hasn't been claimed. If this is your tag, log in and add it to your account to set up the return details.</p>
|
||||||
|
<div class="mt-6 flex justify-center gap-3">
|
||||||
|
<a href="/login" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Log in</a>
|
||||||
|
<a href="/register" class="rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">Create account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if eq .Data.Status "suspended"}}
|
||||||
|
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<div class="text-5xl">🚫</div>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold">This tag is unavailable</h1>
|
||||||
|
<p class="mt-2 text-stone-600">The owner has disabled this tag. Please try another way to return the item.</p>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
|
||||||
|
<div class="bg-stone-900 px-6 py-5 text-white">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
{{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found!
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-stone-300">This item has a WhereWoof tag. Here's how to return it:</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
{{if .Data.PhotoUrl.Valid}}
|
||||||
|
<img src="{{.Data.PhotoUrl.String}}" alt="Item photo" class="mb-4 h-56 w-full rounded-lg object-cover">
|
||||||
|
{{end}}
|
||||||
|
{{if .Data.Description.Valid}}
|
||||||
|
<p class="text-stone-700">{{.Data.Description.String}}</p>
|
||||||
|
{{end}}
|
||||||
|
<dl class="mt-4 space-y-2 text-sm">
|
||||||
|
{{if .Data.Phone.Valid}}
|
||||||
|
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
|
||||||
|
<dt class="font-medium">Call the owner</dt>
|
||||||
|
<dd><a href="tel:{{.Data.Phone.String}}" class="font-semibold text-amber-600 hover:underline">{{.Data.Phone.String}}</a></dd>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Data.Address.Valid}}
|
||||||
|
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
|
||||||
|
<dt class="font-medium">Return to</dt>
|
||||||
|
<dd class="text-right">{{.Data.Address.String}}</dd>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Data.Notes.Valid}}
|
||||||
|
<div class="rounded bg-stone-50 px-4 py-3">
|
||||||
|
<dt class="font-medium">Notes</dt>
|
||||||
|
<dd class="mt-1 text-stone-600">{{.Data.Notes.String}}</dd>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</dl>
|
||||||
|
<p class="mt-6 rounded bg-amber-50 px-4 py-3 text-sm text-amber-800 border border-amber-200">
|
||||||
|
Found this item? Please contact the owner to arrange the return. Thank you for helping!
|
||||||
|
</p>
|
||||||
|
{{if .Data.IsOwner}}
|
||||||
|
<p class="mt-4 text-center text-sm"><a href="/account/tags/{{.Data.ID}}/edit" class="text-amber-600 hover:underline">✏️ Edit this tag's details</a></p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
58
plans/frontend-foundation.md
Normal file
58
plans/frontend-foundation.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Phase 1 — Frontend Foundation (GOAT + Postgres)
|
||||||
|
|
||||||
|
Plan for OpenSpec change `frontend-foundation` (proposal/design/specs/tasks in `openspec/changes/frontend-foundation/`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
WhereWoof is a return-tag platform (not a tracker). This change builds the GOAT front-end foundation: a Go app serving owner auth, tag setup/management, and the public tag page, against a single shared Postgres. Laravel admin, scan flow (geolocation/SMS), billing, and deployment are later phases.
|
||||||
|
|
||||||
|
Current state: greenfield. `frontend/` and `admin/` are empty. Go 1.26.2 installed on .27. `sqlc` not installed (dev-only codegen — install on .27, generated code committed). `psql` not installed anywhere (ad-hoc SQL via docker exec on .13). .13 reachable over SSH with Docker (sam user). No long-term installs on .27 — everything runtime (DB + binary) lives on .13.
|
||||||
|
|
||||||
|
Execution style (agreed): master/worker — main pi session orchestrates; subagents do chunks (`database` for schema/queries, `coder-pro` for Go code, `code-analysis` for review).
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
- **`frontend/`** Go module (module path `wherewoof/frontend`, stdlib `net/http` + Go 1.22+ ServeMux, no framework).
|
||||||
|
- Layout: `main.go`; `internal/db` (pgx pool + queries); `internal/auth` (sessions + bcrypt); `internal/handlers`; `templates/` (html/template); `static/`.
|
||||||
|
- **`db/schema.sql`** at repo root = canonical schema (users, tags, scans). Applied via a small embedded Go migrator (`make db-up`) — no psql dependency. Ad-hoc SQL via `make psql` → `ssh sam@192.168.20.13 docker exec -i wherewoof-db psql ...`.
|
||||||
|
- **Auth**: gorilla/sessions cookie store (HttpOnly, SameSite=Lax, `SESSION_SECRET` env), bcrypt passwords, `RequireAuth` middleware, `currentUser` for templates.
|
||||||
|
- **Routes** (Phase 1 subset of `where_woof.md`): `/`, `/t/{tag_code}`, `/register`, `/login`, `/logout`, `/account`, `/account/tags` (add), `/account/tags/{id}/edit`, `/account/tags/{id}/delete`.
|
||||||
|
- **Templates**: `base.html` + per-page; HTMX partials for account list/edit; Alpine for small behaviours; Tailwind via CDN.
|
||||||
|
- **Seed**: `cmd/seed` inserts `TEST000001..TEST000010` tag codes.
|
||||||
|
- **Postgres (dev + prod, on .13)**: dedicated `wherewoof-db` Postgres 16 container on .13 (`~/Docker/Containers/wherewoof-db/docker-compose.yml`, port 5433→**5434** (5433 = Langfuse PG, 5432 = ai-resume PG), named volume for data, app user `wherewoof`). Dev on .27 connects over LAN: `postgres://wherewoof:<pw>@192.168.20.13:5434/wherewoof`. Same DB host from day one — no .27 installs.
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
- `frontend/go.mod`, `frontend/main.go`
|
||||||
|
- `frontend/internal/db/db.go`, `queries.sql` (+ generated code)
|
||||||
|
- `frontend/internal/auth/session.go`, `passwords.go`
|
||||||
|
- `frontend/internal/handlers/auth.go`, `tags.go`, `tag_page.go`
|
||||||
|
- `frontend/templates/base.html`, `index.html`, `register.html`, `login.html`, `account.html`, `tag-edit.html`, `tag-public.html`, `not-found.html`
|
||||||
|
- `frontend/static/style.css`
|
||||||
|
- `frontend/cmd/seed/main.go`
|
||||||
|
- `db/schema.sql`
|
||||||
|
- `Makefile`, `sqlc.yaml`
|
||||||
|
- (infra, on .13, not in repo): `~/Docker/Containers/wherewoof-db/docker-compose.yml`
|
||||||
|
|
||||||
|
## Reuse
|
||||||
|
|
||||||
|
- `openspec/changes/frontend-foundation/specs/*/spec.md` — each requirement is a test checklist.
|
||||||
|
- `where_woof.md` — endpoints table, schema v2, scan-flow context (Phase 2 reference).
|
||||||
|
- `awesome-design.md` / `mockups/` — styling tokens for Tailwind (optional, Phase 1 minimal).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [x] 1. Provision Postgres on .13: `wherewoof-db` container (port 5433, volume), create `wherewoof` DB + app user (via SSH + docker exec)
|
||||||
|
- [x] 2. Install sqlc on .27 (`go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`); scaffold Go module + dirs; `db/schema.sql`; Makefile (`db-up`, `seed`, `run`, `generate`, `psql`)
|
||||||
|
- [x] 3. sqlc.yaml + `queries.sql` for users/tags CRUD; generate
|
||||||
|
- [x] 4. Config + `main.go` wiring (pool, router, templates, static)
|
||||||
|
- [x] 5. Auth: register (bcrypt, unique email, auto-login), login/logout, RequireAuth, currentUser
|
||||||
|
- [x] 6. Tag management: bind by code (reject owned, 20-limit), My Tags list, edit → active, remove → unset
|
||||||
|
- [x] 7. Public tag page: lookup, not-found page, unset prompt / active details / suspended, owner edit affordance
|
||||||
|
- [x] 8. Base layout + Tailwind; home page
|
||||||
|
- [x] 9. Seed; end-to-end verification of every spec scenario; `openspec validate`; commit
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `make db-up && make seed && make run` (DATABASE_URL → `192.168.20.13:5433`) → register → add tag (TEST000001) → edit details → public page shows details; duplicate email rejected; second account can't bind owned tag; 21st tag rejected; remove → re-bind works; unknown code → not-found page; suspended tag → unavailable.
|
||||||
|
- Run `openspec validate frontend-foundation`; commit code + artifacts.
|
||||||
Reference in New Issue
Block a user