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,384 +0,0 @@
1
- import type {
2
- AttachmentObjectMetadata,
3
- ChatAttachment,
4
- ChatConversation,
5
- ChatMessage,
6
- ChatUser,
7
- CreateAttachmentUploadInput,
8
- CreateAttachmentUploadResult,
9
- CreateConversationInput,
10
- ListMessagesInput,
11
- ListMessagesResult,
12
- CreateMessageInput,
13
- CreateUserInput,
14
- FinalizeAttachmentInput,
15
- UpdateConversationInput,
16
- UpdateMessageInput,
17
- WebhookProcessResult,
18
- } from "./types";
19
- import { createDb } from "../db/client";
20
- import { ChatRepository } from "../db/repository";
21
- import { createAttachmentStorage, type AttachmentStorage } from "../storage";
22
- import { createWebhookAdapter, type WebhookAdapter } from "../webhooks";
23
-
24
- export class AppError extends Error {
25
- constructor(
26
- readonly status: number,
27
- readonly code: string,
28
- message: string
29
- ) {
30
- super(message);
31
- }
32
- }
33
-
34
- export type ChatService = {
35
- createUser(input: CreateUserInput): Promise<ChatUser>;
36
- getUser(userId: string): Promise<ChatUser>;
37
- getUserByUsername(username: string): Promise<ChatUser>;
38
- createConversation(input: CreateConversationInput): Promise<ChatConversation>;
39
- getConversation(conversationId: string): Promise<ChatConversation>;
40
- updateConversation(conversationId: string, input: UpdateConversationInput): Promise<ChatConversation>;
41
- deleteConversation(conversationId: string): Promise<void>;
42
- addParticipant(conversationId: string, userId: string): Promise<ChatConversation>;
43
- removeParticipant(conversationId: string, userId: string): Promise<void>;
44
- listMessages(conversationId: string, input?: ListMessagesInput): Promise<ListMessagesResult>;
45
- createMessage(conversationId: string, input: CreateMessageInput): Promise<ChatMessage>;
46
- updateMessage(conversationId: string, messageId: string, input: UpdateMessageInput): Promise<ChatMessage>;
47
- deleteMessage(conversationId: string, messageId: string): Promise<void>;
48
- createAttachmentUpload(input: CreateAttachmentUploadInput): Promise<CreateAttachmentUploadResult>;
49
- finalizeAttachment(attachmentId: string, input: FinalizeAttachmentInput): Promise<ChatAttachment>;
50
- getAttachment(attachmentId: string): Promise<ChatAttachment>;
51
- deleteAttachment(attachmentId: string): Promise<void>;
52
- processWebhook(provider: string, headers: Headers, rawBody: string): Promise<WebhookProcessResult>;
53
- };
54
-
55
- export class DefaultChatService implements ChatService {
56
- constructor(
57
- private readonly repository: ChatRepository,
58
- private readonly storage: AttachmentStorage,
59
- private readonly webhookAdapter: WebhookAdapter
60
- ) {}
61
-
62
- async createUser(input: CreateUserInput) {
63
- const username = input.username.trim().toLowerCase();
64
- if (!username) {
65
- throw new AppError(400, "invalid_username", "username is required");
66
- }
67
-
68
- const existing = await this.repository.getUserByUsername(username);
69
- if (existing) {
70
- throw new AppError(409, "username_taken", `username ${username} already exists`);
71
- }
72
-
73
- return this.repository.createUser({
74
- id: crypto.randomUUID(),
75
- username,
76
- displayName: normalizeNullableText(input.displayName),
77
- });
78
- }
79
-
80
- async getUser(userId: string) {
81
- return this.requireUser(userId);
82
- }
83
-
84
- async getUserByUsername(username: string) {
85
- const normalized = username.trim().toLowerCase();
86
- if (!normalized) {
87
- throw new AppError(400, "invalid_username", "username is required");
88
- }
89
-
90
- const user = await this.repository.getUserByUsername(normalized);
91
- if (!user) {
92
- throw new AppError(404, "user_not_found", `user ${normalized} not found`);
93
- }
94
- return user;
95
- }
96
-
97
- async createConversation(input: CreateConversationInput) {
98
- const createdBy = await this.requireUser(input.createdByUserId);
99
- const participantUserIds = new Set([createdBy.id, ...(input.participantUserIds ?? []).map((value) => value.trim()).filter(Boolean)]);
100
- await this.requireUsers(Array.from(participantUserIds));
101
-
102
- return this.repository.createConversation({
103
- id: crypto.randomUUID(),
104
- createdByUserId: createdBy.id,
105
- title: normalizeNullableText(input.title),
106
- participantUserIds: Array.from(participantUserIds),
107
- });
108
- }
109
-
110
- async getConversation(conversationId: string) {
111
- return this.requireConversation(conversationId);
112
- }
113
-
114
- async updateConversation(conversationId: string, input: UpdateConversationInput) {
115
- await this.requireConversation(conversationId);
116
- return this.repository.updateConversation(conversationId, normalizeNullableText(input.title));
117
- }
118
-
119
- async deleteConversation(conversationId: string) {
120
- await this.requireConversation(conversationId);
121
- await this.repository.softDeleteConversation(conversationId);
122
- }
123
-
124
- async addParticipant(conversationId: string, userId: string) {
125
- await this.requireConversation(conversationId);
126
- const user = await this.requireUser(userId);
127
- await this.repository.addParticipant(conversationId, user.id);
128
- return this.requireConversation(conversationId);
129
- }
130
-
131
- async removeParticipant(conversationId: string, userId: string) {
132
- const conversation = await this.requireConversation(conversationId);
133
- const isParticipant = conversation.participants.some((participant) => participant.id === userId);
134
- if (!isParticipant) {
135
- throw new AppError(404, "participant_not_found", `user ${userId} is not a participant`);
136
- }
137
- await this.repository.removeParticipant(conversationId, userId);
138
- }
139
-
140
- async listMessages(conversationId: string, input: ListMessagesInput = {}) {
141
- await this.requireConversation(conversationId);
142
- const limit = normalizeMessagePageSize(input.limit);
143
- const cursor = parseMessageCursor(input.cursor);
144
- const result = await this.repository.listMessages(conversationId, { limit, cursor });
145
- return {
146
- messages: result.messages,
147
- nextCursor: result.hasMore ? encodeMessageCursor(result.messages[result.messages.length - 1]!) : undefined,
148
- };
149
- }
150
-
151
- async createMessage(conversationId: string, input: CreateMessageInput) {
152
- const conversation = await this.requireConversation(conversationId);
153
- const user = await this.requireUser(input.userId);
154
- if (!conversation.participants.some((participant) => participant.id === user.id)) {
155
- throw new AppError(409, "not_a_participant", `user ${user.id} is not a participant`);
156
- }
157
-
158
- const body = input.body.trim();
159
- if (!body) {
160
- throw new AppError(400, "invalid_body", "message body is required");
161
- }
162
-
163
- return this.repository.createMessage({
164
- id: crypto.randomUUID(),
165
- conversationId,
166
- userId: user.id,
167
- body,
168
- });
169
- }
170
-
171
- async updateMessage(conversationId: string, messageId: string, input: UpdateMessageInput) {
172
- await this.requireConversation(conversationId);
173
- const existing = await this.repository.getMessageById(messageId);
174
- if (!existing || existing.conversationId !== conversationId) {
175
- throw new AppError(404, "message_not_found", `message ${messageId} not found`);
176
- }
177
-
178
- const body = input.body.trim();
179
- if (!body) {
180
- throw new AppError(400, "invalid_body", "message body is required");
181
- }
182
-
183
- return this.repository.updateMessage(messageId, body);
184
- }
185
-
186
- async deleteMessage(conversationId: string, messageId: string) {
187
- await this.requireConversation(conversationId);
188
- const existing = await this.repository.getMessageById(messageId);
189
- if (!existing || existing.conversationId !== conversationId) {
190
- throw new AppError(404, "message_not_found", `message ${messageId} not found`);
191
- }
192
- await this.repository.softDeleteMessage(messageId);
193
- }
194
-
195
- async createAttachmentUpload(input: CreateAttachmentUploadInput) {
196
- if (!input.contentType.startsWith("image/")) {
197
- throw new AppError(400, "invalid_content_type", "only image uploads are supported");
198
- }
199
- await this.requireConversation(input.conversationId);
200
- const user = await this.requireUser(input.uploadedByUserId);
201
- if (input.byteSize <= 0) {
202
- throw new AppError(400, "invalid_byte_size", "byte_size must be positive");
203
- }
204
-
205
- const attachmentId = crypto.randomUUID();
206
- const uploadTarget = await this.storage.createSignedUpload({
207
- attachmentId,
208
- conversationId: input.conversationId,
209
- filename: input.filename,
210
- contentType: input.contentType,
211
- });
212
-
213
- const attachment = await this.repository.createAttachment({
214
- id: attachmentId,
215
- conversationId: input.conversationId,
216
- uploadedByUserId: user.id,
217
- storageBucket: uploadTarget.bucket,
218
- storageKey: uploadTarget.key,
219
- contentType: input.contentType,
220
- byteSize: input.byteSize,
221
- filename: input.filename,
222
- });
223
-
224
- return {
225
- attachment: { ...attachment, publicUrl: uploadTarget.publicUrl },
226
- upload: uploadTarget.upload,
227
- };
228
- }
229
-
230
- async finalizeAttachment(attachmentId: string, input: FinalizeAttachmentInput) {
231
- const attachment = await this.requireAttachment(attachmentId);
232
- let messageId: string | null = null;
233
- if (input.messageId) {
234
- const message = await this.repository.getMessageById(input.messageId);
235
- if (!message || message.conversationId !== attachment.conversationId) {
236
- throw new AppError(404, "message_not_found", `message ${input.messageId} not found`);
237
- }
238
- messageId = message.id;
239
- }
240
-
241
- const metadata = await this.storage.getObjectMetadata({
242
- bucket: attachment.storageBucket,
243
- key: attachment.storageKey,
244
- });
245
- this.validateAttachmentMetadata(attachment, metadata);
246
-
247
- const finalized = await this.repository.finalizeAttachment(attachmentId, {
248
- messageId,
249
- contentType: metadata.contentType,
250
- byteSize: metadata.byteSize,
251
- });
252
- return { ...finalized, publicUrl: metadata.publicUrl };
253
- }
254
-
255
- async getAttachment(attachmentId: string) {
256
- return this.requireAttachment(attachmentId);
257
- }
258
-
259
- async deleteAttachment(attachmentId: string) {
260
- await this.requireAttachment(attachmentId);
261
- await this.repository.softDeleteAttachment(attachmentId);
262
- }
263
-
264
- async processWebhook(provider: string, headers: Headers, rawBody: string): Promise<WebhookProcessResult> {
265
- const event = await this.webhookAdapter.normalize(provider, headers, rawBody);
266
- const existing = await this.repository.getWebhookEvent(event.provider, event.externalEventId);
267
- if (existing) {
268
- return { event: existing, duplicate: true };
269
- }
270
-
271
- const created = await this.repository.createWebhookEvent(event);
272
- const status = event.signatureValid ? "processed" : "failed";
273
- const updated = await this.repository.markWebhookEventProcessed(created.id, status);
274
- return { event: updated, duplicate: false };
275
- }
276
-
277
- private async requireUser(userId: string) {
278
- const user = await this.repository.getUserById(userId);
279
- if (!user) {
280
- throw new AppError(404, "user_not_found", `user ${userId} not found`);
281
- }
282
- return user;
283
- }
284
-
285
- private async requireUsers(userIds: string[]) {
286
- const users = await this.repository.listUsersByIds(userIds);
287
- if (users.length !== userIds.length) {
288
- throw new AppError(404, "user_not_found", "one or more users do not exist");
289
- }
290
- return users;
291
- }
292
-
293
- private async requireConversation(conversationId: string) {
294
- const conversation = await this.repository.getConversationById(conversationId);
295
- if (!conversation) {
296
- throw new AppError(404, "conversation_not_found", `conversation ${conversationId} not found`);
297
- }
298
- return conversation;
299
- }
300
-
301
- private async requireAttachment(attachmentId: string) {
302
- const attachment = await this.repository.getAttachmentById(attachmentId);
303
- if (!attachment) {
304
- throw new AppError(404, "attachment_not_found", `attachment ${attachmentId} not found`);
305
- }
306
- const metadata = await this.storage.getObjectMetadata({
307
- bucket: attachment.storageBucket,
308
- key: attachment.storageKey,
309
- }).catch(() => null);
310
- return {
311
- ...attachment,
312
- publicUrl: metadata?.publicUrl ?? attachment.publicUrl,
313
- };
314
- }
315
-
316
- private validateAttachmentMetadata(attachment: ChatAttachment, metadata: AttachmentObjectMetadata) {
317
- if (metadata.contentType !== attachment.contentType) {
318
- throw new AppError(409, "content_type_mismatch", "uploaded object content_type does not match pending attachment");
319
- }
320
- if (metadata.byteSize !== attachment.byteSize) {
321
- throw new AppError(409, "byte_size_mismatch", "uploaded object byte_size does not match pending attachment");
322
- }
323
- }
324
- }
325
-
326
- export function createDefaultChatService() {
327
- const db = createDb();
328
- return new DefaultChatService(new ChatRepository(db), createAttachmentStorage(), createWebhookAdapter());
329
- }
330
-
331
- function normalizeNullableText(value: string | null | undefined) {
332
- if (value === undefined) {
333
- return undefined;
334
- }
335
- if (value === null) {
336
- return null;
337
- }
338
- const trimmed = value.trim();
339
- return trimmed || null;
340
- }
341
-
342
- const DEFAULT_MESSAGE_PAGE_SIZE = 50;
343
- const MAX_MESSAGE_PAGE_SIZE = 100;
344
-
345
- function normalizeMessagePageSize(limit: number | null | undefined) {
346
- if (limit == null) {
347
- return DEFAULT_MESSAGE_PAGE_SIZE;
348
- }
349
- if (!Number.isInteger(limit) || limit <= 0) {
350
- throw new AppError(400, "invalid_limit", "limit must be a positive integer");
351
- }
352
- if (limit > MAX_MESSAGE_PAGE_SIZE) {
353
- throw new AppError(400, "invalid_limit", `limit must be at most ${MAX_MESSAGE_PAGE_SIZE}`);
354
- }
355
- return limit;
356
- }
357
-
358
- type MessageCursor = {
359
- createdAt: string;
360
- id: string;
361
- };
362
-
363
- function parseMessageCursor(cursor: string | null | undefined): MessageCursor | undefined {
364
- if (!cursor) {
365
- return undefined;
366
- }
367
-
368
- try {
369
- const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<MessageCursor>;
370
- if (typeof value.createdAt !== "string" || typeof value.id !== "string" || !value.createdAt || !value.id) {
371
- throw new Error("invalid cursor payload");
372
- }
373
- return {
374
- createdAt: value.createdAt,
375
- id: value.id,
376
- };
377
- } catch {
378
- throw new AppError(400, "invalid_cursor", "cursor is invalid");
379
- }
380
- }
381
-
382
- function encodeMessageCursor(message: { createdAt: string; id: string }) {
383
- return Buffer.from(JSON.stringify({ createdAt: message.createdAt, id: message.id }), "utf8").toString("base64url");
384
- }
@@ -1,142 +0,0 @@
1
- export type ChatUser = {
2
- id: string;
3
- username: string;
4
- displayName: string | null;
5
- createdAt: string;
6
- updatedAt: string;
7
- };
8
-
9
- export type ChatConversation = {
10
- id: string;
11
- title: string | null;
12
- createdByUserId: string;
13
- participants: ChatUser[];
14
- createdAt: string;
15
- updatedAt: string;
16
- };
17
-
18
- export type ChatMessage = {
19
- id: string;
20
- conversationId: string;
21
- userId: string;
22
- body: string;
23
- editedAt: string | null;
24
- createdAt: string;
25
- updatedAt: string;
26
- attachments: ChatMessageAttachment[];
27
- };
28
-
29
- export type ChatMessageAttachment = {
30
- id: string;
31
- filename: string;
32
- contentType: string;
33
- byteSize: number;
34
- status: "pending" | "ready" | "deleted";
35
- publicUrl: string;
36
- };
37
-
38
- export type ChatAttachment = {
39
- id: string;
40
- conversationId: string;
41
- messageId: string | null;
42
- uploadedByUserId: string;
43
- storageBucket: string;
44
- storageKey: string;
45
- contentType: string;
46
- byteSize: number;
47
- filename: string;
48
- status: "pending" | "ready" | "deleted";
49
- publicUrl: string;
50
- createdAt: string;
51
- updatedAt: string;
52
- };
53
-
54
- export type WebhookEventRecord = {
55
- id: string;
56
- provider: string;
57
- externalEventId: string;
58
- eventType: string;
59
- signatureValid: boolean;
60
- status: "received" | "processed" | "failed";
61
- payloadJson: string;
62
- receivedAt: string;
63
- processedAt: string | null;
64
- };
65
-
66
- export type CreateUserInput = {
67
- username: string;
68
- displayName?: string | null;
69
- };
70
-
71
- export type CreateConversationInput = {
72
- createdByUserId: string;
73
- title?: string | null;
74
- participantUserIds?: string[];
75
- };
76
-
77
- export type UpdateConversationInput = {
78
- title?: string | null;
79
- };
80
-
81
- export type CreateMessageInput = {
82
- userId: string;
83
- body: string;
84
- };
85
-
86
- export type ListMessagesInput = {
87
- cursor?: string | null;
88
- limit?: number | null;
89
- };
90
-
91
- export type UpdateMessageInput = {
92
- body: string;
93
- };
94
-
95
- export type CreateAttachmentUploadInput = {
96
- conversationId: string;
97
- uploadedByUserId: string;
98
- filename: string;
99
- contentType: string;
100
- byteSize: number;
101
- };
102
-
103
- export type FinalizeAttachmentInput = {
104
- messageId?: string | null;
105
- };
106
-
107
- export type AttachmentUploadTarget = {
108
- method: "PUT";
109
- url: string;
110
- headers: Record<string, string>;
111
- };
112
-
113
- export type CreateAttachmentUploadResult = {
114
- attachment: ChatAttachment;
115
- upload: AttachmentUploadTarget;
116
- };
117
-
118
- export type AttachmentObjectMetadata = {
119
- bucket: string;
120
- key: string;
121
- contentType: string;
122
- byteSize: number;
123
- publicUrl: string;
124
- };
125
-
126
- export type NormalizedWebhookEvent = {
127
- provider: string;
128
- externalEventId: string;
129
- eventType: string;
130
- signatureValid: boolean;
131
- payloadJson: string;
132
- };
133
-
134
- export type WebhookProcessResult = {
135
- event: WebhookEventRecord;
136
- duplicate: boolean;
137
- };
138
-
139
- export type ListMessagesResult = {
140
- messages: ChatMessage[];
141
- nextCursor?: string;
142
- };
@@ -1,72 +0,0 @@
1
- import { Storage } from "@google-cloud/storage";
2
- import type { AttachmentObjectMetadata, AttachmentUploadTarget } from "./chat/types";
3
-
4
- export type AttachmentStorage = {
5
- createSignedUpload(input: {
6
- attachmentId: string;
7
- conversationId: string;
8
- filename: string;
9
- contentType: string;
10
- }): Promise<{ bucket: string; key: string; upload: AttachmentUploadTarget; publicUrl: string }>;
11
- getObjectMetadata(input: { bucket: string; key: string }): Promise<AttachmentObjectMetadata>;
12
- };
13
-
14
- export class GcsAttachmentStorage implements AttachmentStorage {
15
- constructor(
16
- private readonly bucketName = requireAttachmentBucket(),
17
- private readonly publicBaseUrl = Bun.env.ATTACHMENT_PUBLIC_BASE_URL?.trim() || `https://storage.googleapis.com/${requireAttachmentBucket()}`
18
- ) {}
19
-
20
- async createSignedUpload(input: { attachmentId: string; conversationId: string; filename: string; contentType: string }) {
21
- const storage = new Storage();
22
- const bucket = storage.bucket(this.bucketName);
23
- const key = `attachments/${input.conversationId}/${input.attachmentId}/${sanitizeFilename(input.filename)}`;
24
- const file = bucket.file(key);
25
- const [url] = await file.getSignedUrl({
26
- action: "write",
27
- version: "v4",
28
- expires: Date.now() + 15 * 60 * 1000,
29
- contentType: input.contentType,
30
- });
31
- return {
32
- bucket: this.bucketName,
33
- key,
34
- upload: {
35
- method: "PUT" as const,
36
- url,
37
- headers: {
38
- "Content-Type": input.contentType,
39
- },
40
- },
41
- publicUrl: `${this.publicBaseUrl.replace(/\/+$/g, "")}/${key}`,
42
- };
43
- }
44
-
45
- async getObjectMetadata(input: { bucket: string; key: string }): Promise<AttachmentObjectMetadata> {
46
- const storage = new Storage();
47
- const [metadata] = await storage.bucket(input.bucket).file(input.key).getMetadata();
48
- return {
49
- bucket: input.bucket,
50
- key: input.key,
51
- contentType: String(metadata.contentType ?? ""),
52
- byteSize: Number(metadata.size ?? 0),
53
- publicUrl: `${this.publicBaseUrl.replace(/\/+$/g, "")}/${input.key}`,
54
- };
55
- }
56
- }
57
-
58
- export function createAttachmentStorage() {
59
- return new GcsAttachmentStorage();
60
- }
61
-
62
- export function requireAttachmentBucket() {
63
- const bucket = Bun.env.ATTACHMENT_BUCKET?.trim();
64
- if (!bucket) {
65
- throw new Error("ATTACHMENT_BUCKET is required");
66
- }
67
- return bucket;
68
- }
69
-
70
- function sanitizeFilename(filename: string) {
71
- return filename.trim().replace(/[^a-zA-Z0-9._-]+/g, "-") || "upload.bin";
72
- }
@@ -1,35 +0,0 @@
1
- import type { NormalizedWebhookEvent } from "./chat/types";
2
-
3
- export type WebhookAdapter = {
4
- normalize(provider: string, headers: Headers, rawBody: string): Promise<NormalizedWebhookEvent>;
5
- };
6
-
7
- export class GenericJsonWebhookAdapter implements WebhookAdapter {
8
- async normalize(provider: string, headers: Headers, rawBody: string): Promise<NormalizedWebhookEvent> {
9
- const payload = parseJson(rawBody);
10
- const secret = Bun.env[`WEBHOOK_${provider.toUpperCase()}_SECRET`]?.trim();
11
- const incomingSecret = headers.get("x-webhook-secret")?.trim() ?? "";
12
- const externalEventId = String(payload.id ?? headers.get("x-event-id") ?? crypto.randomUUID());
13
- const eventType = String(payload.type ?? headers.get("x-event-type") ?? "generic.event");
14
-
15
- return {
16
- provider,
17
- externalEventId,
18
- eventType,
19
- signatureValid: secret ? incomingSecret === secret : true,
20
- payloadJson: rawBody,
21
- };
22
- }
23
- }
24
-
25
- export function createWebhookAdapter() {
26
- return new GenericJsonWebhookAdapter();
27
- }
28
-
29
- function parseJson(rawBody: string) {
30
- try {
31
- return JSON.parse(rawBody) as Record<string, unknown>;
32
- } catch {
33
- return {};
34
- }
35
- }