create-svc 0.1.10 → 0.1.12

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 (171) hide show
  1. package/README.md +51 -47
  2. package/index.ts +2 -2
  3. package/package.json +10 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +196 -33
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +232 -41
  14. package/src/scaffold.ts +81 -36
  15. package/src/service.test.ts +30 -0
  16. package/src/service.ts +65 -0
  17. package/src/vault.test.ts +61 -1
  18. package/src/vault.ts +77 -15
  19. package/templates/shared/.github/workflows/ci.yml +2 -1
  20. package/templates/shared/.github/workflows/deploy.yml +2 -0
  21. package/templates/shared/README.md +124 -47
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  27. package/templates/shared/scripts/cloudrun/cli.ts +329 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  29. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  30. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  31. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -44
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +402 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Makefile +14 -8
  53. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  54. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  55. package/templates/variants/bun-connectrpc/package.json +12 -5
  56. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  57. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  58. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  59. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  60. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  61. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  62. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  63. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  64. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  65. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  66. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  67. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  68. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  69. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  70. package/templates/variants/bun-hono/Makefile +14 -8
  71. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  72. package/templates/variants/bun-hono/package.json +12 -5
  73. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  74. package/templates/variants/bun-hono/src/auth.ts +181 -0
  75. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  76. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  77. package/templates/variants/bun-hono/src/index.ts +65 -180
  78. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  79. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  80. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  81. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  82. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  83. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  84. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  85. package/templates/variants/go-chi/Makefile +27 -11
  86. package/templates/variants/go-chi/atlas.hcl +8 -0
  87. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  88. package/templates/variants/go-chi/go.mod +1 -3
  89. package/templates/variants/go-chi/internal/app/service.go +202 -685
  90. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  91. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  92. package/templates/variants/go-chi/internal/config/config.go +27 -11
  93. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  94. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  95. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  96. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  97. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  98. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  99. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  100. package/templates/variants/go-chi/package.json +7 -1
  101. package/templates/variants/go-connectrpc/Makefile +26 -9
  102. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  103. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  104. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  105. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  106. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  107. package/templates/variants/go-connectrpc/go.mod +1 -1
  108. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  109. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  110. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  111. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  112. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  113. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  114. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  115. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  116. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  117. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  118. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  119. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  120. package/templates/variants/go-connectrpc/package.json +7 -1
  121. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  122. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  123. package/templates/root/.github/workflows/ci.yml +0 -26
  124. package/templates/root/.github/workflows/deploy.yml +0 -22
  125. package/templates/root/Dockerfile +0 -23
  126. package/templates/root/README.md +0 -69
  127. package/templates/root/buf.gen.yaml +0 -10
  128. package/templates/root/buf.yaml +0 -9
  129. package/templates/root/cmd/server/main.go +0 -44
  130. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  131. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  132. package/templates/root/go.mod +0 -10
  133. package/templates/root/internal/app/service.go +0 -152
  134. package/templates/root/internal/app/token_source.go +0 -50
  135. package/templates/root/internal/cloudflare/client.go +0 -160
  136. package/templates/root/internal/config/config.go +0 -55
  137. package/templates/root/internal/connectapi/handler.go +0 -79
  138. package/templates/root/internal/httpapi/routes.go +0 -93
  139. package/templates/root/internal/vault/client.go +0 -148
  140. package/templates/root/package.json +0 -12
  141. package/templates/root/protos/dns/v1/dns.proto +0 -58
  142. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  143. package/templates/root/scripts/cloudrun/config.ts +0 -50
  144. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  145. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  146. package/templates/root/service.yaml +0 -50
  147. package/templates/root/test/go.test.ts +0 -19
  148. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  149. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  150. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  151. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  152. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  153. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  154. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  155. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  156. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  157. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  158. package/templates/variants/bun-hono/src/storage.ts +0 -72
  159. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  160. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  161. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  162. package/templates/variants/go-chi/buf.yaml +0 -9
  163. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  164. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  165. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  166. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  167. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  168. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  169. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  170. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
  171. /package/bin/{create-svc.mjs → service.mjs} +0 -0
@@ -13,7 +13,7 @@ import (
13
13
  "{{MODULE_PATH}}/internal/app"
14
14
  )
15
15
 
16
- func RegisterRoutes(router chi.Router, service *app.ChatService) {
16
+ func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
17
17
  router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
18
18
  writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
19
19
  })
