create-svc 0.1.8 → 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 +142 -13
  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 +62 -5
  13. package/src/vault.ts +24 -4
  14. package/templates/shared/README.md +143 -26
  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 +100 -14
  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
@@ -2,108 +2,800 @@ package app
2
2
 
3
3
  import (
4
4
  "context"
5
+ "database/sql"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "errors"
5
9
  "fmt"
6
- "sync"
10
+ "net/http"
11
+ "os"
12
+ "strings"
13
+ "time"
14
+
15
+ "cloud.google.com/go/storage"
16
+ "github.com/jmoiron/sqlx"
7
17
  )
8
18
 
9
- type Record struct {
10
- ID string `json:"id"`
11
- Type string `json:"type"`
12
- Name string `json:"name"`
13
- Content string `json:"content"`
14
- TTL int `json:"ttl"`
15
- Proxied bool `json:"proxied"`
19
+ type User struct {
20
+ ID string `json:"id" db:"id"`
21
+ Username string `json:"username" db:"username"`
22
+ DisplayName string `json:"display_name,omitempty" db:"display_name"`
23
+ CreatedAt string `json:"created_at" db:"created_at"`
24
+ UpdatedAt string `json:"updated_at" db:"updated_at"`
25
+ }
26
+
27
+ type Conversation struct {
28
+ ID string `json:"id" db:"id"`
29
+ Title string `json:"title,omitempty" db:"title"`
30
+ CreatedByUserID string `json:"created_by_user_id" db:"created_by_user_id"`
31
+ Participants []User `json:"participants"`
32
+ CreatedAt string `json:"created_at" db:"created_at"`
33
+ UpdatedAt string `json:"updated_at" db:"updated_at"`
34
+ }
35
+
36
+ type Message struct {
37
+ ID string `json:"id" db:"id"`
38
+ ConversationID string `json:"conversation_id" db:"conversation_id"`
39
+ UserID string `json:"user_id" db:"user_id"`
40
+ Body string `json:"body" db:"body"`
41
+ EditedAt string `json:"edited_at,omitempty" db:"edited_at"`
42
+ CreatedAt string `json:"created_at" db:"created_at"`
43
+ UpdatedAt string `json:"updated_at" db:"updated_at"`
44
+ Attachments []MessageAttachment `json:"attachments"`
45
+ }
46
+
47
+ type MessageAttachment struct {
48
+ ID string `json:"id"`
49
+ Filename string `json:"filename"`
50
+ ContentType string `json:"content_type"`
51
+ ByteSize int64 `json:"byte_size"`
52
+ Status string `json:"status"`
53
+ PublicURL string `json:"public_url"`
54
+ }
55
+
56
+ type Attachment struct {
57
+ ID string `json:"id" db:"id"`
58
+ ConversationID string `json:"conversation_id" db:"conversation_id"`
59
+ MessageID string `json:"message_id,omitempty" db:"message_id"`
60
+ UploadedByUserID string `json:"uploaded_by_user_id" db:"uploaded_by_user_id"`
61
+ StorageBucket string `json:"storage_bucket" db:"storage_bucket"`
62
+ StorageKey string `json:"storage_key" db:"storage_key"`
63
+ ContentType string `json:"content_type" db:"content_type"`
64
+ ByteSize int64 `json:"byte_size" db:"byte_size"`
65
+ Filename string `json:"filename" db:"filename"`
66
+ Status string `json:"status" db:"status"`
67
+ PublicURL string `json:"public_url"`
68
+ CreatedAt string `json:"created_at" db:"created_at"`
69
+ UpdatedAt string `json:"updated_at" db:"updated_at"`
70
+ }
71
+
72
+ type UploadTarget struct {
73
+ Method string `json:"method"`
74
+ URL string `json:"url"`
75
+ Headers map[string]string `json:"headers"`
76
+ }
77
+
78
+ type CreateAttachmentUploadResult struct {
79
+ Attachment Attachment `json:"attachment"`
80
+ Upload UploadTarget `json:"upload"`
81
+ }
82
+
83
+ type WebhookEvent struct {
84
+ ID string `json:"id" db:"id"`
85
+ Provider string `json:"provider" db:"provider"`
86
+ ExternalEventID string `json:"external_event_id" db:"external_event_id"`
87
+ EventType string `json:"event_type" db:"event_type"`
88
+ SignatureValid bool `json:"signature_valid"`
89
+ Status string `json:"status" db:"status"`
90
+ PayloadJSON string `json:"payload_json" db:"payload_json"`
91
+ ReceivedAt string `json:"received_at" db:"received_at"`
92
+ ProcessedAt string `json:"processed_at,omitempty" db:"processed_at"`
93
+ }
94
+
95
+ type CreateUserInput struct {
96
+ Username string `json:"username"`
97
+ DisplayName string `json:"display_name"`
98
+ }
99
+
100
+ type CreateConversationInput struct {
101
+ CreatedByUserID string `json:"created_by_user_id"`
102
+ Title string `json:"title"`
103
+ ParticipantUserIDs []string `json:"participant_user_ids"`
104
+ }
105
+
106
+ type UpdateConversationInput struct {
107
+ Title string `json:"title"`
108
+ }
109
+
110
+ type CreateMessageInput struct {
111
+ UserID string `json:"user_id"`
112
+ Body string `json:"body"`
113
+ }
114
+
115
+ type ListMessagesInput struct {
116
+ Cursor string `json:"cursor"`
117
+ Limit int `json:"limit"`
118
+ }
119
+
120
+ type ListMessagesResult struct {
121
+ Messages []Message `json:"messages"`
122
+ NextCursor string `json:"next_cursor,omitempty"`
123
+ }
124
+
125
+ type UpdateMessageInput struct {
126
+ Body string `json:"body"`
127
+ }
128
+
129
+ type CreateAttachmentUploadInput struct {
130
+ ConversationID string `json:"conversation_id"`
131
+ UploadedByUserID string `json:"user_id"`
132
+ Filename string `json:"filename"`
133
+ ContentType string `json:"content_type"`
134
+ ByteSize int64 `json:"byte_size"`
135
+ }
136
+
137
+ type FinalizeAttachmentInput struct {
138
+ MessageID string `json:"message_id"`
139
+ }
140
+
141
+ type AppError struct {
142
+ Status int
143
+ Code string
144
+ Err error
145
+ }
146
+
147
+ func (e *AppError) Error() string { return e.Err.Error() }
148
+
149
+ type Storage interface {
150
+ CreateSignedUpload(ctx context.Context, attachmentID string, conversationID string, filename string, contentType string) (bucket string, key string, upload UploadTarget, publicURL string, err error)
151
+ GetObjectMetadata(ctx context.Context, bucket string, key string) (contentType string, byteSize int64, publicURL string, err error)
152
+ }
153
+
154
+ type WebhookAdapter interface {
155
+ Normalize(provider string, headers http.Header, rawBody []byte) (NormalizedWebhookEvent, error)
156
+ }
157
+
158
+ type NormalizedWebhookEvent struct {
159
+ Provider string
160
+ ExternalEventID string
161
+ EventType string
162
+ SignatureValid bool
163
+ PayloadJSON string
164
+ }
165
+
166
+ type GenericWebhookAdapter struct{}
167
+
168
+ func (GenericWebhookAdapter) Normalize(provider string, headers http.Header, rawBody []byte) (NormalizedWebhookEvent, error) {
169
+ var payload map[string]any
170
+ _ = json.Unmarshal(rawBody, &payload)
171
+ secret := strings.TrimSpace(os.Getenv("WEBHOOK_" + strings.ToUpper(provider) + "_SECRET"))
172
+ incomingSecret := strings.TrimSpace(headers.Get("X-Webhook-Secret"))
173
+ externalEventID, _ := payload["id"].(string)
174
+ if externalEventID == "" {
175
+ externalEventID = strings.TrimSpace(headers.Get("X-Event-Id"))
176
+ }
177
+ if externalEventID == "" {
178
+ externalEventID = fmt.Sprintf("evt-%d", time.Now().UnixNano())
179
+ }
180
+ eventType, _ := payload["type"].(string)
181
+ if eventType == "" {
182
+ eventType = strings.TrimSpace(headers.Get("X-Event-Type"))
183
+ }
184
+ if eventType == "" {
185
+ eventType = "generic.event"
186
+ }
187
+
188
+ return NormalizedWebhookEvent{
189
+ Provider: provider,
190
+ ExternalEventID: externalEventID,
191
+ EventType: eventType,
192
+ SignatureValid: secret == "" || incomingSecret == secret,
193
+ PayloadJSON: string(rawBody),
194
+ }, nil
16
195
  }
17
196
 
18
- type CreateRecordInput struct {
19
- Type string `json:"type"`
20
- Name string `json:"name"`
21
- Content string `json:"content"`
22
- TTL int `json:"ttl"`
23
- Proxied bool `json:"proxied"`
197
+ type GCSStorage struct {
198
+ bucketName string
199
+ publicBaseURL string
200
+ client *storage.Client
24
201
  }
25
202
 
26
- type UpdateRecordInput struct {
27
- Type string `json:"type"`
28
- Name string `json:"name"`
29
- Content string `json:"content"`
30
- TTL int `json:"ttl"`
31
- Proxied bool `json:"proxied"`
203
+ func NewGCSStorage(bucketName string, publicBaseURL string, client *storage.Client) *GCSStorage {
204
+ if publicBaseURL == "" {
205
+ publicBaseURL = "https://storage.googleapis.com/" + bucketName
206
+ }
207
+ return &GCSStorage{bucketName: bucketName, publicBaseURL: strings.TrimRight(publicBaseURL, "/"), client: client}
32
208
  }
33
209
 
34
- type DNSService struct {
35
- mu sync.RWMutex
36
- nextID int
37
- records map[string]Record
210
+ func (s *GCSStorage) CreateSignedUpload(_ context.Context, attachmentID string, conversationID string, filename string, contentType string) (string, string, UploadTarget, string, error) {
211
+ key := fmt.Sprintf("attachments/%s/%s/%s", conversationID, attachmentID, sanitizeFilename(filename))
212
+ url, err := s.client.Bucket(s.bucketName).SignedURL(key, &storage.SignedURLOptions{
213
+ Method: http.MethodPut,
214
+ Expires: time.Now().Add(15 * time.Minute),
215
+ ContentType: contentType,
216
+ Scheme: storage.SigningSchemeV4,
217
+ })
218
+ if err != nil {
219
+ return "", "", UploadTarget{}, "", err
220
+ }
221
+ return s.bucketName, key, UploadTarget{
222
+ Method: http.MethodPut,
223
+ URL: url,
224
+ Headers: map[string]string{
225
+ "Content-Type": contentType,
226
+ },
227
+ }, s.publicBaseURL + "/" + key, nil
38
228
  }
39
229
 
40
- func NewDNSService() *DNSService {
41
- return &DNSService{
42
- nextID: 1,
43
- records: map[string]Record{},
230
+ func (s *GCSStorage) GetObjectMetadata(ctx context.Context, bucket string, key string) (string, int64, string, error) {
231
+ attrs, err := s.client.Bucket(bucket).Object(key).Attrs(ctx)
232
+ if err != nil {
233
+ return "", 0, "", err
44
234
  }
235
+ return attrs.ContentType, attrs.Size, s.publicBaseURL + "/" + key, nil
45
236
  }
46
237
 
47
- func (s *DNSService) ListRecords(ctx context.Context) ([]Record, error) {
48
- _ = ctx
49
- s.mu.RLock()
50
- defer s.mu.RUnlock()
238
+ type ChatService struct {
239
+ db *sqlx.DB
240
+ storage Storage
241
+ webhookAdapter WebhookAdapter
242
+ }
51
243
 
52
- out := make([]Record, 0, len(s.records))
53
- for _, record := range s.records {
54
- out = append(out, record)
244
+ func OpenDatabase(ctx context.Context, databaseURL string) (*sqlx.DB, error) {
245
+ if strings.TrimSpace(databaseURL) == "" {
246
+ return nil, errors.New("DATABASE_URL is required")
55
247
  }
56
- return out, nil
248
+ return sqlx.ConnectContext(ctx, "pgx", databaseURL)
249
+ }
250
+
251
+ func NewChatService(db *sqlx.DB, storage Storage, webhookAdapter WebhookAdapter) *ChatService {
252
+ return &ChatService{db: db, storage: storage, webhookAdapter: webhookAdapter}
57
253
  }
58
254
 
59
- func (s *DNSService) CreateRecord(ctx context.Context, input CreateRecordInput) (Record, error) {
60
- _ = ctx
61
- s.mu.Lock()
62
- defer s.mu.Unlock()
255
+ func (s *ChatService) CreateUser(ctx context.Context, input CreateUserInput) (User, error) {
256
+ username := strings.ToLower(strings.TrimSpace(input.Username))
257
+ if username == "" {
258
+ return User{}, &AppError{Status: 400, Code: "invalid_username", Err: errors.New("username is required")}
259
+ }
260
+ var count int
261
+ if err := s.db.GetContext(ctx, &count, `select count(*) from users where username = $1`, username); err != nil {
262
+ return User{}, err
263
+ }
264
+ if count > 0 {
265
+ return User{}, &AppError{Status: 409, Code: "username_taken", Err: fmt.Errorf("username %s already exists", username)}
266
+ }
267
+ id := fmt.Sprintf("usr_%d", time.Now().UnixNano())
268
+ var user User
269
+ err := s.db.GetContext(ctx, &user, `
270
+ insert into users (id, username, display_name)
271
+ values ($1, $2, nullif($3, ''))
272
+ returning id, username, coalesce(display_name, '') as display_name, created_at::text, updated_at::text
273
+ `, id, username, strings.TrimSpace(input.DisplayName))
274
+ return user, err
275
+ }
276
+
277
+ func (s *ChatService) GetUser(ctx context.Context, userID string) (User, error) {
278
+ var user User
279
+ err := s.db.GetContext(ctx, &user, `
280
+ select id, username, coalesce(display_name, '') as display_name, created_at::text, updated_at::text
281
+ from users where id = $1`, userID)
282
+ if err != nil {
283
+ return User{}, notFoundIfNoRows(err, "user_not_found", "user not found")
284
+ }
285
+ return user, nil
286
+ }
287
+
288
+ func (s *ChatService) GetUserByUsername(ctx context.Context, username string) (User, error) {
289
+ var user User
290
+ err := s.db.GetContext(ctx, &user, `
291
+ select id, username, coalesce(display_name, '') as display_name, created_at::text, updated_at::text
292
+ from users where username = $1`, strings.ToLower(strings.TrimSpace(username)))
293
+ if err != nil {
294
+ return User{}, notFoundIfNoRows(err, "user_not_found", "user not found")
295
+ }
296
+ return user, nil
297
+ }
298
+
299
+ func (s *ChatService) CreateConversation(ctx context.Context, input CreateConversationInput) (Conversation, error) {
300
+ if _, err := s.GetUser(ctx, input.CreatedByUserID); err != nil {
301
+ return Conversation{}, err
302
+ }
303
+ conversationID := fmt.Sprintf("con_%d", time.Now().UnixNano())
304
+ tx, err := s.db.BeginTxx(ctx, nil)
305
+ if err != nil {
306
+ return Conversation{}, err
307
+ }
308
+ defer tx.Rollback()
309
+
310
+ if _, err := tx.ExecContext(ctx, `
311
+ insert into conversations (id, title, created_by_user_id)
312
+ values ($1, nullif($2, ''), $3)`, conversationID, strings.TrimSpace(input.Title), input.CreatedByUserID); err != nil {
313
+ return Conversation{}, err
314
+ }
63
315
 
64
- record := Record{
65
- ID: fmt.Sprintf("%d", s.nextID),
66
- Type: input.Type,
67
- Name: input.Name,
68
- Content: input.Content,
69
- TTL: input.TTL,
70
- Proxied: input.Proxied,
316
+ participantIDs := uniqueStrings(append([]string{input.CreatedByUserID}, input.ParticipantUserIDs...))
317
+ for _, userID := range participantIDs {
318
+ if _, err := s.GetUser(ctx, userID); err != nil {
319
+ return Conversation{}, err
320
+ }
321
+ if _, err := tx.ExecContext(ctx, `
322
+ insert into conversation_participants (conversation_id, user_id)
323
+ values ($1, $2) on conflict do nothing`, conversationID, userID); err != nil {
324
+ return Conversation{}, err
325
+ }
71
326
  }
72
327
 
73
- s.nextID += 1
74
- s.records[record.ID] = record
75
- return record, nil
328
+ if err := tx.Commit(); err != nil {
329
+ return Conversation{}, err
330
+ }
331
+ return s.GetConversation(ctx, conversationID)
76
332
  }
77
333
 
78
- func (s *DNSService) UpdateRecord(ctx context.Context, id string, input UpdateRecordInput) (Record, error) {
79
- _ = ctx
80
- s.mu.Lock()
81
- defer s.mu.Unlock()
334
+ func (s *ChatService) GetConversation(ctx context.Context, conversationID string) (Conversation, error) {
335
+ var conversation Conversation
336
+ err := s.db.GetContext(ctx, &conversation, `
337
+ select id, coalesce(title, '') as title, created_by_user_id, created_at::text, updated_at::text
338
+ from conversations
339
+ where id = $1 and deleted_at is null`, conversationID)
340
+ if err != nil {
341
+ return Conversation{}, notFoundIfNoRows(err, "conversation_not_found", "conversation not found")
342
+ }
343
+ var participants []User
344
+ if err := s.db.SelectContext(ctx, &participants, `
345
+ select u.id, u.username, coalesce(u.display_name, '') as display_name, u.created_at::text, u.updated_at::text
346
+ from conversation_participants cp
347
+ join users u on u.id = cp.user_id
348
+ where cp.conversation_id = $1
349
+ order by u.username asc`, conversationID); err != nil {
350
+ return Conversation{}, err
351
+ }
352
+ conversation.Participants = participants
353
+ return conversation, nil
354
+ }
82
355
 
83
- record, ok := s.records[id]
84
- if !ok {
85
- return Record{}, fmt.Errorf("record %s not found", id)
356
+ func (s *ChatService) UpdateConversation(ctx context.Context, conversationID string, input UpdateConversationInput) (Conversation, error) {
357
+ if _, err := s.GetConversation(ctx, conversationID); err != nil {
358
+ return Conversation{}, err
359
+ }
360
+ if _, err := s.db.ExecContext(ctx, `
361
+ update conversations set title = nullif($2, ''), updated_at = now() where id = $1`, conversationID, strings.TrimSpace(input.Title)); err != nil {
362
+ return Conversation{}, err
86
363
  }
364
+ return s.GetConversation(ctx, conversationID)
365
+ }
87
366
 
88
- record.Type = input.Type
89
- record.Name = input.Name
90
- record.Content = input.Content
91
- record.TTL = input.TTL
92
- record.Proxied = input.Proxied
93
- s.records[id] = record
367
+ func (s *ChatService) DeleteConversation(ctx context.Context, conversationID string) error {
368
+ if _, err := s.GetConversation(ctx, conversationID); err != nil {
369
+ return err
370
+ }
371
+ _, err := s.db.ExecContext(ctx, `
372
+ update conversations set deleted_at = now(), updated_at = now() where id = $1`, conversationID)
373
+ return err
374
+ }
94
375
 
95
- return record, nil
376
+ func (s *ChatService) AddParticipant(ctx context.Context, conversationID string, userID string) (Conversation, error) {
377
+ if _, err := s.GetConversation(ctx, conversationID); err != nil {
378
+ return Conversation{}, err
379
+ }
380
+ if _, err := s.GetUser(ctx, userID); err != nil {
381
+ return Conversation{}, err
382
+ }
383
+ if _, err := s.db.ExecContext(ctx, `
384
+ insert into conversation_participants (conversation_id, user_id)
385
+ values ($1, $2) on conflict do nothing`, conversationID, userID); err != nil {
386
+ return Conversation{}, err
387
+ }
388
+ return s.GetConversation(ctx, conversationID)
96
389
  }
97
390
 
98
- func (s *DNSService) DeleteRecord(ctx context.Context, id string) error {
99
- _ = ctx
100
- s.mu.Lock()
101
- defer s.mu.Unlock()
391
+ func (s *ChatService) RemoveParticipant(ctx context.Context, conversationID string, userID string) error {
392
+ _, err := s.db.ExecContext(ctx, `
393
+ delete from conversation_participants where conversation_id = $1 and user_id = $2`, conversationID, userID)
394
+ return err
395
+ }
396
+
397
+ func (s *ChatService) ListMessages(ctx context.Context, conversationID string, input ListMessagesInput) (ListMessagesResult, error) {
398
+ if _, err := s.GetConversation(ctx, conversationID); err != nil {
399
+ return ListMessagesResult{}, err
400
+ }
401
+ limit, err := normalizeMessagePageSize(input.Limit)
402
+ if err != nil {
403
+ return ListMessagesResult{}, err
404
+ }
405
+ cursor, err := parseMessageCursor(input.Cursor)
406
+ if err != nil {
407
+ return ListMessagesResult{}, err
408
+ }
102
409
 
103
- if _, ok := s.records[id]; !ok {
104
- return fmt.Errorf("record %s not found", id)
410
+ query := `
411
+ select id, conversation_id, user_id, body, coalesce(edited_at::text, '') as edited_at, created_at::text, updated_at::text
412
+ from messages
413
+ where conversation_id = $1 and deleted_at is null`
414
+ args := []any{conversationID}
415
+ if cursor != nil {
416
+ query += `
417
+ and (
418
+ created_at < $2
419
+ or (created_at = $2 and id < $3)
420
+ )`
421
+ args = append(args, cursor.CreatedAt, cursor.ID)
422
+ }
423
+ query += fmt.Sprintf(`
424
+ order by created_at desc, id desc
425
+ limit %d`, limit+1)
426
+
427
+ var items []Message
428
+ if err := s.db.SelectContext(ctx, &items, query, args...); err != nil {
429
+ return ListMessagesResult{}, err
430
+ }
431
+ hasMore := len(items) > limit
432
+ if hasMore {
433
+ items = items[:limit]
434
+ }
435
+ if err := s.attachMessageAttachments(ctx, items); err != nil {
436
+ return ListMessagesResult{}, err
437
+ }
438
+
439
+ result := ListMessagesResult{Messages: items}
440
+ if hasMore && len(items) > 0 {
441
+ result.NextCursor = encodeMessageCursor(items[len(items)-1])
442
+ }
443
+ return result, nil
444
+ }
445
+
446
+ func (s *ChatService) CreateMessage(ctx context.Context, conversationID string, input CreateMessageInput) (Message, error) {
447
+ conversation, err := s.GetConversation(ctx, conversationID)
448
+ if err != nil {
449
+ return Message{}, err
450
+ }
451
+ if !hasParticipant(conversation.Participants, input.UserID) {
452
+ return Message{}, &AppError{Status: 409, Code: "not_a_participant", Err: errors.New("user is not a participant")}
453
+ }
454
+ body := strings.TrimSpace(input.Body)
455
+ if body == "" {
456
+ return Message{}, &AppError{Status: 400, Code: "invalid_body", Err: errors.New("message body is required")}
457
+ }
458
+ id := fmt.Sprintf("msg_%d", time.Now().UnixNano())
459
+ var message Message
460
+ err = s.db.GetContext(ctx, &message, `
461
+ insert into messages (id, conversation_id, user_id, body)
462
+ values ($1, $2, $3, $4)
463
+ returning id, conversation_id, user_id, body, coalesce(edited_at::text, '') as edited_at, created_at::text, updated_at::text
464
+ `, id, conversationID, input.UserID, body)
465
+ return message, err
466
+ }
467
+
468
+ func (s *ChatService) UpdateMessage(ctx context.Context, conversationID string, messageID string, input UpdateMessageInput) (Message, error) {
469
+ if _, err := s.GetConversation(ctx, conversationID); err != nil {
470
+ return Message{}, err
471
+ }
472
+ body := strings.TrimSpace(input.Body)
473
+ if body == "" {
474
+ return Message{}, &AppError{Status: 400, Code: "invalid_body", Err: errors.New("message body is required")}
475
+ }
476
+ var message Message
477
+ err := s.db.GetContext(ctx, &message, `
478
+ update messages
479
+ set body = $3, edited_at = now(), updated_at = now()
480
+ where id = $1 and conversation_id = $2 and deleted_at is null
481
+ returning id, conversation_id, user_id, body, coalesce(edited_at::text, '') as edited_at, created_at::text, updated_at::text
482
+ `, messageID, conversationID, body)
483
+ if err != nil {
484
+ return Message{}, notFoundIfNoRows(err, "message_not_found", "message not found")
485
+ }
486
+ return message, nil
487
+ }
488
+
489
+ func (s *ChatService) DeleteMessage(ctx context.Context, conversationID string, messageID string) error {
490
+ _, err := s.db.ExecContext(ctx, `
491
+ update messages set deleted_at = now(), updated_at = now() where id = $1 and conversation_id = $2`, messageID, conversationID)
492
+ return err
493
+ }
494
+
495
+ func (s *ChatService) CreateAttachmentUpload(ctx context.Context, input CreateAttachmentUploadInput) (CreateAttachmentUploadResult, error) {
496
+ if _, err := s.GetConversation(ctx, input.ConversationID); err != nil {
497
+ return CreateAttachmentUploadResult{}, err
498
+ }
499
+ if _, err := s.GetUser(ctx, input.UploadedByUserID); err != nil {
500
+ return CreateAttachmentUploadResult{}, err
501
+ }
502
+ if !strings.HasPrefix(input.ContentType, "image/") {
503
+ return CreateAttachmentUploadResult{}, &AppError{Status: 400, Code: "invalid_content_type", Err: errors.New("only image uploads are supported")}
504
+ }
505
+ id := fmt.Sprintf("att_%d", time.Now().UnixNano())
506
+ bucket, key, upload, publicURL, err := s.storage.CreateSignedUpload(ctx, id, input.ConversationID, input.Filename, input.ContentType)
507
+ if err != nil {
508
+ return CreateAttachmentUploadResult{}, err
509
+ }
510
+ var attachment Attachment
511
+ err = s.db.GetContext(ctx, &attachment, `
512
+ insert into attachments (id, conversation_id, uploaded_by_user_id, storage_bucket, storage_key, content_type, byte_size, filename, status)
513
+ values ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
514
+ returning id, conversation_id, coalesce(message_id, '') as message_id, uploaded_by_user_id, storage_bucket, storage_key, content_type, byte_size, filename, status, created_at::text, updated_at::text
515
+ `, id, input.ConversationID, input.UploadedByUserID, bucket, key, input.ContentType, input.ByteSize, input.Filename)
516
+ if err != nil {
517
+ return CreateAttachmentUploadResult{}, err
518
+ }
519
+ attachment.PublicURL = publicURL
520
+ return CreateAttachmentUploadResult{Attachment: attachment, Upload: upload}, nil
521
+ }
522
+
523
+ func (s *ChatService) GetAttachment(ctx context.Context, attachmentID string) (Attachment, error) {
524
+ var attachment Attachment
525
+ err := s.db.GetContext(ctx, &attachment, `
526
+ select id, conversation_id, coalesce(message_id, '') as message_id, uploaded_by_user_id, storage_bucket, storage_key, content_type, byte_size, filename, status, created_at::text, updated_at::text
527
+ from attachments where id = $1 and deleted_at is null`, attachmentID)
528
+ if err != nil {
529
+ return Attachment{}, notFoundIfNoRows(err, "attachment_not_found", "attachment not found")
530
+ }
531
+ _, _, publicURL, metaErr := s.storage.GetObjectMetadata(ctx, attachment.StorageBucket, attachment.StorageKey)
532
+ if metaErr == nil {
533
+ attachment.PublicURL = publicURL
534
+ }
535
+ return attachment, nil
536
+ }
537
+
538
+ func (s *ChatService) FinalizeAttachment(ctx context.Context, attachmentID string, input FinalizeAttachmentInput) (Attachment, error) {
539
+ attachment, err := s.GetAttachment(ctx, attachmentID)
540
+ if err != nil {
541
+ return Attachment{}, err
542
+ }
543
+ if input.MessageID != "" {
544
+ var exists int
545
+ if err := s.db.GetContext(ctx, &exists, `select count(*) from messages where id = $1 and conversation_id = $2 and deleted_at is null`, input.MessageID, attachment.ConversationID); err != nil {
546
+ return Attachment{}, err
547
+ }
548
+ if exists == 0 {
549
+ return Attachment{}, &AppError{Status: 404, Code: "message_not_found", Err: errors.New("message not found")}
550
+ }
551
+ }
552
+ contentType, byteSize, publicURL, err := s.storage.GetObjectMetadata(ctx, attachment.StorageBucket, attachment.StorageKey)
553
+ if err != nil {
554
+ return Attachment{}, err
555
+ }
556
+ if contentType != attachment.ContentType || byteSize != attachment.ByteSize {
557
+ return Attachment{}, &AppError{Status: 409, Code: "attachment_mismatch", Err: errors.New("uploaded object metadata does not match pending attachment")}
558
+ }
559
+ var updated Attachment
560
+ err = s.db.GetContext(ctx, &updated, `
561
+ update attachments
562
+ set message_id = nullif($2, ''), status = 'ready', updated_at = now()
563
+ where id = $1
564
+ returning id, conversation_id, coalesce(message_id, '') as message_id, uploaded_by_user_id, storage_bucket, storage_key, content_type, byte_size, filename, status, created_at::text, updated_at::text
565
+ `, attachmentID, input.MessageID)
566
+ if err != nil {
567
+ return Attachment{}, err
568
+ }
569
+ updated.PublicURL = publicURL
570
+ return updated, nil
571
+ }
572
+
573
+ func (s *ChatService) DeleteAttachment(ctx context.Context, attachmentID string) error {
574
+ _, err := s.db.ExecContext(ctx, `
575
+ update attachments set deleted_at = now(), status = 'deleted', updated_at = now() where id = $1`, attachmentID)
576
+ return err
577
+ }
578
+
579
+ func (s *ChatService) ProcessWebhook(ctx context.Context, provider string, headers http.Header, rawBody []byte) (WebhookEvent, bool, error) {
580
+ event, err := s.webhookAdapter.Normalize(provider, headers, rawBody)
581
+ if err != nil {
582
+ return WebhookEvent{}, false, err
583
+ }
584
+ var existing WebhookEvent
585
+ err = s.db.GetContext(ctx, &existing, `
586
+ select id, provider, external_event_id, event_type, signature_valid = 'true' as signature_valid, status, payload_json, received_at::text, coalesce(processed_at::text, '') as processed_at
587
+ from webhook_events where provider = $1 and external_event_id = $2`, event.Provider, event.ExternalEventID)
588
+ if err == nil {
589
+ return existing, true, nil
590
+ }
591
+ if !errors.Is(err, sql.ErrNoRows) {
592
+ return WebhookEvent{}, false, err
593
+ }
594
+ status := "processed"
595
+ if !event.SignatureValid {
596
+ status = "failed"
597
+ }
598
+ id := fmt.Sprintf("wh_%d", time.Now().UnixNano())
599
+ var created WebhookEvent
600
+ err = s.db.GetContext(ctx, &created, `
601
+ insert into webhook_events (id, provider, external_event_id, event_type, signature_valid, status, payload_json, processed_at)
602
+ values ($1, $2, $3, $4, $5, $6, $7, now())
603
+ returning id, provider, external_event_id, event_type, signature_valid = 'true' as signature_valid, status, payload_json, received_at::text, coalesce(processed_at::text, '') as processed_at
604
+ `, id, event.Provider, event.ExternalEventID, event.EventType, boolString(event.SignatureValid), status, event.PayloadJSON)
605
+ return created, false, err
606
+ }
607
+
608
+ func (s *ChatService) attachMessageAttachments(ctx context.Context, messages []Message) error {
609
+ if len(messages) == 0 {
610
+ return nil
611
+ }
612
+
613
+ messageIDs := make([]string, 0, len(messages))
614
+ messageIndex := make(map[string]*Message, len(messages))
615
+ for index := range messages {
616
+ messageIDs = append(messageIDs, messages[index].ID)
617
+ messageIndex[messages[index].ID] = &messages[index]
618
+ }
619
+
620
+ query, args, err := sqlx.In(`
621
+ select id, message_id, filename, content_type, byte_size, status, storage_bucket, storage_key
622
+ from attachments
623
+ where message_id in (?) and status = 'ready' and deleted_at is null
624
+ order by created_at asc, id asc`, messageIDs)
625
+ if err != nil {
626
+ return err
627
+ }
628
+ query = s.db.Rebind(query)
629
+
630
+ var rows []struct {
631
+ ID string `db:"id"`
632
+ MessageID string `db:"message_id"`
633
+ Filename string `db:"filename"`
634
+ ContentType string `db:"content_type"`
635
+ ByteSize int64 `db:"byte_size"`
636
+ Status string `db:"status"`
637
+ StorageBucket string `db:"storage_bucket"`
638
+ StorageKey string `db:"storage_key"`
639
+ }
640
+ if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
641
+ return err
642
+ }
643
+
644
+ for _, row := range rows {
645
+ message := messageIndex[row.MessageID]
646
+ if message == nil {
647
+ continue
648
+ }
649
+ message.Attachments = append(message.Attachments, MessageAttachment{
650
+ ID: row.ID,
651
+ Filename: row.Filename,
652
+ ContentType: row.ContentType,
653
+ ByteSize: row.ByteSize,
654
+ Status: row.Status,
655
+ PublicURL: buildAttachmentPublicURL(row.StorageBucket, row.StorageKey),
656
+ })
105
657
  }
106
658
 
107
- delete(s.records, id)
108
659
  return nil
109
660
  }
661
+
662
+ const defaultMessagePageSize = 50
663
+ const maxMessagePageSize = 100
664
+
665
+ func normalizeMessagePageSize(limit int) (int, error) {
666
+ if limit == 0 {
667
+ return defaultMessagePageSize, nil
668
+ }
669
+ if limit < 0 {
670
+ return 0, &AppError{Status: 400, Code: "invalid_limit", Err: errors.New("limit must be a positive integer")}
671
+ }
672
+ if limit > maxMessagePageSize {
673
+ return 0, &AppError{Status: 400, Code: "invalid_limit", Err: fmt.Errorf("limit must be at most %d", maxMessagePageSize)}
674
+ }
675
+ return limit, nil
676
+ }
677
+
678
+ type messageCursor struct {
679
+ CreatedAt time.Time `json:"created_at"`
680
+ ID string `json:"id"`
681
+ }
682
+
683
+ func parseMessageCursor(raw string) (*messageCursor, error) {
684
+ if strings.TrimSpace(raw) == "" {
685
+ return nil, nil
686
+ }
687
+ decoded, err := base64.RawURLEncoding.DecodeString(raw)
688
+ if err != nil {
689
+ return nil, &AppError{Status: 400, Code: "invalid_cursor", Err: errors.New("cursor is invalid")}
690
+ }
691
+
692
+ var cursor struct {
693
+ CreatedAt string `json:"createdAt"`
694
+ ID string `json:"id"`
695
+ }
696
+ if err := json.Unmarshal(decoded, &cursor); err != nil {
697
+ return nil, &AppError{Status: 400, Code: "invalid_cursor", Err: errors.New("cursor is invalid")}
698
+ }
699
+ if strings.TrimSpace(cursor.CreatedAt) == "" || strings.TrimSpace(cursor.ID) == "" {
700
+ return nil, &AppError{Status: 400, Code: "invalid_cursor", Err: errors.New("cursor is invalid")}
701
+ }
702
+ createdAt, err := parseCursorTime(cursor.CreatedAt)
703
+ if err != nil {
704
+ return nil, &AppError{Status: 400, Code: "invalid_cursor", Err: errors.New("cursor is invalid")}
705
+ }
706
+ return &messageCursor{CreatedAt: createdAt, ID: cursor.ID}, nil
707
+ }
708
+
709
+ func encodeMessageCursor(message Message) string {
710
+ payload, _ := json.Marshal(map[string]string{
711
+ "createdAt": message.CreatedAt,
712
+ "id": message.ID,
713
+ })
714
+ return base64.RawURLEncoding.EncodeToString(payload)
715
+ }
716
+
717
+ func buildAttachmentPublicURL(bucket string, key string) string {
718
+ base := strings.TrimSpace(os.Getenv("ATTACHMENT_PUBLIC_BASE_URL"))
719
+ if base == "" {
720
+ base = "https://storage.googleapis.com/" + bucket
721
+ }
722
+ return strings.TrimRight(base, "/") + "/" + key
723
+ }
724
+
725
+ func parseCursorTime(value string) (time.Time, error) {
726
+ layouts := []string{
727
+ time.RFC3339Nano,
728
+ "2006-01-02 15:04:05.999999999Z07:00",
729
+ "2006-01-02 15:04:05.999999999Z07",
730
+ "2006-01-02 15:04:05Z07:00",
731
+ "2006-01-02 15:04:05Z07",
732
+ }
733
+ for _, layout := range layouts {
734
+ parsed, err := time.Parse(layout, value)
735
+ if err == nil {
736
+ return parsed, nil
737
+ }
738
+ }
739
+ return time.Time{}, fmt.Errorf("invalid cursor time: %s", value)
740
+ }
741
+
742
+ func boolString(value bool) string {
743
+ if value {
744
+ return "true"
745
+ }
746
+ return "false"
747
+ }
748
+
749
+ func notFoundIfNoRows(err error, code string, message string) error {
750
+ if errors.Is(err, sql.ErrNoRows) {
751
+ return &AppError{Status: 404, Code: code, Err: errors.New(message)}
752
+ }
753
+ return err
754
+ }
755
+
756
+ func hasParticipant(participants []User, userID string) bool {
757
+ for _, participant := range participants {
758
+ if participant.ID == userID {
759
+ return true
760
+ }
761
+ }
762
+ return false
763
+ }
764
+
765
+ func uniqueStrings(values []string) []string {
766
+ seen := map[string]struct{}{}
767
+ out := make([]string, 0, len(values))
768
+ for _, value := range values {
769
+ value = strings.TrimSpace(value)
770
+ if value == "" {
771
+ continue
772
+ }
773
+ if _, ok := seen[value]; ok {
774
+ continue
775
+ }
776
+ seen[value] = struct{}{}
777
+ out = append(out, value)
778
+ }
779
+ return out
780
+ }
781
+
782
+ func sanitizeFilename(filename string) string {
783
+ filename = strings.TrimSpace(filename)
784
+ if filename == "" {
785
+ return "upload.bin"
786
+ }
787
+ return strings.Map(func(r rune) rune {
788
+ switch {
789
+ case r >= 'a' && r <= 'z':
790
+ return r
791
+ case r >= 'A' && r <= 'Z':
792
+ return r
793
+ case r >= '0' && r <= '9':
794
+ return r
795
+ case r == '.', r == '-', r == '_':
796
+ return r
797
+ default:
798
+ return '-'
799
+ }
800
+ }, filename)
801
+ }