create-svc 0.1.9 → 0.1.10

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 (91) hide show
  1. package/README.md +130 -11
  2. package/package.json +9 -4
  3. package/src/cli.test.ts +29 -8
  4. package/src/cli.ts +103 -70
  5. package/src/naming.test.ts +4 -2
  6. package/src/naming.ts +9 -1
  7. package/src/neon.ts +10 -8
  8. package/src/post-scaffold.ts +7 -28
  9. package/src/profiles.ts +28 -0
  10. package/src/scaffold.test.ts +126 -15
  11. package/src/scaffold.ts +94 -23
  12. package/src/vault.test.ts +33 -9
  13. package/src/vault.ts +4 -3
  14. package/templates/shared/README.md +135 -24
  15. package/templates/shared/docker-compose.yml +19 -0
  16. package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
  17. package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
  18. package/templates/shared/scripts/cloudrun/config.ts +14 -19
  19. package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
  20. package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
  21. package/templates/shared/scripts/cloudrun/lib.ts +88 -112
  22. package/templates/shared/scripts/cloudrun/neon.ts +82 -13
  23. package/templates/shared/service.yaml +44 -1
  24. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  25. package/templates/variants/bun-connectrpc/Makefile +4 -1
  26. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
  27. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
  28. package/templates/variants/bun-connectrpc/package.json +17 -0
  29. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
  30. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  31. package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
  32. package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
  33. package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
  34. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  35. package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
  36. package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
  37. package/templates/variants/bun-connectrpc/src/index.ts +294 -22
  38. package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
  39. package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
  40. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  41. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
  42. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  43. package/templates/variants/bun-hono/Makefile +4 -1
  44. package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
  45. package/templates/variants/bun-hono/package.json +13 -0
  46. package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
  47. package/templates/variants/bun-hono/src/chat/service.ts +384 -0
  48. package/templates/variants/bun-hono/src/chat/types.ts +142 -0
  49. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  50. package/templates/variants/bun-hono/src/db/repository.ts +479 -0
  51. package/templates/variants/bun-hono/src/db/schema.ts +75 -0
  52. package/templates/variants/bun-hono/src/index.ts +254 -8
  53. package/templates/variants/bun-hono/src/storage.ts +72 -0
  54. package/templates/variants/bun-hono/src/webhooks.ts +35 -0
  55. package/templates/variants/bun-hono/test/app.test.ts +60 -6
  56. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
  57. package/templates/variants/bun-hono/tsconfig.json +1 -0
  58. package/templates/variants/go-chi/Makefile +6 -2
  59. package/templates/variants/go-chi/buf.gen.yaml +2 -0
  60. package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
  61. package/templates/variants/go-chi/cmd/server/main.go +16 -15
  62. package/templates/variants/go-chi/go.mod +3 -0
  63. package/templates/variants/go-chi/internal/app/service.go +763 -71
  64. package/templates/variants/go-chi/internal/config/config.go +22 -7
  65. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
  66. package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
  67. package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
  68. package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
  69. package/templates/variants/go-chi/test/go.test.ts +4 -1
  70. package/templates/variants/go-connectrpc/Makefile +6 -2
  71. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  72. package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
  73. package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
  74. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
  75. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
  76. package/templates/variants/go-connectrpc/go.mod +4 -0
  77. package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
  78. package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
  79. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
  80. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
  81. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
  82. package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
  83. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
  84. package/templates/shared/.env.example +0 -10
  85. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  86. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  87. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  88. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  89. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  90. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  91. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -0,0 +1,479 @@
