local-review 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/demo/lib/gitworld.js +224 -0
- package/dist/demo/world/api.js +2436 -0
- package/dist/demo/world/infra.js +189 -0
- package/dist/demo/world/people.js +16 -0
- package/dist/demo/world/review.js +578 -0
- package/dist/demo/world/sdk.js +376 -0
- package/dist/demo/world/web.js +657 -0
- package/dist/server/cli.js +28 -0
- package/dist/server/config.js +3 -0
- package/dist/server/examples.js +204 -0
- package/dist/server/index.js +17 -2
- package/dist/server/projects.js +2 -0
- package/dist/web/assets/{angular-html-DJ4eInGE.js → angular-html-F7zRgWCZ.js} +1 -1
- package/dist/web/assets/{angular-ts-DBhgg2a7.js → angular-ts-BpEpytbw.js} +1 -1
- package/dist/web/assets/{apl-TCQAu201.js → apl-CXKG97CP.js} +1 -1
- package/dist/web/assets/{astro-DVtFuwC2.js → astro-Bbu7K9fw.js} +1 -1
- package/dist/web/assets/{blade-CMp-sZYg.js → blade-BIHnATAa.js} +1 -1
- package/dist/web/assets/{c-DgqspIM2.js → c-C-acQYm6.js} +1 -1
- package/dist/web/assets/{chapel-NTLt7i8E.js → chapel-CO4oLJ27.js} +1 -1
- package/dist/web/assets/{cobol-CJiWQrT4.js → cobol-DwOqj9Jg.js} +1 -1
- package/dist/web/assets/{coffee-Bv_hE6wf.js → coffee-D4-tkAkt.js} +1 -1
- package/dist/web/assets/{cpp-CuUsyAvc.js → cpp-B5AO2QHP.js} +1 -1
- package/dist/web/assets/{crystal-BeLopiD_.js → crystal-Da5vXEdt.js} +1 -1
- package/dist/web/assets/{css-OngwBV3K.js → css-BPZthOhS.js} +1 -1
- package/dist/web/assets/{edge-BFLildoE.js → edge-Dax-tr1_.js} +1 -1
- package/dist/web/assets/{elixir-1Fs6FjVs.js → elixir-Bn9yrJM6.js} +1 -1
- package/dist/web/assets/{elm-piMM67vF.js → elm-BOsm44l2.js} +1 -1
- package/dist/web/assets/{erb-JJ0sF8GL.js → erb-Gt3RYukx.js} +1 -1
- package/dist/web/assets/{git-rebase-CtqXywWe.js → git-rebase-PkordWam.js} +1 -1
- package/dist/web/assets/{glimmer-js-BPewWxGC.js → glimmer-js-B5msfXVo.js} +1 -1
- package/dist/web/assets/{glimmer-ts-DyUQWB47.js → glimmer-ts-CR148_Kt.js} +1 -1
- package/dist/web/assets/{glsl-D6tJoMaT.js → glsl-qur0cRcN.js} +1 -1
- package/dist/web/assets/{graphql-E6u2hkWE.js → graphql-C42K488Q.js} +1 -1
- package/dist/web/assets/{hack-Djq191-P.js → hack-NLFouBH4.js} +1 -1
- package/dist/web/assets/{haml-C0LqmZG3.js → haml-DKn_-I-I.js} +1 -1
- package/dist/web/assets/{handlebars-DwdxF6uN.js → handlebars-CQReOdSJ.js} +1 -1
- package/dist/web/assets/{html-derivative-DTVrLAw3.js → html-derivative-DoqJMb8S.js} +1 -1
- package/dist/web/assets/{html-B5Myt0fy.js → html-iUF_eP_4.js} +1 -1
- package/dist/web/assets/{http-CFoh3cQf.js → http-BJrCx5B5.js} +1 -1
- package/dist/web/assets/{hurl-B_38naAo.js → hurl-DlDfbRcL.js} +1 -1
- package/dist/web/assets/{index-CfxIojO0.js → index-CH6ceWie.js} +52 -52
- package/dist/web/assets/index-DU33o_Lu.css +1 -0
- package/dist/web/assets/{java-odnexk6r.js → java-DdxicsS9.js} +1 -1
- package/dist/web/assets/{javascript-DvFCJKws.js → javascript-Dw8rInq-.js} +1 -1
- package/dist/web/assets/{jinja-UeLt2DYu.js → jinja-ztBbs9jc.js} +1 -1
- package/dist/web/assets/{jison-HNaDYeqE.js → jison-DyOjkTQj.js} +1 -1
- package/dist/web/assets/{json-D57CnrjR.js → json-C6t0u9rP.js} +1 -1
- package/dist/web/assets/{jsx-o_5ZAydi.js → jsx-BpIc6zqG.js} +1 -1
- package/dist/web/assets/{julia-20UebbrD.js → julia-CvA9RPfz.js} +1 -1
- package/dist/web/assets/{just-DqnPCg-Q.js → just-CiH5trEM.js} +1 -1
- package/dist/web/assets/{latex-CZhIB-LV.js → latex-BbpuYBUm.js} +1 -1
- package/dist/web/assets/{liquid-BzouPsN0.js → liquid-Dy7K9saE.js} +1 -1
- package/dist/web/assets/{lua-BXclv8Fm.js → lua-CLN5DayT.js} +1 -1
- package/dist/web/assets/{marko-M78_INlG.js → marko-Be70_WI8.js} +1 -1
- package/dist/web/assets/{mdc-CBluOiY7.js → mdc-RgJIMLnK.js} +1 -1
- package/dist/web/assets/{nginx-CZFFQFbT.js → nginx-GhsGoiRj.js} +1 -1
- package/dist/web/assets/{nim-17kKsGqk.js → nim-BaIl7YzC.js} +1 -1
- package/dist/web/assets/{org-BZABHRQH.js → org-ksusUjYr.js} +1 -1
- package/dist/web/assets/{perl-CMrAQ9Je.js → perl-KX9noYTv.js} +1 -1
- package/dist/web/assets/{php-DwA3kETk.js → php-C-szMz57.js} +1 -1
- package/dist/web/assets/{pug-D4fM85kA.js → pug-nbUZSc0V.js} +1 -1
- package/dist/web/assets/{qml-C3893RHi.js → qml-BD2H6imu.js} +1 -1
- package/dist/web/assets/{r-DwJhLW77.js → r-CbR0TzPr.js} +1 -1
- package/dist/web/assets/{razor-_KkCNZ8V.js → razor-D_SYu_ob.js} +1 -1
- package/dist/web/assets/{regexp-BZ9NHg3b.js → regexp-BIEFfqXG.js} +1 -1
- package/dist/web/assets/{rst-gdK-1c4t.js → rst-D21NyilJ.js} +1 -1
- package/dist/web/assets/{ruby-DsmqYtlH.js → ruby-CCk7F-X_.js} +1 -1
- package/dist/web/assets/{sas-BNGv1ScF.js → sas-BasHIiX3.js} +1 -1
- package/dist/web/assets/{scss-DjYUXT73.js → scss-DL8S6L92.js} +1 -1
- package/dist/web/assets/{shellscript-DBtV-eli.js → shellscript-D-B4NAUX.js} +1 -1
- package/dist/web/assets/{shellsession-BKEV8Fnx.js → shellsession-LwE3hLIg.js} +1 -1
- package/dist/web/assets/{soy-DUapMxos.js → soy-DaUNAouD.js} +1 -1
- package/dist/web/assets/{sql-B-UyqKkd.js → sql-Drw6fxyA.js} +1 -1
- package/dist/web/assets/{stata-Xq3iZiMK.js → stata-Bk-VgNHo.js} +1 -1
- package/dist/web/assets/{surrealql-CzXacK8i.js → surrealql-BfOZjDkH.js} +1 -1
- package/dist/web/assets/{svelte-CZaBEmPP.js → svelte-BP8BXHT0.js} +1 -1
- package/dist/web/assets/{templ-CxkGuUpr.js → templ-BgcZlshd.js} +1 -1
- package/dist/web/assets/{tex-DP74JH6g.js → tex-BQczXiA6.js} +1 -1
- package/dist/web/assets/{ts-tags-BAcrCE2Y.js → ts-tags-CVFTjDsr.js} +1 -1
- package/dist/web/assets/{tsx-Dw11YP6z.js → tsx-j3JIiZaT.js} +1 -1
- package/dist/web/assets/{twig-BMVjBxD6.js → twig-D7saNHJy.js} +1 -1
- package/dist/web/assets/{typescript-DoBLlVdC.js → typescript-D62o9E27.js} +1 -1
- package/dist/web/assets/{typst-w2u_vl4_.js → typst-DQOeX7Fq.js} +1 -1
- package/dist/web/assets/{vue-BmuHTv5p.js → vue-BpdrgvY0.js} +1 -1
- package/dist/web/assets/{vue-html-Dt0zSF4I.js → vue-html-Cwi6ng_G.js} +1 -1
- package/dist/web/assets/{vue-vine-wvJ1BTex.js → vue-vine-BO8zdtYP.js} +1 -1
- package/dist/web/assets/{xml-CXftIBCQ.js → xml-DgdKyO3D.js} +1 -1
- package/dist/web/assets/{xsl-BoHSOMz1.js → xsl-CLXFRDPl.js} +1 -1
- package/dist/web/assets/{yaml-dGreldtD.js → yaml-BYa9ePnt.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/dist/web/assets/index-QywYYqTw.css +0 -1
|
@@ -0,0 +1,2436 @@
|
|
|
1
|
+
// orchard-api: the Go service. Base history (May–September 2026), Sam's webhook-retries stack (merged with merge
|
|
2
|
+
// commits; the archived project), Theo's calendar feed branch, and Maya's waitlists branch: its Groundwork stack
|
|
3
|
+
// squash-merged on GitHub, the rest rebased onto the new main, then "Promote the first waitlisted member" amended
|
|
4
|
+
// after review (one branch tag left behind: `moved`).
|
|
5
|
+
import { RepoBuilder, after, edits, replace } from '../lib/gitworld.js';
|
|
6
|
+
import { at, june, maya, remote, sam, theo } from './people.js';
|
|
7
|
+
const MOD = 'github.com/orchard-studio-demo/orchard-api';
|
|
8
|
+
const append = (s) => (old) => old + s;
|
|
9
|
+
export function buildApi() {
|
|
10
|
+
const r = new RepoBuilder('orchard-api', remote('orchard-api'));
|
|
11
|
+
// ---------------------------------------------------------------- base history
|
|
12
|
+
const a1 = r.commit(null, 'Initial service skeleton', {
|
|
13
|
+
'go.mod': `module ${MOD}
|
|
14
|
+
|
|
15
|
+
go 1.22
|
|
16
|
+
|
|
17
|
+
require github.com/go-chi/chi/v5 v5.0.12
|
|
18
|
+
`,
|
|
19
|
+
'README.md': `# orchard-api
|
|
20
|
+
|
|
21
|
+
The HTTP API behind Orchard: studios publish a class schedule, members book spots in classes.
|
|
22
|
+
|
|
23
|
+
## Running locally
|
|
24
|
+
|
|
25
|
+
docker compose up -d postgres
|
|
26
|
+
make migrate
|
|
27
|
+
make run
|
|
28
|
+
|
|
29
|
+
The API listens on \`:8080\` (set \`PORT\` to change it) and reads \`DATABASE_URL\`.
|
|
30
|
+
|
|
31
|
+
## Layout
|
|
32
|
+
|
|
33
|
+
- \`cmd/orchard-api\`: the server binary
|
|
34
|
+
- \`internal/httpapi\`: routing, auth and the JSON handlers
|
|
35
|
+
- \`internal/store\`: every SQL query, one file per table
|
|
36
|
+
- \`migrations\`: plain SQL, applied in order by \`make migrate\`
|
|
37
|
+
`,
|
|
38
|
+
Makefile: `.PHONY: run test vet migrate
|
|
39
|
+
|
|
40
|
+
run:
|
|
41
|
+
go run ./cmd/orchard-api
|
|
42
|
+
|
|
43
|
+
test:
|
|
44
|
+
go test ./...
|
|
45
|
+
|
|
46
|
+
vet:
|
|
47
|
+
go vet ./...
|
|
48
|
+
|
|
49
|
+
migrate:
|
|
50
|
+
for f in migrations/*.sql; do psql "$$DATABASE_URL" -v ON_ERROR_STOP=1 -f $$f; done
|
|
51
|
+
`,
|
|
52
|
+
'cmd/orchard-api/main.go': `package main
|
|
53
|
+
|
|
54
|
+
import (
|
|
55
|
+
"context"
|
|
56
|
+
"errors"
|
|
57
|
+
"log/slog"
|
|
58
|
+
"net/http"
|
|
59
|
+
"os"
|
|
60
|
+
"os/signal"
|
|
61
|
+
"time"
|
|
62
|
+
|
|
63
|
+
"${MOD}/internal/httpapi"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
func main() {
|
|
67
|
+
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
68
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
69
|
+
defer stop()
|
|
70
|
+
|
|
71
|
+
srv := &http.Server{
|
|
72
|
+
Addr: ":" + envOr("PORT", "8080"),
|
|
73
|
+
Handler: httpapi.NewRouter(log),
|
|
74
|
+
ReadHeaderTimeout: 5 * time.Second,
|
|
75
|
+
}
|
|
76
|
+
go func() {
|
|
77
|
+
<-ctx.Done()
|
|
78
|
+
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
79
|
+
defer cancel()
|
|
80
|
+
_ = srv.Shutdown(shutdown)
|
|
81
|
+
}()
|
|
82
|
+
log.Info("listening", "addr", srv.Addr)
|
|
83
|
+
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
|
84
|
+
log.Error("serve", "err", err)
|
|
85
|
+
os.Exit(1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func envOr(key, fallback string) string {
|
|
90
|
+
if v := os.Getenv(key); v != "" {
|
|
91
|
+
return v
|
|
92
|
+
}
|
|
93
|
+
return fallback
|
|
94
|
+
}
|
|
95
|
+
`,
|
|
96
|
+
'internal/httpapi/router.go': `package httpapi
|
|
97
|
+
|
|
98
|
+
import (
|
|
99
|
+
"log/slog"
|
|
100
|
+
"net/http"
|
|
101
|
+
|
|
102
|
+
"github.com/go-chi/chi/v5"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
type handlers struct {
|
|
106
|
+
log *slog.Logger
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// NewRouter wires every route the API serves.
|
|
110
|
+
func NewRouter(log *slog.Logger) http.Handler {
|
|
111
|
+
h := &handlers{log: log}
|
|
112
|
+
r := chi.NewRouter()
|
|
113
|
+
r.Get("/healthz", h.health)
|
|
114
|
+
return r
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func (h *handlers) health(w http.ResponseWriter, r *http.Request) {
|
|
118
|
+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
119
|
+
}
|
|
120
|
+
`,
|
|
121
|
+
'internal/httpapi/respond.go': `package httpapi
|
|
122
|
+
|
|
123
|
+
import (
|
|
124
|
+
"encoding/json"
|
|
125
|
+
"net/http"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
129
|
+
w.Header().Set("Content-Type", "application/json")
|
|
130
|
+
w.WriteHeader(status)
|
|
131
|
+
_ = json.NewEncoder(w).Encode(v)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// writeError sends the API's error shape: {"error": {"code": "...", "message": "..."}}.
|
|
135
|
+
// Codes are stable and clients switch on them; messages are for people.
|
|
136
|
+
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
137
|
+
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
|
|
138
|
+
}
|
|
139
|
+
`,
|
|
140
|
+
}, { author: theo, at: at('2026-05-04 09:20') });
|
|
141
|
+
const a2 = r.commit(a1, 'Add the schema and a store for classes\n\nStudios, members, classes and bookings in one first migration; the store package owns every query.', {
|
|
142
|
+
'go.mod': replace('require github.com/go-chi/chi/v5 v5.0.12\n', `require (
|
|
143
|
+
github.com/go-chi/chi/v5 v5.0.12
|
|
144
|
+
github.com/jackc/pgx/v5 v5.5.5
|
|
145
|
+
)
|
|
146
|
+
`),
|
|
147
|
+
'migrations/0001_init.sql': `CREATE TABLE studios (
|
|
148
|
+
id bigserial PRIMARY KEY,
|
|
149
|
+
name text NOT NULL,
|
|
150
|
+
timezone text NOT NULL DEFAULT 'UTC'
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
CREATE TABLE members (
|
|
154
|
+
id bigserial PRIMARY KEY,
|
|
155
|
+
studio_id bigint NOT NULL REFERENCES studios (id),
|
|
156
|
+
name text NOT NULL,
|
|
157
|
+
email text NOT NULL,
|
|
158
|
+
is_staff boolean NOT NULL DEFAULT false,
|
|
159
|
+
api_token text UNIQUE,
|
|
160
|
+
UNIQUE (studio_id, email)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
CREATE TABLE classes (
|
|
164
|
+
id bigserial PRIMARY KEY,
|
|
165
|
+
studio_id bigint NOT NULL REFERENCES studios (id),
|
|
166
|
+
title text NOT NULL,
|
|
167
|
+
coach text NOT NULL,
|
|
168
|
+
starts_at timestamptz NOT NULL,
|
|
169
|
+
minutes integer NOT NULL CHECK (minutes > 0),
|
|
170
|
+
capacity integer NOT NULL CHECK (capacity > 0)
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE INDEX classes_schedule ON classes (studio_id, starts_at);
|
|
174
|
+
|
|
175
|
+
CREATE TABLE bookings (
|
|
176
|
+
id bigserial PRIMARY KEY,
|
|
177
|
+
class_id bigint NOT NULL REFERENCES classes (id) ON DELETE CASCADE,
|
|
178
|
+
member_id bigint NOT NULL REFERENCES members (id),
|
|
179
|
+
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'cancelled')),
|
|
180
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE INDEX bookings_class ON bookings (class_id) WHERE status = 'confirmed';
|
|
184
|
+
`,
|
|
185
|
+
'internal/store/db.go': `package store
|
|
186
|
+
|
|
187
|
+
import (
|
|
188
|
+
"context"
|
|
189
|
+
"errors"
|
|
190
|
+
|
|
191
|
+
"github.com/jackc/pgx/v5"
|
|
192
|
+
"github.com/jackc/pgx/v5/pgxpool"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
// ErrNotFound is returned when a row a caller asked for doesn't exist.
|
|
196
|
+
var ErrNotFound = errors.New("not found")
|
|
197
|
+
|
|
198
|
+
// DB is the API's handle on Postgres. Every query lives in this package, one file per table.
|
|
199
|
+
type DB struct {
|
|
200
|
+
pool *pgxpool.Pool
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func Open(ctx context.Context, url string) (*DB, error) {
|
|
204
|
+
pool, err := pgxpool.New(ctx, url)
|
|
205
|
+
if err != nil {
|
|
206
|
+
return nil, err
|
|
207
|
+
}
|
|
208
|
+
if err := pool.Ping(ctx); err != nil {
|
|
209
|
+
pool.Close()
|
|
210
|
+
return nil, err
|
|
211
|
+
}
|
|
212
|
+
return &DB{pool: pool}, nil
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func (db *DB) Close() { db.pool.Close() }
|
|
216
|
+
|
|
217
|
+
// Tx runs fn in a transaction, committing if it returns nil and rolling back otherwise.
|
|
218
|
+
func (db *DB) Tx(ctx context.Context, fn func(pgx.Tx) error) error {
|
|
219
|
+
return pgx.BeginFunc(ctx, db.pool, fn)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func notFound(err error) error {
|
|
223
|
+
if errors.Is(err, pgx.ErrNoRows) {
|
|
224
|
+
return ErrNotFound
|
|
225
|
+
}
|
|
226
|
+
return err
|
|
227
|
+
}
|
|
228
|
+
`,
|
|
229
|
+
'internal/store/classes.go': `package store
|
|
230
|
+
|
|
231
|
+
import (
|
|
232
|
+
"context"
|
|
233
|
+
"time"
|
|
234
|
+
|
|
235
|
+
"github.com/jackc/pgx/v5"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
type Class struct {
|
|
239
|
+
ID int64
|
|
240
|
+
StudioID int64
|
|
241
|
+
Title string
|
|
242
|
+
Coach string
|
|
243
|
+
StartsAt time.Time
|
|
244
|
+
Minutes int
|
|
245
|
+
Capacity int
|
|
246
|
+
Booked int // confirmed bookings
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const classColumns = \`c.id, c.studio_id, c.title, c.coach, c.starts_at, c.minutes, c.capacity,
|
|
250
|
+
(SELECT count(*) FROM bookings b WHERE b.class_id = c.id AND b.status = 'confirmed')\`
|
|
251
|
+
|
|
252
|
+
func scanClass(row pgx.Row) (Class, error) {
|
|
253
|
+
var c Class
|
|
254
|
+
err := row.Scan(&c.ID, &c.StudioID, &c.Title, &c.Coach, &c.StartsAt, &c.Minutes, &c.Capacity, &c.Booked)
|
|
255
|
+
return c, notFound(err)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ClassesBetween lists a studio's classes starting in [from, to), earliest first.
|
|
259
|
+
func (db *DB) ClassesBetween(ctx context.Context, studioID int64, from, to time.Time) ([]Class, error) {
|
|
260
|
+
rows, err := db.pool.Query(ctx, \`
|
|
261
|
+
SELECT \`+classColumns+\`
|
|
262
|
+
FROM classes c
|
|
263
|
+
WHERE c.studio_id = $1 AND c.starts_at >= $2 AND c.starts_at < $3
|
|
264
|
+
ORDER BY c.starts_at\`, studioID, from, to)
|
|
265
|
+
if err != nil {
|
|
266
|
+
return nil, err
|
|
267
|
+
}
|
|
268
|
+
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (Class, error) { return scanClass(row) })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func (db *DB) Class(ctx context.Context, id int64) (Class, error) {
|
|
272
|
+
return scanClass(db.pool.QueryRow(ctx, \`SELECT \`+classColumns+\` FROM classes c WHERE c.id = $1\`, id))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// LockClass reads a class and locks its row until the transaction ends. Anything that hands out
|
|
276
|
+
// spots in a class takes this lock first, so two requests can't both see the same free spot.
|
|
277
|
+
func LockClass(ctx context.Context, tx pgx.Tx, id int64) (Class, error) {
|
|
278
|
+
return scanClass(tx.QueryRow(ctx, \`SELECT \`+classColumns+\` FROM classes c WHERE c.id = $1 FOR UPDATE OF c\`, id))
|
|
279
|
+
}
|
|
280
|
+
`,
|
|
281
|
+
'internal/store/members.go': `package store
|
|
282
|
+
|
|
283
|
+
import "context"
|
|
284
|
+
|
|
285
|
+
type Member struct {
|
|
286
|
+
ID int64
|
|
287
|
+
StudioID int64
|
|
288
|
+
Name string
|
|
289
|
+
Email string
|
|
290
|
+
IsStaff bool
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// MemberByToken finds the member an API token belongs to.
|
|
294
|
+
func (db *DB) MemberByToken(ctx context.Context, token string) (Member, error) {
|
|
295
|
+
var m Member
|
|
296
|
+
err := db.pool.QueryRow(ctx, \`
|
|
297
|
+
SELECT id, studio_id, name, email, is_staff FROM members WHERE api_token = $1\`, token,
|
|
298
|
+
).Scan(&m.ID, &m.StudioID, &m.Name, &m.Email, &m.IsStaff)
|
|
299
|
+
return m, notFound(err)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func (db *DB) Member(ctx context.Context, id int64) (Member, error) {
|
|
303
|
+
var m Member
|
|
304
|
+
err := db.pool.QueryRow(ctx, \`
|
|
305
|
+
SELECT id, studio_id, name, email, is_staff FROM members WHERE id = $1\`, id,
|
|
306
|
+
).Scan(&m.ID, &m.StudioID, &m.Name, &m.Email, &m.IsStaff)
|
|
307
|
+
return m, notFound(err)
|
|
308
|
+
}
|
|
309
|
+
`,
|
|
310
|
+
'cmd/orchard-api/main.go': edits([' "time"\n\n "' + MOD + '/internal/httpapi"\n', ' "time"\n\n "' + MOD + '/internal/httpapi"\n "' + MOD + '/internal/store"\n'], [
|
|
311
|
+
' srv := &http.Server{\n',
|
|
312
|
+
` db, err := store.Open(ctx, os.Getenv("DATABASE_URL"))
|
|
313
|
+
if err != nil {
|
|
314
|
+
log.Error("open database", "err", err)
|
|
315
|
+
os.Exit(1)
|
|
316
|
+
}
|
|
317
|
+
defer db.Close()
|
|
318
|
+
|
|
319
|
+
srv := &http.Server{
|
|
320
|
+
`,
|
|
321
|
+
], ['httpapi.NewRouter(log)', 'httpapi.NewRouter(db, log)']),
|
|
322
|
+
'internal/httpapi/router.go': edits([' "github.com/go-chi/chi/v5"\n)', ' "github.com/go-chi/chi/v5"\n\n "' + MOD + '/internal/store"\n)'], ['type handlers struct {\n log *slog.Logger\n}', 'type handlers struct {\n db *store.DB\n log *slog.Logger\n}'], ['func NewRouter(log *slog.Logger) http.Handler {\n h := &handlers{log: log}', 'func NewRouter(db *store.DB, log *slog.Logger) http.Handler {\n h := &handlers{db: db, log: log}']),
|
|
323
|
+
}, { author: theo, at: at('2026-05-11 14:05') });
|
|
324
|
+
const a3 = r.commit(a2, 'Add the schedule and class endpoints', {
|
|
325
|
+
'internal/httpapi/classes.go': `package httpapi
|
|
326
|
+
|
|
327
|
+
import (
|
|
328
|
+
"errors"
|
|
329
|
+
"net/http"
|
|
330
|
+
"time"
|
|
331
|
+
|
|
332
|
+
"${MOD}/internal/store"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
type classJSON struct {
|
|
336
|
+
ID int64 \`json:"id"\`
|
|
337
|
+
Title string \`json:"title"\`
|
|
338
|
+
Coach string \`json:"coach"\`
|
|
339
|
+
StartsAt time.Time \`json:"starts_at"\`
|
|
340
|
+
Minutes int \`json:"minutes"\`
|
|
341
|
+
Capacity int \`json:"capacity"\`
|
|
342
|
+
SpotsLeft int \`json:"spots_left"\`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func toClassJSON(c store.Class) classJSON {
|
|
346
|
+
return classJSON{
|
|
347
|
+
ID: c.ID, Title: c.Title, Coach: c.Coach, StartsAt: c.StartsAt, Minutes: c.Minutes,
|
|
348
|
+
Capacity: c.Capacity, SpotsLeft: max(c.Capacity-c.Booked, 0),
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// GET /studios/{studio}/schedule?from=2026-05-18 lists a week of classes.
|
|
353
|
+
func (h *handlers) schedule(w http.ResponseWriter, r *http.Request) {
|
|
354
|
+
studio, ok := pathID(w, r, "studio")
|
|
355
|
+
if !ok {
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
from, err := time.Parse(time.DateOnly, r.URL.Query().Get("from"))
|
|
359
|
+
if err != nil {
|
|
360
|
+
from = time.Now().UTC().Truncate(24 * time.Hour)
|
|
361
|
+
}
|
|
362
|
+
classes, err := h.db.ClassesBetween(r.Context(), studio, from, from.AddDate(0, 0, 7))
|
|
363
|
+
if err != nil {
|
|
364
|
+
h.fail(w, r, err)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
out := make([]classJSON, 0, len(classes))
|
|
368
|
+
for _, c := range classes {
|
|
369
|
+
out = append(out, toClassJSON(c))
|
|
370
|
+
}
|
|
371
|
+
writeJSON(w, http.StatusOK, map[string]any{"classes": out})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// GET /classes/{id}
|
|
375
|
+
func (h *handlers) class(w http.ResponseWriter, r *http.Request) {
|
|
376
|
+
id, ok := pathID(w, r, "id")
|
|
377
|
+
if !ok {
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
c, err := h.db.Class(r.Context(), id)
|
|
381
|
+
if errors.Is(err, store.ErrNotFound) {
|
|
382
|
+
writeError(w, http.StatusNotFound, "not_found", "No such class.")
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if err != nil {
|
|
386
|
+
h.fail(w, r, err)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
writeJSON(w, http.StatusOK, toClassJSON(c))
|
|
390
|
+
}
|
|
391
|
+
`,
|
|
392
|
+
'internal/httpapi/auth.go': `package httpapi
|
|
393
|
+
|
|
394
|
+
import (
|
|
395
|
+
"context"
|
|
396
|
+
"net/http"
|
|
397
|
+
"strings"
|
|
398
|
+
|
|
399
|
+
"${MOD}/internal/store"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
type memberKey struct{}
|
|
403
|
+
|
|
404
|
+
// authenticate resolves the bearer token to a member, or answers 401.
|
|
405
|
+
func (h *handlers) authenticate(next http.Handler) http.Handler {
|
|
406
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
407
|
+
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
408
|
+
if !ok || token == "" {
|
|
409
|
+
writeError(w, http.StatusUnauthorized, "unauthenticated", "Sign in to continue.")
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
m, err := h.db.MemberByToken(r.Context(), token)
|
|
413
|
+
if err != nil {
|
|
414
|
+
writeError(w, http.StatusUnauthorized, "unauthenticated", "Your session has expired. Sign in again.")
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), memberKey{}, m)))
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
func memberFrom(ctx context.Context) store.Member {
|
|
422
|
+
return ctx.Value(memberKey{}).(store.Member)
|
|
423
|
+
}
|
|
424
|
+
`,
|
|
425
|
+
'internal/httpapi/respond.go': edits([' "encoding/json"\n "net/http"\n)', ' "encoding/json"\n "net/http"\n "strconv"\n\n "github.com/go-chi/chi/v5"\n)'], [
|
|
426
|
+
' writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})\n}\n',
|
|
427
|
+
` writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// pathID reads a numeric URL parameter, answering 404 if it isn't one.
|
|
431
|
+
func pathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
|
|
432
|
+
id, err := strconv.ParseInt(chi.URLParam(r, name), 10, 64)
|
|
433
|
+
if err != nil || id <= 0 {
|
|
434
|
+
writeError(w, http.StatusNotFound, "not_found", "Not found.")
|
|
435
|
+
return 0, false
|
|
436
|
+
}
|
|
437
|
+
return id, true
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// fail logs an unexpected error and answers 500 without leaking it.
|
|
441
|
+
func (h *handlers) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|
442
|
+
h.log.ErrorContext(r.Context(), "request failed", "method", r.Method, "path", r.URL.Path, "err", err)
|
|
443
|
+
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong on our side.")
|
|
444
|
+
}
|
|
445
|
+
`,
|
|
446
|
+
]),
|
|
447
|
+
'internal/httpapi/router.go': replace(' r.Get("/healthz", h.health)\n', ` r.Get("/healthz", h.health)
|
|
448
|
+
|
|
449
|
+
r.Group(func(r chi.Router) {
|
|
450
|
+
r.Use(h.authenticate)
|
|
451
|
+
r.Get("/studios/{studio}/schedule", h.schedule)
|
|
452
|
+
r.Get("/classes/{id}", h.class)
|
|
453
|
+
})
|
|
454
|
+
`),
|
|
455
|
+
}, { author: june, at: at('2026-05-19 11:42') });
|
|
456
|
+
const a4 = r.commit(a3, 'Add bookings with a capacity check\n\nBooking locks the class row, so two members racing for the last spot can\'t both get it.', {
|
|
457
|
+
'internal/store/bookings.go': `package store
|
|
458
|
+
|
|
459
|
+
import (
|
|
460
|
+
"context"
|
|
461
|
+
"time"
|
|
462
|
+
|
|
463
|
+
"github.com/jackc/pgx/v5"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
type Booking struct {
|
|
467
|
+
ID int64
|
|
468
|
+
ClassID int64
|
|
469
|
+
MemberID int64
|
|
470
|
+
Status string // confirmed | cancelled
|
|
471
|
+
CreatedAt time.Time
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// CountBookings counts a class's confirmed bookings.
|
|
475
|
+
func CountBookings(ctx context.Context, tx pgx.Tx, classID int64) (int, error) {
|
|
476
|
+
var n int
|
|
477
|
+
err := tx.QueryRow(ctx, \`SELECT count(*) FROM bookings WHERE class_id = $1 AND status = 'confirmed'\`, classID).Scan(&n)
|
|
478
|
+
return n, err
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// HasBooking reports whether a member already holds a confirmed spot in a class.
|
|
482
|
+
func HasBooking(ctx context.Context, tx pgx.Tx, classID, memberID int64) (bool, error) {
|
|
483
|
+
var ok bool
|
|
484
|
+
err := tx.QueryRow(ctx, \`
|
|
485
|
+
SELECT EXISTS (SELECT 1 FROM bookings WHERE class_id = $1 AND member_id = $2 AND status = 'confirmed')\`,
|
|
486
|
+
classID, memberID).Scan(&ok)
|
|
487
|
+
return ok, err
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
func CreateBooking(ctx context.Context, tx pgx.Tx, classID, memberID int64) (Booking, error) {
|
|
491
|
+
b := Booking{ClassID: classID, MemberID: memberID, Status: "confirmed"}
|
|
492
|
+
err := tx.QueryRow(ctx, \`
|
|
493
|
+
INSERT INTO bookings (class_id, member_id) VALUES ($1, $2)
|
|
494
|
+
RETURNING id, created_at\`, classID, memberID).Scan(&b.ID, &b.CreatedAt)
|
|
495
|
+
return b, err
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// MemberBooking is a booking with its class, for "My bookings".
|
|
499
|
+
type MemberBooking struct {
|
|
500
|
+
Booking
|
|
501
|
+
Class Class
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// MemberBookings lists a member's confirmed bookings for classes starting after since, soonest first.
|
|
505
|
+
func (db *DB) MemberBookings(ctx context.Context, memberID int64, since time.Time) ([]MemberBooking, error) {
|
|
506
|
+
rows, err := db.pool.Query(ctx, \`
|
|
507
|
+
SELECT b.id, b.class_id, b.member_id, b.status, b.created_at, \`+classColumns+\`
|
|
508
|
+
FROM bookings b JOIN classes c ON c.id = b.class_id
|
|
509
|
+
WHERE b.member_id = $1 AND b.status = 'confirmed' AND c.starts_at >= $2
|
|
510
|
+
ORDER BY c.starts_at\`, memberID, since)
|
|
511
|
+
if err != nil {
|
|
512
|
+
return nil, err
|
|
513
|
+
}
|
|
514
|
+
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (MemberBooking, error) {
|
|
515
|
+
var mb MemberBooking
|
|
516
|
+
c := &mb.Class
|
|
517
|
+
err := row.Scan(&mb.ID, &mb.ClassID, &mb.MemberID, &mb.Status, &mb.CreatedAt,
|
|
518
|
+
&c.ID, &c.StudioID, &c.Title, &c.Coach, &c.StartsAt, &c.Minutes, &c.Capacity, &c.Booked)
|
|
519
|
+
return mb, err
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
`,
|
|
523
|
+
'internal/booking/service.go': `// Package booking holds the rules for handing out spots in classes.
|
|
524
|
+
package booking
|
|
525
|
+
|
|
526
|
+
import (
|
|
527
|
+
"context"
|
|
528
|
+
"errors"
|
|
529
|
+
"time"
|
|
530
|
+
|
|
531
|
+
"github.com/jackc/pgx/v5"
|
|
532
|
+
|
|
533
|
+
"${MOD}/internal/store"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
var (
|
|
537
|
+
ErrClassFull = errors.New("class is full")
|
|
538
|
+
ErrAlreadyBooked = errors.New("already booked")
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
type Service struct {
|
|
542
|
+
db *store.DB
|
|
543
|
+
now func() time.Time
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
func NewService(db *store.DB) *Service {
|
|
547
|
+
return &Service{db: db, now: time.Now}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Book gives a member a spot in a class, if there's one free.
|
|
551
|
+
func (s *Service) Book(ctx context.Context, classID, memberID int64) (store.Booking, error) {
|
|
552
|
+
var b store.Booking
|
|
553
|
+
err := s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
554
|
+
class, err := store.LockClass(ctx, tx, classID)
|
|
555
|
+
if err != nil {
|
|
556
|
+
return err
|
|
557
|
+
}
|
|
558
|
+
booked, err := store.HasBooking(ctx, tx, classID, memberID)
|
|
559
|
+
if err != nil {
|
|
560
|
+
return err
|
|
561
|
+
}
|
|
562
|
+
if booked {
|
|
563
|
+
return ErrAlreadyBooked
|
|
564
|
+
}
|
|
565
|
+
n, err := store.CountBookings(ctx, tx, classID)
|
|
566
|
+
if err != nil {
|
|
567
|
+
return err
|
|
568
|
+
}
|
|
569
|
+
if n >= class.Capacity {
|
|
570
|
+
return ErrClassFull
|
|
571
|
+
}
|
|
572
|
+
b, err = store.CreateBooking(ctx, tx, classID, memberID)
|
|
573
|
+
return err
|
|
574
|
+
})
|
|
575
|
+
return b, err
|
|
576
|
+
}
|
|
577
|
+
`,
|
|
578
|
+
'internal/httpapi/bookings.go': `package httpapi
|
|
579
|
+
|
|
580
|
+
import (
|
|
581
|
+
"errors"
|
|
582
|
+
"net/http"
|
|
583
|
+
"time"
|
|
584
|
+
|
|
585
|
+
"${MOD}/internal/booking"
|
|
586
|
+
"${MOD}/internal/store"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
type bookingJSON struct {
|
|
590
|
+
ID int64 \`json:"id"\`
|
|
591
|
+
ClassID int64 \`json:"class_id"\`
|
|
592
|
+
Status string \`json:"status"\`
|
|
593
|
+
Class *classJSON \`json:"class,omitempty"\`
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
func toBookingJSON(b store.Booking) bookingJSON {
|
|
597
|
+
return bookingJSON{ID: b.ID, ClassID: b.ClassID, Status: b.Status}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// POST /classes/{id}/bookings
|
|
601
|
+
func (h *handlers) book(w http.ResponseWriter, r *http.Request) {
|
|
602
|
+
classID, ok := pathID(w, r, "id")
|
|
603
|
+
if !ok {
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
b, err := h.bookings.Book(r.Context(), classID, memberFrom(r.Context()).ID)
|
|
607
|
+
switch {
|
|
608
|
+
case errors.Is(err, store.ErrNotFound):
|
|
609
|
+
writeError(w, http.StatusNotFound, "not_found", "No such class.")
|
|
610
|
+
case errors.Is(err, booking.ErrClassFull):
|
|
611
|
+
writeError(w, http.StatusConflict, "class_full", "This class is full.")
|
|
612
|
+
case errors.Is(err, booking.ErrAlreadyBooked):
|
|
613
|
+
writeError(w, http.StatusConflict, "already_booked", "You already have a spot in this class.")
|
|
614
|
+
case err != nil:
|
|
615
|
+
h.fail(w, r, err)
|
|
616
|
+
default:
|
|
617
|
+
writeJSON(w, http.StatusCreated, toBookingJSON(b))
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// GET /me/bookings lists the member's upcoming bookings.
|
|
622
|
+
func (h *handlers) myBookings(w http.ResponseWriter, r *http.Request) {
|
|
623
|
+
list, err := h.db.MemberBookings(r.Context(), memberFrom(r.Context()).ID, time.Now())
|
|
624
|
+
if err != nil {
|
|
625
|
+
h.fail(w, r, err)
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
out := make([]bookingJSON, 0, len(list))
|
|
629
|
+
for _, mb := range list {
|
|
630
|
+
b := toBookingJSON(mb.Booking)
|
|
631
|
+
c := toClassJSON(mb.Class)
|
|
632
|
+
b.Class = &c
|
|
633
|
+
out = append(out, b)
|
|
634
|
+
}
|
|
635
|
+
writeJSON(w, http.StatusOK, map[string]any{"bookings": out})
|
|
636
|
+
}
|
|
637
|
+
`,
|
|
638
|
+
'internal/httpapi/router.go': edits([' "' + MOD + '/internal/store"\n)', ' "' + MOD + '/internal/booking"\n "' + MOD + '/internal/store"\n)'], ['type handlers struct {\n db *store.DB\n log *slog.Logger\n}', 'type handlers struct {\n db *store.DB\n bookings *booking.Service\n log *slog.Logger\n}'], ['func NewRouter(db *store.DB, log *slog.Logger) http.Handler {\n h := &handlers{db: db, log: log}', 'func NewRouter(db *store.DB, bookings *booking.Service, log *slog.Logger) http.Handler {\n h := &handlers{db: db, bookings: bookings, log: log}'], [' r.Get("/classes/{id}", h.class)\n', ' r.Get("/classes/{id}", h.class)\n r.Post("/classes/{id}/bookings", h.book)\n r.Get("/me/bookings", h.myBookings)\n']),
|
|
639
|
+
'cmd/orchard-api/main.go': edits([' "' + MOD + '/internal/httpapi"\n', ' "' + MOD + '/internal/booking"\n "' + MOD + '/internal/httpapi"\n'], ['httpapi.NewRouter(db, log)', 'httpapi.NewRouter(db, booking.NewService(db), log)']),
|
|
640
|
+
'README.md': replace("- `internal/httpapi`: routing, auth and the JSON handlers\n", "- `internal/httpapi`: routing, auth and the JSON handlers\n- `internal/booking`: the booking rules (capacity, who gets a spot)\n"),
|
|
641
|
+
}, { author: theo, at: at('2026-05-27 16:31') });
|
|
642
|
+
const a5 = r.commit(a4, 'Run tests and vet in CI', {
|
|
643
|
+
'.github/workflows/ci.yml': `name: ci
|
|
644
|
+
on:
|
|
645
|
+
pull_request:
|
|
646
|
+
push:
|
|
647
|
+
branches: [main]
|
|
648
|
+
|
|
649
|
+
jobs:
|
|
650
|
+
test:
|
|
651
|
+
runs-on: ubuntu-latest
|
|
652
|
+
services:
|
|
653
|
+
postgres:
|
|
654
|
+
image: postgres:16
|
|
655
|
+
env:
|
|
656
|
+
POSTGRES_PASSWORD: postgres
|
|
657
|
+
ports: ["5432:5432"]
|
|
658
|
+
options: --health-cmd pg_isready --health-interval 5s --health-retries 10
|
|
659
|
+
env:
|
|
660
|
+
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
|
|
661
|
+
steps:
|
|
662
|
+
- uses: actions/checkout@v4
|
|
663
|
+
- uses: actions/setup-go@v5
|
|
664
|
+
with:
|
|
665
|
+
go-version: "1.22"
|
|
666
|
+
- run: for f in migrations/*.sql; do psql "$TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f $f; done
|
|
667
|
+
- run: go vet ./...
|
|
668
|
+
- run: go test -race ./...
|
|
669
|
+
`,
|
|
670
|
+
}, { author: june, at: at('2026-06-02 10:15') });
|
|
671
|
+
const a6 = r.commit(a5, 'Add booking service tests', {
|
|
672
|
+
'internal/store/storetest/storetest.go': `// Package storetest gives tests a real Postgres (TEST_DATABASE_URL) with throwaway rows.
|
|
673
|
+
package storetest
|
|
674
|
+
|
|
675
|
+
import (
|
|
676
|
+
"context"
|
|
677
|
+
"fmt"
|
|
678
|
+
"os"
|
|
679
|
+
"sync/atomic"
|
|
680
|
+
"testing"
|
|
681
|
+
"time"
|
|
682
|
+
|
|
683
|
+
"github.com/jackc/pgx/v5"
|
|
684
|
+
|
|
685
|
+
"${MOD}/internal/store"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
var seq atomic.Int64
|
|
689
|
+
|
|
690
|
+
// New connects to the test database, skipping the test if there isn't one.
|
|
691
|
+
func New(t *testing.T) *store.DB {
|
|
692
|
+
t.Helper()
|
|
693
|
+
url := os.Getenv("TEST_DATABASE_URL")
|
|
694
|
+
if url == "" {
|
|
695
|
+
t.Skip("TEST_DATABASE_URL not set")
|
|
696
|
+
}
|
|
697
|
+
db, err := store.Open(context.Background(), url)
|
|
698
|
+
if err != nil {
|
|
699
|
+
t.Fatal(err)
|
|
700
|
+
}
|
|
701
|
+
t.Cleanup(db.Close)
|
|
702
|
+
return db
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
func exec(t *testing.T, sql string, args ...any) int64 {
|
|
706
|
+
t.Helper()
|
|
707
|
+
conn, err := pgx.Connect(context.Background(), os.Getenv("TEST_DATABASE_URL"))
|
|
708
|
+
if err != nil {
|
|
709
|
+
t.Fatal(err)
|
|
710
|
+
}
|
|
711
|
+
defer conn.Close(context.Background())
|
|
712
|
+
var id int64
|
|
713
|
+
if err := conn.QueryRow(context.Background(), sql, args...).Scan(&id); err != nil {
|
|
714
|
+
t.Fatal(err)
|
|
715
|
+
}
|
|
716
|
+
return id
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Class creates a class tomorrow, in a new studio, with the given capacity.
|
|
720
|
+
func Class(t *testing.T, db *store.DB, capacity int) store.Class {
|
|
721
|
+
t.Helper()
|
|
722
|
+
studio := exec(t, \`INSERT INTO studios (name) VALUES ('Test studio') RETURNING id\`)
|
|
723
|
+
id := exec(t, \`
|
|
724
|
+
INSERT INTO classes (studio_id, title, coach, starts_at, minutes, capacity)
|
|
725
|
+
VALUES ($1, 'Vinyasa flow', 'Test coach', $2, 60, $3) RETURNING id\`,
|
|
726
|
+
studio, time.Now().Add(24*time.Hour), capacity)
|
|
727
|
+
c, err := db.Class(context.Background(), id)
|
|
728
|
+
if err != nil {
|
|
729
|
+
t.Fatal(err)
|
|
730
|
+
}
|
|
731
|
+
return c
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Member creates a member with a unique email.
|
|
735
|
+
func Member(t *testing.T, db *store.DB) store.Member {
|
|
736
|
+
t.Helper()
|
|
737
|
+
n := seq.Add(1)
|
|
738
|
+
studio := exec(t, \`INSERT INTO studios (name) VALUES ('Test studio') RETURNING id\`)
|
|
739
|
+
id := exec(t, \`INSERT INTO members (studio_id, name, email) VALUES ($1, $2, $3) RETURNING id\`,
|
|
740
|
+
studio, fmt.Sprintf("Member %d", n), fmt.Sprintf("member%d@test.invalid", n))
|
|
741
|
+
m, err := db.Member(context.Background(), id)
|
|
742
|
+
if err != nil {
|
|
743
|
+
t.Fatal(err)
|
|
744
|
+
}
|
|
745
|
+
return m
|
|
746
|
+
}
|
|
747
|
+
`,
|
|
748
|
+
'internal/booking/service_test.go': `package booking_test
|
|
749
|
+
|
|
750
|
+
import (
|
|
751
|
+
"context"
|
|
752
|
+
"errors"
|
|
753
|
+
"testing"
|
|
754
|
+
|
|
755
|
+
"${MOD}/internal/booking"
|
|
756
|
+
"${MOD}/internal/store/storetest"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
var ctx = context.Background()
|
|
760
|
+
|
|
761
|
+
func TestBookTakesASpot(t *testing.T) {
|
|
762
|
+
db := storetest.New(t)
|
|
763
|
+
svc := booking.NewService(db)
|
|
764
|
+
class := storetest.Class(t, db, 2)
|
|
765
|
+
m := storetest.Member(t, db)
|
|
766
|
+
|
|
767
|
+
if _, err := svc.Book(ctx, class.ID, m.ID); err != nil {
|
|
768
|
+
t.Fatal(err)
|
|
769
|
+
}
|
|
770
|
+
if _, err := svc.Book(ctx, class.ID, m.ID); !errors.Is(err, booking.ErrAlreadyBooked) {
|
|
771
|
+
t.Fatalf("second booking: got %v, want ErrAlreadyBooked", err)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
func TestBookRefusesAFullClass(t *testing.T) {
|
|
776
|
+
db := storetest.New(t)
|
|
777
|
+
svc := booking.NewService(db)
|
|
778
|
+
class := storetest.Class(t, db, 1)
|
|
779
|
+
|
|
780
|
+
if _, err := svc.Book(ctx, class.ID, storetest.Member(t, db).ID); err != nil {
|
|
781
|
+
t.Fatal(err)
|
|
782
|
+
}
|
|
783
|
+
if _, err := svc.Book(ctx, class.ID, storetest.Member(t, db).ID); !errors.Is(err, booking.ErrClassFull) {
|
|
784
|
+
t.Fatalf("got %v, want ErrClassFull", err)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
`,
|
|
788
|
+
}, { author: sam, at: at('2026-06-09 15:48') });
|
|
789
|
+
const a7 = r.commit(a6, 'Let members cancel a booking', {
|
|
790
|
+
'internal/store/bookings.go': after(' return b, err\n}\n', `
|
|
791
|
+
// CancelBooking cancels a member's confirmed booking. Someone else's booking is ErrNotFound.
|
|
792
|
+
func CancelBooking(ctx context.Context, tx pgx.Tx, id, memberID int64) (Booking, error) {
|
|
793
|
+
b := Booking{ID: id, MemberID: memberID, Status: "cancelled"}
|
|
794
|
+
err := tx.QueryRow(ctx, \`
|
|
795
|
+
UPDATE bookings SET status = 'cancelled'
|
|
796
|
+
WHERE id = $1 AND member_id = $2 AND status = 'confirmed'
|
|
797
|
+
RETURNING class_id, created_at\`, id, memberID).Scan(&b.ClassID, &b.CreatedAt)
|
|
798
|
+
return b, notFound(err)
|
|
799
|
+
}
|
|
800
|
+
`),
|
|
801
|
+
'internal/booking/service.go': append(`
|
|
802
|
+
// Cancel cancels one of a member's bookings, freeing the spot.
|
|
803
|
+
func (s *Service) Cancel(ctx context.Context, bookingID, memberID int64) error {
|
|
804
|
+
return s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
805
|
+
_, err := store.CancelBooking(ctx, tx, bookingID, memberID)
|
|
806
|
+
return err
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
`),
|
|
810
|
+
'internal/httpapi/bookings.go': append(`
|
|
811
|
+
// DELETE /me/bookings/{id}
|
|
812
|
+
func (h *handlers) cancelBooking(w http.ResponseWriter, r *http.Request) {
|
|
813
|
+
id, ok := pathID(w, r, "id")
|
|
814
|
+
if !ok {
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
err := h.bookings.Cancel(r.Context(), id, memberFrom(r.Context()).ID)
|
|
818
|
+
switch {
|
|
819
|
+
case errors.Is(err, store.ErrNotFound):
|
|
820
|
+
writeError(w, http.StatusNotFound, "not_found", "No such booking.")
|
|
821
|
+
case err != nil:
|
|
822
|
+
h.fail(w, r, err)
|
|
823
|
+
default:
|
|
824
|
+
w.WriteHeader(http.StatusNoContent)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
`),
|
|
828
|
+
'internal/httpapi/router.go': after(' r.Get("/me/bookings", h.myBookings)\n', ' r.Delete("/me/bookings/{id}", h.cancelBooking)\n'),
|
|
829
|
+
}, { author: maya, at: at('2026-06-23 13:07') });
|
|
830
|
+
const a8 = r.commit(a7, 'Email members when a booking is confirmed', {
|
|
831
|
+
'internal/notify/notify.go': `// Package notify sends members messages: booking confirmations, offers from the waitlist.
|
|
832
|
+
package notify
|
|
833
|
+
|
|
834
|
+
import "context"
|
|
835
|
+
|
|
836
|
+
type Message struct {
|
|
837
|
+
MemberID int64
|
|
838
|
+
Subject string
|
|
839
|
+
Body string
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
type Notifier interface {
|
|
843
|
+
Notify(ctx context.Context, m Message) error
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Discard drops every message (tests, local runs without SMTP).
|
|
847
|
+
var Discard Notifier = discard{}
|
|
848
|
+
|
|
849
|
+
type discard struct{}
|
|
850
|
+
|
|
851
|
+
func (discard) Notify(context.Context, Message) error { return nil }
|
|
852
|
+
`,
|
|
853
|
+
'internal/notify/email.go': `package notify
|
|
854
|
+
|
|
855
|
+
import (
|
|
856
|
+
"context"
|
|
857
|
+
"fmt"
|
|
858
|
+
"net/smtp"
|
|
859
|
+
|
|
860
|
+
"${MOD}/internal/store"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
// Email sends messages over SMTP to the member's address.
|
|
864
|
+
type Email struct {
|
|
865
|
+
db *store.DB
|
|
866
|
+
addr string
|
|
867
|
+
from string
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
func NewEmail(db *store.DB, addr, from string) *Email {
|
|
871
|
+
return &Email{db: db, addr: addr, from: from}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
func (e *Email) Notify(ctx context.Context, m Message) error {
|
|
875
|
+
member, err := e.db.Member(ctx, m.MemberID)
|
|
876
|
+
if err != nil {
|
|
877
|
+
return fmt.Errorf("notify member %d: %w", m.MemberID, err)
|
|
878
|
+
}
|
|
879
|
+
msg := fmt.Sprintf("From: %s\\r\\nTo: %s\\r\\nSubject: %s\\r\\n\\r\\n%s", e.from, member.Email, m.Subject, m.Body)
|
|
880
|
+
return smtp.SendMail(e.addr, nil, e.from, []string{member.Email}, []byte(msg))
|
|
881
|
+
}
|
|
882
|
+
`,
|
|
883
|
+
'internal/booking/service.go': edits([' "' + MOD + '/internal/store"\n)', ' "' + MOD + '/internal/notify"\n "' + MOD + '/internal/store"\n)'], ['type Service struct {\n db *store.DB\n now func() time.Time\n}', 'type Service struct {\n db *store.DB\n notify notify.Notifier\n now func() time.Time\n}'], ['func NewService(db *store.DB) *Service {\n return &Service{db: db, now: time.Now}', 'func NewService(db *store.DB, n notify.Notifier) *Service {\n return &Service{db: db, notify: n, now: time.Now}'], [
|
|
884
|
+
' b, err = store.CreateBooking(ctx, tx, classID, memberID)\n return err\n })\n return b, err\n}',
|
|
885
|
+
` b, err = store.CreateBooking(ctx, tx, classID, memberID)
|
|
886
|
+
return err
|
|
887
|
+
})
|
|
888
|
+
if err != nil {
|
|
889
|
+
return b, err
|
|
890
|
+
}
|
|
891
|
+
// after the commit: a failed email shouldn't undo the booking
|
|
892
|
+
_ = s.notify.Notify(ctx, notify.Message{MemberID: memberID, Subject: "You're booked in", Body: "See you in class."})
|
|
893
|
+
return b, nil
|
|
894
|
+
}`,
|
|
895
|
+
]),
|
|
896
|
+
'internal/booking/service_test.go': edits([' "' + MOD + '/internal/booking"\n', ' "' + MOD + '/internal/booking"\n "' + MOD + '/internal/notify"\n'], ['func TestBookTakesASpot(t *testing.T) {\n db := storetest.New(t)\n svc := booking.NewService(db)', 'func TestBookTakesASpot(t *testing.T) {\n db := storetest.New(t)\n svc := booking.NewService(db, notify.Discard)'], ['func TestBookRefusesAFullClass(t *testing.T) {\n db := storetest.New(t)\n svc := booking.NewService(db)', 'func TestBookRefusesAFullClass(t *testing.T) {\n db := storetest.New(t)\n svc := booking.NewService(db, notify.Discard)']),
|
|
897
|
+
'cmd/orchard-api/main.go': edits([' "' + MOD + '/internal/httpapi"\n', ' "' + MOD + '/internal/httpapi"\n "' + MOD + '/internal/notify"\n'], [
|
|
898
|
+
' defer db.Close()\n\n',
|
|
899
|
+
` defer db.Close()
|
|
900
|
+
|
|
901
|
+
mailer := notify.NewEmail(db, envOr("SMTP_ADDR", "localhost:1025"), envOr("MAIL_FROM", "bookings@orchard.example"))
|
|
902
|
+
bookings := booking.NewService(db, mailer)
|
|
903
|
+
|
|
904
|
+
`,
|
|
905
|
+
], ['httpapi.NewRouter(db, booking.NewService(db), log)', 'httpapi.NewRouter(db, bookings, log)']),
|
|
906
|
+
}, { author: sam, at: at('2026-07-07 10:52') });
|
|
907
|
+
const a9 = r.commit(a8, 'Accept payment webhooks from the processor', {
|
|
908
|
+
'internal/payments/webhook.go': `// Package payments handles the payment processor's webhooks.
|
|
909
|
+
package payments
|
|
910
|
+
|
|
911
|
+
import (
|
|
912
|
+
"crypto/hmac"
|
|
913
|
+
"crypto/sha256"
|
|
914
|
+
"encoding/hex"
|
|
915
|
+
"encoding/json"
|
|
916
|
+
"errors"
|
|
917
|
+
"strconv"
|
|
918
|
+
"strings"
|
|
919
|
+
"time"
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
// Event is the part of a processor webhook we use.
|
|
923
|
+
type Event struct {
|
|
924
|
+
ID string \`json:"id"\`
|
|
925
|
+
Type string \`json:"type"\` // payment.succeeded, payment.refunded, ...
|
|
926
|
+
Data json.RawMessage \`json:"data"\`
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
var ErrBadSignature = errors.New("bad webhook signature")
|
|
930
|
+
|
|
931
|
+
// Verify checks the processor's signature header, "t=<unix>,v1=<hex hmac>", where the HMAC is
|
|
932
|
+
// SHA-256 of "<t>.<body>". Signatures older than five minutes are refused (replays).
|
|
933
|
+
func Verify(secret []byte, header string, body []byte, now time.Time) error {
|
|
934
|
+
var ts, sig string
|
|
935
|
+
for _, part := range strings.Split(header, ",") {
|
|
936
|
+
k, v, _ := strings.Cut(part, "=")
|
|
937
|
+
switch k {
|
|
938
|
+
case "t":
|
|
939
|
+
ts = v
|
|
940
|
+
case "v1":
|
|
941
|
+
sig = v
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
unix, err := strconv.ParseInt(ts, 10, 64)
|
|
945
|
+
if err != nil || now.Sub(time.Unix(unix, 0)) > 5*time.Minute {
|
|
946
|
+
return ErrBadSignature
|
|
947
|
+
}
|
|
948
|
+
mac := hmac.New(sha256.New, secret)
|
|
949
|
+
mac.Write([]byte(ts + "."))
|
|
950
|
+
mac.Write(body)
|
|
951
|
+
want, err := hex.DecodeString(sig)
|
|
952
|
+
if err != nil || !hmac.Equal(mac.Sum(nil), want) {
|
|
953
|
+
return ErrBadSignature
|
|
954
|
+
}
|
|
955
|
+
return nil
|
|
956
|
+
}
|
|
957
|
+
`,
|
|
958
|
+
'internal/httpapi/webhooks.go': `package httpapi
|
|
959
|
+
|
|
960
|
+
import (
|
|
961
|
+
"encoding/json"
|
|
962
|
+
"io"
|
|
963
|
+
"net/http"
|
|
964
|
+
"os"
|
|
965
|
+
"time"
|
|
966
|
+
|
|
967
|
+
"${MOD}/internal/payments"
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
var webhookSecret = []byte(os.Getenv("PAYMENTS_WEBHOOK_SECRET"))
|
|
971
|
+
|
|
972
|
+
// POST /webhooks/payments
|
|
973
|
+
func (h *handlers) paymentWebhook(w http.ResponseWriter, r *http.Request) {
|
|
974
|
+
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
975
|
+
if err != nil {
|
|
976
|
+
writeError(w, http.StatusBadRequest, "bad_request", "Unreadable body.")
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
if err := payments.Verify(webhookSecret, r.Header.Get("Processor-Signature"), body, time.Now()); err != nil {
|
|
980
|
+
writeError(w, http.StatusUnauthorized, "bad_signature", "Signature check failed.")
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
var e payments.Event
|
|
984
|
+
if err := json.Unmarshal(body, &e); err != nil {
|
|
985
|
+
writeError(w, http.StatusBadRequest, "bad_request", "Not a webhook event.")
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
if err := h.db.ApplyPaymentEvent(r.Context(), e.ID, e.Type, e.Data); err != nil {
|
|
989
|
+
h.fail(w, r, err)
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
w.WriteHeader(http.StatusNoContent)
|
|
993
|
+
}
|
|
994
|
+
`,
|
|
995
|
+
'internal/store/payments.go': `package store
|
|
996
|
+
|
|
997
|
+
import (
|
|
998
|
+
"context"
|
|
999
|
+
"encoding/json"
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
// ApplyPaymentEvent records what a payment event means for the member's credits. Unknown event
|
|
1003
|
+
// types are ignored, so the processor adding new ones doesn't break us.
|
|
1004
|
+
func (db *DB) ApplyPaymentEvent(ctx context.Context, eventID, kind string, data json.RawMessage) error {
|
|
1005
|
+
var p struct {
|
|
1006
|
+
MemberID int64 \`json:"member_ref,string"\`
|
|
1007
|
+
Credits int \`json:"credits"\`
|
|
1008
|
+
}
|
|
1009
|
+
if err := json.Unmarshal(data, &p); err != nil {
|
|
1010
|
+
return err
|
|
1011
|
+
}
|
|
1012
|
+
switch kind {
|
|
1013
|
+
case "payment.succeeded":
|
|
1014
|
+
_, err := db.pool.Exec(ctx, \`UPDATE members SET credits = credits + $2 WHERE id = $1\`, p.MemberID, p.Credits)
|
|
1015
|
+
return err
|
|
1016
|
+
case "payment.refunded":
|
|
1017
|
+
_, err := db.pool.Exec(ctx, \`UPDATE members SET credits = greatest(credits - $2, 0) WHERE id = $1\`, p.MemberID, p.Credits)
|
|
1018
|
+
return err
|
|
1019
|
+
}
|
|
1020
|
+
return nil
|
|
1021
|
+
}
|
|
1022
|
+
`,
|
|
1023
|
+
'internal/httpapi/router.go': after(' r.Get("/healthz", h.health)\n', ' r.Post("/webhooks/payments", h.paymentWebhook)\n'),
|
|
1024
|
+
}, { author: sam, at: at('2026-07-21 17:20') });
|
|
1025
|
+
// Sam's webhook-retries stack (the archived project): three stacked PRs, merged on GitHub with merge commits.
|
|
1026
|
+
const h1 = r.commit(a9, 'Record webhook deliveries before processing them\n\nEvery delivery is stored first and processed second, so a failure in processing never loses an event\nand a redelivery of the same event id is a no-op.', {
|
|
1027
|
+
'migrations/0003_webhook_deliveries.sql': `CREATE TABLE webhook_deliveries (
|
|
1028
|
+
event_id text PRIMARY KEY,
|
|
1029
|
+
kind text NOT NULL,
|
|
1030
|
+
payload jsonb NOT NULL,
|
|
1031
|
+
received_at timestamptz NOT NULL DEFAULT now(),
|
|
1032
|
+
processed_at timestamptz,
|
|
1033
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
1034
|
+
last_attempt_at timestamptz,
|
|
1035
|
+
last_error text
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
CREATE INDEX webhook_deliveries_pending ON webhook_deliveries (received_at) WHERE processed_at IS NULL;
|
|
1039
|
+
`,
|
|
1040
|
+
'internal/store/deliveries.go': `package store
|
|
1041
|
+
|
|
1042
|
+
import (
|
|
1043
|
+
"context"
|
|
1044
|
+
"encoding/json"
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
// RecordDelivery stores a webhook delivery. It reports false for an event id we've already stored
|
|
1048
|
+
// (the processor redelivers on timeouts).
|
|
1049
|
+
func (db *DB) RecordDelivery(ctx context.Context, eventID, kind string, payload json.RawMessage) (bool, error) {
|
|
1050
|
+
tag, err := db.pool.Exec(ctx, \`
|
|
1051
|
+
INSERT INTO webhook_deliveries (event_id, kind, payload) VALUES ($1, $2, $3)
|
|
1052
|
+
ON CONFLICT (event_id) DO NOTHING\`, eventID, kind, payload)
|
|
1053
|
+
return tag.RowsAffected() == 1, err
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// MarkDelivery records the outcome of one processing attempt.
|
|
1057
|
+
func (db *DB) MarkDelivery(ctx context.Context, eventID string, procErr error) error {
|
|
1058
|
+
if procErr == nil {
|
|
1059
|
+
_, err := db.pool.Exec(ctx, \`
|
|
1060
|
+
UPDATE webhook_deliveries SET processed_at = now(), attempts = attempts + 1, last_attempt_at = now(), last_error = NULL
|
|
1061
|
+
WHERE event_id = $1\`, eventID)
|
|
1062
|
+
return err
|
|
1063
|
+
}
|
|
1064
|
+
_, err := db.pool.Exec(ctx, \`
|
|
1065
|
+
UPDATE webhook_deliveries SET attempts = attempts + 1, last_attempt_at = now(), last_error = $2 WHERE event_id = $1\`,
|
|
1066
|
+
eventID, procErr.Error())
|
|
1067
|
+
return err
|
|
1068
|
+
}
|
|
1069
|
+
`,
|
|
1070
|
+
'internal/httpapi/webhooks.go': replace(` if err := h.db.ApplyPaymentEvent(r.Context(), e.ID, e.Type, e.Data); err != nil {
|
|
1071
|
+
h.fail(w, r, err)
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
w.WriteHeader(http.StatusNoContent)`, ` fresh, err := h.db.RecordDelivery(r.Context(), e.ID, e.Type, body)
|
|
1075
|
+
if err != nil {
|
|
1076
|
+
h.fail(w, r, err)
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
// stored: from here on a failure is ours to retry, not the processor's
|
|
1080
|
+
w.WriteHeader(http.StatusNoContent)
|
|
1081
|
+
if fresh {
|
|
1082
|
+
perr := h.db.ApplyPaymentEvent(r.Context(), e.ID, e.Type, e.Data)
|
|
1083
|
+
if err := h.db.MarkDelivery(r.Context(), e.ID, perr); err != nil {
|
|
1084
|
+
h.log.ErrorContext(r.Context(), "mark delivery", "event", e.ID, "err", err)
|
|
1085
|
+
}
|
|
1086
|
+
}`),
|
|
1087
|
+
}, { author: sam, at: at('2026-07-28 11:03') });
|
|
1088
|
+
const h2 = r.commit(h1, 'Retry failed deliveries with exponential backoff', {
|
|
1089
|
+
'internal/payments/retry.go': `package payments
|
|
1090
|
+
|
|
1091
|
+
import (
|
|
1092
|
+
"context"
|
|
1093
|
+
"log/slog"
|
|
1094
|
+
"time"
|
|
1095
|
+
|
|
1096
|
+
"${MOD}/internal/store"
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
// MaxAttempts is when we stop retrying a delivery and leave it for a person (the dead-letter list).
|
|
1100
|
+
const MaxAttempts = 8
|
|
1101
|
+
|
|
1102
|
+
// Backoff is the wait before attempt n+1: 30s, 1m, 2m, 4m ... capped at 1h.
|
|
1103
|
+
func Backoff(attempts int) time.Duration {
|
|
1104
|
+
d := 30 * time.Second << min(max(attempts-1, 0), 7)
|
|
1105
|
+
return min(d, time.Hour)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// RetryLoop reprocesses failed deliveries until ctx ends.
|
|
1109
|
+
func RetryLoop(ctx context.Context, db *store.DB, log *slog.Logger) {
|
|
1110
|
+
tick := time.NewTicker(15 * time.Second)
|
|
1111
|
+
defer tick.Stop()
|
|
1112
|
+
for {
|
|
1113
|
+
select {
|
|
1114
|
+
case <-ctx.Done():
|
|
1115
|
+
return
|
|
1116
|
+
case <-tick.C:
|
|
1117
|
+
}
|
|
1118
|
+
due, err := db.DueDeliveries(ctx, time.Now(), MaxAttempts, Backoff)
|
|
1119
|
+
if err != nil {
|
|
1120
|
+
log.Error("list due deliveries", "err", err)
|
|
1121
|
+
continue
|
|
1122
|
+
}
|
|
1123
|
+
for _, d := range due {
|
|
1124
|
+
perr := db.ApplyPaymentEvent(ctx, d.EventID, d.Kind, d.Payload)
|
|
1125
|
+
if err := db.MarkDelivery(ctx, d.EventID, perr); err != nil {
|
|
1126
|
+
log.Error("mark delivery", "event", d.EventID, "err", err)
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
`,
|
|
1132
|
+
'internal/payments/retry_test.go': `package payments
|
|
1133
|
+
|
|
1134
|
+
import (
|
|
1135
|
+
"testing"
|
|
1136
|
+
"time"
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
func TestBackoff(t *testing.T) {
|
|
1140
|
+
for attempts, want := range map[int]time.Duration{
|
|
1141
|
+
1: 30 * time.Second,
|
|
1142
|
+
2: time.Minute,
|
|
1143
|
+
4: 4 * time.Minute,
|
|
1144
|
+
7: 32 * time.Minute,
|
|
1145
|
+
8: time.Hour,
|
|
1146
|
+
30: time.Hour,
|
|
1147
|
+
} {
|
|
1148
|
+
if got := Backoff(attempts); got != want {
|
|
1149
|
+
t.Errorf("Backoff(%d) = %v, want %v", attempts, got, want)
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
`,
|
|
1154
|
+
'internal/store/deliveries.go': edits(['import (\n "context"\n "encoding/json"\n)', 'import (\n "context"\n "encoding/json"\n "time"\n)'], [
|
|
1155
|
+
'// MarkDelivery records',
|
|
1156
|
+
`type Delivery struct {
|
|
1157
|
+
EventID string
|
|
1158
|
+
Kind string
|
|
1159
|
+
Payload []byte
|
|
1160
|
+
Attempts int
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// DueDeliveries lists unprocessed deliveries whose backoff has passed and that haven't run out of attempts.
|
|
1164
|
+
func (db *DB) DueDeliveries(ctx context.Context, now time.Time, maxAttempts int, backoff func(int) time.Duration) ([]Delivery, error) {
|
|
1165
|
+
rows, err := db.pool.Query(ctx, \`
|
|
1166
|
+
SELECT event_id, kind, payload, attempts, coalesce(last_attempt_at, received_at) FROM webhook_deliveries
|
|
1167
|
+
WHERE processed_at IS NULL AND attempts BETWEEN 1 AND $1 - 1
|
|
1168
|
+
ORDER BY received_at LIMIT 100\`, maxAttempts)
|
|
1169
|
+
if err != nil {
|
|
1170
|
+
return nil, err
|
|
1171
|
+
}
|
|
1172
|
+
var due []Delivery
|
|
1173
|
+
for rows.Next() {
|
|
1174
|
+
var d Delivery
|
|
1175
|
+
var last time.Time
|
|
1176
|
+
if err := rows.Scan(&d.EventID, &d.Kind, &d.Payload, &d.Attempts, &last); err != nil {
|
|
1177
|
+
return nil, err
|
|
1178
|
+
}
|
|
1179
|
+
if now.Sub(last) >= backoff(d.Attempts) {
|
|
1180
|
+
due = append(due, d)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return due, rows.Err()
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// MarkDelivery records`,
|
|
1187
|
+
]),
|
|
1188
|
+
'cmd/orchard-api/main.go': edits([' "' + MOD + '/internal/notify"\n', ' "' + MOD + '/internal/notify"\n "' + MOD + '/internal/payments"\n'], [' bookings := booking.NewService(db, mailer)\n', ' bookings := booking.NewService(db, mailer)\n go payments.RetryLoop(ctx, db, log)\n']),
|
|
1189
|
+
}, { author: sam, at: at('2026-07-29 15:40') });
|
|
1190
|
+
const h3 = r.commit(h2, 'List deliveries that keep failing for staff', {
|
|
1191
|
+
'internal/httpapi/admin.go': `package httpapi
|
|
1192
|
+
|
|
1193
|
+
import "net/http"
|
|
1194
|
+
|
|
1195
|
+
// GET /admin/webhooks/dead-letters: deliveries that used up their retries.
|
|
1196
|
+
func (h *handlers) deadLetters(w http.ResponseWriter, r *http.Request) {
|
|
1197
|
+
list, err := h.db.DeadLetters(r.Context())
|
|
1198
|
+
if err != nil {
|
|
1199
|
+
h.fail(w, r, err)
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
writeJSON(w, http.StatusOK, map[string]any{"deliveries": list})
|
|
1203
|
+
}
|
|
1204
|
+
`,
|
|
1205
|
+
'internal/store/deliveries.go': (old) => replace(' "time"\n)', ' "time"\n\n "github.com/jackc/pgx/v5"\n)')(old) + `
|
|
1206
|
+
type DeadLetter struct {
|
|
1207
|
+
EventID string \`json:"event_id"\`
|
|
1208
|
+
Kind string \`json:"kind"\`
|
|
1209
|
+
Attempts int \`json:"attempts"\`
|
|
1210
|
+
LastError string \`json:"last_error"\`
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// DeadLetters lists deliveries that are still unprocessed after their last allowed attempt.
|
|
1214
|
+
func (db *DB) DeadLetters(ctx context.Context) ([]DeadLetter, error) {
|
|
1215
|
+
rows, err := db.pool.Query(ctx, \`
|
|
1216
|
+
SELECT event_id, kind, attempts, coalesce(last_error, '') FROM webhook_deliveries
|
|
1217
|
+
WHERE processed_at IS NULL AND attempts >= 8 ORDER BY received_at DESC\`)
|
|
1218
|
+
if err != nil {
|
|
1219
|
+
return nil, err
|
|
1220
|
+
}
|
|
1221
|
+
return pgx.CollectRows(rows, pgx.RowToStructByPos[DeadLetter])
|
|
1222
|
+
}
|
|
1223
|
+
`,
|
|
1224
|
+
'internal/httpapi/auth.go': append(`
|
|
1225
|
+
// requireStaff answers 403 unless the member works at the studio.
|
|
1226
|
+
func (h *handlers) requireStaff(next http.Handler) http.Handler {
|
|
1227
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
1228
|
+
if !memberFrom(r.Context()).IsStaff {
|
|
1229
|
+
writeError(w, http.StatusForbidden, "forbidden", "Only studio staff can do that.")
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
next.ServeHTTP(w, r)
|
|
1233
|
+
})
|
|
1234
|
+
}
|
|
1235
|
+
`),
|
|
1236
|
+
'internal/httpapi/router.go': replace(' r.Delete("/me/bookings/{id}", h.cancelBooking)\n })\n', ` r.Delete("/me/bookings/{id}", h.cancelBooking)
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
r.Group(func(r chi.Router) {
|
|
1240
|
+
r.Use(h.authenticate, h.requireStaff)
|
|
1241
|
+
r.Get("/admin/webhooks/dead-letters", h.deadLetters)
|
|
1242
|
+
})
|
|
1243
|
+
`),
|
|
1244
|
+
}, { author: sam, at: at('2026-07-30 09:55') });
|
|
1245
|
+
const a10 = r.commit(a9, 'Document the API error codes', {
|
|
1246
|
+
'README.md': append(`
|
|
1247
|
+
## Errors
|
|
1248
|
+
|
|
1249
|
+
Every error is \`{"error": {"code": "...", "message": "..."}}\`. Clients switch on \`code\`; \`message\` is
|
|
1250
|
+
for people and may change.
|
|
1251
|
+
|
|
1252
|
+
| code | status | when |
|
|
1253
|
+
| --- | --- | --- |
|
|
1254
|
+
| \`unauthenticated\` | 401 | no token, or an expired one |
|
|
1255
|
+
| \`not_found\` | 404 | the class, booking or member doesn't exist (or isn't yours) |
|
|
1256
|
+
| \`class_full\` | 409 | no spots left |
|
|
1257
|
+
| \`already_booked\` | 409 | you already have a spot in this class |
|
|
1258
|
+
| \`internal\` | 500 | our fault; it's logged |
|
|
1259
|
+
`),
|
|
1260
|
+
}, { author: june, at: at('2026-08-03 10:20') });
|
|
1261
|
+
const merger = { author: sam, committer: sam };
|
|
1262
|
+
const m88 = r.commit(a10, `Merge pull request #88 from orchard-studio-demo/sam/webhook-deliveries\n\nRecord webhook deliveries before processing them`, {}, { ...merger, at: at('2026-08-04 14:12'), merge: h1, key: 'merge #88' });
|
|
1263
|
+
const m89 = r.commit(m88, `Merge pull request #89 from orchard-studio-demo/sam/webhook-retry\n\nRetry failed deliveries with exponential backoff`, {}, { ...merger, at: at('2026-08-05 09:31'), merge: h2, key: 'merge #89' });
|
|
1264
|
+
const m90 = r.commit(m89, `Merge pull request #90 from orchard-studio-demo/sam/webhook-dead-letters\n\nList deliveries that keep failing for staff`, {}, { ...merger, at: at('2026-08-06 16:47'), merge: h3, key: 'merge #90' });
|
|
1265
|
+
// a merge commit's tree is the merged result: take the PR's files over main's
|
|
1266
|
+
for (const [m, h] of [[m88, h1], [m89, h2], [m90, h3]])
|
|
1267
|
+
mergeTrees(m, h, a9);
|
|
1268
|
+
const a14 = r.commit(m90, 'Bump Go to 1.23', {
|
|
1269
|
+
'go.mod': replace('go 1.22\n', 'go 1.23\n'),
|
|
1270
|
+
'.github/workflows/ci.yml': replace('go-version: "1.22"', 'go-version: "1.23"'),
|
|
1271
|
+
}, { author: june, at: at('2026-08-19 11:02') });
|
|
1272
|
+
const a15 = r.commit(a14, 'Log a request id on every request', {
|
|
1273
|
+
'internal/httpapi/middleware.go': `package httpapi
|
|
1274
|
+
|
|
1275
|
+
import (
|
|
1276
|
+
"context"
|
|
1277
|
+
"crypto/rand"
|
|
1278
|
+
"encoding/hex"
|
|
1279
|
+
"net/http"
|
|
1280
|
+
"time"
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
type requestIDKey struct{}
|
|
1284
|
+
|
|
1285
|
+
// requestID tags each request with an id (the caller's X-Request-Id, or a new one) and echoes it back.
|
|
1286
|
+
func requestID(next http.Handler) http.Handler {
|
|
1287
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
1288
|
+
id := r.Header.Get("X-Request-Id")
|
|
1289
|
+
if id == "" || len(id) > 64 {
|
|
1290
|
+
b := make([]byte, 8)
|
|
1291
|
+
_, _ = rand.Read(b)
|
|
1292
|
+
id = hex.EncodeToString(b)
|
|
1293
|
+
}
|
|
1294
|
+
w.Header().Set("X-Request-Id", id)
|
|
1295
|
+
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
|
|
1296
|
+
})
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
type statusWriter struct {
|
|
1300
|
+
http.ResponseWriter
|
|
1301
|
+
status int
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
func (w *statusWriter) WriteHeader(code int) {
|
|
1305
|
+
w.status = code
|
|
1306
|
+
w.ResponseWriter.WriteHeader(code)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
func (h *handlers) logRequests(next http.Handler) http.Handler {
|
|
1310
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
1311
|
+
start := time.Now()
|
|
1312
|
+
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
1313
|
+
next.ServeHTTP(sw, r)
|
|
1314
|
+
id, _ := r.Context().Value(requestIDKey{}).(string)
|
|
1315
|
+
h.log.InfoContext(r.Context(), "request", "id", id, "method", r.Method, "path", r.URL.Path,
|
|
1316
|
+
"status", sw.status, "ms", time.Since(start).Milliseconds())
|
|
1317
|
+
})
|
|
1318
|
+
}
|
|
1319
|
+
`,
|
|
1320
|
+
'internal/httpapi/router.go': replace(' r := chi.NewRouter()\n', ' r := chi.NewRouter()\n r.Use(requestID, h.logRequests)\n'),
|
|
1321
|
+
}, { author: theo, at: at('2026-08-27 15:26') });
|
|
1322
|
+
const a16 = r.commit(a15, 'Index bookings by member for My bookings', {
|
|
1323
|
+
'migrations/0004_bookings_member_index.sql': `-- "My bookings" filters by member; without this it scans every confirmed booking.
|
|
1324
|
+
CREATE INDEX CONCURRENTLY bookings_member ON bookings (member_id) WHERE status = 'confirmed';
|
|
1325
|
+
`,
|
|
1326
|
+
}, { author: june, at: at('2026-09-03 09:44') });
|
|
1327
|
+
// Theo's calendar feed (a small single-repo project), branched off a16
|
|
1328
|
+
const cal1 = r.commit(a16, 'Add an iCalendar encoder for bookings', {
|
|
1329
|
+
'internal/ical/ical.go': `// Package ical writes the small subset of RFC 5545 that calendar apps need for a booking.
|
|
1330
|
+
package ical
|
|
1331
|
+
|
|
1332
|
+
import (
|
|
1333
|
+
"fmt"
|
|
1334
|
+
"io"
|
|
1335
|
+
"strings"
|
|
1336
|
+
"time"
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
type Event struct {
|
|
1340
|
+
UID string
|
|
1341
|
+
Summary string
|
|
1342
|
+
Location string
|
|
1343
|
+
Start time.Time
|
|
1344
|
+
End time.Time
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Write writes a VCALENDAR with one VEVENT per event. Lines are CRLF-terminated and folded at 75
|
|
1348
|
+
// octets, as the RFC requires; Outlook rejects feeds that don't fold.
|
|
1349
|
+
func Write(w io.Writer, name string, events []Event) error {
|
|
1350
|
+
var b strings.Builder
|
|
1351
|
+
line := func(s string) {
|
|
1352
|
+
for len(s) > 75 {
|
|
1353
|
+
b.WriteString(s[:75] + "\\r\\n ")
|
|
1354
|
+
s = s[75:]
|
|
1355
|
+
}
|
|
1356
|
+
b.WriteString(s + "\\r\\n")
|
|
1357
|
+
}
|
|
1358
|
+
line("BEGIN:VCALENDAR")
|
|
1359
|
+
line("VERSION:2.0")
|
|
1360
|
+
line("PRODID:-//Orchard//Bookings//EN")
|
|
1361
|
+
line("X-WR-CALNAME:" + escape(name))
|
|
1362
|
+
for _, e := range events {
|
|
1363
|
+
line("BEGIN:VEVENT")
|
|
1364
|
+
line("UID:" + e.UID)
|
|
1365
|
+
line("DTSTAMP:" + stamp(e.Start))
|
|
1366
|
+
line("DTSTART:" + stamp(e.Start))
|
|
1367
|
+
line("DTEND:" + stamp(e.End))
|
|
1368
|
+
line("SUMMARY:" + escape(e.Summary))
|
|
1369
|
+
if e.Location != "" {
|
|
1370
|
+
line("LOCATION:" + escape(e.Location))
|
|
1371
|
+
}
|
|
1372
|
+
line("END:VEVENT")
|
|
1373
|
+
}
|
|
1374
|
+
line("END:VCALENDAR")
|
|
1375
|
+
_, err := io.WriteString(w, b.String())
|
|
1376
|
+
return err
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
func stamp(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
|
1380
|
+
|
|
1381
|
+
func escape(s string) string {
|
|
1382
|
+
return strings.NewReplacer("\\\\", "\\\\\\\\", ";", "\\\\;", ",", "\\\\,", "\\n", "\\\\n").Replace(s)
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
var _ = fmt.Sprintf
|
|
1386
|
+
`,
|
|
1387
|
+
'internal/ical/ical_test.go': `package ical
|
|
1388
|
+
|
|
1389
|
+
import (
|
|
1390
|
+
"strings"
|
|
1391
|
+
"testing"
|
|
1392
|
+
"time"
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
func TestWriteFoldsLongLines(t *testing.T) {
|
|
1396
|
+
var b strings.Builder
|
|
1397
|
+
start := time.Date(2026, 9, 21, 7, 0, 0, 0, time.UTC)
|
|
1398
|
+
err := Write(&b, "My classes", []Event{{
|
|
1399
|
+
UID: "booking-1@orchard", Summary: strings.Repeat("Sunrise vinyasa, ", 8),
|
|
1400
|
+
Start: start, End: start.Add(time.Hour),
|
|
1401
|
+
}})
|
|
1402
|
+
if err != nil {
|
|
1403
|
+
t.Fatal(err)
|
|
1404
|
+
}
|
|
1405
|
+
for _, l := range strings.Split(b.String(), "\\r\\n") {
|
|
1406
|
+
if len(l) > 75 {
|
|
1407
|
+
t.Errorf("line longer than 75 octets: %q", l)
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
`,
|
|
1412
|
+
}, { author: theo, at: at('2026-09-09 10:05') });
|
|
1413
|
+
const cal2 = r.commit(cal1, 'Serve a private calendar feed per member\n\nGET /calendar/{token}.ics, unauthenticated by design (calendar apps can\'t send a bearer token),\nso the token is the secret: 32 random bytes, stored hashed.', {
|
|
1414
|
+
'migrations/0005_calendar_tokens.sql': `ALTER TABLE members ADD COLUMN calendar_token_hash bytea UNIQUE;
|
|
1415
|
+
`,
|
|
1416
|
+
'internal/httpapi/calendar.go': `package httpapi
|
|
1417
|
+
|
|
1418
|
+
import (
|
|
1419
|
+
"crypto/sha256"
|
|
1420
|
+
"fmt"
|
|
1421
|
+
"net/http"
|
|
1422
|
+
"strings"
|
|
1423
|
+
"time"
|
|
1424
|
+
|
|
1425
|
+
"github.com/go-chi/chi/v5"
|
|
1426
|
+
|
|
1427
|
+
"${MOD}/internal/ical"
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
// GET /calendar/{token}.ics: the member's upcoming bookings as a calendar feed.
|
|
1431
|
+
func (h *handlers) calendarFeed(w http.ResponseWriter, r *http.Request) {
|
|
1432
|
+
token := strings.TrimSuffix(chi.URLParam(r, "token"), ".ics")
|
|
1433
|
+
sum := sha256.Sum256([]byte(token))
|
|
1434
|
+
m, err := h.db.MemberByCalendarToken(r.Context(), sum[:])
|
|
1435
|
+
if err != nil {
|
|
1436
|
+
writeError(w, http.StatusNotFound, "not_found", "Not found.")
|
|
1437
|
+
return
|
|
1438
|
+
}
|
|
1439
|
+
list, err := h.db.MemberBookings(r.Context(), m.ID, time.Now().AddDate(0, 0, -7))
|
|
1440
|
+
if err != nil {
|
|
1441
|
+
h.fail(w, r, err)
|
|
1442
|
+
return
|
|
1443
|
+
}
|
|
1444
|
+
events := make([]ical.Event, 0, len(list))
|
|
1445
|
+
for _, b := range list {
|
|
1446
|
+
events = append(events, ical.Event{
|
|
1447
|
+
UID: fmt.Sprintf("booking-%d@orchard", b.ID),
|
|
1448
|
+
Summary: b.Class.Title + " with " + b.Class.Coach,
|
|
1449
|
+
Start: b.Class.StartsAt,
|
|
1450
|
+
End: b.Class.StartsAt.Add(time.Duration(b.Class.Minutes) * time.Minute),
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
|
1454
|
+
w.Header().Set("Cache-Control", "private, max-age=900")
|
|
1455
|
+
_ = ical.Write(w, "Orchard classes", events)
|
|
1456
|
+
}
|
|
1457
|
+
`,
|
|
1458
|
+
'internal/store/members.go': append(`
|
|
1459
|
+
func (db *DB) MemberByCalendarToken(ctx context.Context, hash []byte) (Member, error) {
|
|
1460
|
+
var m Member
|
|
1461
|
+
err := db.pool.QueryRow(ctx, \`
|
|
1462
|
+
SELECT id, studio_id, name, email, is_staff FROM members WHERE calendar_token_hash = $1\`, hash,
|
|
1463
|
+
).Scan(&m.ID, &m.StudioID, &m.Name, &m.Email, &m.IsStaff)
|
|
1464
|
+
return m, notFound(err)
|
|
1465
|
+
}
|
|
1466
|
+
`),
|
|
1467
|
+
'internal/httpapi/router.go': after(' r.Post("/webhooks/payments", h.paymentWebhook)\n', ' r.Get("/calendar/{token}", h.calendarFeed)\n'),
|
|
1468
|
+
}, { author: theo, at: at('2026-09-10 14:30') });
|
|
1469
|
+
const cal3 = r.commit(cal2, 'Rotate calendar tokens from account settings', {
|
|
1470
|
+
'internal/httpapi/calendar.go': edits(['import (\n "crypto/sha256"\n', 'import (\n "crypto/rand"\n "crypto/sha256"\n "encoding/base64"\n'], [
|
|
1471
|
+
'// GET /calendar/{token}.ics',
|
|
1472
|
+
`// POST /me/calendar-token issues a new feed URL; the old one stops working at once.
|
|
1473
|
+
func (h *handlers) rotateCalendarToken(w http.ResponseWriter, r *http.Request) {
|
|
1474
|
+
raw := make([]byte, 32)
|
|
1475
|
+
if _, err := rand.Read(raw); err != nil {
|
|
1476
|
+
h.fail(w, r, err)
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
token := base64.RawURLEncoding.EncodeToString(raw)
|
|
1480
|
+
sum := sha256.Sum256([]byte(token))
|
|
1481
|
+
if err := h.db.SetCalendarToken(r.Context(), memberFrom(r.Context()).ID, sum[:]); err != nil {
|
|
1482
|
+
h.fail(w, r, err)
|
|
1483
|
+
return
|
|
1484
|
+
}
|
|
1485
|
+
writeJSON(w, http.StatusOK, map[string]string{"url": "/calendar/" + token + ".ics"})
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// GET /calendar/{token}.ics`,
|
|
1489
|
+
]),
|
|
1490
|
+
'internal/store/members.go': append(`
|
|
1491
|
+
func (db *DB) SetCalendarToken(ctx context.Context, memberID int64, hash []byte) error {
|
|
1492
|
+
_, err := db.pool.Exec(ctx, \`UPDATE members SET calendar_token_hash = $2 WHERE id = $1\`, memberID, hash)
|
|
1493
|
+
return err
|
|
1494
|
+
}
|
|
1495
|
+
`),
|
|
1496
|
+
'internal/httpapi/router.go': after(' r.Delete("/me/bookings/{id}", h.cancelBooking)\n', ' r.Post("/me/calendar-token", h.rotateCalendarToken)\n'),
|
|
1497
|
+
}, { author: theo, at: at('2026-09-11 09:12') });
|
|
1498
|
+
// ---------------------------------------------------------------- Maya's waitlists branch (off a16)
|
|
1499
|
+
const f1 = r.commit(a16, 'Add the waitlist_entries table\n\nOne row per member per class. Position is set when they join; status tracks the offer\nlifecycle (waiting, offered, accepted, left, expired). Nothing reads or writes it yet.', {
|
|
1500
|
+
'migrations/0005_waitlist.sql': `CREATE TYPE waitlist_status AS ENUM ('waiting', 'offered', 'accepted', 'left', 'expired');
|
|
1501
|
+
|
|
1502
|
+
CREATE TABLE waitlist_entries (
|
|
1503
|
+
id bigserial PRIMARY KEY,
|
|
1504
|
+
class_id bigint NOT NULL REFERENCES classes (id) ON DELETE CASCADE,
|
|
1505
|
+
member_id bigint NOT NULL REFERENCES members (id),
|
|
1506
|
+
position integer NOT NULL,
|
|
1507
|
+
status waitlist_status NOT NULL DEFAULT 'waiting',
|
|
1508
|
+
hold_until timestamptz,
|
|
1509
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
1510
|
+
UNIQUE (class_id, member_id)
|
|
1511
|
+
);
|
|
1512
|
+
|
|
1513
|
+
-- the next person in line for a class
|
|
1514
|
+
CREATE INDEX waitlist_entries_next ON waitlist_entries (class_id, position) WHERE status = 'waiting';
|
|
1515
|
+
-- offers whose hold has run out (the expiry sweep)
|
|
1516
|
+
CREATE INDEX waitlist_entries_hold ON waitlist_entries (hold_until) WHERE status = 'offered';
|
|
1517
|
+
`,
|
|
1518
|
+
}, { author: maya, at: at('2026-09-08 10:14'), key: 'f1' });
|
|
1519
|
+
const f2 = r.commit(f1, 'Add a waitlist store', {
|
|
1520
|
+
'internal/store/waitlist.go': `package store
|
|
1521
|
+
|
|
1522
|
+
import (
|
|
1523
|
+
"context"
|
|
1524
|
+
"time"
|
|
1525
|
+
|
|
1526
|
+
"github.com/jackc/pgx/v5"
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
type WaitlistStatus string
|
|
1530
|
+
|
|
1531
|
+
const (
|
|
1532
|
+
WaitlistWaiting WaitlistStatus = "waiting"
|
|
1533
|
+
WaitlistOffered WaitlistStatus = "offered" // a spot opened up; it's held for them until HoldUntil
|
|
1534
|
+
WaitlistAccepted WaitlistStatus = "accepted" // they took the spot: there's a booking now
|
|
1535
|
+
WaitlistLeft WaitlistStatus = "left"
|
|
1536
|
+
WaitlistExpired WaitlistStatus = "expired" // the hold ran out before they accepted
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
type WaitlistEntry struct {
|
|
1540
|
+
ID int64
|
|
1541
|
+
ClassID int64
|
|
1542
|
+
MemberID int64
|
|
1543
|
+
Position int
|
|
1544
|
+
Status WaitlistStatus
|
|
1545
|
+
HoldUntil *time.Time
|
|
1546
|
+
CreatedAt time.Time
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const waitlistColumns = \`id, class_id, member_id, position, status, hold_until, created_at\`
|
|
1550
|
+
|
|
1551
|
+
func scanWaitlistEntry(row pgx.Row) (WaitlistEntry, error) {
|
|
1552
|
+
var e WaitlistEntry
|
|
1553
|
+
err := row.Scan(&e.ID, &e.ClassID, &e.MemberID, &e.Position, &e.Status, &e.HoldUntil, &e.CreatedAt)
|
|
1554
|
+
return e, notFound(err)
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// JoinWaitlist puts a member at the back of a class's line. Someone who left (or whose offer
|
|
1558
|
+
// expired) and joins again goes to the back, not to their old place.
|
|
1559
|
+
func JoinWaitlist(ctx context.Context, tx pgx.Tx, classID, memberID int64) (WaitlistEntry, error) {
|
|
1560
|
+
return scanWaitlistEntry(tx.QueryRow(ctx, \`
|
|
1561
|
+
INSERT INTO waitlist_entries (class_id, member_id, position)
|
|
1562
|
+
VALUES ($1, $2, (SELECT coalesce(max(position), 0) + 1 FROM waitlist_entries WHERE class_id = $1))
|
|
1563
|
+
ON CONFLICT (class_id, member_id) DO UPDATE
|
|
1564
|
+
SET position = EXCLUDED.position, status = 'waiting', hold_until = NULL, created_at = now()
|
|
1565
|
+
RETURNING \`+waitlistColumns, classID, memberID))
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// LeaveWaitlist takes a member out of a class's line (or gives back a spot they were offered).
|
|
1569
|
+
func LeaveWaitlist(ctx context.Context, tx pgx.Tx, classID, memberID int64) error {
|
|
1570
|
+
_, err := tx.Exec(ctx, \`
|
|
1571
|
+
UPDATE waitlist_entries SET status = 'left'
|
|
1572
|
+
WHERE class_id = $1 AND member_id = $2 AND status IN ('waiting', 'offered')\`, classID, memberID)
|
|
1573
|
+
return err
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// FirstWaiting is the member at the front of a class's line, or ErrNotFound if nobody is waiting.
|
|
1577
|
+
func FirstWaiting(ctx context.Context, tx pgx.Tx, classID int64) (WaitlistEntry, error) {
|
|
1578
|
+
return scanWaitlistEntry(tx.QueryRow(ctx, \`
|
|
1579
|
+
SELECT \`+waitlistColumns+\` FROM waitlist_entries
|
|
1580
|
+
WHERE class_id = $1 AND status = 'waiting'
|
|
1581
|
+
ORDER BY position
|
|
1582
|
+
LIMIT 1\`, classID))
|
|
1583
|
+
}
|
|
1584
|
+
`,
|
|
1585
|
+
'internal/store/waitlist_test.go': `package store_test
|
|
1586
|
+
|
|
1587
|
+
import (
|
|
1588
|
+
"context"
|
|
1589
|
+
"testing"
|
|
1590
|
+
|
|
1591
|
+
"github.com/jackc/pgx/v5"
|
|
1592
|
+
|
|
1593
|
+
"${MOD}/internal/store"
|
|
1594
|
+
"${MOD}/internal/store/storetest"
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
func TestRejoiningGoesToTheBack(t *testing.T) {
|
|
1598
|
+
ctx := context.Background()
|
|
1599
|
+
db := storetest.New(t)
|
|
1600
|
+
class := storetest.Class(t, db, 1)
|
|
1601
|
+
a, b := storetest.Member(t, db), storetest.Member(t, db)
|
|
1602
|
+
|
|
1603
|
+
err := db.Tx(ctx, func(tx pgx.Tx) error {
|
|
1604
|
+
for _, m := range []store.Member{a, b} {
|
|
1605
|
+
if _, err := store.JoinWaitlist(ctx, tx, class.ID, m.ID); err != nil {
|
|
1606
|
+
return err
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if err := store.LeaveWaitlist(ctx, tx, class.ID, a.ID); err != nil {
|
|
1610
|
+
return err
|
|
1611
|
+
}
|
|
1612
|
+
again, err := store.JoinWaitlist(ctx, tx, class.ID, a.ID)
|
|
1613
|
+
if err != nil {
|
|
1614
|
+
return err
|
|
1615
|
+
}
|
|
1616
|
+
if again.Position != 3 {
|
|
1617
|
+
t.Errorf("rejoined at position %d, want 3 (the back)", again.Position)
|
|
1618
|
+
}
|
|
1619
|
+
first, err := store.FirstWaiting(ctx, tx, class.ID)
|
|
1620
|
+
if err != nil {
|
|
1621
|
+
return err
|
|
1622
|
+
}
|
|
1623
|
+
if first.MemberID != b.ID {
|
|
1624
|
+
t.Errorf("first in line is member %d, want %d", first.MemberID, b.ID)
|
|
1625
|
+
}
|
|
1626
|
+
return nil
|
|
1627
|
+
})
|
|
1628
|
+
if err != nil {
|
|
1629
|
+
t.Fatal(err)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
`,
|
|
1633
|
+
}, { author: maya, at: at('2026-09-08 15:37'), key: 'f2' });
|
|
1634
|
+
const f3 = r.commit(f2, "Let members join a full class's waitlist", {
|
|
1635
|
+
'internal/booking/waitlist.go': `package booking
|
|
1636
|
+
|
|
1637
|
+
import (
|
|
1638
|
+
"context"
|
|
1639
|
+
"errors"
|
|
1640
|
+
|
|
1641
|
+
"github.com/jackc/pgx/v5"
|
|
1642
|
+
|
|
1643
|
+
"${MOD}/internal/store"
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
// ErrClassNotFull is returned when someone tries to join the waitlist of a class they could just book.
|
|
1647
|
+
var ErrClassNotFull = errors.New("class has free spots")
|
|
1648
|
+
|
|
1649
|
+
// JoinWaitlist puts a member at the back of a full class's waitlist.
|
|
1650
|
+
func (s *Service) JoinWaitlist(ctx context.Context, classID, memberID int64) (store.WaitlistEntry, error) {
|
|
1651
|
+
var e store.WaitlistEntry
|
|
1652
|
+
err := s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
1653
|
+
class, err := store.LockClass(ctx, tx, classID)
|
|
1654
|
+
if err != nil {
|
|
1655
|
+
return err
|
|
1656
|
+
}
|
|
1657
|
+
booked, err := store.HasBooking(ctx, tx, classID, memberID)
|
|
1658
|
+
if err != nil {
|
|
1659
|
+
return err
|
|
1660
|
+
}
|
|
1661
|
+
if booked {
|
|
1662
|
+
return ErrAlreadyBooked
|
|
1663
|
+
}
|
|
1664
|
+
n, err := store.CountBookings(ctx, tx, classID)
|
|
1665
|
+
if err != nil {
|
|
1666
|
+
return err
|
|
1667
|
+
}
|
|
1668
|
+
if n < class.Capacity {
|
|
1669
|
+
return ErrClassNotFull
|
|
1670
|
+
}
|
|
1671
|
+
e, err = store.JoinWaitlist(ctx, tx, classID, memberID)
|
|
1672
|
+
return err
|
|
1673
|
+
})
|
|
1674
|
+
return e, err
|
|
1675
|
+
}
|
|
1676
|
+
`,
|
|
1677
|
+
'internal/httpapi/waitlist.go': `package httpapi
|
|
1678
|
+
|
|
1679
|
+
import (
|
|
1680
|
+
"errors"
|
|
1681
|
+
"net/http"
|
|
1682
|
+
"time"
|
|
1683
|
+
|
|
1684
|
+
"${MOD}/internal/booking"
|
|
1685
|
+
"${MOD}/internal/store"
|
|
1686
|
+
)
|
|
1687
|
+
|
|
1688
|
+
type waitlistJSON struct {
|
|
1689
|
+
ID int64 \`json:"id"\`
|
|
1690
|
+
ClassID int64 \`json:"class_id"\`
|
|
1691
|
+
Position int \`json:"position"\`
|
|
1692
|
+
Status string \`json:"status"\`
|
|
1693
|
+
HoldUntil *time.Time \`json:"hold_until,omitempty"\`
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
func toWaitlistJSON(e store.WaitlistEntry) waitlistJSON {
|
|
1697
|
+
return waitlistJSON{ID: e.ID, ClassID: e.ClassID, Position: e.Position, Status: string(e.Status), HoldUntil: e.HoldUntil}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// POST /classes/{id}/waitlist joins the waitlist of a full class.
|
|
1701
|
+
func (h *handlers) joinWaitlist(w http.ResponseWriter, r *http.Request) {
|
|
1702
|
+
classID, ok := pathID(w, r, "id")
|
|
1703
|
+
if !ok {
|
|
1704
|
+
return
|
|
1705
|
+
}
|
|
1706
|
+
e, err := h.bookings.JoinWaitlist(r.Context(), classID, memberFrom(r.Context()).ID)
|
|
1707
|
+
switch {
|
|
1708
|
+
case errors.Is(err, store.ErrNotFound):
|
|
1709
|
+
writeError(w, http.StatusNotFound, "not_found", "No such class.")
|
|
1710
|
+
case errors.Is(err, booking.ErrClassNotFull):
|
|
1711
|
+
writeError(w, http.StatusConflict, "class_not_full", "This class has space: book it instead.")
|
|
1712
|
+
case errors.Is(err, booking.ErrAlreadyBooked):
|
|
1713
|
+
writeError(w, http.StatusConflict, "already_booked", "You already have a spot in this class.")
|
|
1714
|
+
case err != nil:
|
|
1715
|
+
h.fail(w, r, err)
|
|
1716
|
+
default:
|
|
1717
|
+
writeJSON(w, http.StatusCreated, toWaitlistJSON(e))
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
`,
|
|
1721
|
+
'internal/httpapi/router.go': after(' r.Post("/classes/{id}/bookings", h.book)\n', ' r.Post("/classes/{id}/waitlist", h.joinWaitlist)\n'),
|
|
1722
|
+
'README.md': replace('| `already_booked` | 409 | you already have a spot in this class |\n', '| `already_booked` | 409 | you already have a spot in this class |\n| `class_not_full` | 409 | joining the waitlist of a class you could book |\n'),
|
|
1723
|
+
}, { author: maya, at: at('2026-09-09 11:20'), key: 'f3' });
|
|
1724
|
+
const f4 = r.commit(f3, 'Let members leave a waitlist', {
|
|
1725
|
+
'internal/booking/waitlist.go': append(`
|
|
1726
|
+
// LeaveWaitlist takes a member off a class's waitlist. Leaving a waitlist you're not on isn't an error:
|
|
1727
|
+
// the app retries on flaky connections.
|
|
1728
|
+
func (s *Service) LeaveWaitlist(ctx context.Context, classID, memberID int64) error {
|
|
1729
|
+
return s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
1730
|
+
return store.LeaveWaitlist(ctx, tx, classID, memberID)
|
|
1731
|
+
})
|
|
1732
|
+
}
|
|
1733
|
+
`),
|
|
1734
|
+
'internal/httpapi/waitlist.go': append(`
|
|
1735
|
+
// DELETE /classes/{id}/waitlist
|
|
1736
|
+
func (h *handlers) leaveWaitlist(w http.ResponseWriter, r *http.Request) {
|
|
1737
|
+
classID, ok := pathID(w, r, "id")
|
|
1738
|
+
if !ok {
|
|
1739
|
+
return
|
|
1740
|
+
}
|
|
1741
|
+
if err := h.bookings.LeaveWaitlist(r.Context(), classID, memberFrom(r.Context()).ID); err != nil {
|
|
1742
|
+
h.fail(w, r, err)
|
|
1743
|
+
return
|
|
1744
|
+
}
|
|
1745
|
+
w.WriteHeader(http.StatusNoContent)
|
|
1746
|
+
}
|
|
1747
|
+
`),
|
|
1748
|
+
'internal/httpapi/router.go': after(' r.Post("/classes/{id}/waitlist", h.joinWaitlist)\n', ' r.Delete("/classes/{id}/waitlist", h.leaveWaitlist)\n'),
|
|
1749
|
+
}, { author: maya, at: at('2026-09-09 16:02'), key: 'f4' });
|
|
1750
|
+
const f5 = r.commit(f4, 'Show waitlist position in the bookings API', {
|
|
1751
|
+
'internal/store/waitlist.go': append(`
|
|
1752
|
+
// MemberWaitlistEntry is one of a member's waitlist places, with its class and live position.
|
|
1753
|
+
type MemberWaitlistEntry struct {
|
|
1754
|
+
WaitlistEntry
|
|
1755
|
+
Class Class
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// MemberWaitlist lists the waitlists a member is on (waiting or offered a spot) for classes that
|
|
1759
|
+
// haven't started. Position counts only the people still waiting ahead of them.
|
|
1760
|
+
func (db *DB) MemberWaitlist(ctx context.Context, memberID int64, since time.Time) ([]MemberWaitlistEntry, error) {
|
|
1761
|
+
rows, err := db.pool.Query(ctx, \`
|
|
1762
|
+
SELECT w.id, w.class_id, w.member_id,
|
|
1763
|
+
(SELECT count(*) FROM waitlist_entries a
|
|
1764
|
+
WHERE a.class_id = w.class_id AND a.status = 'waiting' AND a.position <= w.position),
|
|
1765
|
+
w.status, w.hold_until, w.created_at, \`+classColumns+\`
|
|
1766
|
+
FROM waitlist_entries w JOIN classes c ON c.id = w.class_id
|
|
1767
|
+
WHERE w.member_id = $1 AND w.status IN ('waiting', 'offered') AND c.starts_at >= $2
|
|
1768
|
+
ORDER BY c.starts_at\`, memberID, since)
|
|
1769
|
+
if err != nil {
|
|
1770
|
+
return nil, err
|
|
1771
|
+
}
|
|
1772
|
+
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (MemberWaitlistEntry, error) {
|
|
1773
|
+
var e MemberWaitlistEntry
|
|
1774
|
+
c := &e.Class
|
|
1775
|
+
err := row.Scan(&e.ID, &e.ClassID, &e.MemberID, &e.Position, &e.Status, &e.HoldUntil, &e.CreatedAt,
|
|
1776
|
+
&c.ID, &c.StudioID, &c.Title, &c.Coach, &c.StartsAt, &c.Minutes, &c.Capacity, &c.Booked)
|
|
1777
|
+
return e, err
|
|
1778
|
+
})
|
|
1779
|
+
}
|
|
1780
|
+
`),
|
|
1781
|
+
'internal/httpapi/bookings.go': edits([
|
|
1782
|
+
' out := make([]bookingJSON, 0, len(list))\n',
|
|
1783
|
+
` waiting, err := h.db.MemberWaitlist(r.Context(), memberFrom(r.Context()).ID, time.Now())
|
|
1784
|
+
if err != nil {
|
|
1785
|
+
h.fail(w, r, err)
|
|
1786
|
+
return
|
|
1787
|
+
}
|
|
1788
|
+
out := make([]bookingJSON, 0, len(list))
|
|
1789
|
+
`,
|
|
1790
|
+
], [
|
|
1791
|
+
' writeJSON(w, http.StatusOK, map[string]any{"bookings": out})',
|
|
1792
|
+
` wl := make([]waitlistJSON, 0, len(waiting))
|
|
1793
|
+
for _, e := range waiting {
|
|
1794
|
+
j := toWaitlistJSON(e.WaitlistEntry)
|
|
1795
|
+
c := toClassJSON(e.Class)
|
|
1796
|
+
j.Class = &c
|
|
1797
|
+
wl = append(wl, j)
|
|
1798
|
+
}
|
|
1799
|
+
writeJSON(w, http.StatusOK, map[string]any{"bookings": out, "waitlist": wl})`,
|
|
1800
|
+
]),
|
|
1801
|
+
'internal/httpapi/waitlist.go': replace(' HoldUntil *time.Time `json:"hold_until,omitempty"`\n}', ' HoldUntil *time.Time `json:"hold_until,omitempty"`\n Class *classJSON `json:"class,omitempty"`\n}'),
|
|
1802
|
+
}, { author: maya, at: at('2026-09-10 09:48'), key: 'f5' });
|
|
1803
|
+
// "Promote the first waitlisted member when a spot opens", as first written (v1) and after review (v2)
|
|
1804
|
+
const f6 = r.commit(f5, 'Promote the first waitlisted member when a spot opens\n\nCancelling a booking offers the freed spot to the first member in the waitlist, who has\nHoldWindow to accept it.', promoteChanges('v1'), { author: maya, at: at('2026-09-10 14:55'), key: 'f6' });
|
|
1805
|
+
const f7 = r.commit(f6, 'Expire unconfirmed promotions after the hold window', expiryChanges('v1'), { author: maya, at: at('2026-09-11 10:30'), key: 'f7' });
|
|
1806
|
+
const f8 = r.commit(f7, 'Send promotion notifications', {
|
|
1807
|
+
'migrations/0006_notification_outbox.sql': `-- Messages are queued in the same transaction as the change they announce, and sent by
|
|
1808
|
+
-- notify.Relay after it commits: an offer that rolls back never emails anyone.
|
|
1809
|
+
CREATE TABLE notification_outbox (
|
|
1810
|
+
id bigserial PRIMARY KEY,
|
|
1811
|
+
member_id bigint NOT NULL REFERENCES members (id),
|
|
1812
|
+
subject text NOT NULL,
|
|
1813
|
+
body text NOT NULL,
|
|
1814
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
1815
|
+
sent_at timestamptz
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
CREATE INDEX notification_outbox_unsent ON notification_outbox (id) WHERE sent_at IS NULL;
|
|
1819
|
+
`,
|
|
1820
|
+
'internal/store/outbox.go': `package store
|
|
1821
|
+
|
|
1822
|
+
import (
|
|
1823
|
+
"context"
|
|
1824
|
+
|
|
1825
|
+
"github.com/jackc/pgx/v5"
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
// EnqueueNotification queues a message in the caller's transaction.
|
|
1829
|
+
func EnqueueNotification(ctx context.Context, tx pgx.Tx, memberID int64, subject, body string) error {
|
|
1830
|
+
_, err := tx.Exec(ctx, \`INSERT INTO notification_outbox (member_id, subject, body) VALUES ($1, $2, $3)\`, memberID, subject, body)
|
|
1831
|
+
return err
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
type Outgoing struct {
|
|
1835
|
+
ID int64
|
|
1836
|
+
MemberID int64
|
|
1837
|
+
Subject string
|
|
1838
|
+
Body string
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// ClaimNotifications locks up to limit unsent messages for this transaction. SKIP LOCKED lets every
|
|
1842
|
+
// API replica run a relay: each one takes different rows, and a message is sent once.
|
|
1843
|
+
func ClaimNotifications(ctx context.Context, tx pgx.Tx, limit int) ([]Outgoing, error) {
|
|
1844
|
+
rows, err := tx.Query(ctx, \`
|
|
1845
|
+
SELECT id, member_id, subject, body FROM notification_outbox
|
|
1846
|
+
WHERE sent_at IS NULL ORDER BY id LIMIT $1
|
|
1847
|
+
FOR UPDATE SKIP LOCKED\`, limit)
|
|
1848
|
+
if err != nil {
|
|
1849
|
+
return nil, err
|
|
1850
|
+
}
|
|
1851
|
+
return pgx.CollectRows(rows, pgx.RowToStructByPos[Outgoing])
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
func MarkSent(ctx context.Context, tx pgx.Tx, id int64) error {
|
|
1855
|
+
_, err := tx.Exec(ctx, \`UPDATE notification_outbox SET sent_at = now() WHERE id = $1\`, id)
|
|
1856
|
+
return err
|
|
1857
|
+
}
|
|
1858
|
+
`,
|
|
1859
|
+
'internal/notify/relay.go': `package notify
|
|
1860
|
+
|
|
1861
|
+
import (
|
|
1862
|
+
"context"
|
|
1863
|
+
"log/slog"
|
|
1864
|
+
"time"
|
|
1865
|
+
|
|
1866
|
+
"github.com/jackc/pgx/v5"
|
|
1867
|
+
|
|
1868
|
+
"${MOD}/internal/store"
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
// Relay sends queued messages until ctx ends.
|
|
1872
|
+
func Relay(ctx context.Context, db *store.DB, n Notifier, log *slog.Logger) {
|
|
1873
|
+
tick := time.NewTicker(2 * time.Second)
|
|
1874
|
+
defer tick.Stop()
|
|
1875
|
+
for {
|
|
1876
|
+
select {
|
|
1877
|
+
case <-ctx.Done():
|
|
1878
|
+
return
|
|
1879
|
+
case <-tick.C:
|
|
1880
|
+
}
|
|
1881
|
+
err := db.Tx(ctx, func(tx pgx.Tx) error {
|
|
1882
|
+
batch, err := store.ClaimNotifications(ctx, tx, 50)
|
|
1883
|
+
if err != nil {
|
|
1884
|
+
return err
|
|
1885
|
+
}
|
|
1886
|
+
for _, m := range batch {
|
|
1887
|
+
if err := n.Notify(ctx, Message{MemberID: m.MemberID, Subject: m.Subject, Body: m.Body}); err != nil {
|
|
1888
|
+
log.Warn("notify", "outbox", m.ID, "err", err)
|
|
1889
|
+
continue // stays unsent; retried on the next tick
|
|
1890
|
+
}
|
|
1891
|
+
if err := store.MarkSent(ctx, tx, m.ID); err != nil {
|
|
1892
|
+
return err
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return nil
|
|
1896
|
+
})
|
|
1897
|
+
if err != nil {
|
|
1898
|
+
log.Error("relay notifications", "err", err)
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
`,
|
|
1903
|
+
'internal/notify/messages.go': `package notify
|
|
1904
|
+
|
|
1905
|
+
import (
|
|
1906
|
+
"fmt"
|
|
1907
|
+
"time"
|
|
1908
|
+
|
|
1909
|
+
"${MOD}/internal/store"
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
// SpotOffered tells a member a spot opened up in a class they were waiting for.
|
|
1913
|
+
func SpotOffered(memberID int64, class store.Class, holdUntil time.Time) Message {
|
|
1914
|
+
return Message{
|
|
1915
|
+
MemberID: memberID,
|
|
1916
|
+
Subject: "A spot opened up in " + class.Title,
|
|
1917
|
+
Body: fmt.Sprintf("Good news: a spot in %s with %s (%s) is yours if you want it.\\n\\n"+
|
|
1918
|
+
"We're holding it for you until %s. Open the app to take it; after that it goes to the next person in line.",
|
|
1919
|
+
class.Title, class.Coach, class.StartsAt.Format("Mon 2 Jan, 15:04"), holdUntil.Format("15:04")),
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
`,
|
|
1923
|
+
'internal/booking/promote.go': edits([' "' + MOD + '/internal/store"\n', ' "' + MOD + '/internal/notify"\n "' + MOD + '/internal/store"\n'], [
|
|
1924
|
+
' if err := store.OfferSpot(ctx, tx, entry.ID, holdUntil); err != nil {\n return nil, err\n }\n',
|
|
1925
|
+
` if err := store.OfferSpot(ctx, tx, entry.ID, holdUntil); err != nil {
|
|
1926
|
+
return nil, err
|
|
1927
|
+
}
|
|
1928
|
+
// queued in this transaction, so the email only goes out if the offer commits
|
|
1929
|
+
msg := notify.SpotOffered(entry.MemberID, class, holdUntil)
|
|
1930
|
+
if err := store.EnqueueNotification(ctx, tx, msg.MemberID, msg.Subject, msg.Body); err != nil {
|
|
1931
|
+
return nil, err
|
|
1932
|
+
}
|
|
1933
|
+
`,
|
|
1934
|
+
]),
|
|
1935
|
+
'cmd/orchard-api/main.go': replace(' go payments.RetryLoop(ctx, db, log)\n', ' go payments.RetryLoop(ctx, db, log)\n go notify.Relay(ctx, db, mailer, log)\n'),
|
|
1936
|
+
}, { author: maya, at: at('2026-09-11 15:12'), key: 'f8' });
|
|
1937
|
+
const f9 = r.commit(f8, 'Let studio staff reorder a waitlist', {
|
|
1938
|
+
'internal/store/waitlist.go': append(`
|
|
1939
|
+
// MoveInWaitlist puts a waiting entry at a new position, shifting the people between.
|
|
1940
|
+
func MoveInWaitlist(ctx context.Context, tx pgx.Tx, classID, entryID int64, to int) error {
|
|
1941
|
+
_, err := tx.Exec(ctx, \`
|
|
1942
|
+
WITH moving AS (SELECT position AS from_pos FROM waitlist_entries WHERE id = $2 AND class_id = $1)
|
|
1943
|
+
UPDATE waitlist_entries w SET position = CASE
|
|
1944
|
+
WHEN w.id = $2 THEN $3
|
|
1945
|
+
WHEN m.from_pos < $3 AND w.position > m.from_pos AND w.position <= $3 THEN w.position - 1
|
|
1946
|
+
WHEN m.from_pos > $3 AND w.position >= $3 AND w.position < m.from_pos THEN w.position + 1
|
|
1947
|
+
ELSE w.position END
|
|
1948
|
+
FROM moving m
|
|
1949
|
+
WHERE w.class_id = $1\`, classID, entryID, to)
|
|
1950
|
+
return err
|
|
1951
|
+
}
|
|
1952
|
+
`),
|
|
1953
|
+
'internal/httpapi/admin.go': edits(['import "net/http"', 'import (\n "encoding/json"\n "net/http"\n\n "github.com/jackc/pgx/v5"\n\n "' + MOD + '/internal/store"\n)']),
|
|
1954
|
+
'internal/httpapi/waitlist_admin.go': `package httpapi
|
|
1955
|
+
|
|
1956
|
+
import (
|
|
1957
|
+
"context"
|
|
1958
|
+
"encoding/json"
|
|
1959
|
+
"net/http"
|
|
1960
|
+
|
|
1961
|
+
"github.com/jackc/pgx/v5"
|
|
1962
|
+
|
|
1963
|
+
"${MOD}/internal/store"
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
// PUT /admin/classes/{id}/waitlist/{entry}/position {"position": 1}: staff move someone up or down
|
|
1967
|
+
// the line, e.g. a regular who was double-booked by mistake.
|
|
1968
|
+
func (h *handlers) moveInWaitlist(w http.ResponseWriter, r *http.Request) {
|
|
1969
|
+
classID, ok := pathID(w, r, "id")
|
|
1970
|
+
if !ok {
|
|
1971
|
+
return
|
|
1972
|
+
}
|
|
1973
|
+
entryID, ok := pathID(w, r, "entry")
|
|
1974
|
+
if !ok {
|
|
1975
|
+
return
|
|
1976
|
+
}
|
|
1977
|
+
var req struct {
|
|
1978
|
+
Position int \`json:"position"\`
|
|
1979
|
+
}
|
|
1980
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Position < 1 {
|
|
1981
|
+
writeError(w, http.StatusBadRequest, "bad_request", "position must be a number from 1.")
|
|
1982
|
+
return
|
|
1983
|
+
}
|
|
1984
|
+
err := h.db.Tx(r.Context(), func(tx pgx.Tx) error {
|
|
1985
|
+
if _, err := store.LockClass(context.WithoutCancel(r.Context()), tx, classID); err != nil {
|
|
1986
|
+
return err
|
|
1987
|
+
}
|
|
1988
|
+
return store.MoveInWaitlist(r.Context(), tx, classID, entryID, req.Position)
|
|
1989
|
+
})
|
|
1990
|
+
if err != nil {
|
|
1991
|
+
h.fail(w, r, err)
|
|
1992
|
+
return
|
|
1993
|
+
}
|
|
1994
|
+
w.WriteHeader(http.StatusNoContent)
|
|
1995
|
+
}
|
|
1996
|
+
`,
|
|
1997
|
+
'internal/httpapi/router.go': after(' r.Get("/admin/webhooks/dead-letters", h.deadLetters)\n', ' r.Put("/admin/classes/{id}/waitlist/{entry}/position", h.moveInWaitlist)\n'),
|
|
1998
|
+
}, { author: maya, at: at('2026-09-12 11:41'), key: 'f9' });
|
|
1999
|
+
const f10 = r.commit(f9, 'Add a per-class waitlist cap', {
|
|
2000
|
+
'migrations/0007_waitlist_cap.sql': `-- NULL: no cap. Studios asked for this for small classes, where a line of 30 is just noise.
|
|
2001
|
+
ALTER TABLE classes ADD COLUMN waitlist_cap integer CHECK (waitlist_cap > 0);
|
|
2002
|
+
`,
|
|
2003
|
+
'internal/booking/waitlist.go': edits(['var ErrClassNotFull = errors.New("class has free spots")', 'var ErrClassNotFull = errors.New("class has free spots")\n\n// ErrWaitlistFull is returned when a class\'s waitlist is at its cap.\nvar ErrWaitlistFull = errors.New("waitlist is full")'], [
|
|
2004
|
+
' e, err = store.JoinWaitlist(ctx, tx, classID, memberID)',
|
|
2005
|
+
` if class.WaitlistCap > 0 {
|
|
2006
|
+
waiting, err := store.CountWaiting(ctx, tx, classID)
|
|
2007
|
+
if err != nil {
|
|
2008
|
+
return err
|
|
2009
|
+
}
|
|
2010
|
+
if waiting >= class.WaitlistCap {
|
|
2011
|
+
return ErrWaitlistFull
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
e, err = store.JoinWaitlist(ctx, tx, classID, memberID)`,
|
|
2015
|
+
]),
|
|
2016
|
+
'internal/store/waitlist.go': append(`
|
|
2017
|
+
func CountWaiting(ctx context.Context, tx pgx.Tx, classID int64) (int, error) {
|
|
2018
|
+
var n int
|
|
2019
|
+
err := tx.QueryRow(ctx, \`SELECT count(*) FROM waitlist_entries WHERE class_id = $1 AND status = 'waiting'\`, classID).Scan(&n)
|
|
2020
|
+
return n, err
|
|
2021
|
+
}
|
|
2022
|
+
`),
|
|
2023
|
+
'internal/store/classes.go': replace(' Booked int // confirmed bookings\n', ' Booked int // confirmed bookings\n\n WaitlistCap int // 0: no cap\n'),
|
|
2024
|
+
}, { author: maya, at: at('2026-09-12 16:20'), key: 'f10' });
|
|
2025
|
+
// main moves on while the stack is in review
|
|
2026
|
+
const a17 = r.commit(a16, 'Refuse bookings for classes that have already started', {
|
|
2027
|
+
'internal/booking/service.go': edits([' ErrAlreadyBooked = errors.New("already booked")\n', ' ErrAlreadyBooked = errors.New("already booked")\n ErrClassStarted = errors.New("class has started")\n'], [' booked, err := store.HasBooking(ctx, tx, classID, memberID)\n', ' if !class.StartsAt.After(s.now()) {\n return ErrClassStarted\n }\n booked, err := store.HasBooking(ctx, tx, classID, memberID)\n']),
|
|
2028
|
+
'internal/httpapi/bookings.go': replace(' case errors.Is(err, booking.ErrClassFull):\n', ' case errors.Is(err, booking.ErrClassStarted):\n writeError(w, http.StatusConflict, "class_started", "This class has already started.")\n case errors.Is(err, booking.ErrClassFull):\n'),
|
|
2029
|
+
}, { author: theo, at: at('2026-09-11 13:35') });
|
|
2030
|
+
// Groundwork (#112, #113) goes up and is squash-merged; GitHub rebases #113 onto main after #112 lands
|
|
2031
|
+
const s112 = r.commit(a17, 'Add the waitlist_entries table (#112)', f1.changes, { author: maya, at: at('2026-09-14 10:02'), key: 'squash #112' });
|
|
2032
|
+
const [f2gh] = r.replay([f2], s112, { at: at('2026-09-14 10:02'), tag: 'reviewed' });
|
|
2033
|
+
r.rekey(f2gh, 'f2 on GitHub');
|
|
2034
|
+
r.rekey(r.get('f2@reviewed'), 'f2');
|
|
2035
|
+
const s113 = r.commit(s112, 'Add a waitlist store (#113)', f2.changes, { author: maya, at: at('2026-09-14 10:09'), key: 'squash #113' });
|
|
2036
|
+
// Maya freezes the landed PRs, then rebases the rest of the stack onto the new main (with --update-refs)
|
|
2037
|
+
r.replay([f3, f4, f5, f6, f7, f8, f9, f10], s113, { at: at('2026-09-15 08:40'), tag: 'before rebase' });
|
|
2038
|
+
// review of "Promote…": the fix is squashed into it (and the call site in "Expire…" adapted); the stack rebased
|
|
2039
|
+
// again. The branch tag on "Expire…" wasn't moved (no --update-refs that time): it shows as `moved`.
|
|
2040
|
+
const f6v1 = r.get('f6');
|
|
2041
|
+
r.rekey(f6v1, 'f6@v1');
|
|
2042
|
+
const f6v2 = r.commit(f6v1.parent, f6v1.message, promoteChanges('v2'), { author: maya, at: f6v1.authorAt, committedAt: at('2026-09-16 15:18'), key: 'f6' });
|
|
2043
|
+
r.replay(['f7', 'f8', 'f9', 'f10'].map((k) => r.get(k)), f6v2, { at: at('2026-09-16 15:18'), tag: 'v1', override: { f7: expiryChanges('v2') } });
|
|
2044
|
+
// ---------------------------------------------------------------- refs
|
|
2045
|
+
r.ref('main', a17);
|
|
2046
|
+
r.ref('refs/remotes/origin/main', s113);
|
|
2047
|
+
r.ref('refs/remotes/origin/HEAD', s113);
|
|
2048
|
+
r.ref('maya/waitlists', r.get('f10'));
|
|
2049
|
+
// branch tags, one per PR (AGENTS.md "Branch tagging"); the merged ones were deleted on GitHub but are kept locally
|
|
2050
|
+
r.ref('maya/waitlist-table', f1);
|
|
2051
|
+
r.ref('maya/waitlist-store', r.get('f2'));
|
|
2052
|
+
r.ref('maya/waitlist-join', r.get('f3'));
|
|
2053
|
+
r.ref('maya/waitlist-leave', r.get('f4'));
|
|
2054
|
+
r.ref('maya/waitlist-position', r.get('f5'));
|
|
2055
|
+
r.ref('maya/promote-on-cancel', r.get('f6'));
|
|
2056
|
+
r.ref('maya/promotion-expiry', r.get('f7@v1')); // left behind by the second rebase: `moved`
|
|
2057
|
+
r.ref('maya/promotion-emails', r.get('f8'));
|
|
2058
|
+
r.ref('refs/remotes/origin/maya/waitlist-join', r.get('f3'));
|
|
2059
|
+
r.ref('refs/remotes/origin/maya/waitlist-leave', r.get('f4'));
|
|
2060
|
+
r.ref('refs/remotes/origin/maya/waitlist-position', r.get('f5'));
|
|
2061
|
+
r.ref('refs/pull/112/head', f1);
|
|
2062
|
+
r.ref('refs/pull/113/head', f2gh);
|
|
2063
|
+
// Sam's merged stack: the PR heads, as fetched from GitHub's pull refs
|
|
2064
|
+
r.ref('refs/pull/88/head', h1);
|
|
2065
|
+
r.ref('refs/pull/89/head', h2);
|
|
2066
|
+
r.ref('refs/pull/90/head', h3);
|
|
2067
|
+
// Theo's calendar feed
|
|
2068
|
+
r.ref('theo/calendar-feed', cal3);
|
|
2069
|
+
r.ref('refs/remotes/origin/theo/calendar-feed', cal3);
|
|
2070
|
+
return r;
|
|
2071
|
+
}
|
|
2072
|
+
/** A merge commit's tree: main's files plus whatever the merged branch changed since `base`. */
|
|
2073
|
+
function mergeTrees(merge, branch, base) {
|
|
2074
|
+
for (const [p, c] of branch.files)
|
|
2075
|
+
if (base.files.get(p) !== c)
|
|
2076
|
+
merge.files.set(p, c);
|
|
2077
|
+
for (const p of base.files.keys())
|
|
2078
|
+
if (!branch.files.has(p))
|
|
2079
|
+
merge.files.delete(p);
|
|
2080
|
+
}
|
|
2081
|
+
/** "Promote the first waitlisted member when a spot opens": v1 as first reviewed; v2 after the review. */
|
|
2082
|
+
function promoteChanges(v) {
|
|
2083
|
+
const head = v === 'v1'
|
|
2084
|
+
? `// promoteFirstWaiting offers a freed spot in a class to the first member in its line, and holds
|
|
2085
|
+
// it for them for HoldWindow. It returns the entry it promoted, or nil if the class is still full
|
|
2086
|
+
// or nobody is waiting.
|
|
2087
|
+
func (s *Service) promoteFirstWaiting(ctx context.Context, tx pgx.Tx, classID int64) (*store.WaitlistEntry, error) {
|
|
2088
|
+
class, err := s.db.Class(ctx, classID)
|
|
2089
|
+
if err != nil {
|
|
2090
|
+
return nil, err
|
|
2091
|
+
}
|
|
2092
|
+
n, err := store.CountBookings(ctx, tx, classID)
|
|
2093
|
+
if err != nil {
|
|
2094
|
+
return nil, err
|
|
2095
|
+
}
|
|
2096
|
+
if n >= class.Capacity {
|
|
2097
|
+
return nil, nil
|
|
2098
|
+
}
|
|
2099
|
+
entry, err := store.FirstWaiting(ctx, tx, classID)`
|
|
2100
|
+
: `// promoteFirstWaiting offers a freed spot in a class to the first member in its line, and holds
|
|
2101
|
+
// it for them for HoldWindow. It runs in the caller's transaction, which must hold the class lock
|
|
2102
|
+
// (store.LockClass). It returns the entry it promoted, or nil if the class is still full or nobody
|
|
2103
|
+
// is waiting.
|
|
2104
|
+
func (s *Service) promoteFirstWaiting(ctx context.Context, tx pgx.Tx, class store.Class) (*store.WaitlistEntry, error) {
|
|
2105
|
+
n, err := store.CountBookings(ctx, tx, class.ID)
|
|
2106
|
+
if err != nil {
|
|
2107
|
+
return nil, err
|
|
2108
|
+
}
|
|
2109
|
+
if n >= class.Capacity {
|
|
2110
|
+
return nil, nil
|
|
2111
|
+
}
|
|
2112
|
+
entry, err := store.FirstWaiting(ctx, tx, class.ID)`;
|
|
2113
|
+
const cancel = v === 'v1'
|
|
2114
|
+
? ` b, err := store.CancelBooking(ctx, tx, bookingID, memberID)
|
|
2115
|
+
if err != nil {
|
|
2116
|
+
return err
|
|
2117
|
+
}
|
|
2118
|
+
_, err = s.promoteFirstWaiting(ctx, tx, b.ClassID)
|
|
2119
|
+
return err`
|
|
2120
|
+
: ` b, err := store.CancelBooking(ctx, tx, bookingID, memberID)
|
|
2121
|
+
if err != nil {
|
|
2122
|
+
return err
|
|
2123
|
+
}
|
|
2124
|
+
// lock the class first: two cancellations at once must not both see the same free spot
|
|
2125
|
+
class, err := store.LockClass(ctx, tx, b.ClassID)
|
|
2126
|
+
if err != nil {
|
|
2127
|
+
return err
|
|
2128
|
+
}
|
|
2129
|
+
_, err = s.promoteFirstWaiting(ctx, tx, class)
|
|
2130
|
+
return err`;
|
|
2131
|
+
const concurrencyTest = v === 'v1'
|
|
2132
|
+
? ''
|
|
2133
|
+
: `
|
|
2134
|
+
func TestConcurrentCancellationsOfferOneSpotEach(t *testing.T) {
|
|
2135
|
+
db := storetest.New(t)
|
|
2136
|
+
svc := booking.NewService(db, notify.Discard)
|
|
2137
|
+
class := storetest.Class(t, db, 2)
|
|
2138
|
+
var bookings []store.Booking
|
|
2139
|
+
for range 2 {
|
|
2140
|
+
b, err := svc.Book(ctx, class.ID, storetest.Member(t, db).ID)
|
|
2141
|
+
if err != nil {
|
|
2142
|
+
t.Fatal(err)
|
|
2143
|
+
}
|
|
2144
|
+
bookings = append(bookings, b)
|
|
2145
|
+
}
|
|
2146
|
+
waiting := []store.Member{storetest.Member(t, db), storetest.Member(t, db), storetest.Member(t, db)}
|
|
2147
|
+
for _, m := range waiting {
|
|
2148
|
+
if _, err := svc.JoinWaitlist(ctx, class.ID, m.ID); err != nil {
|
|
2149
|
+
t.Fatal(err)
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
var wg sync.WaitGroup
|
|
2154
|
+
for _, b := range bookings {
|
|
2155
|
+
wg.Add(1)
|
|
2156
|
+
go func() {
|
|
2157
|
+
defer wg.Done()
|
|
2158
|
+
if err := svc.Cancel(ctx, b.ID, b.MemberID); err != nil {
|
|
2159
|
+
t.Error(err)
|
|
2160
|
+
}
|
|
2161
|
+
}()
|
|
2162
|
+
}
|
|
2163
|
+
wg.Wait()
|
|
2164
|
+
|
|
2165
|
+
// two spots freed: the first two in line get one each, the third keeps waiting
|
|
2166
|
+
want := []store.WaitlistStatus{store.WaitlistOffered, store.WaitlistOffered, store.WaitlistWaiting}
|
|
2167
|
+
for i, m := range waiting {
|
|
2168
|
+
if got := storetest.WaitlistStatus(t, db, class.ID, m.ID); got != want[i] {
|
|
2169
|
+
t.Errorf("member %d in line: %s, want %s", i+1, got, want[i])
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
`;
|
|
2174
|
+
return {
|
|
2175
|
+
'internal/booking/promote.go': `package booking
|
|
2176
|
+
|
|
2177
|
+
import (
|
|
2178
|
+
"context"
|
|
2179
|
+
"errors"
|
|
2180
|
+
"time"
|
|
2181
|
+
|
|
2182
|
+
"github.com/jackc/pgx/v5"
|
|
2183
|
+
|
|
2184
|
+
"${MOD}/internal/store"
|
|
2185
|
+
)
|
|
2186
|
+
|
|
2187
|
+
// HoldWindow is how long a member has to accept a spot offered to them from the waitlist.
|
|
2188
|
+
const HoldWindow = 2 * time.Hour
|
|
2189
|
+
|
|
2190
|
+
// ErrOfferGone is returned when a member accepts an offer that expired or was withdrawn.
|
|
2191
|
+
var ErrOfferGone = errors.New("offer is no longer open")
|
|
2192
|
+
|
|
2193
|
+
${head}
|
|
2194
|
+
if errors.Is(err, store.ErrNotFound) {
|
|
2195
|
+
return nil, nil
|
|
2196
|
+
}
|
|
2197
|
+
if err != nil {
|
|
2198
|
+
return nil, err
|
|
2199
|
+
}
|
|
2200
|
+
holdUntil := s.now().Add(HoldWindow)
|
|
2201
|
+
if err := store.OfferSpot(ctx, tx, entry.ID, holdUntil); err != nil {
|
|
2202
|
+
return nil, err
|
|
2203
|
+
}
|
|
2204
|
+
entry.Status, entry.HoldUntil = store.WaitlistOffered, &holdUntil
|
|
2205
|
+
return &entry, nil
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// AcceptOffer turns a member's open offer into a booking.
|
|
2209
|
+
func (s *Service) AcceptOffer(ctx context.Context, entryID, memberID int64) (store.Booking, error) {
|
|
2210
|
+
var b store.Booking
|
|
2211
|
+
err := s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
2212
|
+
e, err := store.TakeOffer(ctx, tx, entryID, memberID, s.now())
|
|
2213
|
+
if errors.Is(err, store.ErrNotFound) {
|
|
2214
|
+
return ErrOfferGone
|
|
2215
|
+
}
|
|
2216
|
+
if err != nil {
|
|
2217
|
+
return err
|
|
2218
|
+
}
|
|
2219
|
+
b, err = store.CreateBooking(ctx, tx, e.ClassID, memberID)
|
|
2220
|
+
return err
|
|
2221
|
+
})
|
|
2222
|
+
return b, err
|
|
2223
|
+
}
|
|
2224
|
+
`,
|
|
2225
|
+
'internal/booking/promote_test.go': `package booking_test
|
|
2226
|
+
|
|
2227
|
+
import (
|
|
2228
|
+
"testing"${v === 'v2' ? '\n "sync"' : ''}
|
|
2229
|
+
|
|
2230
|
+
"${MOD}/internal/booking"
|
|
2231
|
+
"${MOD}/internal/notify"
|
|
2232
|
+
"${MOD}/internal/store"
|
|
2233
|
+
"${MOD}/internal/store/storetest"
|
|
2234
|
+
)
|
|
2235
|
+
|
|
2236
|
+
func TestCancelOffersTheSpotToTheFirstInLine(t *testing.T) {
|
|
2237
|
+
db := storetest.New(t)
|
|
2238
|
+
svc := booking.NewService(db, notify.Discard)
|
|
2239
|
+
class := storetest.Class(t, db, 1)
|
|
2240
|
+
booker, first, second := storetest.Member(t, db), storetest.Member(t, db), storetest.Member(t, db)
|
|
2241
|
+
|
|
2242
|
+
b, err := svc.Book(ctx, class.ID, booker.ID)
|
|
2243
|
+
if err != nil {
|
|
2244
|
+
t.Fatal(err)
|
|
2245
|
+
}
|
|
2246
|
+
for _, m := range []store.Member{first, second} {
|
|
2247
|
+
if _, err := svc.JoinWaitlist(ctx, class.ID, m.ID); err != nil {
|
|
2248
|
+
t.Fatal(err)
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
if err := svc.Cancel(ctx, b.ID, booker.ID); err != nil {
|
|
2252
|
+
t.Fatal(err)
|
|
2253
|
+
}
|
|
2254
|
+
if got := storetest.WaitlistStatus(t, db, class.ID, first.ID); got != store.WaitlistOffered {
|
|
2255
|
+
t.Errorf("first in line: %s, want offered", got)
|
|
2256
|
+
}
|
|
2257
|
+
if got := storetest.WaitlistStatus(t, db, class.ID, second.ID); got != store.WaitlistWaiting {
|
|
2258
|
+
t.Errorf("second in line: %s, want waiting", got)
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
${concurrencyTest}`,
|
|
2262
|
+
'internal/booking/service.go': edits(['// Cancel cancels one of a member\'s bookings, freeing the spot.', '// Cancel cancels one of a member\'s bookings and offers the freed spot to the waitlist.'], [' _, err := store.CancelBooking(ctx, tx, bookingID, memberID)\n return err', cancel]),
|
|
2263
|
+
'internal/store/waitlist.go': append(`
|
|
2264
|
+
// OfferSpot marks a waiting entry as offered a spot, held for them until holdUntil.
|
|
2265
|
+
func OfferSpot(ctx context.Context, tx pgx.Tx, entryID int64, holdUntil time.Time) error {
|
|
2266
|
+
_, err := tx.Exec(ctx, \`
|
|
2267
|
+
UPDATE waitlist_entries SET status = 'offered', hold_until = $2
|
|
2268
|
+
WHERE id = $1 AND status = 'waiting'\`, entryID, holdUntil)
|
|
2269
|
+
return err
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// TakeOffer accepts a member's open offer, if it's theirs and its hold hasn't run out.
|
|
2273
|
+
func TakeOffer(ctx context.Context, tx pgx.Tx, entryID, memberID int64, now time.Time) (WaitlistEntry, error) {
|
|
2274
|
+
return scanWaitlistEntry(tx.QueryRow(ctx, \`
|
|
2275
|
+
UPDATE waitlist_entries SET status = 'accepted'
|
|
2276
|
+
WHERE id = $1 AND member_id = $2 AND status = 'offered' AND hold_until > $3
|
|
2277
|
+
RETURNING \`+waitlistColumns, entryID, memberID, now))
|
|
2278
|
+
}
|
|
2279
|
+
`),
|
|
2280
|
+
'internal/store/storetest/storetest.go': append(`
|
|
2281
|
+
// WaitlistStatus is a member's status in a class's waitlist.
|
|
2282
|
+
func WaitlistStatus(t *testing.T, db *store.DB, classID, memberID int64) store.WaitlistStatus {
|
|
2283
|
+
t.Helper()
|
|
2284
|
+
conn, err := pgx.Connect(context.Background(), os.Getenv("TEST_DATABASE_URL"))
|
|
2285
|
+
if err != nil {
|
|
2286
|
+
t.Fatal(err)
|
|
2287
|
+
}
|
|
2288
|
+
defer conn.Close(context.Background())
|
|
2289
|
+
var s store.WaitlistStatus
|
|
2290
|
+
err = conn.QueryRow(context.Background(), \`
|
|
2291
|
+
SELECT status FROM waitlist_entries WHERE class_id = $1 AND member_id = $2\`, classID, memberID).Scan(&s)
|
|
2292
|
+
if err != nil {
|
|
2293
|
+
t.Fatal(err)
|
|
2294
|
+
}
|
|
2295
|
+
return s
|
|
2296
|
+
}
|
|
2297
|
+
`),
|
|
2298
|
+
'internal/httpapi/offers.go': `package httpapi
|
|
2299
|
+
|
|
2300
|
+
import (
|
|
2301
|
+
"errors"
|
|
2302
|
+
"net/http"
|
|
2303
|
+
|
|
2304
|
+
"${MOD}/internal/booking"
|
|
2305
|
+
)
|
|
2306
|
+
|
|
2307
|
+
// POST /me/offers/{id}/accept takes a spot offered from the waitlist.
|
|
2308
|
+
func (h *handlers) acceptOffer(w http.ResponseWriter, r *http.Request) {
|
|
2309
|
+
id, ok := pathID(w, r, "id")
|
|
2310
|
+
if !ok {
|
|
2311
|
+
return
|
|
2312
|
+
}
|
|
2313
|
+
b, err := h.bookings.AcceptOffer(r.Context(), id, memberFrom(r.Context()).ID)
|
|
2314
|
+
switch {
|
|
2315
|
+
case errors.Is(err, booking.ErrOfferGone):
|
|
2316
|
+
writeError(w, http.StatusGone, "offer_gone", "This spot is no longer held for you.")
|
|
2317
|
+
case err != nil:
|
|
2318
|
+
h.fail(w, r, err)
|
|
2319
|
+
default:
|
|
2320
|
+
writeJSON(w, http.StatusCreated, toBookingJSON(b))
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
`,
|
|
2324
|
+
'internal/httpapi/router.go': after(' r.Delete("/me/bookings/{id}", h.cancelBooking)\n', ' r.Post("/me/offers/{id}/accept", h.acceptOffer)\n'),
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
/** "Expire unconfirmed promotions after the hold window"; v2 has the call site adapted to v2 of "Promote…". */
|
|
2328
|
+
function expiryChanges(v) {
|
|
2329
|
+
const body = v === 'v1'
|
|
2330
|
+
? ` err := s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
2331
|
+
ok, err := store.ExpireOffer(ctx, tx, e.ID, s.now())
|
|
2332
|
+
if err != nil || !ok {
|
|
2333
|
+
return err // !ok: accepted, or expired by another sweep, since we listed it
|
|
2334
|
+
}
|
|
2335
|
+
n++
|
|
2336
|
+
_, err = s.promoteFirstWaiting(ctx, tx, e.ClassID)
|
|
2337
|
+
return err
|
|
2338
|
+
})`
|
|
2339
|
+
: ` err := s.db.Tx(ctx, func(tx pgx.Tx) error {
|
|
2340
|
+
class, err := store.LockClass(ctx, tx, e.ClassID)
|
|
2341
|
+
if err != nil {
|
|
2342
|
+
return err
|
|
2343
|
+
}
|
|
2344
|
+
ok, err := store.ExpireOffer(ctx, tx, e.ID, s.now())
|
|
2345
|
+
if err != nil || !ok {
|
|
2346
|
+
return err // !ok: accepted, or expired by another sweep, since we listed it
|
|
2347
|
+
}
|
|
2348
|
+
n++
|
|
2349
|
+
_, err = s.promoteFirstWaiting(ctx, tx, class)
|
|
2350
|
+
return err
|
|
2351
|
+
})`;
|
|
2352
|
+
return {
|
|
2353
|
+
'internal/booking/expiry.go': `package booking
|
|
2354
|
+
|
|
2355
|
+
import (
|
|
2356
|
+
"context"
|
|
2357
|
+
|
|
2358
|
+
"github.com/jackc/pgx/v5"
|
|
2359
|
+
|
|
2360
|
+
"${MOD}/internal/store"
|
|
2361
|
+
)
|
|
2362
|
+
|
|
2363
|
+
// ExpireHolds ends the offers whose hold has run out, and offers each of those spots to the next
|
|
2364
|
+
// member in line. It returns how many offers it expired. The sweep-holds command runs it every
|
|
2365
|
+
// few minutes; two sweeps at once are harmless.
|
|
2366
|
+
func (s *Service) ExpireHolds(ctx context.Context) (int, error) {
|
|
2367
|
+
expired, err := s.db.ExpiredOffers(ctx, s.now())
|
|
2368
|
+
if err != nil {
|
|
2369
|
+
return 0, err
|
|
2370
|
+
}
|
|
2371
|
+
n := 0
|
|
2372
|
+
for _, e := range expired {
|
|
2373
|
+
${body}
|
|
2374
|
+
if err != nil {
|
|
2375
|
+
return n, err
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
return n, nil
|
|
2379
|
+
}
|
|
2380
|
+
`,
|
|
2381
|
+
'internal/store/waitlist.go': append(`
|
|
2382
|
+
// ExpiredOffers lists offers whose hold ran out before now.
|
|
2383
|
+
func (db *DB) ExpiredOffers(ctx context.Context, now time.Time) ([]WaitlistEntry, error) {
|
|
2384
|
+
rows, err := db.pool.Query(ctx, \`
|
|
2385
|
+
SELECT \`+waitlistColumns+\` FROM waitlist_entries
|
|
2386
|
+
WHERE status = 'offered' AND hold_until <= $1
|
|
2387
|
+
ORDER BY hold_until LIMIT 500\`, now)
|
|
2388
|
+
if err != nil {
|
|
2389
|
+
return nil, err
|
|
2390
|
+
}
|
|
2391
|
+
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (WaitlistEntry, error) { return scanWaitlistEntry(row) })
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// ExpireOffer ends an offer whose hold has run out. It reports false if the offer was accepted or
|
|
2395
|
+
// expired in the meantime.
|
|
2396
|
+
func ExpireOffer(ctx context.Context, tx pgx.Tx, entryID int64, now time.Time) (bool, error) {
|
|
2397
|
+
tag, err := tx.Exec(ctx, \`
|
|
2398
|
+
UPDATE waitlist_entries SET status = 'expired'
|
|
2399
|
+
WHERE id = $1 AND status = 'offered' AND hold_until <= $2\`, entryID, now)
|
|
2400
|
+
return tag.RowsAffected() == 1, err
|
|
2401
|
+
}
|
|
2402
|
+
`),
|
|
2403
|
+
'cmd/orchard-api/sweep.go': `package main
|
|
2404
|
+
|
|
2405
|
+
import (
|
|
2406
|
+
"context"
|
|
2407
|
+
"log/slog"
|
|
2408
|
+
|
|
2409
|
+
"${MOD}/internal/booking"
|
|
2410
|
+
)
|
|
2411
|
+
|
|
2412
|
+
// sweepHolds is \`orchard-api sweep-holds\`: expire waitlist offers nobody took in time. It runs as a
|
|
2413
|
+
// Kubernetes CronJob (orchard-infra: k8s/api/cronjobs.yaml).
|
|
2414
|
+
func sweepHolds(ctx context.Context, bookings *booking.Service, log *slog.Logger) int {
|
|
2415
|
+
n, err := bookings.ExpireHolds(ctx)
|
|
2416
|
+
if err != nil {
|
|
2417
|
+
log.Error("sweep holds", "err", err, "expired", n)
|
|
2418
|
+
return 1
|
|
2419
|
+
}
|
|
2420
|
+
log.Info("swept holds", "expired", n)
|
|
2421
|
+
return 0
|
|
2422
|
+
}
|
|
2423
|
+
`,
|
|
2424
|
+
'cmd/orchard-api/main.go': replace(' bookings := booking.NewService(db, mailer)\n', ` bookings := booking.NewService(db, mailer)
|
|
2425
|
+
if len(os.Args) > 1 && os.Args[1] == "sweep-holds" {
|
|
2426
|
+
os.Exit(sweepHolds(ctx, bookings, log))
|
|
2427
|
+
}
|
|
2428
|
+
`),
|
|
2429
|
+
'README.md': append(`
|
|
2430
|
+
## Commands
|
|
2431
|
+
|
|
2432
|
+
\`orchard-api sweep-holds\` expires waitlist offers whose hold window (two hours) has passed and offers
|
|
2433
|
+
those spots to the next person in line. Production runs it every five minutes.
|
|
2434
|
+
`),
|
|
2435
|
+
};
|
|
2436
|
+
}
|