A quick vibecoded webapp on exe.dev that I liked enough to save the source for.

Initial Stay In Touch app

Ubuntu 87471381

+5
AGENT.md
··· 1 + # Agent Instructions 2 + 3 + This is a Go web application template for exe.dev. 4 + 5 + See README.md for details on the structure and components.
+10
Makefile
··· 1 + .PHONY: build clean stop start restart test 2 + 3 + build: 4 + go build -o stayintouch ./cmd/srv 5 + 6 + clean: 7 + rm -f stayintouch 8 + 9 + test: 10 + go test ./...
+57
README.md
··· 1 + # Go Shelley Template 2 + 3 + This is a starter template for building Go web applications on exe.dev. It demonstrates end-to-end usage including HTTP handlers, authentication, database integration, and deployment. 4 + 5 + Use this as a foundation to build your own service. 6 + 7 + ## Building and Running 8 + 9 + Build with `make build`, then run `./srv`. The server listens on port 8000 by default. 10 + 11 + ## Running as a systemd service 12 + 13 + To run the server as a systemd service: 14 + 15 + ```bash 16 + # Install the service file 17 + sudo cp srv.service /etc/systemd/system/srv.service 18 + 19 + # Reload systemd and enable the service 20 + sudo systemctl daemon-reload 21 + sudo systemctl enable srv.service 22 + 23 + # Start the service 24 + sudo systemctl start srv 25 + 26 + # Check status 27 + systemctl status srv 28 + 29 + # View logs 30 + journalctl -u srv -f 31 + ``` 32 + 33 + To restart after code changes: 34 + 35 + ```bash 36 + make build 37 + sudo systemctl restart srv 38 + ``` 39 + 40 + ## Authorization 41 + 42 + exe.dev provides authorization headers and login/logout links 43 + that this template uses. 44 + 45 + When proxied through exed, requests will include `X-ExeDev-UserID` and 46 + `X-ExeDev-Email` if the user is authenticated via exe.dev. 47 + 48 + ## Database 49 + 50 + This template uses sqlite (`db.sqlite3`). SQL queries are managed with sqlc. 51 + 52 + ## Code layout 53 + 54 + - `cmd/srv`: main package (binary entrypoint) 55 + - `srv`: HTTP server logic (handlers) 56 + - `srv/templates`: Go HTML templates 57 + - `db`: SQLite open + migrations (001-base.sql)
+30
cmd/srv/main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "os" 7 + 8 + "srv.exe.dev/srv" 9 + ) 10 + 11 + var flagListenAddr = flag.String("listen", ":8000", "address to listen on") 12 + 13 + func main() { 14 + if err := run(); err != nil { 15 + fmt.Fprintln(os.Stderr, err) 16 + } 17 + } 18 + 19 + func run() error { 20 + flag.Parse() 21 + hostname, err := os.Hostname() 22 + if err != nil { 23 + hostname = "unknown" 24 + } 25 + server, err := srv.New("db.sqlite3", hostname) 26 + if err != nil { 27 + return fmt.Errorf("create server: %w", err) 28 + } 29 + return server.Serve(*flagListenAddr) 30 + }
+115
db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + "log/slog" 9 + "regexp" 10 + "sort" 11 + "strconv" 12 + 13 + _ "modernc.org/sqlite" 14 + ) 15 + 16 + //go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate 17 + 18 + //go:embed migrations/*.sql 19 + var migrationFS embed.FS 20 + 21 + // Open opens an sqlite database and prepares pragmas suitable for a small web app. 22 + func Open(path string) (*sql.DB, error) { 23 + db, err := sql.Open("sqlite", path) 24 + if err != nil { 25 + return nil, err 26 + } 27 + // Light pragmas similar 28 + if _, err := db.Exec("PRAGMA foreign_keys=ON;"); err != nil { 29 + _ = db.Close() 30 + return nil, fmt.Errorf("enable foreign keys: %w", err) 31 + } 32 + if _, err := db.Exec("PRAGMA journal_mode=wal;"); err != nil { 33 + _ = db.Close() 34 + return nil, fmt.Errorf("set WAL: %w", err) 35 + } 36 + if _, err := db.Exec("PRAGMA busy_timeout=1000;"); err != nil { 37 + _ = db.Close() 38 + return nil, fmt.Errorf("set busy_timeout: %w", err) 39 + } 40 + return db, nil 41 + } 42 + 43 + // RunMigrations executes database migrations in numeric order (NNN-*.sql), 44 + // similar in spirit to exed's exedb.RunMigrations. 45 + func RunMigrations(db *sql.DB) error { 46 + entries, err := migrationFS.ReadDir("migrations") 47 + if err != nil { 48 + return fmt.Errorf("read migrations dir: %w", err) 49 + } 50 + var migrations []string 51 + pat := regexp.MustCompile(`^(\d{3})-.*\.sql$`) 52 + for _, e := range entries { 53 + if e.IsDir() { 54 + continue 55 + } 56 + name := e.Name() 57 + if pat.MatchString(name) { 58 + migrations = append(migrations, name) 59 + } 60 + } 61 + sort.Strings(migrations) 62 + 63 + executed := make(map[int]bool) 64 + var tableName string 65 + err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&tableName) 66 + switch { 67 + case err == nil: 68 + rows, err := db.Query("SELECT migration_number FROM migrations") 69 + if err != nil { 70 + return fmt.Errorf("query executed migrations: %w", err) 71 + } 72 + defer rows.Close() 73 + for rows.Next() { 74 + var n int 75 + if err := rows.Scan(&n); err != nil { 76 + return fmt.Errorf("scan migration number: %w", err) 77 + } 78 + executed[n] = true 79 + } 80 + case errors.Is(err, sql.ErrNoRows): 81 + slog.Info("db: migrations table not found; running all migrations") 82 + default: 83 + return fmt.Errorf("check migrations table: %w", err) 84 + } 85 + 86 + for _, m := range migrations { 87 + match := pat.FindStringSubmatch(m) 88 + if len(match) != 2 { 89 + return fmt.Errorf("invalid migration filename: %s", m) 90 + } 91 + n, err := strconv.Atoi(match[1]) 92 + if err != nil { 93 + return fmt.Errorf("parse migration number %s: %w", m, err) 94 + } 95 + if executed[n] { 96 + continue 97 + } 98 + if err := executeMigration(db, m); err != nil { 99 + return fmt.Errorf("execute %s: %w", m, err) 100 + } 101 + slog.Info("db: applied migration", "file", m, "number", n) 102 + } 103 + return nil 104 + } 105 + 106 + func executeMigration(db *sql.DB, filename string) error { 107 + content, err := migrationFS.ReadFile("migrations/" + filename) 108 + if err != nil { 109 + return fmt.Errorf("read %s: %w", filename, err) 110 + } 111 + if _, err := db.Exec(string(content)); err != nil { 112 + return fmt.Errorf("exec %s: %w", filename, err) 113 + } 114 + return nil 115 + }
+212
db/dbgen/contacts.sql.go
··· 1 + // Code generated by sqlc. DO NOT EDIT. 2 + // versions: 3 + // sqlc v1.30.0 4 + // source: contacts.sql 5 + 6 + package dbgen 7 + 8 + import ( 9 + "context" 10 + "time" 11 + ) 12 + 13 + const createContact = `-- name: CreateContact :one 14 + INSERT INTO contacts (user_id, name, frequency_days, created_at) 15 + VALUES (?, ?, ?, ?) 16 + RETURNING id, user_id, name, frequency_days, created_at 17 + ` 18 + 19 + type CreateContactParams struct { 20 + UserID string `json:"user_id"` 21 + Name string `json:"name"` 22 + FrequencyDays int64 `json:"frequency_days"` 23 + CreatedAt time.Time `json:"created_at"` 24 + } 25 + 26 + func (q *Queries) CreateContact(ctx context.Context, arg CreateContactParams) (Contact, error) { 27 + row := q.db.QueryRowContext(ctx, createContact, 28 + arg.UserID, 29 + arg.Name, 30 + arg.FrequencyDays, 31 + arg.CreatedAt, 32 + ) 33 + var i Contact 34 + err := row.Scan( 35 + &i.ID, 36 + &i.UserID, 37 + &i.Name, 38 + &i.FrequencyDays, 39 + &i.CreatedAt, 40 + ) 41 + return i, err 42 + } 43 + 44 + const createInteraction = `-- name: CreateInteraction :one 45 + INSERT INTO interactions (contact_id, notes, interacted_at) 46 + VALUES (?, ?, ?) 47 + RETURNING id, contact_id, notes, interacted_at 48 + ` 49 + 50 + type CreateInteractionParams struct { 51 + ContactID int64 `json:"contact_id"` 52 + Notes *string `json:"notes"` 53 + InteractedAt time.Time `json:"interacted_at"` 54 + } 55 + 56 + func (q *Queries) CreateInteraction(ctx context.Context, arg CreateInteractionParams) (Interaction, error) { 57 + row := q.db.QueryRowContext(ctx, createInteraction, arg.ContactID, arg.Notes, arg.InteractedAt) 58 + var i Interaction 59 + err := row.Scan( 60 + &i.ID, 61 + &i.ContactID, 62 + &i.Notes, 63 + &i.InteractedAt, 64 + ) 65 + return i, err 66 + } 67 + 68 + const deleteContact = `-- name: DeleteContact :exec 69 + DELETE FROM contacts WHERE id = ? AND user_id = ? 70 + ` 71 + 72 + type DeleteContactParams struct { 73 + ID int64 `json:"id"` 74 + UserID string `json:"user_id"` 75 + } 76 + 77 + func (q *Queries) DeleteContact(ctx context.Context, arg DeleteContactParams) error { 78 + _, err := q.db.ExecContext(ctx, deleteContact, arg.ID, arg.UserID) 79 + return err 80 + } 81 + 82 + const deleteInteraction = `-- name: DeleteInteraction :exec 83 + DELETE FROM interactions WHERE id = ? 84 + ` 85 + 86 + func (q *Queries) DeleteInteraction(ctx context.Context, id int64) error { 87 + _, err := q.db.ExecContext(ctx, deleteInteraction, id) 88 + return err 89 + } 90 + 91 + const getContact = `-- name: GetContact :one 92 + SELECT id, user_id, name, frequency_days, created_at FROM contacts WHERE id = ? AND user_id = ? 93 + ` 94 + 95 + type GetContactParams struct { 96 + ID int64 `json:"id"` 97 + UserID string `json:"user_id"` 98 + } 99 + 100 + func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (Contact, error) { 101 + row := q.db.QueryRowContext(ctx, getContact, arg.ID, arg.UserID) 102 + var i Contact 103 + err := row.Scan( 104 + &i.ID, 105 + &i.UserID, 106 + &i.Name, 107 + &i.FrequencyDays, 108 + &i.CreatedAt, 109 + ) 110 + return i, err 111 + } 112 + 113 + const getContactsByUser = `-- name: GetContactsByUser :many 114 + SELECT 115 + c.id, c.user_id, c.name, c.frequency_days, c.created_at, 116 + (SELECT MAX(interacted_at) FROM interactions WHERE contact_id = c.id) as last_interaction 117 + FROM contacts c 118 + WHERE c.user_id = ? 119 + ORDER BY c.name 120 + ` 121 + 122 + type GetContactsByUserRow struct { 123 + ID int64 `json:"id"` 124 + UserID string `json:"user_id"` 125 + Name string `json:"name"` 126 + FrequencyDays int64 `json:"frequency_days"` 127 + CreatedAt time.Time `json:"created_at"` 128 + LastInteraction interface{} `json:"last_interaction"` 129 + } 130 + 131 + func (q *Queries) GetContactsByUser(ctx context.Context, userID string) ([]GetContactsByUserRow, error) { 132 + rows, err := q.db.QueryContext(ctx, getContactsByUser, userID) 133 + if err != nil { 134 + return nil, err 135 + } 136 + defer rows.Close() 137 + items := []GetContactsByUserRow{} 138 + for rows.Next() { 139 + var i GetContactsByUserRow 140 + if err := rows.Scan( 141 + &i.ID, 142 + &i.UserID, 143 + &i.Name, 144 + &i.FrequencyDays, 145 + &i.CreatedAt, 146 + &i.LastInteraction, 147 + ); err != nil { 148 + return nil, err 149 + } 150 + items = append(items, i) 151 + } 152 + if err := rows.Close(); err != nil { 153 + return nil, err 154 + } 155 + if err := rows.Err(); err != nil { 156 + return nil, err 157 + } 158 + return items, nil 159 + } 160 + 161 + const getInteractionsByContact = `-- name: GetInteractionsByContact :many 162 + SELECT id, contact_id, notes, interacted_at FROM interactions WHERE contact_id = ? ORDER BY interacted_at DESC 163 + ` 164 + 165 + func (q *Queries) GetInteractionsByContact(ctx context.Context, contactID int64) ([]Interaction, error) { 166 + rows, err := q.db.QueryContext(ctx, getInteractionsByContact, contactID) 167 + if err != nil { 168 + return nil, err 169 + } 170 + defer rows.Close() 171 + items := []Interaction{} 172 + for rows.Next() { 173 + var i Interaction 174 + if err := rows.Scan( 175 + &i.ID, 176 + &i.ContactID, 177 + &i.Notes, 178 + &i.InteractedAt, 179 + ); err != nil { 180 + return nil, err 181 + } 182 + items = append(items, i) 183 + } 184 + if err := rows.Close(); err != nil { 185 + return nil, err 186 + } 187 + if err := rows.Err(); err != nil { 188 + return nil, err 189 + } 190 + return items, nil 191 + } 192 + 193 + const updateContact = `-- name: UpdateContact :exec 194 + UPDATE contacts SET name = ?, frequency_days = ? WHERE id = ? AND user_id = ? 195 + ` 196 + 197 + type UpdateContactParams struct { 198 + Name string `json:"name"` 199 + FrequencyDays int64 `json:"frequency_days"` 200 + ID int64 `json:"id"` 201 + UserID string `json:"user_id"` 202 + } 203 + 204 + func (q *Queries) UpdateContact(ctx context.Context, arg UpdateContactParams) error { 205 + _, err := q.db.ExecContext(ctx, updateContact, 206 + arg.Name, 207 + arg.FrequencyDays, 208 + arg.ID, 209 + arg.UserID, 210 + ) 211 + return err 212 + }
+31
db/dbgen/db.go
··· 1 + // Code generated by sqlc. DO NOT EDIT. 2 + // versions: 3 + // sqlc v1.30.0 4 + 5 + package dbgen 6 + 7 + import ( 8 + "context" 9 + "database/sql" 10 + ) 11 + 12 + type DBTX interface { 13 + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 14 + PrepareContext(context.Context, string) (*sql.Stmt, error) 15 + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 16 + QueryRowContext(context.Context, string, ...interface{}) *sql.Row 17 + } 18 + 19 + func New(db DBTX) *Queries { 20 + return &Queries{db: db} 21 + } 22 + 23 + type Queries struct { 24 + db DBTX 25 + } 26 + 27 + func (q *Queries) WithTx(tx *sql.Tx) *Queries { 28 + return &Queries{ 29 + db: tx, 30 + } 31 + }
+30
db/dbgen/models.go
··· 1 + // Code generated by sqlc. DO NOT EDIT. 2 + // versions: 3 + // sqlc v1.30.0 4 + 5 + package dbgen 6 + 7 + import ( 8 + "time" 9 + ) 10 + 11 + type Contact struct { 12 + ID int64 `json:"id"` 13 + UserID string `json:"user_id"` 14 + Name string `json:"name"` 15 + FrequencyDays int64 `json:"frequency_days"` 16 + CreatedAt time.Time `json:"created_at"` 17 + } 18 + 19 + type Interaction struct { 20 + ID int64 `json:"id"` 21 + ContactID int64 `json:"contact_id"` 22 + Notes *string `json:"notes"` 23 + InteractedAt time.Time `json:"interacted_at"` 24 + } 25 + 26 + type Migration struct { 27 + MigrationNumber int64 `json:"migration_number"` 28 + MigrationName string `json:"migration_name"` 29 + ExecutedAt time.Time `json:"executed_at"` 30 + }
+34
db/migrations/001-base.sql
··· 1 + -- Base schema 2 + -- 3 + -- Migrations tracking table 4 + CREATE TABLE IF NOT EXISTS migrations ( 5 + migration_number INTEGER PRIMARY KEY, 6 + migration_name TEXT NOT NULL, 7 + executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 8 + ); 9 + 10 + -- People to keep in touch with 11 + CREATE TABLE IF NOT EXISTS contacts ( 12 + id INTEGER PRIMARY KEY AUTOINCREMENT, 13 + user_id TEXT NOT NULL, 14 + name TEXT NOT NULL, 15 + frequency_days INTEGER NOT NULL DEFAULT 30, 16 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 17 + ); 18 + 19 + CREATE INDEX IF NOT EXISTS idx_contacts_user_id ON contacts(user_id); 20 + 21 + -- Interactions/catch-ups with contacts 22 + CREATE TABLE IF NOT EXISTS interactions ( 23 + id INTEGER PRIMARY KEY AUTOINCREMENT, 24 + contact_id INTEGER NOT NULL, 25 + notes TEXT, 26 + interacted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 27 + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE 28 + ); 29 + 30 + CREATE INDEX IF NOT EXISTS idx_interactions_contact_id ON interactions(contact_id); 31 + 32 + -- Record execution of this migration 33 + INSERT OR IGNORE INTO migrations (migration_number, migration_name) 34 + VALUES (001, '001-base');
+32
db/queries/contacts.sql
··· 1 + -- name: CreateContact :one 2 + INSERT INTO contacts (user_id, name, frequency_days, created_at) 3 + VALUES (?, ?, ?, ?) 4 + RETURNING *; 5 + 6 + -- name: GetContactsByUser :many 7 + SELECT 8 + c.*, 9 + (SELECT MAX(interacted_at) FROM interactions WHERE contact_id = c.id) as last_interaction 10 + FROM contacts c 11 + WHERE c.user_id = ? 12 + ORDER BY c.name; 13 + 14 + -- name: GetContact :one 15 + SELECT * FROM contacts WHERE id = ? AND user_id = ?; 16 + 17 + -- name: UpdateContact :exec 18 + UPDATE contacts SET name = ?, frequency_days = ? WHERE id = ? AND user_id = ?; 19 + 20 + -- name: DeleteContact :exec 21 + DELETE FROM contacts WHERE id = ? AND user_id = ?; 22 + 23 + -- name: CreateInteraction :one 24 + INSERT INTO interactions (contact_id, notes, interacted_at) 25 + VALUES (?, ?, ?) 26 + RETURNING *; 27 + 28 + -- name: GetInteractionsByContact :many 29 + SELECT * FROM interactions WHERE contact_id = ? ORDER BY interacted_at DESC; 30 + 31 + -- name: DeleteInteraction :exec 32 + DELETE FROM interactions WHERE id = ?;
+14
db/sqlc.yaml
··· 1 + version: "2" 2 + sql: 3 + - engine: "sqlite" 4 + queries: "queries/" 5 + schema: "migrations/" 6 + gen: 7 + go: 8 + package: "dbgen" 9 + out: "dbgen/" 10 + emit_json_tags: true 11 + emit_empty_slices: true 12 + emit_pointers_for_null_types: true 13 + json_tags_case_style: "snake" 14 + sql_package: "database/sql"
+60
go.mod
··· 1 + module srv.exe.dev 2 + 3 + go 1.25.5 4 + 5 + require modernc.org/sqlite v1.39.0 6 + 7 + require ( 8 + cel.dev/expr v0.24.0 // indirect 9 + filippo.io/edwards25519 v1.1.0 // indirect 10 + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 11 + github.com/cubicdaiya/gonp v1.0.4 // indirect 12 + github.com/davecgh/go-spew v1.1.1 // indirect 13 + github.com/dustin/go-humanize v1.0.1 // indirect 14 + github.com/fatih/structtag v1.2.0 // indirect 15 + github.com/go-sql-driver/mysql v1.9.3 // indirect 16 + github.com/google/cel-go v0.26.1 // indirect 17 + github.com/google/uuid v1.6.0 // indirect 18 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 19 + github.com/jackc/pgpassfile v1.0.0 // indirect 20 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 21 + github.com/jackc/pgx/v5 v5.7.5 // indirect 22 + github.com/jackc/puddle/v2 v2.2.2 // indirect 23 + github.com/jinzhu/inflection v1.0.0 // indirect 24 + github.com/mattn/go-isatty v0.0.20 // indirect 25 + github.com/ncruces/go-strftime v0.1.9 // indirect 26 + github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect 27 + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect 28 + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect 29 + github.com/pingcap/log v1.1.0 // indirect 30 + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect 31 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 32 + github.com/riza-io/grpc-go v0.2.0 // indirect 33 + github.com/spf13/cobra v1.9.1 // indirect 34 + github.com/spf13/pflag v1.0.7 // indirect 35 + github.com/sqlc-dev/sqlc v1.30.0 // indirect 36 + github.com/stoewer/go-strcase v1.2.0 // indirect 37 + github.com/tetratelabs/wazero v1.9.0 // indirect 38 + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect 39 + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect 40 + go.uber.org/atomic v1.11.0 // indirect 41 + go.uber.org/multierr v1.11.0 // indirect 42 + go.uber.org/zap v1.27.0 // indirect 43 + golang.org/x/crypto v0.39.0 // indirect 44 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 45 + golang.org/x/net v0.41.0 // indirect 46 + golang.org/x/sync v0.16.0 // indirect 47 + golang.org/x/sys v0.34.0 // indirect 48 + golang.org/x/text v0.26.0 // indirect 49 + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect 50 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect 51 + google.golang.org/grpc v1.75.0 // indirect 52 + google.golang.org/protobuf v1.36.8 // indirect 53 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 54 + gopkg.in/yaml.v3 v3.0.1 // indirect 55 + modernc.org/libc v1.66.3 // indirect 56 + modernc.org/mathutil v1.7.1 // indirect 57 + modernc.org/memory v1.11.0 // indirect 58 + ) 59 + 60 + tool github.com/sqlc-dev/sqlc/cmd/sqlc
+209
go.sum
··· 1 + cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= 2 + cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= 3 + filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 4 + filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 5 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 6 + github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= 7 + github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 8 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 9 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 10 + github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= 11 + github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= 12 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 16 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 17 + github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= 18 + github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= 19 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 20 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 22 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 23 + github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 24 + github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 25 + github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 26 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 27 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 28 + github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= 29 + github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= 30 + github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 31 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 34 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 35 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 36 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 37 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 38 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 39 + github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 40 + github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 41 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 42 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 43 + github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= 44 + github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 45 + github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 46 + github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 47 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 48 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 49 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 50 + github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 51 + github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 52 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 53 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 54 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 55 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 56 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 57 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 58 + github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 59 + github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 60 + github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= 61 + github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= 62 + github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= 63 + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk= 64 + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= 65 + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= 66 + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= 67 + github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= 68 + github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= 69 + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= 70 + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= 71 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 72 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 73 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 75 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 76 + github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= 77 + github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= 78 + github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 79 + github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 80 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 81 + github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 82 + github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 83 + github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 84 + github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 85 + github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 86 + github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c= 87 + github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0= 88 + github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= 89 + github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= 90 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 91 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 92 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 93 + github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 94 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 95 + github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 96 + github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 97 + github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= 98 + github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= 99 + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= 100 + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= 101 + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= 102 + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= 103 + go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 104 + go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 105 + go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 106 + go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 107 + go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 108 + go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 109 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 110 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 111 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 112 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 113 + go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 114 + go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 115 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 116 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 117 + go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 118 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 119 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 120 + go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= 121 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 122 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 123 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 124 + go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= 125 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 126 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 127 + go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= 128 + go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 129 + go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 130 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 131 + golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 132 + golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 133 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 134 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 135 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 136 + golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 137 + golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 138 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 139 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 140 + golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 141 + golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 142 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 143 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 144 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 145 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 146 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 147 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 148 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 149 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 150 + golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 151 + golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 152 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 153 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 154 + golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 155 + golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= 156 + golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 157 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 158 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 159 + gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 160 + gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 161 + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= 162 + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= 163 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= 164 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 165 + google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= 166 + google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= 167 + google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 168 + google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 169 + google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 170 + google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 171 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 172 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 173 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 174 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 175 + gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= 176 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 177 + gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 178 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 179 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 180 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 181 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 182 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 183 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 184 + modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= 185 + modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 186 + modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 187 + modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 188 + modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= 189 + modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 190 + modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 191 + modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 192 + modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 193 + modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 194 + modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= 195 + modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= 196 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 197 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 198 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 199 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 200 + modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 201 + modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 202 + modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 203 + modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 204 + modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= 205 + modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= 206 + modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 207 + modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 208 + modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 209 + modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+18
srv.service
··· 1 + [Unit] 2 + Description=Stay In Touch - Contact tracking app 3 + 4 + [Service] 5 + Type=simple 6 + User=exedev 7 + Group=exedev 8 + WorkingDirectory=/home/exedev/stayintouch 9 + ExecStart=/home/exedev/stayintouch/stayintouch 10 + Restart=always 11 + RestartSec=5 12 + Environment=HOME=/home/exedev 13 + Environment=USER=exedev 14 + StandardOutput=journal 15 + StandardError=journal 16 + 17 + [Install] 18 + WantedBy=multi-user.target
+361
srv/server.go
··· 1 + package srv 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "html/template" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "path/filepath" 11 + "runtime" 12 + "strconv" 13 + "strings" 14 + "time" 15 + 16 + "srv.exe.dev/db" 17 + "srv.exe.dev/db/dbgen" 18 + ) 19 + 20 + type Server struct { 21 + DB *sql.DB 22 + Hostname string 23 + TemplatesDir string 24 + StaticDir string 25 + } 26 + 27 + func New(dbPath, hostname string) (*Server, error) { 28 + _, thisFile, _, _ := runtime.Caller(0) 29 + baseDir := filepath.Dir(thisFile) 30 + srv := &Server{ 31 + Hostname: hostname, 32 + TemplatesDir: filepath.Join(baseDir, "templates"), 33 + StaticDir: filepath.Join(baseDir, "static"), 34 + } 35 + if err := srv.setUpDatabase(dbPath); err != nil { 36 + return nil, err 37 + } 38 + return srv, nil 39 + } 40 + 41 + type ContactView struct { 42 + ID int64 43 + Name string 44 + FrequencyDays int64 45 + LastInteraction *time.Time 46 + DaysOverdue int 47 + Status string // "overdue", "due-soon", "ok" 48 + } 49 + 50 + type pageData struct { 51 + Hostname string 52 + UserEmail string 53 + LoginURL string 54 + LogoutURL string 55 + Contacts []ContactView 56 + } 57 + 58 + func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { 59 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 60 + userEmail := strings.TrimSpace(r.Header.Get("X-ExeDev-Email")) 61 + 62 + data := pageData{ 63 + Hostname: s.Hostname, 64 + UserEmail: userEmail, 65 + LoginURL: loginURLForRequest(r), 66 + LogoutURL: "/__exe.dev/logout", 67 + Contacts: []ContactView{}, 68 + } 69 + 70 + if userID != "" && s.DB != nil { 71 + q := dbgen.New(s.DB) 72 + contacts, err := q.GetContactsByUser(r.Context(), userID) 73 + if err != nil { 74 + slog.Warn("get contacts", "error", err) 75 + } else { 76 + now := time.Now() 77 + for _, c := range contacts { 78 + cv := ContactView{ 79 + ID: c.ID, 80 + Name: c.Name, 81 + FrequencyDays: c.FrequencyDays, 82 + Status: "ok", 83 + } 84 + if c.LastInteraction != nil { 85 + if lastStr, ok := c.LastInteraction.(string); ok { 86 + t := parseFlexibleTime(lastStr) 87 + if !t.IsZero() { 88 + cv.LastInteraction = &t 89 + } 90 + } 91 + } 92 + if cv.LastInteraction != nil { 93 + daysSince := int(now.Sub(*cv.LastInteraction).Hours() / 24) 94 + cv.DaysOverdue = daysSince - int(c.FrequencyDays) 95 + if cv.DaysOverdue > 0 { 96 + cv.Status = "overdue" 97 + } else if cv.DaysOverdue > -7 { 98 + cv.Status = "due-soon" 99 + } 100 + } else { 101 + // Never interacted - consider overdue 102 + cv.Status = "overdue" 103 + cv.DaysOverdue = int(c.FrequencyDays) 104 + } 105 + data.Contacts = append(data.Contacts, cv) 106 + } 107 + } 108 + } 109 + 110 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 111 + if err := s.renderTemplate(w, "index.html", data); err != nil { 112 + slog.Warn("render template", "url", r.URL.Path, "error", err) 113 + } 114 + } 115 + 116 + func (s *Server) HandleAddContact(w http.ResponseWriter, r *http.Request) { 117 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 118 + if userID == "" { 119 + http.Redirect(w, r, "/__exe.dev/login", http.StatusSeeOther) 120 + return 121 + } 122 + 123 + name := strings.TrimSpace(r.FormValue("name")) 124 + freqStr := r.FormValue("frequency_days") 125 + freq, err := strconv.ParseInt(freqStr, 10, 64) 126 + if err != nil || freq < 1 { 127 + freq = 30 128 + } 129 + 130 + if name != "" { 131 + q := dbgen.New(s.DB) 132 + _, err := q.CreateContact(r.Context(), dbgen.CreateContactParams{ 133 + UserID: userID, 134 + Name: name, 135 + FrequencyDays: freq, 136 + CreatedAt: time.Now(), 137 + }) 138 + if err != nil { 139 + slog.Warn("create contact", "error", err) 140 + } 141 + } 142 + 143 + http.Redirect(w, r, "/", http.StatusSeeOther) 144 + } 145 + 146 + func (s *Server) HandleLogInteraction(w http.ResponseWriter, r *http.Request) { 147 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 148 + if userID == "" { 149 + http.Redirect(w, r, "/__exe.dev/login", http.StatusSeeOther) 150 + return 151 + } 152 + 153 + contactIDStr := r.PathValue("id") 154 + contactID, err := strconv.ParseInt(contactIDStr, 10, 64) 155 + if err != nil { 156 + http.Error(w, "Invalid contact ID", http.StatusBadRequest) 157 + return 158 + } 159 + 160 + q := dbgen.New(s.DB) 161 + // Verify contact belongs to user 162 + _, err = q.GetContact(r.Context(), dbgen.GetContactParams{ID: contactID, UserID: userID}) 163 + if err != nil { 164 + http.Error(w, "Contact not found", http.StatusNotFound) 165 + return 166 + } 167 + 168 + notes := strings.TrimSpace(r.FormValue("notes")) 169 + var notesPtr *string 170 + if notes != "" { 171 + notesPtr = &notes 172 + } 173 + 174 + _, err = q.CreateInteraction(r.Context(), dbgen.CreateInteractionParams{ 175 + ContactID: contactID, 176 + Notes: notesPtr, 177 + InteractedAt: time.Now().UTC(), 178 + }) 179 + if err != nil { 180 + slog.Warn("create interaction", "error", err) 181 + } 182 + 183 + http.Redirect(w, r, "/", http.StatusSeeOther) 184 + } 185 + 186 + type contactDetailData struct { 187 + Hostname string 188 + UserEmail string 189 + LoginURL string 190 + LogoutURL string 191 + Contact dbgen.Contact 192 + Interactions []dbgen.Interaction 193 + } 194 + 195 + func (s *Server) HandleContactDetail(w http.ResponseWriter, r *http.Request) { 196 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 197 + if userID == "" { 198 + http.Redirect(w, r, "/__exe.dev/login", http.StatusSeeOther) 199 + return 200 + } 201 + 202 + contactIDStr := r.PathValue("id") 203 + contactID, err := strconv.ParseInt(contactIDStr, 10, 64) 204 + if err != nil { 205 + http.Error(w, "Invalid contact ID", http.StatusBadRequest) 206 + return 207 + } 208 + 209 + q := dbgen.New(s.DB) 210 + contact, err := q.GetContact(r.Context(), dbgen.GetContactParams{ID: contactID, UserID: userID}) 211 + if err != nil { 212 + http.Error(w, "Contact not found", http.StatusNotFound) 213 + return 214 + } 215 + 216 + interactions, err := q.GetInteractionsByContact(r.Context(), contactID) 217 + if err != nil { 218 + slog.Warn("get interactions", "error", err) 219 + interactions = []dbgen.Interaction{} 220 + } 221 + 222 + data := contactDetailData{ 223 + Hostname: s.Hostname, 224 + UserEmail: strings.TrimSpace(r.Header.Get("X-ExeDev-Email")), 225 + LoginURL: loginURLForRequest(r), 226 + LogoutURL: "/__exe.dev/logout", 227 + Contact: contact, 228 + Interactions: interactions, 229 + } 230 + 231 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 232 + if err := s.renderTemplate(w, "contact.html", data); err != nil { 233 + slog.Warn("render template", "error", err) 234 + } 235 + } 236 + 237 + func (s *Server) HandleUpdateContact(w http.ResponseWriter, r *http.Request) { 238 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 239 + if userID == "" { 240 + http.Redirect(w, r, "/__exe.dev/login", http.StatusSeeOther) 241 + return 242 + } 243 + 244 + contactIDStr := r.PathValue("id") 245 + contactID, err := strconv.ParseInt(contactIDStr, 10, 64) 246 + if err != nil { 247 + http.Error(w, "Invalid contact ID", http.StatusBadRequest) 248 + return 249 + } 250 + 251 + name := strings.TrimSpace(r.FormValue("name")) 252 + freqStr := r.FormValue("frequency_days") 253 + freq, err := strconv.ParseInt(freqStr, 10, 64) 254 + if err != nil || freq < 1 { 255 + freq = 30 256 + } 257 + 258 + q := dbgen.New(s.DB) 259 + err = q.UpdateContact(r.Context(), dbgen.UpdateContactParams{ 260 + ID: contactID, 261 + UserID: userID, 262 + Name: name, 263 + FrequencyDays: freq, 264 + }) 265 + if err != nil { 266 + slog.Warn("update contact", "error", err) 267 + } 268 + 269 + http.Redirect(w, r, fmt.Sprintf("/contact/%d", contactID), http.StatusSeeOther) 270 + } 271 + 272 + func (s *Server) HandleDeleteContact(w http.ResponseWriter, r *http.Request) { 273 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 274 + if userID == "" { 275 + http.Redirect(w, r, "/__exe.dev/login", http.StatusSeeOther) 276 + return 277 + } 278 + 279 + contactIDStr := r.PathValue("id") 280 + contactID, err := strconv.ParseInt(contactIDStr, 10, 64) 281 + if err != nil { 282 + http.Error(w, "Invalid contact ID", http.StatusBadRequest) 283 + return 284 + } 285 + 286 + q := dbgen.New(s.DB) 287 + err = q.DeleteContact(r.Context(), dbgen.DeleteContactParams{ 288 + ID: contactID, 289 + UserID: userID, 290 + }) 291 + if err != nil { 292 + slog.Warn("delete contact", "error", err) 293 + } 294 + 295 + http.Redirect(w, r, "/", http.StatusSeeOther) 296 + } 297 + 298 + func parseFlexibleTime(s string) time.Time { 299 + formats := []string{ 300 + "2006-01-02 15:04:05.999999999 -0700 MST", 301 + "2006-01-02 15:04:05.999999999 +0000 UTC", 302 + "2006-01-02 15:04:05-07:00", 303 + "2006-01-02 15:04:05", 304 + time.RFC3339, 305 + time.RFC3339Nano, 306 + } 307 + // Strip any monotonic clock part 308 + if idx := strings.Index(s, " m="); idx > 0 { 309 + s = s[:idx] 310 + } 311 + for _, f := range formats { 312 + if t, err := time.Parse(f, s); err == nil { 313 + return t 314 + } 315 + } 316 + return time.Time{} 317 + } 318 + 319 + func loginURLForRequest(r *http.Request) string { 320 + path := r.URL.RequestURI() 321 + v := url.Values{} 322 + v.Set("redirect", path) 323 + return "/__exe.dev/login?" + v.Encode() 324 + } 325 + 326 + func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error { 327 + path := filepath.Join(s.TemplatesDir, name) 328 + tmpl, err := template.ParseFiles(path) 329 + if err != nil { 330 + return fmt.Errorf("parse template %q: %w", name, err) 331 + } 332 + if err := tmpl.Execute(w, data); err != nil { 333 + return fmt.Errorf("execute template %q: %w", name, err) 334 + } 335 + return nil 336 + } 337 + 338 + func (s *Server) setUpDatabase(dbPath string) error { 339 + wdb, err := db.Open(dbPath) 340 + if err != nil { 341 + return fmt.Errorf("failed to open db: %w", err) 342 + } 343 + s.DB = wdb 344 + if err := db.RunMigrations(wdb); err != nil { 345 + return fmt.Errorf("failed to run migrations: %w", err) 346 + } 347 + return nil 348 + } 349 + 350 + func (s *Server) Serve(addr string) error { 351 + mux := http.NewServeMux() 352 + mux.HandleFunc("GET /{$}", s.HandleRoot) 353 + mux.HandleFunc("POST /contact", s.HandleAddContact) 354 + mux.HandleFunc("GET /contact/{id}", s.HandleContactDetail) 355 + mux.HandleFunc("POST /contact/{id}", s.HandleUpdateContact) 356 + mux.HandleFunc("POST /contact/{id}/delete", s.HandleDeleteContact) 357 + mux.HandleFunc("POST /contact/{id}/interaction", s.HandleLogInteraction) 358 + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir)))) 359 + slog.Info("starting server", "addr", addr) 360 + return http.ListenAndServe(addr, mux) 361 + }
srv/srv

This is a binary file and will not be displayed.

+397
srv/static/style.css
··· 1 + * { 2 + box-sizing: border-box; 3 + margin: 0; 4 + padding: 0; 5 + } 6 + 7 + body { 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 9 + background: #f5f5f5; 10 + color: #333; 11 + line-height: 1.6; 12 + } 13 + 14 + header { 15 + background: #2c3e50; 16 + color: white; 17 + padding: 1rem 2rem; 18 + display: flex; 19 + justify-content: space-between; 20 + align-items: center; 21 + } 22 + 23 + header h1 { 24 + font-size: 1.5rem; 25 + } 26 + 27 + header h1 a { 28 + color: white; 29 + text-decoration: none; 30 + } 31 + 32 + header nav { 33 + display: flex; 34 + gap: 1rem; 35 + align-items: center; 36 + } 37 + 38 + header nav a { 39 + color: #ecf0f1; 40 + text-decoration: none; 41 + } 42 + 43 + header nav a:hover { 44 + text-decoration: underline; 45 + } 46 + 47 + main { 48 + max-width: 800px; 49 + margin: 2rem auto; 50 + padding: 0 1rem; 51 + } 52 + 53 + section { 54 + background: white; 55 + border-radius: 8px; 56 + padding: 1.5rem; 57 + margin-bottom: 1.5rem; 58 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 59 + } 60 + 61 + h2 { 62 + margin-bottom: 1rem; 63 + color: #2c3e50; 64 + } 65 + 66 + h3 { 67 + margin-bottom: 0.75rem; 68 + color: #34495e; 69 + } 70 + 71 + /* Add contact form */ 72 + .add-contact form { 73 + display: flex; 74 + gap: 0.5rem; 75 + flex-wrap: wrap; 76 + } 77 + 78 + .add-contact input[type="text"] { 79 + flex: 1; 80 + min-width: 200px; 81 + padding: 0.75rem; 82 + border: 1px solid #ddd; 83 + border-radius: 4px; 84 + font-size: 1rem; 85 + } 86 + 87 + .add-contact select { 88 + padding: 0.75rem; 89 + border: 1px solid #ddd; 90 + border-radius: 4px; 91 + font-size: 1rem; 92 + background: white; 93 + } 94 + 95 + .add-contact button { 96 + padding: 0.75rem 1.5rem; 97 + background: #27ae60; 98 + color: white; 99 + border: none; 100 + border-radius: 4px; 101 + font-size: 1rem; 102 + cursor: pointer; 103 + } 104 + 105 + .add-contact button:hover { 106 + background: #219a52; 107 + } 108 + 109 + /* Contact list */ 110 + .contact-list { 111 + display: flex; 112 + flex-direction: column; 113 + gap: 1rem; 114 + } 115 + 116 + .contact-card { 117 + border: 1px solid #ddd; 118 + border-radius: 8px; 119 + padding: 1rem; 120 + display: flex; 121 + justify-content: space-between; 122 + align-items: center; 123 + gap: 1rem; 124 + flex-wrap: wrap; 125 + } 126 + 127 + .contact-card.overdue { 128 + border-left: 4px solid #e74c3c; 129 + background: #fef5f5; 130 + } 131 + 132 + .contact-card.due-soon { 133 + border-left: 4px solid #f39c12; 134 + background: #fffbf0; 135 + } 136 + 137 + .contact-card.ok { 138 + border-left: 4px solid #27ae60; 139 + } 140 + 141 + .contact-info { 142 + display: flex; 143 + flex-direction: column; 144 + gap: 0.25rem; 145 + } 146 + 147 + .contact-name { 148 + font-size: 1.2rem; 149 + font-weight: 600; 150 + color: #2c3e50; 151 + text-decoration: none; 152 + } 153 + 154 + .contact-name:hover { 155 + color: #3498db; 156 + } 157 + 158 + .frequency, .last-seen { 159 + font-size: 0.9rem; 160 + color: #7f8c8d; 161 + } 162 + 163 + .status-badge { 164 + font-size: 0.8rem; 165 + padding: 0.25rem 0.5rem; 166 + border-radius: 4px; 167 + font-weight: 500; 168 + } 169 + 170 + .status-badge.overdue { 171 + background: #e74c3c; 172 + color: white; 173 + } 174 + 175 + .status-badge.due-soon { 176 + background: #f39c12; 177 + color: white; 178 + } 179 + 180 + /* Quick log form */ 181 + .quick-log { 182 + display: flex; 183 + gap: 0.5rem; 184 + align-items: center; 185 + } 186 + 187 + .quick-log input { 188 + padding: 0.5rem; 189 + border: 1px solid #ddd; 190 + border-radius: 4px; 191 + width: 150px; 192 + } 193 + 194 + .log-btn { 195 + padding: 0.5rem 1rem; 196 + background: #27ae60; 197 + color: white; 198 + border: none; 199 + border-radius: 4px; 200 + cursor: pointer; 201 + white-space: nowrap; 202 + } 203 + 204 + .log-btn:hover { 205 + background: #219a52; 206 + } 207 + 208 + /* Welcome section */ 209 + .welcome { 210 + text-align: center; 211 + padding: 3rem 1.5rem; 212 + } 213 + 214 + .welcome h2 { 215 + font-size: 1.8rem; 216 + margin-bottom: 1rem; 217 + } 218 + 219 + .welcome p { 220 + color: #7f8c8d; 221 + margin-bottom: 2rem; 222 + max-width: 500px; 223 + margin-left: auto; 224 + margin-right: auto; 225 + } 226 + 227 + .login-btn { 228 + display: inline-block; 229 + padding: 1rem 2rem; 230 + background: #3498db; 231 + color: white; 232 + text-decoration: none; 233 + border-radius: 4px; 234 + font-size: 1.1rem; 235 + } 236 + 237 + .login-btn:hover { 238 + background: #2980b9; 239 + } 240 + 241 + /* Contact detail page */ 242 + .contact-detail { 243 + position: relative; 244 + } 245 + 246 + .edit-form { 247 + display: flex; 248 + flex-direction: column; 249 + gap: 1rem; 250 + } 251 + 252 + .form-row { 253 + display: flex; 254 + flex-direction: column; 255 + gap: 0.25rem; 256 + } 257 + 258 + .form-row label { 259 + font-weight: 500; 260 + color: #7f8c8d; 261 + } 262 + 263 + .form-row input, 264 + .form-row select { 265 + padding: 0.75rem; 266 + border: 1px solid #ddd; 267 + border-radius: 4px; 268 + font-size: 1rem; 269 + } 270 + 271 + .edit-form button { 272 + padding: 0.75rem 1.5rem; 273 + background: #3498db; 274 + color: white; 275 + border: none; 276 + border-radius: 4px; 277 + cursor: pointer; 278 + align-self: flex-start; 279 + } 280 + 281 + .edit-form button:hover { 282 + background: #2980b9; 283 + } 284 + 285 + .delete-form { 286 + margin-top: 1rem; 287 + } 288 + 289 + .delete-btn { 290 + padding: 0.5rem 1rem; 291 + background: #e74c3c; 292 + color: white; 293 + border: none; 294 + border-radius: 4px; 295 + cursor: pointer; 296 + } 297 + 298 + .delete-btn:hover { 299 + background: #c0392b; 300 + } 301 + 302 + /* Log interaction */ 303 + .log-interaction form { 304 + display: flex; 305 + flex-direction: column; 306 + gap: 0.75rem; 307 + } 308 + 309 + .log-interaction textarea { 310 + padding: 0.75rem; 311 + border: 1px solid #ddd; 312 + border-radius: 4px; 313 + font-size: 1rem; 314 + font-family: inherit; 315 + resize: vertical; 316 + min-height: 80px; 317 + } 318 + 319 + .log-interaction button { 320 + padding: 0.75rem 1.5rem; 321 + background: #27ae60; 322 + color: white; 323 + border: none; 324 + border-radius: 4px; 325 + cursor: pointer; 326 + align-self: flex-start; 327 + } 328 + 329 + .log-interaction button:hover { 330 + background: #219a52; 331 + } 332 + 333 + /* Interaction history */ 334 + .interaction-list { 335 + list-style: none; 336 + } 337 + 338 + .interaction-list li { 339 + padding: 1rem 0; 340 + border-bottom: 1px solid #eee; 341 + } 342 + 343 + .interaction-list li:last-child { 344 + border-bottom: none; 345 + } 346 + 347 + .interaction-list .date { 348 + font-size: 0.9rem; 349 + color: #7f8c8d; 350 + } 351 + 352 + .interaction-list .notes { 353 + margin-top: 0.5rem; 354 + } 355 + 356 + .interaction-list .notes.empty { 357 + color: #bdc3c7; 358 + font-style: italic; 359 + } 360 + 361 + /* Empty states */ 362 + .empty { 363 + color: #7f8c8d; 364 + font-style: italic; 365 + } 366 + 367 + .back-link { 368 + display: inline-block; 369 + color: #3498db; 370 + text-decoration: none; 371 + margin-top: 1rem; 372 + } 373 + 374 + .back-link:hover { 375 + text-decoration: underline; 376 + } 377 + 378 + /* Responsive */ 379 + @media (max-width: 600px) { 380 + header { 381 + flex-direction: column; 382 + gap: 0.5rem; 383 + } 384 + 385 + .contact-card { 386 + flex-direction: column; 387 + align-items: stretch; 388 + } 389 + 390 + .quick-log { 391 + flex-direction: column; 392 + } 393 + 394 + .quick-log input { 395 + width: 100%; 396 + } 397 + }
+81
srv/templates/contact.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>{{.Contact.Name}} - Stay In Touch</title> 7 + <link rel="stylesheet" href="/static/style.css"> 8 + </head> 9 + <body> 10 + <header> 11 + <h1><a href="/">Stay In Touch</a></h1> 12 + <nav> 13 + {{if .UserEmail}} 14 + <span>{{.UserEmail}}</span> 15 + <a href="{{.LogoutURL}}">Logout</a> 16 + {{else}} 17 + <a href="{{.LoginURL}}">Login</a> 18 + {{end}} 19 + </nav> 20 + </header> 21 + 22 + <main> 23 + <section class="contact-detail"> 24 + <h2>{{.Contact.Name}}</h2> 25 + 26 + <form method="POST" action="/contact/{{.Contact.ID}}" class="edit-form"> 27 + <div class="form-row"> 28 + <label>Name</label> 29 + <input type="text" name="name" value="{{.Contact.Name}}" required> 30 + </div> 31 + <div class="form-row"> 32 + <label>Check in every</label> 33 + <select name="frequency_days"> 34 + <option value="7" {{if eq .Contact.FrequencyDays 7}}selected{{end}}>Weekly</option> 35 + <option value="14" {{if eq .Contact.FrequencyDays 14}}selected{{end}}>Every 2 weeks</option> 36 + <option value="30" {{if eq .Contact.FrequencyDays 30}}selected{{end}}>Monthly</option> 37 + <option value="90" {{if eq .Contact.FrequencyDays 90}}selected{{end}}>Quarterly</option> 38 + <option value="180" {{if eq .Contact.FrequencyDays 180}}selected{{end}}>Every 6 months</option> 39 + <option value="365" {{if eq .Contact.FrequencyDays 365}}selected{{end}}>Yearly</option> 40 + </select> 41 + </div> 42 + <button type="submit">Save Changes</button> 43 + </form> 44 + 45 + <form method="POST" action="/contact/{{.Contact.ID}}/delete" class="delete-form" onsubmit="return confirm('Are you sure you want to delete this contact?');"> 46 + <button type="submit" class="delete-btn">Delete Contact</button> 47 + </form> 48 + </section> 49 + 50 + <section class="log-interaction"> 51 + <h3>Log an Interaction</h3> 52 + <form method="POST" action="/contact/{{.Contact.ID}}/interaction"> 53 + <textarea name="notes" placeholder="What did you talk about? (optional)"></textarea> 54 + <button type="submit">✓ Log Catch-up</button> 55 + </form> 56 + </section> 57 + 58 + <section class="history"> 59 + <h3>Interaction History</h3> 60 + {{if .Interactions}} 61 + <ul class="interaction-list"> 62 + {{range .Interactions}} 63 + <li> 64 + <span class="date">{{.InteractedAt.Format "Jan 2, 2006 3:04 PM"}}</span> 65 + {{if .Notes}} 66 + <p class="notes">{{.Notes}}</p> 67 + {{else}} 68 + <p class="notes empty">No notes</p> 69 + {{end}} 70 + </li> 71 + {{end}} 72 + </ul> 73 + {{else}} 74 + <p class="empty">No interactions logged yet.</p> 75 + {{end}} 76 + </section> 77 + 78 + <a href="/" class="back-link">← Back to all contacts</a> 79 + </main> 80 + </body> 81 + </html>
+80
srv/templates/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Stay In Touch</title> 7 + <link rel="stylesheet" href="/static/style.css"> 8 + </head> 9 + <body> 10 + <header> 11 + <h1>Stay In Touch</h1> 12 + <nav> 13 + {{if .UserEmail}} 14 + <span>{{.UserEmail}}</span> 15 + <a href="{{.LogoutURL}}">Logout</a> 16 + {{else}} 17 + <a href="{{.LoginURL}}">Login</a> 18 + {{end}} 19 + </nav> 20 + </header> 21 + 22 + <main> 23 + {{if .UserEmail}} 24 + <section class="add-contact"> 25 + <h2>Add Someone</h2> 26 + <form method="POST" action="/contact"> 27 + <input type="text" name="name" placeholder="Name" required> 28 + <select name="frequency_days"> 29 + <option value="7">Weekly</option> 30 + <option value="14">Every 2 weeks</option> 31 + <option value="30" selected>Monthly</option> 32 + <option value="90">Quarterly</option> 33 + <option value="180">Every 6 months</option> 34 + <option value="365">Yearly</option> 35 + </select> 36 + <button type="submit">Add</button> 37 + </form> 38 + </section> 39 + 40 + <section class="contacts"> 41 + <h2>Your People</h2> 42 + {{if .Contacts}} 43 + <div class="contact-list"> 44 + {{range .Contacts}} 45 + <div class="contact-card {{.Status}}"> 46 + <div class="contact-info"> 47 + <a href="/contact/{{.ID}}" class="contact-name">{{.Name}}</a> 48 + <span class="frequency">Every {{.FrequencyDays}} days</span> 49 + {{if .LastInteraction}} 50 + <span class="last-seen">Last: {{.LastInteraction.Format "Jan 2, 2006"}}</span> 51 + {{else}} 52 + <span class="last-seen">Never contacted</span> 53 + {{end}} 54 + {{if eq .Status "overdue"}} 55 + <span class="status-badge overdue">{{.DaysOverdue}} days overdue</span> 56 + {{else if eq .Status "due-soon"}} 57 + <span class="status-badge due-soon">Due soon</span> 58 + {{end}} 59 + </div> 60 + <form method="POST" action="/contact/{{.ID}}/interaction" class="quick-log"> 61 + <input type="text" name="notes" placeholder="Notes (optional)"> 62 + <button type="submit" class="log-btn">✓ Caught up</button> 63 + </form> 64 + </div> 65 + {{end}} 66 + </div> 67 + {{else}} 68 + <p class="empty">No contacts yet. Add someone above!</p> 69 + {{end}} 70 + </section> 71 + {{else}} 72 + <section class="welcome"> 73 + <h2>Keep in touch with the people who matter</h2> 74 + <p>Track when you last connected with friends, family, and colleagues. Never let important relationships slip through the cracks.</p> 75 + <a href="{{.LoginURL}}" class="login-btn">Login to get started</a> 76 + </section> 77 + {{end}} 78 + </main> 79 + </body> 80 + </html>