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
@@ -1,17 +1,32 @@
1
1
  package config
2
2
 
3
- import "os"
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "strings"
7
+ )
4
8
 
5
9
  type Config struct {
6
- Port string
7
- DatabaseURL string
10
+ Port string
11
+ DatabaseURL string
12
+ AttachmentBucket string
13
+ AttachmentPublicBaseURL string
8
14
  }
9
15
 
10
16
  func Load() (Config, error) {
11
- return Config{
12
- Port: envOr("PORT", "8080"),
13
- DatabaseURL: envOr("DATABASE_URL", ""),
14
- }, nil
17
+ cfg := Config{
18
+ Port: envOr("PORT", "8080"),
19
+ DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
20
+ AttachmentBucket: strings.TrimSpace(os.Getenv("ATTACHMENT_BUCKET")),
21
+ AttachmentPublicBaseURL: strings.TrimSpace(os.Getenv("ATTACHMENT_PUBLIC_BASE_URL")),
22
+ }
23
+ if cfg.DatabaseURL == "" {
24
+ return Config{}, errors.New("DATABASE_URL is required")
25
+ }
26
+ if cfg.AttachmentBucket == "" {
27
+ return Config{}, errors.New("ATTACHMENT_BUCKET is required")
28
+ }
29
+ return cfg, nil
15
30
  }
16
31
 