@@ -23,212 +23,105 @@ func RegisterRoutes(router chi.Router, service *app.ChatService) {
23
23
  router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
24
24
  writeJSON(w, http.StatusOK, map[string]string{
25
25
  "service": "{{SERVICE_NAME}}",
26
- "domain": "chat",
26
+ "domain": "waitlist",
27
27
  "apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
28
28
  })
29
29
  })
30
30
 
31
- router.Post("/v1/users", func(w http.ResponseWriter, request *http.Request) {
32
- var input app.CreateUserInput
31
+ router.Post("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
32
+ var input app.JoinWaitlistInput
33
33
  if err := decodeJSON(request, &input); err != nil {
34
34
  writeError(w, err)
35
35
  return
36
36
  }
37
- user, err := service.CreateUser(request.Context(), input)
37
+ result, err := service.JoinWaitlist(request.Context(), input)
38
38
  if err != nil {
39
39
  writeError(w, err)
40
40
  return
41
41
  }
42
- writeJSON(w, http.StatusCreated, map[string]any{"user": user})
43
- })
44
-
45
- router.Get("/v1/users/{userID}", func(w http.ResponseWriter, request *http.Request) {
46
- user, err := service.GetUser(request.Context(), chi.URLParam(request, "userID"))
47
- if err != nil {
48
- writeError(w, err)
49
- return
42
+ status := http.StatusOK
43
+ if result.Created {
44
+ status = http.StatusCreated
50
45
  }
51
- writeJSON(w, http.StatusOK, map[string]any{"user": user})
46
+ writeJSON(w, status, result)
52
47
  })
53
48
 
54
- router.Get("/v1/users", func(w http.ResponseWriter, request *http.Request) {
55
- user, err := service.GetUserByUsername(request.Context(), request.URL.Query().Get("username"))
49
+ router.Get("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
50
+ entry, err := service.GetEntryByEmail(request.Context(), request.URL.Query().Get("email"))
56
51
  if err != nil {
57
52
  writeError(w, err)
58
53
  return
59
54
  }
60
- writeJSON(w, http.StatusOK, map[string]any{"user": user})
55
+ writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
61
56
  })
62
57
 
63
- router.Post("/v1/conversations", func(w http.ResponseWriter, request *http.Request) {
64
- var input app.CreateConversationInput
65
- if err := decodeJSON(request, &input); err != nil {
66
- writeError(w, err)
67
- return
68
- }
69
- conversation, err := service.CreateConversation(request.Context(), input)
58
+ router.Get("/v1/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
59
+ entry, err := service.GetEntry(request.Context(), chi.URLParam(request, "entryID"))
70
60
  if err != nil {
71
61
  writeError(w, err)
72
62
  return
73
63
  }
74
- writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
75
- })
76
-
77
- router.Get("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
78
- conversation, err := service.GetConversation(request.Context(), chi.URLParam(request, "conversationID"))
79
- if err != nil {
80
- writeError(w, err)
81
- return
82
- }
83
- writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
84
- })
85
-
86
- router.Patch("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
87
- var input app.UpdateConversationInput
88
- if err := decodeJSON(request, &input); err != nil {
89
- writeError(w, err)
90
- return
91
- }
92
- conversation, err := service.UpdateConversation(request.Context(), chi.URLParam(request, "conversationID"), input)
93
- if err != nil {
94
- writeError(w, err)
95
- return
96
- }
97
- writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
98
- })
99
-
100
- router.Delete("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
101
- if err := service.DeleteConversation(request.Context(), chi.URLParam(request, "conversationID")); err != nil {
102
- writeError(w, err)
103
- return
104
- }
105
- w.WriteHeader(http.StatusNoContent)
106
- })
107
-
108
- router.Post("/v1/conversations/{conversationID}/participants", func(w http.ResponseWriter, request *http.Request) {
109
- var input struct {
110
- UserID string `json:"user_id"`
111
- }
112
- if err := decodeJSON(request, &input); err != nil {
113
- writeError(w, err)
114
- return
115
- }
116
- conversation, err := service.AddParticipant(request.Context(), chi.URLParam(request, "conversationID"), input.UserID)
117
- if err != nil {
118
- writeError(w, err)
119
- return
120
- }
121
- writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
122
- })
123
-
124
- router.Delete("/v1/conversations/{conversationID}/participants/{userID}", func(w http.ResponseWriter, request *http.Request) {
125
- if err := service.RemoveParticipant(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "userID")); err != nil {
126
- writeError(w, err)
127
- return
128
- }
129
- w.WriteHeader(http.StatusNoContent)
64
+ writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
130
65
  })
