create-svc 0.1.10 → 0.1.11

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 (168) hide show
  1. package/README.md +46 -43
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +12 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +195 -30
  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 +231 -40
  14. package/src/scaffold.ts +84 -29
  15. package/src/vault.test.ts +61 -1
  16. package/src/vault.ts +77 -15
  17. package/templates/shared/.github/workflows/ci.yml +2 -1
  18. package/templates/shared/.github/workflows/deploy.yml +2 -0
  19. package/templates/shared/README.md +124 -47
  20. package/templates/shared/grafana/alerts.yaml +54 -0
  21. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  22. package/templates/shared/scripts/authctl.ts +231 -0
  23. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  24. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  25. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  26. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  27. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  28. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  29. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  30. package/templates/shared/scripts/dev.ts +22 -0
  31. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  32. package/templates/shared/scripts/local-docker.ts +63 -0
  33. package/templates/shared/scripts/local-env.ts +27 -0
  34. package/templates/shared/scripts/seed.ts +73 -0
  35. package/templates/shared/scripts/wait-for-db.ts +32 -0
  36. package/templates/shared/service.config.ts +59 -0
  37. package/templates/shared/service.yaml +24 -44
  38. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  39. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  40. package/templates/targets/workers/Makefile +33 -0
  41. package/templates/targets/workers/README.md +75 -0
  42. package/templates/targets/workers/package.json +35 -0
  43. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  44. package/templates/targets/workers/src/auth.ts +178 -0
  45. package/templates/targets/workers/src/index.ts +198 -0
  46. package/templates/targets/workers/src/storage.ts +370 -0
  47. package/templates/targets/workers/test/app.test.ts +108 -0
  48. package/templates/targets/workers/tsconfig.json +11 -0
  49. package/templates/targets/workers/wrangler.toml +24 -0
  50. package/templates/variants/bun-connectrpc/Makefile +14 -8
  51. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  52. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  53. package/templates/variants/bun-connectrpc/package.json +12 -5
  54. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  55. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  56. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  57. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  58. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  59. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  60. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  61. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  62. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  63. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  64. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  65. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  66. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  67. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  68. package/templates/variants/bun-hono/Makefile +14 -8
  69. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  70. package/templates/variants/bun-hono/package.json +12 -5
  71. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  72. package/templates/variants/bun-hono/src/auth.ts +181 -0
  73. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  74. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  75. package/templates/variants/bun-hono/src/index.ts +65 -180
  76. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  77. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  78. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  79. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  80. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  81. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  82. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  83. package/templates/variants/go-chi/Makefile +27 -11
  84. package/templates/variants/go-chi/atlas.hcl +8 -0
  85. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  86. package/templates/variants/go-chi/go.mod +1 -3
  87. package/templates/variants/go-chi/internal/app/service.go +202 -685
  88. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  89. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  90. package/templates/variants/go-chi/internal/config/config.go +27 -11
  91. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  92. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  93. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  94. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  95. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  96. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  97. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  98. package/templates/variants/go-chi/package.json +7 -1
  99. package/templates/variants/go-connectrpc/Makefile +26 -9
  100. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  101. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  102. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  103. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  104. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  105. package/templates/variants/go-connectrpc/go.mod +1 -1
  106. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  107. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  108. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  109. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  110. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  111. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  112. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  113. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  114. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  115. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  116. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  117. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  118. package/templates/variants/go-connectrpc/package.json +7 -1
  119. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  120. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  121. package/templates/root/.github/workflows/ci.yml +0 -26
  122. package/templates/root/.github/workflows/deploy.yml +0 -22
  123. package/templates/root/Dockerfile +0 -23
  124. package/templates/root/README.md +0 -69
  125. package/templates/root/buf.gen.yaml +0 -10
  126. package/templates/root/buf.yaml +0 -9
  127. package/templates/root/cmd/server/main.go +0 -44
  128. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  129. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  130. package/templates/root/go.mod +0 -10
  131. package/templates/root/internal/app/service.go +0 -152
  132. package/templates/root/internal/app/token_source.go +0 -50
  133. package/templates/root/internal/cloudflare/client.go +0 -160
  134. package/templates/root/internal/config/config.go +0 -55
  135. package/templates/root/internal/connectapi/handler.go +0 -79
  136. package/templates/root/internal/httpapi/routes.go +0 -93
  137. package/templates/root/internal/vault/client.go +0 -148
  138. package/templates/root/package.json +0 -12
  139. package/templates/root/protos/dns/v1/dns.proto +0 -58
  140. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  141. package/templates/root/scripts/cloudrun/config.ts +0 -50
  142. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  143. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  144. package/templates/root/service.yaml +0 -50
  145. package/templates/root/test/go.test.ts +0 -19
  146. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  147. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  148. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  149. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  150. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  151. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  152. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  153. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  154. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  155. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  156. package/templates/variants/bun-hono/src/storage.ts +0 -72
  157. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  158. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  159. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  160. package/templates/variants/go-chi/buf.yaml +0 -9
  161. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  162. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  163. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  164. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  165. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  166. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  167. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  168. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