17
32
  func envOr(key string, fallback string) string {
@@ -0,0 +1,298 @@
1
+ package httpapi
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "net/url"
11
+ "os"
12
+ "strings"
13
+ "testing"
14
+ "time"
15
+
16
+ "github.com/go-chi/chi/v5"
17
+ _ "github.com/jackc/pgx/v5/stdlib"
18
+ "github.com/jmoiron/sqlx"
19
+
20
+ "{{MODULE_PATH}}/internal/app"
21
+ )
22
+
23
+ func TestListMessagesPaginationIncludesAttachmentMetadata(t *testing.T) {
24
+ databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
25
+ if databaseURL == "" {
26
+ t.Skip("DATABASE_URL is required for integration test")
27
+ }
28
+
29
+ t.Setenv("ATTACHMENT_PUBLIC_BASE_URL", "https://storage.test")
30
+
31
+ db, err := app.OpenDatabase(context.Background(), databaseURL)
32
+ if err != nil {
33
+ t.Fatalf("open database: %v", err)
34
+ }
35
+ t.Cleanup(func() { _ = db.Close() })
36
+
37
+ if _, err := db.ExecContext(context.Background(), `
38
+ truncate table
39
+ webhook_events,
40
+ attachments,
41
+ messages,
42
+ conversation_participants,
43
+ conversations,
44
+ users
45
+ restart identity cascade`); err != nil {
46
+ t.Fatalf("truncate tables: %v", err)
47
+ }
48
+
49
+ storage := newFakeStorage()
50
+ service := app.NewChatService(db, storage, app.GenericWebhookAdapter{})
51
+ router := chi.NewRouter()
52
+ RegisterRoutes(router, service)
53
+ server := httptest.NewServer(router)
54
+ t.Cleanup(server.Close)
55
+
56
+ userID := createUser(t, server.URL)
57
+ conversationID := createConversation(t, server.URL, userID)
58
+
59
+ messageIDs := make([]string, 0, 55)
60
+ for index := 1; index <= 55; index++ {
61
+ messageIDs = append(messageIDs, createMessage(t, server.URL, conversationID, userID, fmt.Sprintf("message-%d", index)))
62
+ }
63
+ rewriteMessageTimestamps(t, db, messageIDs)
64
+
65
+ upload := createAttachmentUpload(t, server.URL, conversationID, userID)
66
+ storage.set(upload.Result.Attachment.PublicURL, "image/png", 1234)
67
+ finalizeAttachment(t, server.URL, upload.Result.Attachment.ID, messageIDs[54])
68
+
69
+ firstPage := listMessagesPage(t, server.URL, conversationID, "")
70
+ if len(firstPage.Messages) != 50 {
71
+ t.Fatalf("expected 50 messages, got %d", len(firstPage.Messages))
72
+ }
73
+ if firstPage.Messages[0].Body != "message-55" {
74
+ t.Fatalf("expected newest message first, got %s", firstPage.Messages[0].Body)
75
+ }
76
+ if firstPage.Messages[49].Body != "message-6" {
77
+ t.Fatalf("expected oldest message on first page to be message-6, got %s", firstPage.Messages[49].Body)
78
+ }
79
+ if firstPage.NextCursor == "" {
80
+ t.Fatal("expected next_cursor on first page")
81
+ }
82
+ if len(firstPage.Messages[0].Attachments) != 1 {
83
+ t.Fatalf("expected attachment metadata on newest message, got %#v", firstPage.Messages[0].Attachments)
84
+ }
85
+ if firstPage.Messages[0].Attachments[0].PublicURL != upload.Result.Attachment.PublicURL {
86
+ t.Fatalf("expected public_url %s, got %s", upload.Result.Attachment.PublicURL, firstPage.Messages[0].Attachments[0].PublicURL)
87
+ }
88
+
89
+ secondPage := listMessagesPage(t, server.URL, conversationID, firstPage.NextCursor)
90
+ expectedBodies := []string{"message-5", "message-4", "message-3", "message-2", "message-1"}
91
+ if len(secondPage.Messages) != len(expectedBodies) {
92
+ t.Fatalf("expected %d messages on second page, got %d", len(expectedBodies), len(secondPage.Messages))
93
+ }
94
+ for index, body := range expectedBodies {
95
+ if secondPage.Messages[index].Body != body {
96
+ t.Fatalf("expected %s at index %d, got %s", body, index, secondPage.Messages[index].Body)
97
+ }
98
+ }
99
+ if secondPage.NextCursor != "" {
100
+ t.Fatalf("expected no next cursor on final page, got %s", secondPage.NextCursor)
101
+ }
102
+ }
103
+
104
+ type pageResponse struct {
105
+ Messages []struct {
106
+ ID string `json:"id"`
107
+ Body string `json:"body"`
108
+ Attachments []struct {
109
+ ID string `json:"id"`
110
+ Filename string `json:"filename"`
111
+ ContentType string `json:"content_type"`
112
+ ByteSize int64 `json:"byte_size"`
113
+ Status string `json:"status"`
114
+ PublicURL string `json:"public_url"`
115
+ } `json:"attachments"`
116
+ } `json:"messages"`
117
+ NextCursor string `json:"next_cursor"`
118
+ }
119
+
120
+ type attachmentUploadResponse struct {
121
+ Result struct {
122
+ Attachment struct {
123
+ ID string `json:"id"`
124
+ PublicURL string `json:"public_url"`
125
+ } `json:"attachment"`
126
+ } `json:"result"`
127
+ }
128
+
129
+ type fakeStorage struct {
130
+ metadata map[string]struct {
131
+ contentType string
132
+ byteSize int64
133
+ publicURL string
134
+ }
135
+ }
136
+
137
+ func newFakeStorage() *fakeStorage {
138
+ return &fakeStorage{metadata: map[string]struct {
139
+ contentType string
140
+ byteSize int64
141
+ publicURL string
142
+ }{}}
143
+ }
144
+
145
+ func (f *fakeStorage) CreateSignedUpload(_ context.Context, attachmentID string, conversationID string, filename string, contentType string) (string, string, app.UploadTarget, string, error) {
146
+ key := fmt.Sprintf("attachments/%s/%s/%s", conversationID, attachmentID, filename)
147
+ return "test-bucket", key, app.UploadTarget{
148
+ Method: http.MethodPut,
149
+ URL: "https://uploads.test/" + key,
150
+ Headers: map[string]string{
151
+ "Content-Type": contentType,
152
+ },
153
+ }, "https://storage.test/" + key, nil
154
+ }
155
+
156
+ func (f *fakeStorage) GetObjectMetadata(_ context.Context, bucket string, key string) (string, int64, string, error) {
157
+ entry, ok := f.metadata[bucket+"/"+key]
158
+ if !ok {
159
+ return "", 0, "", fmt.Errorf("missing metadata for %s/%s", bucket, key)
160
+ }
161
+ return entry.contentType, entry.byteSize, entry.publicURL, nil
162
+ }
163
+
164
+ func (f *fakeStorage) set(publicURL string, contentType string, byteSize int64) {
165
+ key := strings.TrimPrefix(publicURL, "https://storage.test/")
166
+ f.metadata["test-bucket/"+key] = struct {
167
+ contentType string
168
+ byteSize int64
169
+ publicURL string
170
+ }{
171
+ contentType: contentType,
172
+ byteSize: byteSize,
173
+ publicURL: publicURL,
174
+ }
175
+ }
176
+
177
+ func createUser(t *testing.T, baseURL string) string {
178
+ var response struct {
179
+ User struct {
180
+ ID string `json:"id"`
181
+ } `json:"user"`
182
+ }
183
+ requestJSON(t, http.MethodPost, baseURL+"/v1/users", map[string]any{
184
+ "username": "alice",
185
+ "display_name": "Alice",
186
+ }, &response)
187
+ return response.User.ID
188
+ }
189
+
190
+ func createConversation(t *testing.T, baseURL string, userID string) string {
191
+ var response struct {
192
+ Conversation struct {
193
+ ID string `json:"id"`
194
+ } `json:"conversation"`
195
+ }
196
+ requestJSON(t, http.MethodPost, baseURL+"/v1/conversations", map[string]any{
197
+ "created_by_user_id": userID,
198
+ "title": "General",
199
+ "participant_user_ids": []string{userID},
200
+ }, &response)
201
+ return response.Conversation.ID
202
+ }
203
+
204
+ func createMessage(t *testing.T, baseURL string, conversationID string, userID string, body string) string {
205
+ var response struct {
206
+ Message struct {
207
+ ID string `json:"id"`
208
+ } `json:"message"`
209
+ }
210
+ requestJSON(t, http.MethodPost, baseURL+"/v1/conversations/"+conversationID+"/messages", map[string]any{
211
+ "user_id": userID,
212
+ "body": body,
213
+ }, &response)
214
+ return response.Message.ID
215
+ }
216
+
217
+ func createAttachmentUpload(t *testing.T, baseURL string, conversationID string, userID string) attachmentUploadResponse {
218
+ var response attachmentUploadResponse
219
+ requestJSON(t, http.MethodPost, baseURL+"/v1/attachments/uploads", map[string]any{
220
+ "conversation_id": conversationID,
221
+ "user_id": userID,
222
+ "filename": "photo.png",
223
+ "content_type": "image/png",
224
+ "byte_size": 1234,
225
+ }, &response)
226
+ return response
227
+ }
228
+
229
+ func finalizeAttachment(t *testing.T, baseURL string, attachmentID string, messageID string) {
230
+ requestJSON(t, http.MethodPost, baseURL+"/v1/attachments/"+attachmentID+"/finalize", map[string]any{
231
+ "message_id": messageID,
232
+ }, &struct{}{})
233
+ }
234
+
235
+ func listMessagesPage(t *testing.T, baseURL string, conversationID string, cursor string) pageResponse {
236
+ requestURL := baseURL + "/v1/conversations/" + conversationID + "/messages"
237
+ if cursor != "" {
238
+ requestURL += "?cursor=" + url.QueryEscape(cursor)
239
+ }
240
+ var response pageResponse
241
+ requestJSON(t, http.MethodGet, requestURL, nil, &response)
242
+ return response
243
+ }
244
+
245
+ func requestJSON(t *testing.T, method string, url string, payload any, out any) {
246
+ t.Helper()
247
+
248
+ var bodyReader *bytes.Reader
249
+ if payload == nil {
250
+ bodyReader = bytes.NewReader(nil)
251
+ } else {
252
+ body, err := json.Marshal(payload)
253
+ if err != nil {
254
+ t.Fatalf("marshal request: %v", err)
255
+ }
256
+ bodyReader = bytes.NewReader(body)
257
+ }
258
+
259
+ request, err := http.NewRequest(method, url, bodyReader)
260
+ if err != nil {
261
+ t.Fatalf("new request: %v", err)
262
+ }
263
+ if payload != nil {
264
+ request.Header.Set("Content-Type", "application/json")
265
+ }
266
+
267
+ response, err := http.DefaultClient.Do(request)
268
+ if err != nil {
269
+ t.Fatalf("do request: %v", err)
270
+ }
271
+ defer response.Body.Close()
272
+
273
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
274
+ var problem map[string]any
275
+ _ = json.NewDecoder(response.Body).Decode(&problem)
276
+ t.Fatalf("unexpected status %d: %#v", response.StatusCode, problem)
277
+ }
278
+
279
+ if out != nil {
280
+ if err := json.NewDecoder(response.Body).Decode(out); err != nil {
281
+ t.Fatalf("decode response: %v", err)
282
+ }
283
+ }
284
+ }
285
+
286
+ func rewriteMessageTimestamps(t *testing.T, db *sqlx.DB, messageIDs []string) {
287
+ t.Helper()
288
+ baseTime := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
289
+ for index, messageID := range messageIDs {
290
+ createdAt := baseTime.Add(time.Duration(index+1) * time.Second)
291
+ if _, err := db.ExecContext(context.Background(), `
292
+ update messages
293
+ set created_at = $2, updated_at = $2
294
+ where id = $1`, messageID, createdAt); err != nil {
295
+ t.Fatalf("rewrite message timestamp: %v", err)
296
+ }
297
+ }
298
+ }
@@ -3,6 +3,7 @@ package httpapi
3
3
  import (
4
4
  "encoding/json"
5
5
  "errors"
6
+ "io"
6
7
  "net/http"
7
8
  "strconv"
8
9
  "strings"
@@ -12,67 +13,259 @@ import (
12
13
  "{{MODULE_PATH}}/internal/app"
13
14
  )
14
15
 
15
- func RegisterRoutes(router chi.Router, service *app.DNSService) {
16
+ func RegisterRoutes(router chi.Router, service *app.ChatService) {
16
17
  router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
17
18
  writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
18
19
  })
19
-
20
- router.Route("/v1/dns/records", func(r chi.Router) {
21
- r.Get("/", func(w http.ResponseWriter, request *http.Request) {
22
- records, err := service.ListRecords(request.Context())
23
- if err != nil {
24
- writeError(w, err)
25
- return
26
- }
27
- writeJSON(w, http.StatusOK, map[string]any{"records": records})
20
+ router.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
21
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
22
+ })
23
+ router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
24
+ writeJSON(w, http.StatusOK, map[string]string{
25
+ "service": "{{SERVICE_NAME}}",
26
+ "domain": "chat",
27
+ "apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
28
28
  })
29
+ })
29
30
 
30
- r.Post("/", func(w http.ResponseWriter, request *http.Request) {
31
- var input app.CreateRecordInput
32
- if err := decodeJSON(request, &input); err != nil {
33
- writeError(w, err)
34
- return
35
- }
31
+ router.Post("/v1/users", func(w http.ResponseWriter, request *http.Request) {
32
+ var input app.CreateUserInput
33
+ if err := decodeJSON(request, &input); err != nil {
34
+ writeError(w, err)
35
+ return
36
+ }
37
+ user, err := service.CreateUser(request.Context(), input)
38
+ if err != nil {
39
+ writeError(w, err)
40
+ return
41
+ }
42
+ writeJSON(w, http.StatusCreated, map[string]any{"user": user})
43
+ })
44
+
45
+ router.Get("/v1/users/{userID}", func(w http.ResponseWriter, request *http.Request) {
46
+ user, err := service.GetUser(request.Context(), chi.URLParam(request, "userID"))
47
+ if err != nil {
48
+ writeError(w, err)
49
+ return
50
+ }
51
+ writeJSON(w, http.StatusOK, map[string]any{"user": user})
52
+ })
53
+
54
+ router.Get("/v1/users", func(w http.ResponseWriter, request *http.Request) {
55
+ user, err := service.GetUserByUsername(request.Context(), request.URL.Query().Get("username"))
56
+ if err != nil {
57
+ writeError(w, err)
58
+ return
59
+ }
60
+ writeJSON(w, http.StatusOK, map[string]any{"user": user})
61
+ })
62
+
63
+ router.Post("/v1/conversations", func(w http.ResponseWriter, request *http.Request) {
64
+ var input app.CreateConversationInput
65
+ if err := decodeJSON(request, &input); err != nil {
66
+ writeError(w, err)
67
+ return
68
+ }
69
+ conversation, err := service.CreateConversation(request.Context(), input)
70
+ if err != nil {
71
+ writeError(w, err)
72
+ return
73
+ }
74
+ writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
75
+ })
76
+
77
+ router.Get("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
78
+ conversation, err := service.GetConversation(request.Context(), chi.URLParam(request, "conversationID"))
79
+ if err != nil {
80
+ writeError(w, err)
81
+ return
82
+ }
83
+ writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
84
+ })
85
+
86
+ router.Patch("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
87
+ var input app.UpdateConversationInput
88
+ if err := decodeJSON(request, &input); err != nil {
89
+ writeError(w, err)
90
+ return
91
+ }
92
+ conversation, err := service.UpdateConversation(request.Context(), chi.URLParam(request, "conversationID"), input)
93
+ if err != nil {
94
+ writeError(w, err)
95
+ return
96
+ }
97
+ writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
98
+ })
99
+
100
+ router.Delete("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
101
+ if err := service.DeleteConversation(request.Context(), chi.URLParam(request, "conversationID")); err != nil {
102
+ writeError(w, err)
103
+ return
104
+ }
105
+ w.WriteHeader(http.StatusNoContent)
106
+ })
107
+
108
+ router.Post("/v1/conversations/{conversationID}/participants", func(w http.ResponseWriter, request *http.Request) {
109
+ var input struct {
110
+ UserID string `json:"user_id"`
111
+ }
112
+ if err := decodeJSON(request, &input); err != nil {
113
+ writeError(w, err)
114
+ return
115
+ }
116
+ conversation, err := service.AddParticipant(request.Context(), chi.URLParam(request, "conversationID"), input.UserID)
117
+ if err != nil {
118
+ writeError(w, err)
119
+ return
120
+ }
121
+ writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
122
+ })
36
123
 