131
66
 
132
- router.Get("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
133
- limit := 0
134
- if rawLimit := strings.TrimSpace(request.URL.Query().Get("limit")); rawLimit != "" {
135
- parsedLimit, err := strconv.Atoi(rawLimit)
136
- if err != nil {
137
- writeError(w, &app.AppError{Status: http.StatusBadRequest, Code: "invalid_limit", Err: errors.New("limit must be a positive integer")})
138
- return
139
- }
140
- limit = parsedLimit
141
- }
142
- result, err := service.ListMessages(request.Context(), chi.URLParam(request, "conversationID"), app.ListMessagesInput{
143
- Cursor: strings.TrimSpace(request.URL.Query().Get("cursor")),
144
- Limit: limit,
67
+ router.Get("/v1/admin/waitlist", func(w http.ResponseWriter, request *http.Request) {
68
+ entries, err := service.ListEntries(request.Context(), app.ListWaitlistEntriesInput{
69
+ Status: request.URL.Query().Get("status"),
70
+ Limit: optionalInt(request.URL.Query().Get("limit")),
145
71
  })
146
72
  if err != nil {
147
73
  writeError(w, err)
148
74
  return
149
75
  }
150
- writeJSON(w, http.StatusOK, result)
151
- })
152
-
153
- router.Post("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
154
- var input app.CreateMessageInput
155
- if err := decodeJSON(request, &input); err != nil {
156
- writeError(w, err)
157
- return
158
- }
159
- message, err := service.CreateMessage(request.Context(), chi.URLParam(request, "conversationID"), input)
160
- if err != nil {
161
- writeError(w, err)
162
- return
163
- }
164
- writeJSON(w, http.StatusCreated, map[string]any{"message": message})
76
+ writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
165
77
  })
166
78
 
167
- router.Patch("/v1/conversations/{conversationID}/messages/{messageID}", func(w http.ResponseWriter, request *http.Request) {
168
- var input app.UpdateMessageInput
169
- if err := decodeJSON(request, &input); err != nil {
170
- writeError(w, err)
171
- return
172
- }
173
- message, err := service.UpdateMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID"), input)
79
+ router.Get("/v1/admin/waitlist/export", func(w http.ResponseWriter, request *http.Request) {
80
+ csv, err := service.ExportEntries(request.Context(), app.ListWaitlistEntriesInput{
81
+ Status: request.URL.Query().Get("status"),
82
+ Limit: optionalInt(request.URL.Query().Get("limit")),
83
+ })
174
84
  if err != nil {
175
85
  writeError(w, err)
176
86
  return
177
87
  }
178
- writeJSON(w, http.StatusOK, map[string]any{"message": message})
88
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
89
+ w.Header().Set("Content-Disposition", `attachment; filename="waitlist.csv"`)
90
+ w.WriteHeader(http.StatusOK)
91
+ _, _ = w.Write([]byte(csv))
179
92
  })
180
93
 
181
- router.Delete("/v1/conversations/{conversationID}/messages/{messageID}", func(w http.ResponseWriter, request *http.Request) {
182
- if err := service.DeleteMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID")); err != nil {
183
- writeError(w, err)
184
- return
185
- }
186
- w.WriteHeader(http.StatusNoContent)
187
- })
188
-
189
- router.Post("/v1/attachments/uploads", func(w http.ResponseWriter, request *http.Request) {
190
- var input app.CreateAttachmentUploadInput
94
+ router.Patch("/v1/admin/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
95
+ var input app.UpdateWaitlistEntryInput
191
96
  if err := decodeJSON(request, &input); err != nil {
192
97
  writeError(w, err)
193
98
  return
194
99
  }
195
- result, err := service.CreateAttachmentUpload(request.Context(), input)
100
+ input.EntryID = chi.URLParam(request, "entryID")
101
+ entry, err := service.UpdateEntry(request.Context(), input)
196
102
  if err != nil {
197
103
  writeError(w, err)
198
104
  return
199
105
  }
200
- writeJSON(w, http.StatusCreated, map[string]any{"result": result})
106
+ writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
201
107
  })
202
108
 
203
- router.Post("/v1/attachments/{attachmentID}/finalize", func(w http.ResponseWriter, request *http.Request) {
204
- var input app.FinalizeAttachmentInput
205
- if err := decodeOptionalJSON(request, &input); err != nil {
109
+ router.Post("/v1/triggers/waitlist", func(w http.ResponseWriter, request *http.Request) {
110
+ var payload map[string]any
111
+ if err := decodeOptionalJSON(request, &payload); err != nil {
206
112
  writeError(w, err)
207
113
  return
208
114
  }
209
- attachment, err := service.FinalizeAttachment(request.Context(), chi.URLParam(request, "attachmentID"), input)
210
- if err != nil {
211
- writeError(w, err)
212
- return
213
- }
214
- writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
215
- })
216
-
217
- router.Get("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
218
- attachment, err := service.GetAttachment(request.Context(), chi.URLParam(request, "attachmentID"))
115
+ trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
116
+ Type: stringValue(payload, "type", "manual"),
117
+ EntryID: stringValue(payload, "entry_id", ""),
118
+ Payload: payload,
119
+ })
219
120
  if err != nil {
220
121
  writeError(w, err)
221
122
  return
222
123
  }
223
- writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
224
- })
225
-
226
- router.Delete("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
227
- if err := service.DeleteAttachment(request.Context(), chi.URLParam(request, "attachmentID")); err != nil {
228
- writeError(w, err)
229
- return
230
- }
231
- w.WriteHeader(http.StatusNoContent)
124
+ writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
232
125
  })
233
126
 
234
127
  router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
@@ -237,16 +130,18 @@ func RegisterRoutes(router chi.Router, service *app.ChatService) {
237
130
  writeError(w, err)
238
131
  return
239
132
  }
240
- event, duplicate, err := service.ProcessWebhook(request.Context(), chi.URLParam(request, "provider"), request.Header, rawBody)
133
+ trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
134
+ Type: "webhook." + chi.URLParam(request, "provider"),
135
+ Payload: map[string]any{
136
+ "headers": request.Header,
137
+ "rawBody": string(rawBody),
138
+ },
139
+ })
241
140
  if err != nil {
242
141
  writeError(w, err)
243
142
  return
244
143
  }
245
- status := http.StatusAccepted
246
- if duplicate {
247
- status = http.StatusOK
248
- }
249
- writeJSON(w, status, map[string]any{"event": event, "duplicate": duplicate})
144
+ writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
250
145
  })