1
+ import { and, asc, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
2
+ import type { createDb } from "./client";
3
+ import { attachments, conversationParticipants, conversations, messages, users, webhookEvents } from "./schema";
4
+ import type {
5
+ ChatAttachment,
6
+ ChatConversation,
7
+ ChatMessage,
8
+ ChatMessageAttachment,
9
+ ChatUser,
10
+ NormalizedWebhookEvent,
11
+ WebhookEventRecord,
12
+ } from "../chat/types";
13
+
14
+ type Database = ReturnType<typeof createDb>;
15
+
16
+ type CreateUserRecord = {
17
+ id: string;
18
+ username: string;
19
+ displayName: string | null | undefined;
20
+ };
21
+
22
+ type CreateConversationRecord = {
23
+ id: string;
24
+ title: string | null | undefined;
25
+ createdByUserId: string;
26
+ participantUserIds: string[];
27
+ };
28
+
29
+ type CreateMessageRecord = {
30
+ id: string;
31
+ conversationId: string;
32
+ userId: string;
33
+ body: string;
34
+ };
35
+
36
+ type CreateAttachmentRecord = {
37
+ id: string;
38
+ conversationId: string;
39
+ uploadedByUserId: string;
40
+ storageBucket: string;
41
+ storageKey: string;
42
+ contentType: string;
43
+ byteSize: number;
44
+ filename: string;
45
+ };
46
+
47
+ type ListMessagesCursor = {
48
+ createdAt: string;
49
+ id: string;
50
+ };
51
+
52
+ type ListMessagesPage = {
53
+ messages: ChatMessage[];
54
+ hasMore: boolean;
55
+ };
56
+
57
+ export class ChatRepository {
58
+ constructor(private readonly db: Database) {}
59
+
60
+ async createUser(input: CreateUserRecord): Promise<ChatUser> {
61
+ const now = new Date();
62
+ const [row] = await this.db
63
+ .insert(users)
64
+ .values({
65
+ id: input.id,
66
+ username: input.username,
67
+ displayName: input.displayName ?? null,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ })
71
+ .returning();
72
+ return toChatUser(row);
73
+ }
74
+
75
+ async getUserById(userId: string): Promise<ChatUser | null> {
76
+ const [row] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
77
+ return row ? toChatUser(row) : null;
78
+ }
79
+
80
+ async getUserByUsername(username: string): Promise<ChatUser | null> {
81
+ const [row] = await this.db.select().from(users).where(eq(users.username, username)).limit(1);
82
+ return row ? toChatUser(row) : null;
83
+ }
84
+
85
+ async listUsersByIds(userIds: string[]): Promise<ChatUser[]> {
86
+ if (userIds.length === 0) {
87
+ return [];
88
+ }
89
+ const rows = await this.db.select().from(users).where(inArray(users.id, userIds));
90
+ return rows.map(toChatUser);
91
+ }
92
+
93
+ async createConversation(input: CreateConversationRecord): Promise<ChatConversation> {
94
+ const now = new Date();
95
+ await this.db.transaction(async (tx) => {
96
+ await tx.insert(conversations).values({
97
+ id: input.id,
98
+ title: input.title ?? null,
99
+ createdByUserId: input.createdByUserId,
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ });
103
+
104
+ await tx.insert(conversationParticipants).values(
105
+ input.participantUserIds.map((userId) => ({
106
+ conversationId: input.id,
107
+ userId,
108
+ joinedAt: now,
109
+ }))
110
+ );
111
+ });
112
+
113
+ return this.getConversationByIdOrThrow(input.id);
114
+ }
115
+
116
+ async getConversationById(conversationId: string): Promise<ChatConversation | null> {
117
+ const [conversation] = await this.db
118
+ .select()
119
+ .from(conversations)
120
+ .where(and(eq(conversations.id, conversationId), isNull(conversations.deletedAt)))
121
+ .limit(1);
122
+
123
+ if (!conversation) {
124
+ return null;
125
+ }
126
+
127
+ const participantRows = await this.db
128
+ .select({
129
+ id: users.id,
130
+ username: users.username,
131
+ displayName: users.displayName,
132
+ createdAt: users.createdAt,
133
+ updatedAt: users.updatedAt,
134
+ })
135
+ .from(conversationParticipants)
136
+ .innerJoin(users, eq(conversationParticipants.userId, users.id))
137
+ .where(eq(conversationParticipants.conversationId, conversationId))
138
+ .orderBy(asc(users.username));
139
+
140
+ return {
141
+ id: conversation.id,
142
+ title: conversation.title,
143
+ createdByUserId: conversation.createdByUserId,
144
+ participants: participantRows.map(toChatUser),
145
+ createdAt: conversation.createdAt.toISOString(),
146
+ updatedAt: conversation.updatedAt.toISOString(),
147
+ };
148
+ }
149
+
150
+ async updateConversation(conversationId: string, title: string | null | undefined): Promise<ChatConversation> {
151
+ await this.db
152
+ .update(conversations)
153
+ .set({
154
+ title: title ?? null,
155
+ updatedAt: new Date(),
156
+ })
157
+ .where(eq(conversations.id, conversationId));
158
+
159
+ return this.getConversationByIdOrThrow(conversationId);
160
+ }
161
+
162
+ async softDeleteConversation(conversationId: string) {
163
+ const now = new Date();
164
+ await this.db
165
+ .update(conversations)
166
+ .set({
167
+ deletedAt: now,
168
+ updatedAt: now,
169
+ })
170
+ .where(eq(conversations.id, conversationId));
171
+ }
172
+
173
+ async addParticipant(conversationId: string, userId: string) {
174
+ const [existing] = await this.db
175
+ .select()
176
+ .from(conversationParticipants)
177
+ .where(and(eq(conversationParticipants.conversationId, conversationId), eq(conversationParticipants.userId, userId)))
178
+ .limit(1);
179
+
180
+ if (!existing) {
181
+ await this.db.insert(conversationParticipants).values({
182
+ conversationId,
183
+ userId,
184
+ joinedAt: new Date(),
185
+ });
186
+ }
187
+ }
188
+
189
+ async removeParticipant(conversationId: string, userId: string) {
190
+ await this.db
191
+ .delete(conversationParticipants)
192
+ .where(and(eq(conversationParticipants.conversationId, conversationId), eq(conversationParticipants.userId, userId)));
193
+ }
194
+
195
+ async listMessages(
196
+ conversationId: string,
197
+ input: {
198
+ limit: number;
199
+ cursor?: ListMessagesCursor;
200
+ }
201
+ ): Promise<ListMessagesPage> {
202
+ const predicate = input.cursor
203
+ ? and(
204
+ eq(messages.conversationId, conversationId),
205
+ isNull(messages.deletedAt),
206
+ or(
207
+ lt(messages.createdAt, new Date(input.cursor.createdAt)),
208
+ and(eq(messages.createdAt, new Date(input.cursor.createdAt)), lt(messages.id, input.cursor.id))
209
+ )
210
+ )
211
+ : and(eq(messages.conversationId, conversationId), isNull(messages.deletedAt));
212
+
213
+ const rows = await this.db
214
+ .select()
215
+ .from(messages)
216
+ .where(predicate)
217
+ .orderBy(desc(messages.createdAt), desc(messages.id))
218
+ .limit(input.limit + 1);
219
+
220
+ const hasMore = rows.length > input.limit;
221
+ const pageRows = hasMore ? rows.slice(0, input.limit) : rows;
222
+ const attachmentMap = await this.listReadyAttachmentsByMessageIds(pageRows.map((row) => row.id));
223
+
224
+ return {
225
+ messages: pageRows.map((row) => toChatMessage(row, attachmentMap.get(row.id) ?? [])),
226
+ hasMore,
227
+ };
228
+ }
229
+
230
+ async createMessage(input: CreateMessageRecord): Promise<ChatMessage> {
231
+ const now = new Date();
232
+ const [row] = await this.db
233
+ .insert(messages)
234
+ .values({
235
+ id: input.id,
236
+ conversationId: input.conversationId,
237
+ userId: input.userId,
238
+ body: input.body,
239
+ createdAt: now,
240
+ updatedAt: now,
241
+ })
242
+ .returning();
243
+ return toChatMessage(row, []);
244
+ }
245
+
246
+ async getMessageById(messageId: string): Promise<ChatMessage | null> {
247
+ const [row] = await this.db.select().from(messages).where(and(eq(messages.id, messageId), isNull(messages.deletedAt))).limit(1);
248
+ return row ? toChatMessage(row, []) : null;
249
+ }
250
+
251
+ async updateMessage(messageId: string, body: string): Promise<ChatMessage> {
252
+ const now = new Date();
253
+ const [row] = await this.db
254
+ .update(messages)
255
+ .set({
256
+ body,
257
+ editedAt: now,
258
+ updatedAt: now,
259
+ })
260
+ .where(eq(messages.id, messageId))
261
+ .returning();
262
+ return toChatMessage(row, []);
263
+ }
264
+
265
+ async softDeleteMessage(messageId: string) {
266
+ const now = new Date();
267
+ await this.db
268
+ .update(messages)
269
+ .set({
270
+ deletedAt: now,
271
+ updatedAt: now,
272
+ })
273
+ .where(eq(messages.id, messageId));
274
+ }
275
+
276
+ async createAttachment(input: CreateAttachmentRecord): Promise<ChatAttachment> {
277
+ const now = new Date();
278
+ const [row] = await this.db
279
+ .insert(attachments)
280
+ .values({
281
+ id: input.id,
282
+ conversationId: input.conversationId,
283
+ uploadedByUserId: input.uploadedByUserId,
284
+ storageBucket: input.storageBucket,
285
+ storageKey: input.storageKey,
286
+ contentType: input.contentType,
287
+ byteSize: input.byteSize,
288
+ filename: input.filename,
289
+ status: "pending",
290
+ createdAt: now,
291
+ updatedAt: now,
292
+ })
293
+ .returning();
294
+ return toChatAttachment(row);
295
+ }
296
+
297
+ async getAttachmentById(attachmentId: string): Promise<ChatAttachment | null> {
298
+ const [row] = await this.db
299
+ .select()
300
+ .from(attachments)
301
+ .where(and(eq(attachments.id, attachmentId), isNull(attachments.deletedAt)))
302
+ .limit(1);
303
+ return row ? toChatAttachment(row) : null;
304
+ }
305
+
306
+ async finalizeAttachment(attachmentId: string, input: { messageId: string | null; contentType: string; byteSize: number }) {
307
+ const [row] = await this.db
308
+ .update(attachments)
309
+ .set({
310
+ messageId: input.messageId,
311
+ contentType: input.contentType,
312
+ byteSize: input.byteSize,
313
+ status: "ready",
314
+ updatedAt: new Date(),
315
+ })
316
+ .where(eq(attachments.id, attachmentId))
317
+ .returning();
318
+ return toChatAttachment(row);
319
+ }
320
+
321
+ async softDeleteAttachment(attachmentId: string) {
322
+ const now = new Date();
323
+ await this.db
324
+ .update(attachments)
325
+ .set({
326
+ deletedAt: now,
327
+ status: "deleted",
328
+ updatedAt: now,
329
+ })
330
+ .where(eq(attachments.id, attachmentId));
331
+ }
332
+
333
+ async getWebhookEvent(provider: string, externalEventId: string): Promise<WebhookEventRecord | null> {
334
+ const [row] = await this.db
335
+ .select()
336
+ .from(webhookEvents)
337
+ .where(and(eq(webhookEvents.provider, provider), eq(webhookEvents.externalEventId, externalEventId)))
338
+ .limit(1);
339
+ return row ? toWebhookEvent(row) : null;
340
+ }
341
+
342
+ async createWebhookEvent(event: NormalizedWebhookEvent): Promise<WebhookEventRecord> {
343
+ const [row] = await this.db
344
+ .insert(webhookEvents)
345
+ .values({
346
+ id: crypto.randomUUID(),
347
+ provider: event.provider,
348
+ externalEventId: event.externalEventId,
349
+ eventType: event.eventType,
350
+ signatureValid: event.signatureValid ? "true" : "false",
351
+ status: "received",
352
+ payloadJson: event.payloadJson,
353
+ receivedAt: new Date(),
354
+ })
355
+ .returning();
356
+ return toWebhookEvent(row);
357
+ }
358
+
359
+ async markWebhookEventProcessed(id: string, status: "processed" | "failed") {
360
+ const [row] = await this.db
361
+ .update(webhookEvents)
362
+ .set({
363
+ status,
364
+ processedAt: new Date(),
365
+ })
366
+ .where(eq(webhookEvents.id, id))
367
+ .returning();
368
+ return toWebhookEvent(row);
369
+ }
370
+
371
+ private async getConversationByIdOrThrow(conversationId: string) {
372
+ const conversation = await this.getConversationById(conversationId);
373
+ if (!conversation) {
374
+ throw new Error(`conversation ${conversationId} not found`);
375
+ }
376
+ return conversation;
377
+ }
378
+
379
+ private async listReadyAttachmentsByMessageIds(messageIds: string[]) {
380
+ if (messageIds.length === 0) {
381
+ return new Map<string, ChatMessageAttachment[]>();
382
+ }
383
+
384
+ const rows = await this.db
385
+ .select()
386
+ .from(attachments)
387
+ .where(
388
+ and(
389
+ inArray(attachments.messageId, messageIds),
390
+ eq(attachments.status, "ready"),
391
+ isNull(attachments.deletedAt)
392
+ )
393
+ )
394
+ .orderBy(asc(attachments.createdAt), asc(attachments.id));
395
+
396
+ const map = new Map<string, ChatMessageAttachment[]>();
397
+ for (const row of rows) {
398
+ if (!row.messageId) {
399
+ continue;
400
+ }
401
+ const bucket = map.get(row.messageId) ?? [];
402
+ bucket.push(toChatMessageAttachment(row));
403
+ map.set(row.messageId, bucket);
404
+ }
405
+ return map;
406
+ }
407
+ }
408
+
409
+ function toChatUser(row: typeof users.$inferSelect): ChatUser {
410
+ return {
411
+ id: row.id,
412
+ username: row.username,
413
+ displayName: row.displayName ?? null,
414
+ createdAt: row.createdAt.toISOString(),
415
+ updatedAt: row.updatedAt.toISOString(),
416
+ };
417
+ }
418
+
419
+ function toChatMessage(row: typeof messages.$inferSelect, messageAttachments: ChatMessageAttachment[]): ChatMessage {
420
+ return {
421
+ id: row.id,
422
+ conversationId: row.conversationId,
423
+ userId: row.userId,
424
+ body: row.body,
425
+ editedAt: row.editedAt?.toISOString() ?? null,
426
+ createdAt: row.createdAt.toISOString(),
427
+ updatedAt: row.updatedAt.toISOString(),
428
+ attachments: messageAttachments,
429
+ };
430
+ }
431
+
432
+ function toChatMessageAttachment(row: typeof attachments.$inferSelect): ChatMessageAttachment {
433
+ return {
434
+ id: row.id,
435
+ filename: row.filename,
436
+ contentType: row.contentType,
437
+ byteSize: row.byteSize,
438
+ status: row.status,
439
+ publicUrl: buildAttachmentPublicUrl(row.storageBucket, row.storageKey),
440
+ };
441
+ }
442
+
443
+ function buildAttachmentPublicUrl(bucket: string, key: string) {
444
+ const configuredBase = Bun.env.ATTACHMENT_PUBLIC_BASE_URL?.trim();
445
+ const base = configuredBase || `https://storage.googleapis.com/${bucket}`;
446
+ return `${base.replace(/\/+$/g, "")}/${key}`;
447
+ }
448
+
449
+ function toChatAttachment(row: typeof attachments.$inferSelect): ChatAttachment {
450
+ return {
451
+ id: row.id,
452
+ conversationId: row.conversationId,
453
+ messageId: row.messageId ?? null,
454
+ uploadedByUserId: row.uploadedByUserId,
455
+ storageBucket: row.storageBucket,
456
+ storageKey: row.storageKey,
457
+ contentType: row.contentType,
458
+ byteSize: row.byteSize,
459
+ filename: row.filename,
460
+ status: row.status,
461
+ publicUrl: "",
462
+ createdAt: row.createdAt.toISOString(),
463
+ updatedAt: row.updatedAt.toISOString(),
464
+ };
465
+ }
466
+
467
+ function toWebhookEvent(row: typeof webhookEvents.$inferSelect): WebhookEventRecord {
468
+ return {
469
+ id: row.id,
470
+ provider: row.provider,
471
+ externalEventId: row.externalEventId,
472
+ eventType: row.eventType,
473
+ signatureValid: row.signatureValid === "true",
474
+ status: row.status,
475
+ payloadJson: row.payloadJson,
476
+ receivedAt: row.receivedAt.toISOString(),
477
+ processedAt: row.processedAt?.toISOString() ?? null,
478
+ };
479
+ }
@@ -0,0 +1,75 @@
1
+ import { integer, pgTable, primaryKey, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
2
+
3
+ export const users = pgTable(
4
+ "users",
5
+ {
6
+ id: text("id").primaryKey(),
7
+ username: text("username").notNull(),
8
+ displayName: text("display_name"),
9
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
10
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
11
+ },
12
+ (table) => [uniqueIndex("users_username_key").on(table.username)]
13
+ );
14
+
15
+ export const conversations = pgTable("conversations", {
16
+ id: text("id").primaryKey(),
17
+ title: text("title"),
18
+ createdByUserId: text("created_by_user_id").notNull().references(() => users.id),
19
+ deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date" }),
20
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
21
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
22
+ });
23
+
24
+ export const conversationParticipants = pgTable(
25
+ "conversation_participants",
26
+ {
27
+ conversationId: text("conversation_id").notNull().references(() => conversations.id),
28
+ userId: text("user_id").notNull().references(() => users.id),
29
+ joinedAt: timestamp("joined_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
30
+ },
31
+ (table) => [primaryKey({ columns: [table.conversationId, table.userId] })]
32
+ );
33
+
34
+ export const messages = pgTable("messages", {
35
+ id: text("id").primaryKey(),
36
+ conversationId: text("conversation_id").notNull().references(() => conversations.id),
37
+ userId: text("user_id").notNull().references(() => users.id),
38
+ body: text("body").notNull(),
39
+ editedAt: timestamp("edited_at", { withTimezone: true, mode: "date" }),
40
+ deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date" }),
41
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
42
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
43
+ });
44
+
45
+ export const attachments = pgTable("attachments", {
46
+ id: text("id").primaryKey(),
47
+ conversationId: text("conversation_id").notNull().references(() => conversations.id),
48
+ messageId: text("message_id").references(() => messages.id),
49
+ uploadedByUserId: text("uploaded_by_user_id").notNull().references(() => users.id),
50
+ storageBucket: text("storage_bucket").notNull(),
51
+ storageKey: text("storage_key").notNull(),
52
+ contentType: text("content_type").notNull(),
53
+ byteSize: integer("byte_size").notNull(),
54
+ filename: text("filename").notNull(),
55
+ status: text("status").$type<"pending" | "ready" | "deleted">().notNull(),
56
+ deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date" }),
57
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
58
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
59
+ });
60
+
61
+ export const webhookEvents = pgTable(
62
+ "webhook_events",
63
+ {
64
+ id: text("id").primaryKey(),
65
+ provider: text("provider").notNull(),
66
+ externalEventId: text("external_event_id").notNull(),
67
+ eventType: text("event_type").notNull(),
68
+ signatureValid: text("signature_valid").notNull(),
69
+ status: text("status").$type<"received" | "processed" | "failed">().notNull(),
70
+ payloadJson: text("payload_json").notNull(),
71
+ receivedAt: timestamp("received_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
72
+ processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
73
+ },
74
+ (table) => [uniqueIndex("webhook_events_provider_external_event_key").on(table.provider, table.externalEventId)]
75
+ );