37
- record, err := service.CreateRecord(request.Context(), input)
124
+ router.Delete("/v1/conversations/{conversationID}/participants/{userID}", func(w http.ResponseWriter, request *http.Request) {
125
+ if err := service.RemoveParticipant(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "userID")); err != nil {
126
+ writeError(w, err)
127
+ return
128
+ }
129
+ w.WriteHeader(http.StatusNoContent)
130
+ })
131
+
132
+ router.Get("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
133
+ limit := 0
134
+ if rawLimit := strings.TrimSpace(request.URL.Query().Get("limit")); rawLimit != "" {
135
+ parsedLimit, err := strconv.Atoi(rawLimit)
38
136
  if err != nil {
39
- writeError(w, err)
137
+ writeError(w, &app.AppError{Status: http.StatusBadRequest, Code: "invalid_limit", Err: errors.New("limit must be a positive integer")})
40
138
  return
41
139
  }
42
- writeJSON(w, http.StatusCreated, map[string]any{"record": record})
140
+ limit = parsedLimit
141
+ }
142
+ result, err := service.ListMessages(request.Context(), chi.URLParam(request, "conversationID"), app.ListMessagesInput{
143
+ Cursor: strings.TrimSpace(request.URL.Query().Get("cursor")),
144
+ Limit: limit,
43
145
  })
146
+ if err != nil {
147
+ writeError(w, err)
148
+ return
149
+ }
150
+ writeJSON(w, http.StatusOK, result)
151
+ })
152
+
153
+ router.Post("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
154
+ var input app.CreateMessageInput
155
+ if err := decodeJSON(request, &input); err != nil {
156
+ writeError(w, err)
157
+ return
158
+ }
159
+ message, err := service.CreateMessage(request.Context(), chi.URLParam(request, "conversationID"), input)
160
+ if err != nil {
161
+ writeError(w, err)
162
+ return
163
+ }
164
+ writeJSON(w, http.StatusCreated, map[string]any{"message": message})
165
+ })
166
+
167
+ router.Patch("/v1/conversations/{conversationID}/messages/{messageID}", func(w http.ResponseWriter, request *http.Request) {
168
+ var input app.UpdateMessageInput
169
+ if err := decodeJSON(request, &input); err != nil {
170
+ writeError(w, err)
171
+ return
172
+ }
173
+ message, err := service.UpdateMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID"), input)
174
+ if err != nil {
175
+ writeError(w, err)
176
+ return
177
+ }
178
+ writeJSON(w, http.StatusOK, map[string]any{"message": message})
179
+ })
180
+
181
+ router.Delete("/v1/conversations/{conversationID}/messages/{messageID}", func(w http.ResponseWriter, request *http.Request) {
182
+ if err := service.DeleteMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID")); err != nil {
183
+ writeError(w, err)
184
+ return
185
+ }
186
+ w.WriteHeader(http.StatusNoContent)
187
+ })
188
+
189
+ router.Post("/v1/attachments/uploads", func(w http.ResponseWriter, request *http.Request) {
190
+ var input app.CreateAttachmentUploadInput
191
+ if err := decodeJSON(request, &input); err != nil {
192
+ writeError(w, err)
193
+ return
194
+ }
195
+ result, err := service.CreateAttachmentUpload(request.Context(), input)
196
+ if err != nil {
197
+ writeError(w, err)
198
+ return
199
+ }
200
+ writeJSON(w, http.StatusCreated, map[string]any{"result": result})
201
+ })
202
+
203
+ router.Post("/v1/attachments/{attachmentID}/finalize", func(w http.ResponseWriter, request *http.Request) {
204
+ var input app.FinalizeAttachmentInput
205
+ if err := decodeOptionalJSON(request, &input); err != nil {
206
+ writeError(w, err)
207
+ return
208
+ }
209
+ attachment, err := service.FinalizeAttachment(request.Context(), chi.URLParam(request, "attachmentID"), input)
210
+ if err != nil {
211
+ writeError(w, err)
212
+ return
213
+ }
214
+ writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
215
+ })
216
+
217
+ router.Get("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
218
+ attachment, err := service.GetAttachment(request.Context(), chi.URLParam(request, "attachmentID"))
219
+ if err != nil {
220
+ writeError(w, err)
221
+ return
222
+ }
223
+ writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
224
+ })
225
+
226
+ router.Delete("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
227
+ if err := service.DeleteAttachment(request.Context(), chi.URLParam(request, "attachmentID")); err != nil {
228
+ writeError(w, err)
229
+ return
230
+ }
231
+ w.WriteHeader(http.StatusNoContent)
232
+ })
233
+
234
+ router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
235
+ rawBody, err := io.ReadAll(request.Body)
236
+ if err != nil {
237
+ writeError(w, err)
238
+ return
239
+ }
240
+ event, duplicate, err := service.ProcessWebhook(request.Context(), chi.URLParam(request, "provider"), request.Header, rawBody)
241
+ if err != nil {
242
+ writeError(w, err)
243
+ return
244
+ }
245
+ status := http.StatusAccepted
246
+ if duplicate {
247
+ status = http.StatusOK
248
+ }
249
+ writeJSON(w, status, map[string]any{"event": event, "duplicate": duplicate})
250
+ })
44
251
 