251
146
 
252
147
  router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
@@ -288,8 +183,34 @@ func writeError(w http.ResponseWriter, err error) {
288
183
  }
289
184
 
290
185
  status := http.StatusInternalServerError
291
- if errors.Is(err, strconv.ErrSyntax) || strings.Contains(strings.ToLower(err.Error()), "json") {
186
+ if strings.Contains(strings.ToLower(err.Error()), "json") {
292
187
  status = http.StatusBadRequest
293
188
  }
294
189
  writeJSON(w, status, map[string]string{"error": err.Error()})
295
190
  }
191
+
192
+ func stringValue(values map[string]any, key string, fallback string) string {
193
+ if values == nil {
194
+ return fallback
195
+ }
196
+ value, ok := values[key]
197
+ if !ok && key == "entry_id" {
198
+ value, ok = values["entryId"]
199
+ }
200
+ if !ok {
201
+ return fallback
202
+ }
203
+ text, ok := value.(string)
204
+ if !ok {
205
+ return fallback
206
+ }
207
+ return strings.TrimSpace(text)
208
+ }
209
+
210
+ func optionalInt(value string) int {
211
+ parsed, err := strconv.Atoi(strings.TrimSpace(value))
212
+ if err != nil {
213
+ return 0
214
+ }
215
+ return parsed
216
+ }
@@ -0,0 +1,199 @@
1
+ package httpapi
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "os"
11
+ "strings"
12
+ "testing"
13
+
14
+ "github.com/go-chi/chi/v5"
15
+ _ "github.com/jackc/pgx/v5/stdlib"
16
+
17
+ "{{MODULE_PATH}}/internal/app"
18
+ )
19
+
20
+ func TestWaitlistJoinIsIdempotentAndRecordsTriggers(t *testing.T) {
21
+ databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
22
+ if databaseURL == "" {
23
+ t.Skip("DATABASE_URL is required for integration test")
24
+ }
25
+
26
+ db, err := app.OpenDatabase(context.Background(), databaseURL)
27
+ if err != nil {
28
+ t.Fatalf("open database: %v", err)
29
+ }
30
+ t.Cleanup(func() { _ = db.Close() })
31
+
32
+ if _, err := db.ExecContext(context.Background(), `
33
+ truncate table
34
+ waitlist_triggers,
35
+ waitlist_entries
36
+ restart identity cascade`); err != nil {
37
+ t.Fatalf("truncate tables: %v", err)
38
+ }
39
+
40
+ service := app.NewWaitlistService(db)
41
+ router := chi.NewRouter()
42
+ RegisterRoutes(router, service)
43
+ server := httptest.NewServer(router)
44
+ t.Cleanup(server.Close)
45
+
46
+ first := joinWaitlist(t, server.URL, "Founder@Example.com")
47
+ if first.Created != true {
48
+ t.Fatal("expected first join to create entry")
49
+ }
50
+ if first.Entry.Email != "founder@example.com" {
51
+ t.Fatalf("expected normalized email, got %s", first.Entry.Email)
52
+ }
53
+
54
+ second := joinWaitlist(t, server.URL, "founder@example.com")
55
+ if second.Created {
56
+ t.Fatal("expected second join to be idempotent")
57
+ }
58
+ if second.Entry.ID != first.Entry.ID {
59
+ t.Fatalf("expected same entry id, got %s and %s", first.Entry.ID, second.Entry.ID)
60
+ }
61
+
62
+ trigger := recordTrigger(t, server.URL, first.Entry.ID)
63
+ if trigger.Trigger.Type != "cron.digest" {
64
+ t.Fatalf("expected cron.digest trigger, got %s", trigger.Trigger.Type)
65
+ }
66
+ if trigger.Trigger.EntryID != first.Entry.ID {
67
+ t.Fatalf("expected trigger entry id %s, got %s", first.Entry.ID, trigger.Trigger.EntryID)
68
+ }
69
+
70
+ updated := updateEntry(t, server.URL, first.Entry.ID, "invited")
71
+ if updated.Entry.Status != "invited" {
72
+ t.Fatalf("expected invited status, got %s", updated.Entry.Status)
73
+ }
74
+
75
+ list := listEntries(t, server.URL, "invited")
76
+ if len(list.Entries) != 1 || list.Entries[0].ID != first.Entry.ID {
77
+ t.Fatalf("expected one invited entry %s, got %+v", first.Entry.ID, list.Entries)
78
+ }
79
+
80
+ exported := exportEntries(t, server.URL, "invited")
81
+ if !strings.Contains(exported, "founder@example.com") {
82
+ t.Fatalf("expected exported csv to contain email, got %s", exported)
83
+ }
84
+ }
85
+
86
+ type joinResponse struct {
87
+ Entry app.WaitlistEntry `json:"entry"`
88
+ Created bool `json:"created"`
89
+ }
90
+
91
+ type triggerResponse struct {
92
+ Trigger app.WaitlistTrigger `json:"trigger"`
93
+ }
94
+
95
+ type entryResponse struct {
96
+ Entry app.WaitlistEntry `json:"entry"`
97
+ }
98
+
99
+ type listResponse struct {
100
+ Entries []app.WaitlistEntry `json:"entries"`
101
+ }
102
+
103
+ func joinWaitlist(t *testing.T, baseURL string, email string) joinResponse {
104
+ t.Helper()
105
+ body := bytes.NewBufferString(`{"email":` + quoteJSON(email) + `,"name":"Founder","company":"Example Co","source":"homepage"}`)
106
+ response, err := http.Post(baseURL+"/v1/waitlist", "application/json", body)
107
+ if err != nil {
108
+ t.Fatalf("join waitlist: %v", err)
109
+ }
110
+ defer response.Body.Close()
111
+ if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusOK {
112
+ t.Fatalf("expected success, got %d", response.StatusCode)
113
+ }
114
+ var payload joinResponse
115
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
116
+ t.Fatalf("decode join response: %v", err)
117
+ }
118
+ return payload
119
+ }
120
+
121
+ func recordTrigger(t *testing.T, baseURL string, entryID string) triggerResponse {
122
+ t.Helper()
123
+ body := bytes.NewBufferString(`{"type":"cron.digest","entry_id":` + quoteJSON(entryID) + `}`)
124
+ response, err := http.Post(baseURL+"/v1/triggers/waitlist", "application/json", body)
125
+ if err != nil {
126
+ t.Fatalf("record trigger: %v", err)
127
+ }
128
+ defer response.Body.Close()
129
+ if response.StatusCode != http.StatusAccepted {
130
+ t.Fatalf("expected 202, got %d", response.StatusCode)
131
+ }
132
+ var payload triggerResponse
133
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
134
+ t.Fatalf("decode trigger response: %v", err)
135
+ }
136
+ return payload
137
+ }
138
+
139
+ func updateEntry(t *testing.T, baseURL string, entryID string, status string) entryResponse {
140
+ t.Helper()
141
+ body := bytes.NewBufferString(`{"status":` + quoteJSON(status) + `}`)
142
+ request, err := http.NewRequest(http.MethodPatch, baseURL+"/v1/admin/waitlist/"+entryID, body)
143
+ if err != nil {
144
+ t.Fatalf("build update entry request: %v", err)
145
+ }
146
+ request.Header.Set("Content-Type", "application/json")
147
+ response, err := http.DefaultClient.Do(request)
148
+ if err != nil {
149
+ t.Fatalf("update entry: %v", err)
150
+ }
151
+ defer response.Body.Close()
152
+ if response.StatusCode != http.StatusOK {
153
+ t.Fatalf("expected 200, got %d", response.StatusCode)
154
+ }
155
+ var payload entryResponse
156
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
157
+ t.Fatalf("decode update response: %v", err)
158
+ }
159
+ return payload
160
+ }
161
+
162
+ func listEntries(t *testing.T, baseURL string, status string) listResponse {
163
+ t.Helper()
164
+ response, err := http.Get(baseURL + "/v1/admin/waitlist?status=" + status)
165
+ if err != nil {
166
+ t.Fatalf("list entries: %v", err)
167
+ }
168
+ defer response.Body.Close()
169
+ if response.StatusCode != http.StatusOK {
170
+ t.Fatalf("expected 200, got %d", response.StatusCode)
171
+ }
172
+ var payload listResponse
173
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
174
+ t.Fatalf("decode list response: %v", err)
175
+ }
176
+ return payload
177
+ }
178
+
179
+ func exportEntries(t *testing.T, baseURL string, status string) string {
180
+ t.Helper()
181
+ response, err := http.Get(baseURL + "/v1/admin/waitlist/export?status=" + status)
182
+ if err != nil {
183
+ t.Fatalf("export entries: %v", err)
184
+ }
185
+ defer response.Body.Close()
186
+ if response.StatusCode != http.StatusOK {
187
+ t.Fatalf("expected 200, got %d", response.StatusCode)
188
+ }
189
+ raw, err := io.ReadAll(response.Body)
190
+ if err != nil {
191
+ t.Fatalf("read export response: %v", err)
192
+ }
193
+ return string(raw)
194
+ }
195
+
196
+ func quoteJSON(value string) string {
197
+ encoded, _ := json.Marshal(value)
198
+ return string(encoded)
199
+ }
@@ -0,0 +1,27 @@
1
+ package temporalapp
2
+
3
+ import "context"
4
+
5
+ type WaitlistFollowUpInput struct {
6
+ TriggerID string
7
+ Email string
8
+ Type string
9
+ }
10
+
11
+ type WaitlistFollowUpResult struct {
12
+ Status string
13
+ TriggerID string
14
+ Email string
15
+ Type string
16
+ }
17
+
18
+ type Activities struct{}
19
+
20
+ func (a *Activities) RecordWaitlistFollowUp(ctx context.Context, input WaitlistFollowUpInput) (WaitlistFollowUpResult, error) {
21
+ return WaitlistFollowUpResult{
22
+ Status: "queued",
23
+ TriggerID: input.TriggerID,
24
+ Email: input.Email,
25
+ Type: input.Type,
26
+ }, nil
27
+ }
@@ -0,0 +1,42 @@
1
+ package temporalapp
2
+
3
+ import (
4
+ "go.temporal.io/sdk/client"
5
+ "go.temporal.io/sdk/worker"
6
+ )
7
+
8
+ type WorkerConfig struct {
9
+ Address string
10
+ Namespace string
11
+ TaskQueue string
12
+ APIKey string
13
+ }
14
+
15
+ func StartWorker(cfg WorkerConfig) (func(), error) {
16
+ options := client.Options{
17
+ HostPort: cfg.Address,
18
+ Namespace: cfg.Namespace,
19
+ }
20
+ if cfg.APIKey != "" {
21
+ options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
22
+ }
23
+
24
+ temporalClient, err := client.Dial(options)
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+
29
+ temporalWorker := worker.New(temporalClient, cfg.TaskQueue, worker.Options{})
30
+ temporalWorker.RegisterWorkflow(WaitlistFollowUpWorkflow)
31
+ temporalWorker.RegisterActivity(&Activities{})
32
+
33
+ if err := temporalWorker.Start(); err != nil {
34
+ temporalClient.Close()
35
+ return nil, err
36
+ }
37
+
38
+ return func() {
39
+ temporalWorker.Stop()
40
+ temporalClient.Close()
41
+ }, nil
42
+ }
@@ -0,0 +1,18 @@
1
+ package temporalapp
2
+
3
+ import (
4
+ "time"
5
+
6
+ "go.temporal.io/sdk/workflow"
7
+ )
8
+
9
+ func WaitlistFollowUpWorkflow(ctx workflow.Context, input WaitlistFollowUpInput) (WaitlistFollowUpResult, error) {
10
+ options := workflow.ActivityOptions{
11
+ StartToCloseTimeout: time.Minute,
12
+ }
13
+ ctx = workflow.WithActivityOptions(ctx, options)
14
+
15
+ var result WaitlistFollowUpResult
16
+ err := workflow.ExecuteActivity(ctx, "RecordWaitlistFollowUp", input).Get(ctx, &result)
17
+ return result, err
18
+ }
@@ -1,63 +1,20 @@
1
- create table if not exists users (
1
+ create table if not exists waitlist_entries (
2
2
  id text primary key,
3
- username text not null unique,
4
- display_name text,
3
+ email text not null unique,
4
+ name text,
5
+ company text,
6
+ source text,
7
+ status text not null default 'joined',
5
8
  created_at timestamptz not null default now(),
6
9
  updated_at timestamptz not null default now()
7
10
  );
8
11
 
9
- create table if not exists conversations (
12
+ create table if not exists waitlist_triggers (
10
13
  id text primary key,
11
- title text,
12
- created_by_user_id text not null references users(id),
13
- deleted_at timestamptz,
14
- created_at timestamptz not null default now(),
15
- updated_at timestamptz not null default now()
16
- );
17
-
18
- create table if not exists conversation_participants (
19
- conversation_id text not null references conversations(id),
20
- user_id text not null references users(id),
21
- joined_at timestamptz not null default now(),
22
- primary key (conversation_id, user_id)
23
- );
24
-
25
- create table if not exists messages (
26
- id text primary key,
27
- conversation_id text not null references conversations(id),
28
- user_id text not null references users(id),
29
- body text not null,
30
- edited_at timestamptz,
31
- deleted_at timestamptz,
32
- created_at timestamptz not null default now(),
33
- updated_at timestamptz not null default now()
34
- );
35
-
36
- create table if not exists attachments (
37
- id text primary key,
38
- conversation_id text not null references conversations(id),
39
- message_id text references messages(id),
40
- uploaded_by_user_id text not null references users(id),
41
- storage_bucket text not null,
42
- storage_key text not null,
43
- content_type text not null,
44
- byte_size bigint not null,
45
- filename text not null,
46
- status text not null,
47
- deleted_at timestamptz,
48
- created_at timestamptz not null default now(),
49
- updated_at timestamptz not null default now()
50
- );
51
-
52
- create table if not exists webhook_events (
53
- id text primary key,
54
- provider text not null,
55
- external_event_id text not null,
56
- event_type text not null,
57
- signature_valid text not null,
58
- status text not null,
14
+ type text not null,
15
+ entry_id text references waitlist_entries(id),
16
+ status text not null default 'queued',
59
17
  payload_json text not null,
60
- received_at timestamptz not null default now(),
61
- processed_at timestamptz,
62
- unique (provider, external_event_id)
18
+ created_at timestamptz not null default now(),
19
+ processed_at timestamptz
63
20
  );