@@ -1,7 +1,9 @@
1
1
  import { Hono } from "hono";
2
- import { AppError, createDefaultChatService, type ChatService } from "./chat/service";
2
+ import { authMiddleware } from "./auth";
3
+ import { AppError, createDefaultWaitlistService, type WaitlistService } from "./waitlist/service";
4
+ import { startTemporalWorker } from "./temporal/worker";
3
5
 
4
- export function createApp(service: ChatService) {
6
+ export function createApp(service: WaitlistService) {
5
7
  const app = new Hono();
6
8
 
7
9
  app.get("/healthz", (context) => context.json({ status: "ok" }));
@@ -9,71 +11,50 @@ export function createApp(service: ChatService) {
9
11
  app.get("/", (context) =>
10
12
  context.json({
11
13
  service: "{{SERVICE_NAME}}",
12
- domain: "chat",
14
+ domain: "waitlist",
13
15
  apiOrigin: "https://api.{{SERVICE_NAME}}.anmho.com",
14
16
  })
15
17
  );
16
18
 
17
- app.post("/v1/users", async (context) => {
19
+ app.use("/v1/*", authMiddleware());
20
+
21
+ app.post("/v1/waitlist", async (context) => {
18
22
  try {
19
23
  const body = await context.req.json();
20
- const user = await service.createUser({
21
- username: String(body.username ?? ""),
22
- displayName: body.display_name ?? body.displayName ?? null,
24
+ const result = await service.joinWaitlist({
25
+ email: String(body.email ?? ""),
26
+ name: body.name ?? null,
27
+ company: body.company ?? null,
28
+ source: body.source ?? null,
23
29
  });
24
- return context.json({ user }, 201);
30
+ return context.json(result, result.created ? 201 : 200);
25
31
  } catch (error) {
26
32
  return writeError(context, error);
27
33
  }
28
34
  });
29
35
 
30
- app.get("/v1/users/:userId", async (context) => {
36
+ app.get("/v1/waitlist", async (context) => {
31
37
  try {
32
- return context.json({ user: await service.getUser(context.req.param("userId")) });
38
+ return context.json({ entry: await service.getWaitlistEntryByEmail(context.req.query("email") ?? "") });
33
39
  } catch (error) {
34
40
  return writeError(context, error);
35
41
  }
36
42
  });
37
43
 
38
- app.get("/v1/users", async (context) => {
44
+ app.get("/v1/waitlist/:entryId", async (context) => {
39
45
  try {
40
- const username = context.req.query("username") ?? "";
41
- return context.json({ user: await service.getUserByUsername(username) });
46
+ return context.json({ entry: await service.getWaitlistEntry(context.req.param("entryId")) });
42
47
  } catch (error) {
43
48
  return writeError(context, error);
44
49
  }
45
50
  });
46
51
 
47
- app.post("/v1/conversations", async (context) => {
52
+ app.get("/v1/admin/waitlist", async (context) => {
48
53
  try {
49
- const body = await context.req.json();
50
- const conversation = await service.createConversation({
51
- createdByUserId: String(body.created_by_user_id ?? body.createdByUserId ?? ""),
52
- title: body.title ?? null,
53
- participantUserIds: Array.isArray(body.participant_user_ids ?? body.participantUserIds)
54
- ? (body.participant_user_ids ?? body.participantUserIds).map(String)
55
- : [],
56
- });
57
- return context.json({ conversation }, 201);
58
- } catch (error) {
59
- return writeError(context, error);
60
- }
61
- });
62
-
63
- app.get("/v1/conversations/:conversationId", async (context) => {
64
- try {
65
- return context.json({ conversation: await service.getConversation(context.req.param("conversationId")) });
66
- } catch (error) {
67
- return writeError(context, error);
68
- }
69
- });
70
-
71
- app.patch("/v1/conversations/:conversationId", async (context) => {
72
- try {
73
- const body = await context.req.json();
74
54
  return context.json({
75
- conversation: await service.updateConversation(context.req.param("conversationId"), {
76
- title: body.title ?? null,
55
+ entries: await service.listWaitlistEntries({
56
+ status: context.req.query("status"),
57
+ limit: parseOptionalNumber(context.req.query("limit")),
77
58
  }),
78
59
  });
79
60
  } catch (error) {
@@ -81,159 +62,46 @@ export function createApp(service: ChatService) {
81
62
  }
82
63
  });
83
64
 
84
- app.delete("/v1/conversations/:conversationId", async (context) => {
85
- try {
86
- await service.deleteConversation(context.req.param("conversationId"));
87
- return context.body(null, 204);
88
- } catch (error) {
89
- return writeError(context, error);
90
- }
91
- });
92
-
93
- app.post("/v1/conversations/:conversationId/participants", async (context) => {
65
+ app.patch("/v1/admin/waitlist/:entryId", async (context) => {
94
66
  try {
95
67
  const body = await context.req.json();
96
- return context.json(
97
- {
98
- conversation: await service.addParticipant(
99
- context.req.param("conversationId"),
100
- String(body.user_id ?? body.userId ?? "")
101
- ),
102
- },
103
- 201
104
- );
105
- } catch (error) {
106
- return writeError(context, error);
107
- }
108
- });
109
-
110
- app.delete("/v1/conversations/:conversationId/participants/:userId", async (context) => {
111
- try {
112
- await service.removeParticipant(context.req.param("conversationId"), context.req.param("userId"));
113
- return context.body(null, 204);
114
- } catch (error) {
115
- return writeError(context, error);
116
- }
117
- });
118
-
119
- app.get("/v1/conversations/:conversationId/messages", async (context) => {
120
- try {
121
- const limit = context.req.query("limit");
122
- const result = await service.listMessages(context.req.param("conversationId"), {
123
- cursor: context.req.query("cursor") ?? undefined,
124
- limit: limit == null ? undefined : Number(limit),
125
- });
126
68
  return context.json({
127
- messages: result.messages.map((message) => ({
128
- id: message.id,
129
- conversation_id: message.conversationId,
130
- user_id: message.userId,
131
- body: message.body,
132
- edited_at: message.editedAt,
133
- created_at: message.createdAt,
134
- updated_at: message.updatedAt,
135
- attachments: message.attachments.map((attachment) => ({
136
- id: attachment.id,
137
- filename: attachment.filename,
138
- content_type: attachment.contentType,
139
- byte_size: attachment.byteSize,
140
- status: attachment.status,
141
- public_url: attachment.publicUrl,
142
- })),
143
- })),
144
- ...(result.nextCursor ? { next_cursor: result.nextCursor } : {}),
69
+ entry: await service.updateWaitlistEntry({
70
+ entryId: context.req.param("entryId"),
71
+ status: String(body.status ?? ""),
72
+ }),
145
73
  });
146
74
  } catch (error) {
147
75
  return writeError(context, error);
148
76
  }
149
77
  });
150
78
 
151
- app.post("/v1/conversations/:conversationId/messages", async (context) => {
152
- try {
153
- const body = await context.req.json();
154
- return context.json(
155
- {
156
- message: await service.createMessage(context.req.param("conversationId"), {
157
- userId: String(body.user_id ?? body.userId ?? ""),
158
- body: String(body.body ?? ""),
159
- }),
160
- },
161
- 201
162
- );
163
- } catch (error) {
164
- return writeError(context, error);
165
- }
166
- });
167
-
168
- app.patch("/v1/conversations/:conversationId/messages/:messageId", async (context) => {
79
+ app.get("/v1/admin/waitlist/export", async (context) => {
169
80
  try {
170
- const body = await context.req.json();
171
- return context.json({
172
- message: await service.updateMessage(
173
- context.req.param("conversationId"),
174
- context.req.param("messageId"),
175
- { body: String(body.body ?? "") }
176
- ),
81
+ const csv = await service.exportWaitlistEntries({
82
+ status: context.req.query("status"),
83
+ limit: parseOptionalNumber(context.req.query("limit")),
177
84
  });
178
- } catch (error) {
179
- return writeError(context, error);
180
- }
181
- });
182
-
183
- app.delete("/v1/conversations/:conversationId/messages/:messageId", async (context) => {
184
- try {
185
- await service.deleteMessage(context.req.param("conversationId"), context.req.param("messageId"));
186
- return context.body(null, 204);
187
- } catch (error) {
188
- return writeError(context, error);
189
- }
190
- });
191
-
192
- app.post("/v1/attachments/uploads", async (context) => {
193
- try {
194
- const body = await context.req.json();
195
- return context.json(
196
- {
197
- result: await service.createAttachmentUpload({
198
- conversationId: String(body.conversation_id ?? body.conversationId ?? ""),
199
- uploadedByUserId: String(body.user_id ?? body.userId ?? ""),
200
- filename: String(body.filename ?? ""),
201
- contentType: String(body.content_type ?? body.contentType ?? ""),
202
- byteSize: Number(body.byte_size ?? body.byteSize ?? 0),
203
- }),
85
+ return new Response(csv, {
86
+ headers: {
87
+ "Content-Type": "text/csv; charset=utf-8",
88
+ "Content-Disposition": 'attachment; filename="waitlist.csv"',
204
89
  },
205
- 201
206
- );
90
+ });
207
91
  } catch (error) {
208
92
  return writeError(context, error);
209
93
  }
210
94
  });
211
95
 
212
- app.post("/v1/attachments/:attachmentId/finalize", async (context) => {
96
+ app.post("/v1/triggers/waitlist", async (context) => {
213
97
  try {
214
98
  const body = await context.req.json().catch(() => ({}));
215
- return context.json({
216
- attachment: await service.finalizeAttachment(context.req.param("attachmentId"), {
217
- messageId: body.message_id ?? body.messageId ?? null,
218
- }),
99
+ const trigger = await service.recordTrigger({
100
+ type: String(body.type ?? "manual"),
101
+ entryId: body.entry_id ?? body.entryId ?? null,
102
+ payload: body,
219
103
  });
220
- } catch (error) {
221
- return writeError(context, error);
222
- }
223
- });
224
-
225
- app.get("/v1/attachments/:attachmentId", async (context) => {
226
- try {
227
- return context.json({ attachment: await service.getAttachment(context.req.param("attachmentId")) });
228
- } catch (error) {
229
- return writeError(context, error);
230
- }
231
- });
232
-
233
- app.delete("/v1/attachments/:attachmentId", async (context) => {
234
- try {
235
- await service.deleteAttachment(context.req.param("attachmentId"));
236
- return context.body(null, 204);
104
+ return context.json({ trigger }, 202);
237
105
  } catch (error) {
238
106
  return writeError(context, error);
239
107
  }
@@ -242,8 +110,15 @@ export function createApp(service: ChatService) {
242
110
  app.post("/webhooks/:provider", async (context) => {
243
111
  try {
244
112
  const rawBody = await context.req.text();
245
- const result = await service.processWebhook(context.req.param("provider"), context.req.raw.headers, rawBody);
246
- return context.json(result, result.duplicate ? 200 : 202);
113
+ const trigger = await service.recordTrigger({
114
+ type: `webhook.${context.req.param("provider")}`,
115
+ entryId: null,
116
+ payload: {
117
+ headers: Object.fromEntries(context.req.raw.headers),
118
+ rawBody,
119
+ },
120
+ });
121
+ return context.json({ trigger }, 202);
247
122
  } catch (error) {
248
123
  return writeError(context, error);
249
124
  }
@@ -258,13 +133,23 @@ function writeError(context: any, error: unknown) {
258
133
  if (error instanceof AppError) {
259
134
  return context.json({ error: error.message, code: error.code }, error.status);
260
135
  }
261
- return context.json({ error: error instanceof Error ? error.message : String(error) }, 500);
136
+
137
+ console.error(error);
138
+ return context.json({ error: "internal server error", code: "internal" }, 500);
139
+ }
140
+
141
+ function parseOptionalNumber(value: string | undefined) {
142
+ return value ? Number(value) : null;
262
143
  }
263
144
 
264
145
  if (import.meta.main) {
265
- const app = createApp(createDefaultChatService());
146
+ const temporalWorker = await startTemporalWorker();
147
+ if (temporalWorker) {
148
+ console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
149
+ }
150
+
266
151
  Bun.serve({
267
- port: Number(Bun.env.PORT ?? 8080),
268
- fetch: app.fetch,
152
+ port: Number(Bun.env.PORT ?? 3000),
153
+ fetch: createApp(createDefaultWaitlistService()).fetch,
269
154
  });
270
155
  }
@@ -0,0 +1,14 @@
1
+ export type WaitlistFollowUpInput = {
2
+ triggerId?: string;
3
+ email?: string;
4
+ type: string;
5
+ };
6
+
7
+ export async function recordWaitlistFollowUp(input: WaitlistFollowUpInput) {
8
+ return {
9
+ status: "queued",
10
+ triggerId: input.triggerId ?? null,
11
+ email: input.email ?? null,
12
+ type: input.type,
13
+ };
14
+ }
@@ -0,0 +1,38 @@
1
+ import { NativeConnection, Worker } from "@temporalio/worker";
2
+ import * as activities from "./activities";
3
+
4
+ export function temporalWorkerEnabled() {
5
+ return (Bun.env.TEMPORAL_ENABLED ?? "").trim().toLowerCase() === "true";
6
+ }
7
+
8
+ export async function startTemporalWorker() {
9
+ if (!temporalWorkerEnabled()) {
10
+ return undefined;
11
+ }
12
+
13
+ const address = Bun.env.TEMPORAL_ADDRESS || "localhost:7233";
14
+ const namespace = Bun.env.TEMPORAL_NAMESPACE || "default";
15
+ const taskQueue = Bun.env.TEMPORAL_TASK_QUEUE || "{{SERVICE_NAME}}";
16
+ const apiKey = Bun.env.TEMPORAL_API_KEY?.trim();
17
+ const connection = await NativeConnection.connect({
18
+ address,
19
+ ...(apiKey ? { apiKey } : {}),
20
+ });
21
+ const worker = await Worker.create({
22
+ connection,
23
+ namespace,
24
+ taskQueue,
25
+ workflowsPath: new URL("./workflows.ts", import.meta.url).pathname,
26
+ activities,
27
+ });
28
+ const running = worker.run();
29
+
30
+ return {
31
+ taskQueue,
32
+ async shutdown() {
33
+ worker.shutdown();
34
+ await running.catch(() => undefined);
35
+ await connection.close();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,10 @@
1
+ import { proxyActivities } from "@temporalio/workflow";
2
+ import type * as activities from "./activities";
3
+
4
+ const { recordWaitlistFollowUp } = proxyActivities<typeof activities>({
5
+ startToCloseTimeout: "1 minute",
6
+ });
7
+
8
+ export async function waitlistFollowUpWorkflow(input: activities.WaitlistFollowUpInput) {
9
+ return await recordWaitlistFollowUp(input);
10
+ }
@@ -0,0 +1,166 @@
1
+ import { createDb } from "../db/client";
2
+ import { WaitlistRepository } from "../db/repository";
3
+ import type {
4
+ JoinWaitlistInput,
5
+ ListWaitlistEntriesInput,
6
+ RecordTriggerInput,
7
+ UpdateWaitlistEntryInput,
8
+ WaitlistEntry,
9
+ WaitlistEntryStatus,
10
+ } from "./types";
11
+
12
+ export class AppError extends Error {
13
+ constructor(
14
+ readonly status: number,
15
+ readonly code: string,
16
+ message: string
17
+ ) {
18
+ super(message);
19
+ }
20
+ }
21
+
22
+ export type WaitlistService = {
23
+ joinWaitlist(input: JoinWaitlistInput): Promise<{ entry: WaitlistEntry; created: boolean }>;
24
+ getWaitlistEntry(entryId: string): Promise<WaitlistEntry>;
25
+ getWaitlistEntryByEmail(email: string): Promise<WaitlistEntry>;
26
+ listWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<WaitlistEntry[]>;
27
+ updateWaitlistEntry(input: UpdateWaitlistEntryInput): Promise<WaitlistEntry>;
28
+ exportWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<string>;
29
+ recordTrigger(input: RecordTriggerInput): Promise<unknown>;
30
+ };
31
+
32
+ export class DefaultWaitlistService implements WaitlistService {
33
+ constructor(private readonly repository: WaitlistRepository) {}
34
+
35
+ async joinWaitlist(input: JoinWaitlistInput) {
36
+ const email = normalizeEmail(input.email);
37
+ const existing = await this.repository.getEntryByEmail(email);
38
+ if (existing) {
39
+ return { entry: existing, created: false };
40
+ }
41
+
42
+ const entry = await this.repository.createEntry({
43
+ id: crypto.randomUUID(),
44
+ email,
45
+ name: normalizeNullableText(input.name),
46
+ company: normalizeNullableText(input.company),
47
+ source: normalizeNullableText(input.source),
48
+ });
49
+
50
+ await this.repository.createTrigger({
51
+ id: crypto.randomUUID(),
52
+ type: "waitlist.joined",
53
+ entryId: entry.id,
54
+ payload: { email: entry.email, source: entry.source },
55
+ });
56
+
57
+ return { entry, created: true };
58
+ }
59
+
60
+ async getWaitlistEntry(entryId: string) {
61
+ const entry = await this.repository.getEntryById(entryId.trim());
62
+ if (!entry) {
63
+ throw new AppError(404, "entry_not_found", `waitlist entry ${entryId} not found`);
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ async getWaitlistEntryByEmail(email: string) {
69
+ const entry = await this.repository.getEntryByEmail(normalizeEmail(email));
70
+ if (!entry) {
71
+ throw new AppError(404, "entry_not_found", `waitlist entry for ${email} not found`);
72
+ }
73
+ return entry;
74
+ }
75
+
76
+ async listWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
77
+ return this.repository.listEntries({
78
+ status: input.status ? normalizeStatus(input.status) : null,
79
+ limit: input.limit,
80
+ });
81
+ }
82
+
83
+ async updateWaitlistEntry(input: UpdateWaitlistEntryInput) {
84
+ const entryId = input.entryId.trim();
85
+ if (!entryId) {
86
+ throw new AppError(400, "invalid_entry_id", "entry id is required");
87
+ }
88
+ const entry = await this.repository.updateEntryStatus(entryId, normalizeStatus(input.status));
89
+ if (!entry) {
90
+ throw new AppError(404, "entry_not_found", `waitlist entry ${input.entryId} not found`);
91
+ }
92
+ return entry;
93
+ }
94
+
95
+ async exportWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
96
+ return entriesToCsv(await this.listWaitlistEntries(input));
97
+ }
98
+
99
+ async recordTrigger(input: RecordTriggerInput) {
100
+ const type = input.type.trim();
101
+ if (!type) {
102
+ throw new AppError(400, "invalid_trigger_type", "trigger type is required");
103
+ }
104
+
105
+ if (input.entryId) {
106
+ await this.getWaitlistEntry(input.entryId);
107
+ }
108
+
109
+ return this.repository.createTrigger({
110
+ id: crypto.randomUUID(),
111
+ type,
112
+ entryId: input.entryId?.trim() || null,
113
+ payload: input.payload ?? {},
114
+ });
115
+ }
116
+ }
117
+
118
+ export function createDefaultWaitlistService() {
119
+ return new DefaultWaitlistService(new WaitlistRepository(createDb()));
120
+ }
121
+
122
+ function normalizeEmail(value: string) {
123
+ const email = value.trim().toLowerCase();
124
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
125
+ throw new AppError(400, "invalid_email", "valid email is required");
126
+ }
127
+ return email;
128
+ }
129
+
130
+ function normalizeNullableText(value: string | null | undefined) {
131
+ const normalized = value?.trim();
132
+ return normalized ? normalized : null;
133
+ }
134
+
135
+ function normalizeStatus(value: string): WaitlistEntryStatus {
136
+ const status = value.trim().toLowerCase();
137
+ if (status === "joined" || status === "invited" || status === "converted" || status === "archived") {
138
+ return status;
139
+ }
140
+ throw new AppError(400, "invalid_status", "status must be one of joined, invited, converted, archived");
141
+ }
142
+
143
+ function entriesToCsv(entries: WaitlistEntry[]) {
144
+ const headers = ["id", "email", "name", "company", "source", "status", "created_at", "updated_at"];
145
+ return [
146
+ headers.join(","),
147
+ ...entries.map((entry) =>
148
+ [
149
+ entry.id,
150
+ entry.email,
151
+ entry.name ?? "",
152
+ entry.company ?? "",
153
+ entry.source ?? "",
154
+ entry.status,
155
+ entry.createdAt,
156
+ entry.updatedAt,
157
+ ]
158
+ .map(csvCell)
159
+ .join(",")
160
+ ),
161
+ ].join("\n");
162
+ }
163
+
164
+ function csvCell(value: string) {
165
+ return `"${value.replaceAll('"', '""')}"`;
166
+ }
@@ -0,0 +1,50 @@
1
+ export type WaitlistEntry = {
2
+ id: string;
3
+ email: string;
4
+ name: string | null;
5
+ company: string | null;
6
+ source: string | null;
7
+ status: WaitlistEntryStatus;
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ };
11
+
12
+ export type WaitlistTrigger = {
13
+ id: string;
14
+ type: string;
15
+ entryId: string | null;
16
+ status: "queued" | "processed" | "failed";
17
+ payload: unknown;
18
+ createdAt: string;
19
+ processedAt: string | null;
20
+ };
21
+
22
+ export type JoinWaitlistInput = {
23
+ email: string;
24
+ name?: string | null;
25
+ company?: string | null;
26
+ source?: string | null;
27
+ };
28
+
29
+ export type JoinWaitlistResult = {
30
+ entry: WaitlistEntry;
31
+ created: boolean;
32
+ };
33
+
34
+ export type WaitlistEntryStatus = "joined" | "invited" | "converted" | "archived";
35
+
36
+ export type ListWaitlistEntriesInput = {
37
+ status?: string | null;
38
+ limit?: number | null;
39
+ };
40
+
41
+ export type UpdateWaitlistEntryInput = {
42
+ entryId: string;
43
+ status: string;
44
+ };
45
+
46
+ export type RecordTriggerInput = {
47
+ type: string;
48
+ entryId?: string | null;
49
+ payload?: unknown;
50
+ };