45
- r.Route("/{recordID}", func(r chi.Router) {
46
- r.Put("/", func(w http.ResponseWriter, request *http.Request) {
47
- var input app.UpdateRecordInput
48
- if err := decodeJSON(request, &input); err != nil {
49
- writeError(w, err)
50
- return
51
- }
52
-
53
- record, err := service.UpdateRecord(request.Context(), chi.URLParam(request, "recordID"), input)
54
- if err != nil {
55
- writeError(w, err)
56
- return
57
- }
58
- writeJSON(w, http.StatusOK, map[string]any{"record": record})
59
- })
60
-
61
- r.Delete("/", func(w http.ResponseWriter, request *http.Request) {
62
- if err := service.DeleteRecord(request.Context(), chi.URLParam(request, "recordID")); err != nil {
63
- writeError(w, err)
64
- return
65
- }
66
- w.WriteHeader(http.StatusNoContent)
67
- })
252
+ router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
253
+ writeJSON(w, http.StatusOK, map[string]string{
254
+ "status": "ok",
255
+ "provider": chi.URLParam(request, "provider"),
68
256
  })
69
257
  })
70
258
  }
71
259
 
72
260
  func decodeJSON(request *http.Request, out any) error {
73
261
  defer request.Body.Close()
262
+ return json.NewDecoder(request.Body).Decode(out)
263
+ }
74
264
 
75
- if err := json.NewDecoder(request.Body).Decode(out); err != nil {
265
+ func decodeOptionalJSON(request *http.Request, out any) error {
266
+ defer request.Body.Close()
267
+ decoder := json.NewDecoder(request.Body)
268
+ if err := decoder.Decode(out); err != nil && !errors.Is(err, io.EOF) {
76
269
  return err
77
270
  }
78
271
  return nil
@@ -85,6 +278,15 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
85
278
  }
86
279
 
87
280
  func writeError(w http.ResponseWriter, err error) {
281
+ var appErr *app.AppError
282
+ if errors.As(err, &appErr) {
283
+ writeJSON(w, appErr.Status, map[string]string{
284
+ "error": appErr.Error(),
285
+ "code": appErr.Code,
286
+ })
287
+ return
288
+ }
289
+
88
290
  status := http.StatusInternalServerError
89
291
  if errors.Is(err, strconv.ErrSyntax) || strings.Contains(strings.ToLower(err.Error()), "json") {
90
292
  status = http.StatusBadRequest