+5
AGENT.md
+5
AGENT.md
+10
Makefile
+10
Makefile
+57
README.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 = ¬es
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
srv/srv
This is a binary file and will not be displayed.
+397
srv/static/style.css
+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
+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
+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>