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.
Files changed (93) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/demo/lib/gitworld.js +224 -0
  3. package/dist/demo/world/api.js +2436 -0
  4. package/dist/demo/world/infra.js +189 -0
  5. package/dist/demo/world/people.js +16 -0
  6. package/dist/demo/world/review.js +578 -0
  7. package/dist/demo/world/sdk.js +376 -0
  8. package/dist/demo/world/web.js +657 -0
  9. package/dist/server/cli.js +28 -0
  10. package/dist/server/config.js +3 -0
  11. package/dist/server/examples.js +204 -0
  12. package/dist/server/index.js +17 -2
  13. package/dist/server/projects.js +2 -0
  14. package/dist/web/assets/{angular-html-DJ4eInGE.js → angular-html-F7zRgWCZ.js} +1 -1
  15. package/dist/web/assets/{angular-ts-DBhgg2a7.js → angular-ts-BpEpytbw.js} +1 -1
  16. package/dist/web/assets/{apl-TCQAu201.js → apl-CXKG97CP.js} +1 -1
  17. package/dist/web/assets/{astro-DVtFuwC2.js → astro-Bbu7K9fw.js} +1 -1
  18. package/dist/web/assets/{blade-CMp-sZYg.js → blade-BIHnATAa.js} +1 -1
  19. package/dist/web/assets/{c-DgqspIM2.js → c-C-acQYm6.js} +1 -1
  20. package/dist/web/assets/{chapel-NTLt7i8E.js → chapel-CO4oLJ27.js} +1 -1
  21. package/dist/web/assets/{cobol-CJiWQrT4.js → cobol-DwOqj9Jg.js} +1 -1
  22. package/dist/web/assets/{coffee-Bv_hE6wf.js → coffee-D4-tkAkt.js} +1 -1
  23. package/dist/web/assets/{cpp-CuUsyAvc.js → cpp-B5AO2QHP.js} +1 -1
  24. package/dist/web/assets/{crystal-BeLopiD_.js → crystal-Da5vXEdt.js} +1 -1
  25. package/dist/web/assets/{css-OngwBV3K.js → css-BPZthOhS.js} +1 -1
  26. package/dist/web/assets/{edge-BFLildoE.js → edge-Dax-tr1_.js} +1 -1
  27. package/dist/web/assets/{elixir-1Fs6FjVs.js → elixir-Bn9yrJM6.js} +1 -1
  28. package/dist/web/assets/{elm-piMM67vF.js → elm-BOsm44l2.js} +1 -1
  29. package/dist/web/assets/{erb-JJ0sF8GL.js → erb-Gt3RYukx.js} +1 -1
  30. package/dist/web/assets/{git-rebase-CtqXywWe.js → git-rebase-PkordWam.js} +1 -1
  31. package/dist/web/assets/{glimmer-js-BPewWxGC.js → glimmer-js-B5msfXVo.js} +1 -1
  32. package/dist/web/assets/{glimmer-ts-DyUQWB47.js → glimmer-ts-CR148_Kt.js} +1 -1
  33. package/dist/web/assets/{glsl-D6tJoMaT.js → glsl-qur0cRcN.js} +1 -1
  34. package/dist/web/assets/{graphql-E6u2hkWE.js → graphql-C42K488Q.js} +1 -1
  35. package/dist/web/assets/{hack-Djq191-P.js → hack-NLFouBH4.js} +1 -1
  36. package/dist/web/assets/{haml-C0LqmZG3.js → haml-DKn_-I-I.js} +1 -1
  37. package/dist/web/assets/{handlebars-DwdxF6uN.js → handlebars-CQReOdSJ.js} +1 -1
  38. package/dist/web/assets/{html-derivative-DTVrLAw3.js → html-derivative-DoqJMb8S.js} +1 -1
  39. package/dist/web/assets/{html-B5Myt0fy.js → html-iUF_eP_4.js} +1 -1
  40. package/dist/web/assets/{http-CFoh3cQf.js → http-BJrCx5B5.js} +1 -1
  41. package/dist/web/assets/{hurl-B_38naAo.js → hurl-DlDfbRcL.js} +1 -1
  42. package/dist/web/assets/{index-CfxIojO0.js → index-CH6ceWie.js} +52 -52
  43. package/dist/web/assets/index-DU33o_Lu.css +1 -0
  44. package/dist/web/assets/{java-odnexk6r.js → java-DdxicsS9.js} +1 -1
  45. package/dist/web/assets/{javascript-DvFCJKws.js → javascript-Dw8rInq-.js} +1 -1
  46. package/dist/web/assets/{jinja-UeLt2DYu.js → jinja-ztBbs9jc.js} +1 -1
  47. package/dist/web/assets/{jison-HNaDYeqE.js → jison-DyOjkTQj.js} +1 -1
  48. package/dist/web/assets/{json-D57CnrjR.js → json-C6t0u9rP.js} +1 -1
  49. package/dist/web/assets/{jsx-o_5ZAydi.js → jsx-BpIc6zqG.js} +1 -1
  50. package/dist/web/assets/{julia-20UebbrD.js → julia-CvA9RPfz.js} +1 -1
  51. package/dist/web/assets/{just-DqnPCg-Q.js → just-CiH5trEM.js} +1 -1
  52. package/dist/web/assets/{latex-CZhIB-LV.js → latex-BbpuYBUm.js} +1 -1
  53. package/dist/web/assets/{liquid-BzouPsN0.js → liquid-Dy7K9saE.js} +1 -1
  54. package/dist/web/assets/{lua-BXclv8Fm.js → lua-CLN5DayT.js} +1 -1
  55. package/dist/web/assets/{marko-M78_INlG.js → marko-Be70_WI8.js} +1 -1
  56. package/dist/web/assets/{mdc-CBluOiY7.js → mdc-RgJIMLnK.js} +1 -1
  57. package/dist/web/assets/{nginx-CZFFQFbT.js → nginx-GhsGoiRj.js} +1 -1
  58. package/dist/web/assets/{nim-17kKsGqk.js → nim-BaIl7YzC.js} +1 -1
  59. package/dist/web/assets/{org-BZABHRQH.js → org-ksusUjYr.js} +1 -1
  60. package/dist/web/assets/{perl-CMrAQ9Je.js → perl-KX9noYTv.js} +1 -1
  61. package/dist/web/assets/{php-DwA3kETk.js → php-C-szMz57.js} +1 -1
  62. package/dist/web/assets/{pug-D4fM85kA.js → pug-nbUZSc0V.js} +1 -1
  63. package/dist/web/assets/{qml-C3893RHi.js → qml-BD2H6imu.js} +1 -1
  64. package/dist/web/assets/{r-DwJhLW77.js → r-CbR0TzPr.js} +1 -1
  65. package/dist/web/assets/{razor-_KkCNZ8V.js → razor-D_SYu_ob.js} +1 -1
  66. package/dist/web/assets/{regexp-BZ9NHg3b.js → regexp-BIEFfqXG.js} +1 -1
  67. package/dist/web/assets/{rst-gdK-1c4t.js → rst-D21NyilJ.js} +1 -1
  68. package/dist/web/assets/{ruby-DsmqYtlH.js → ruby-CCk7F-X_.js} +1 -1
  69. package/dist/web/assets/{sas-BNGv1ScF.js → sas-BasHIiX3.js} +1 -1
  70. package/dist/web/assets/{scss-DjYUXT73.js → scss-DL8S6L92.js} +1 -1
  71. package/dist/web/assets/{shellscript-DBtV-eli.js → shellscript-D-B4NAUX.js} +1 -1
  72. package/dist/web/assets/{shellsession-BKEV8Fnx.js → shellsession-LwE3hLIg.js} +1 -1
  73. package/dist/web/assets/{soy-DUapMxos.js → soy-DaUNAouD.js} +1 -1
  74. package/dist/web/assets/{sql-B-UyqKkd.js → sql-Drw6fxyA.js} +1 -1
  75. package/dist/web/assets/{stata-Xq3iZiMK.js → stata-Bk-VgNHo.js} +1 -1
  76. package/dist/web/assets/{surrealql-CzXacK8i.js → surrealql-BfOZjDkH.js} +1 -1
  77. package/dist/web/assets/{svelte-CZaBEmPP.js → svelte-BP8BXHT0.js} +1 -1
  78. package/dist/web/assets/{templ-CxkGuUpr.js → templ-BgcZlshd.js} +1 -1
  79. package/dist/web/assets/{tex-DP74JH6g.js → tex-BQczXiA6.js} +1 -1
  80. package/dist/web/assets/{ts-tags-BAcrCE2Y.js → ts-tags-CVFTjDsr.js} +1 -1
  81. package/dist/web/assets/{tsx-Dw11YP6z.js → tsx-j3JIiZaT.js} +1 -1
  82. package/dist/web/assets/{twig-BMVjBxD6.js → twig-D7saNHJy.js} +1 -1
  83. package/dist/web/assets/{typescript-DoBLlVdC.js → typescript-D62o9E27.js} +1 -1
  84. package/dist/web/assets/{typst-w2u_vl4_.js → typst-DQOeX7Fq.js} +1 -1
  85. package/dist/web/assets/{vue-BmuHTv5p.js → vue-BpdrgvY0.js} +1 -1
  86. package/dist/web/assets/{vue-html-Dt0zSF4I.js → vue-html-Cwi6ng_G.js} +1 -1
  87. package/dist/web/assets/{vue-vine-wvJ1BTex.js → vue-vine-BO8zdtYP.js} +1 -1
  88. package/dist/web/assets/{xml-CXftIBCQ.js → xml-DgdKyO3D.js} +1 -1
  89. package/dist/web/assets/{xsl-BoHSOMz1.js → xsl-CLXFRDPl.js} +1 -1
  90. package/dist/web/assets/{yaml-dGreldtD.js → yaml-BYa9ePnt.js} +1 -1
  91. package/dist/web/index.html +2 -2
  92. package/package.json +3 -2
  93. 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
+ }