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
@@ -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
+ };