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 {
@@ -6,74 +6,286 @@ import (
6
6
 
7
7
  "connectrpc.com/connect"
8
8
 
9
- dnsv1 "{{MODULE_PATH}}/gen/dns/v1"
10
- dnsv1connect "{{MODULE_PATH}}/gen/dns/v1/dnsv1connect"
9
+ chatv1 "{{MODULE_PATH}}/gen/chat/v1"
10
+ chatv1connect "{{MODULE_PATH}}/gen/chat/v1/chatv1connect"
11
11
  "{{MODULE_PATH}}/internal/app"
12
12
  )
13
13
 
14
14
  type Handler struct {
15
- service *app.DNSService
15
+ service *app.ChatService
16
16
  }
17
17
 
18
- func NewHandler(service *app.DNSService) (string, http.Handler) {
19
- return dnsv1connect.NewDNSServiceHandler(&Handler{service: service})
18
+ func NewHandler(service *app.ChatService) (string, http.Handler) {
19
+ return chatv1connect.NewChatServiceHandler(&Handler{service: service})
20
20
  }
21
21
 
22
- func (h *Handler) ListRecords(ctx context.Context, _ *connect.Request[dnsv1.ListRecordsRequest]) (*connect.Response[dnsv1.ListRecordsResponse], error) {
23
- records, err := h.service.ListRecords(ctx)
22
+ func (h *Handler) CreateUser(ctx context.Context, request *connect.Request[chatv1.CreateUserRequest]) (*connect.Response[chatv1.CreateUserResponse], error) {
23
+ user, err := h.service.CreateUser(ctx, app.CreateUserInput{
24
+ Username: request.Msg.GetUsername(),
25
+ DisplayName: request.Msg.GetDisplayName(),
26
+ })
27
+ if err != nil {
28
+ return nil, toConnectError(err)
29
+ }
30
+ return connect.NewResponse(&chatv1.CreateUserResponse{User: toProtoUser(user)}), nil
31
+ }
32
+
33
+ func (h *Handler) GetUser(ctx context.Context, request *connect.Request[chatv1.GetUserRequest]) (*connect.Response[chatv1.GetUserResponse], error) {
34
+ user, err := h.service.GetUser(ctx, request.Msg.GetUserId())
35
+ if err != nil {
36
+ return nil, toConnectError(err)
37
+ }
38
+ return connect.NewResponse(&chatv1.GetUserResponse{User: toProtoUser(user)}), nil
39
+ }
40
+
41
+ func (h *Handler) GetUserByUsername(ctx context.Context, request *connect.Request[chatv1.GetUserByUsernameRequest]) (*connect.Response[chatv1.GetUserByUsernameResponse], error) {
42
+ user, err := h.service.GetUserByUsername(ctx, request.Msg.GetUsername())
43
+ if err != nil {
44
+ return nil, toConnectError(err)
45
+ }
46
+ return connect.NewResponse(&chatv1.GetUserByUsernameResponse{User: toProtoUser(user)}), nil
47
+ }
48
+
49
+ func (h *Handler) CreateConversation(ctx context.Context, request *connect.Request[chatv1.CreateConversationRequest]) (*connect.Response[chatv1.CreateConversationResponse], error) {
50
+ conversation, err := h.service.CreateConversation(ctx, app.CreateConversationInput{
51
+ CreatedByUserID: request.Msg.GetCreatedByUserId(),
52
+ Title: request.Msg.GetTitle(),
53
+ ParticipantUserIDs: request.Msg.GetParticipantUserIds(),
54
+ })
55
+ if err != nil {
56
+ return nil, toConnectError(err)
57
+ }
58
+ return connect.NewResponse(&chatv1.CreateConversationResponse{Conversation: toProtoConversation(conversation)}), nil
59
+ }
60
+
61
+ func (h *Handler) GetConversation(ctx context.Context, request *connect.Request[chatv1.GetConversationRequest]) (*connect.Response[chatv1.GetConversationResponse], error) {
62
+ conversation, err := h.service.GetConversation(ctx, request.Msg.GetConversationId())
63
+ if err != nil {
64
+ return nil, toConnectError(err)
65
+ }
66
+ return connect.NewResponse(&chatv1.GetConversationResponse{Conversation: toProtoConversation(conversation)}), nil
67
+ }
68
+
69
+ func (h *Handler) UpdateConversation(ctx context.Context, request *connect.Request[chatv1.UpdateConversationRequest]) (*connect.Response[chatv1.UpdateConversationResponse], error) {
70
+ conversation, err := h.service.UpdateConversation(ctx, request.Msg.GetConversationId(), app.UpdateConversationInput{
71
+ Title: request.Msg.GetTitle(),
72
+ })
24
73
  if err != nil {
25
- return nil, connect.NewError(connect.CodeInternal, err)
74
+ return nil, toConnectError(err)
75
+ }
76
+ return connect.NewResponse(&chatv1.UpdateConversationResponse{Conversation: toProtoConversation(conversation)}), nil
77
+ }
78
+
79
+ func (h *Handler) DeleteConversation(ctx context.Context, request *connect.Request[chatv1.DeleteConversationRequest]) (*connect.Response[chatv1.DeleteConversationResponse], error) {
80
+ if err := h.service.DeleteConversation(ctx, request.Msg.GetConversationId()); err != nil {
81
+ return nil, toConnectError(err)
26
82
  }
83
+ return connect.NewResponse(&chatv1.DeleteConversationResponse{}), nil
84
+ }
27
85
 
28
- response := &dnsv1.ListRecordsResponse{Records: make([]*dnsv1.Record, 0, len(records))}
29
- for _, record := range records {
30
- response.Records = append(response.Records, toProtoRecord(record))
86
+ func (h *Handler) AddConversationParticipant(ctx context.Context, request *connect.Request[chatv1.AddConversationParticipantRequest]) (*connect.Response[chatv1.AddConversationParticipantResponse], error) {
87
+ conversation, err := h.service.AddParticipant(ctx, request.Msg.GetConversationId(), request.Msg.GetUserId())
88
+ if err != nil {
89
+ return nil, toConnectError(err)
31
90
  }
32
- return connect.NewResponse(response), nil
91
+ return connect.NewResponse(&chatv1.AddConversationParticipantResponse{Conversation: toProtoConversation(conversation)}), nil
33
92
  }
34
93
 
35
- func (h *Handler) CreateRecord(ctx context.Context, request *connect.Request[dnsv1.CreateRecordRequest]) (*connect.Response[dnsv1.CreateRecordResponse], error) {
36
- record, err := h.service.CreateRecord(ctx, app.CreateRecordInput{
37
- Type: request.Msg.GetType(),
38
- Name: request.Msg.GetName(),
39
- Content: request.Msg.GetContent(),
40
- TTL: int(request.Msg.GetTtl()),
41
- Proxied: request.Msg.GetProxied(),
94
+ func (h *Handler) RemoveConversationParticipant(ctx context.Context, request *connect.Request[chatv1.RemoveConversationParticipantRequest]) (*connect.Response[chatv1.RemoveConversationParticipantResponse], error) {
95
+ if err := h.service.RemoveParticipant(ctx, request.Msg.GetConversationId(), request.Msg.GetUserId()); err != nil {
96
+ return nil, toConnectError(err)
97
+ }
98
+ return connect.NewResponse(&chatv1.RemoveConversationParticipantResponse{}), nil
99
+ }
100
+
101
+ func (h *Handler) ListMessages(ctx context.Context, request *connect.Request[chatv1.ListMessagesRequest]) (*connect.Response[chatv1.ListMessagesResponse], error) {
102
+ result, err := h.service.ListMessages(ctx, request.Msg.GetConversationId(), app.ListMessagesInput{
103
+ Cursor: request.Msg.GetCursor(),
104
+ Limit: int(request.Msg.GetLimit()),
42
105
  })
43
106
  if err != nil {
44
- return nil, connect.NewError(connect.CodeInternal, err)
107
+ return nil, toConnectError(err)
108
+ }
109
+ items := make([]*chatv1.Message, 0, len(result.Messages))
110
+ for _, message := range result.Messages {
111
+ items = append(items, toProtoMessage(message))
45
112
  }
46
- return connect.NewResponse(&dnsv1.CreateRecordResponse{Record: toProtoRecord(record)}), nil
113
+ return connect.NewResponse(&chatv1.ListMessagesResponse{
114
+ Messages: items,
115
+ NextCursor: result.NextCursor,
116
+ }), nil
47
117
  }
48
118
 
49
- func (h *Handler) UpdateRecord(ctx context.Context, request *connect.Request[dnsv1.UpdateRecordRequest]) (*connect.Response[dnsv1.UpdateRecordResponse], error) {
50
- record, err := h.service.UpdateRecord(ctx, request.Msg.GetId(), app.UpdateRecordInput{
51
- Type: request.Msg.GetType(),
52
- Name: request.Msg.GetName(),
53
- Content: request.Msg.GetContent(),
54
- TTL: int(request.Msg.GetTtl()),
55
- Proxied: request.Msg.GetProxied(),
119
+ func (h *Handler) CreateMessage(ctx context.Context, request *connect.Request[chatv1.CreateMessageRequest]) (*connect.Response[chatv1.CreateMessageResponse], error) {
120
+ message, err := h.service.CreateMessage(ctx, request.Msg.GetConversationId(), app.CreateMessageInput{
121
+ UserID: request.Msg.GetUserId(),
122
+ Body: request.Msg.GetBody(),
56
123
  })
57
124
  if err != nil {
58
- return nil, connect.NewError(connect.CodeInternal, err)
125
+ return nil, toConnectError(err)
59
126
  }
60
- return connect.NewResponse(&dnsv1.UpdateRecordResponse{Record: toProtoRecord(record)}), nil
127
+ return connect.NewResponse(&chatv1.CreateMessageResponse{Message: toProtoMessage(message)}), nil
61
128
  }
62
129
 
63
- func (h *Handler) DeleteRecord(ctx context.Context, request *connect.Request[dnsv1.DeleteRecordRequest]) (*connect.Response[dnsv1.DeleteRecordResponse], error) {
64
- if err := h.service.DeleteRecord(ctx, request.Msg.GetId()); err != nil {
65
- return nil, connect.NewError(connect.CodeInternal, err)
130
+ func (h *Handler) UpdateMessage(ctx context.Context, request *connect.Request[chatv1.UpdateMessageRequest]) (*connect.Response[chatv1.UpdateMessageResponse], error) {
131
+ message, err := h.service.UpdateMessage(ctx, request.Msg.GetConversationId(), request.Msg.GetMessageId(), app.UpdateMessageInput{
132
+ Body: request.Msg.GetBody(),
133
+ })
134
+ if err != nil {
135
+ return nil, toConnectError(err)
136
+ }
137
+ return connect.NewResponse(&chatv1.UpdateMessageResponse{Message: toProtoMessage(message)}), nil
138
+ }
139
+
140
+ func (h *Handler) DeleteMessage(ctx context.Context, request *connect.Request[chatv1.DeleteMessageRequest]) (*connect.Response[chatv1.DeleteMessageResponse], error) {
141
+ if err := h.service.DeleteMessage(ctx, request.Msg.GetConversationId(), request.Msg.GetMessageId()); err != nil {
142
+ return nil, toConnectError(err)
143
+ }
144
+ return connect.NewResponse(&chatv1.DeleteMessageResponse{}), nil
145
+ }
146
+
147
+ func (h *Handler) CreateAttachmentUpload(ctx context.Context, request *connect.Request[chatv1.CreateAttachmentUploadRequest]) (*connect.Response[chatv1.CreateAttachmentUploadResponse], error) {
148
+ result, err := h.service.CreateAttachmentUpload(ctx, app.CreateAttachmentUploadInput{
149
+ ConversationID: request.Msg.GetConversationId(),
150
+ UploadedByUserID: request.Msg.GetUserId(),
151
+ Filename: request.Msg.GetFilename(),
152
+ ContentType: request.Msg.GetContentType(),
153
+ ByteSize: request.Msg.GetByteSize(),
154
+ })
155
+ if err != nil {
156
+ return nil, toConnectError(err)
157
+ }
158
+ return connect.NewResponse(&chatv1.CreateAttachmentUploadResponse{
159
+ Attachment: toProtoAttachment(result.Attachment),
160
+ Upload: &chatv1.UploadTarget{
161
+ Method: result.Upload.Method,
162
+ Url: result.Upload.URL,
163
+ Headers: result.Upload.Headers,
164
+ },
165
+ }), nil
166
+ }
167
+
168
+ func (h *Handler) FinalizeAttachment(ctx context.Context, request *connect.Request[chatv1.FinalizeAttachmentRequest]) (*connect.Response[chatv1.FinalizeAttachmentResponse], error) {
169
+ attachment, err := h.service.FinalizeAttachment(ctx, request.Msg.GetAttachmentId(), app.FinalizeAttachmentInput{
170
+ MessageID: request.Msg.GetMessageId(),
171
+ })
172
+ if err != nil {
173
+ return nil, toConnectError(err)
174
+ }
175
+ return connect.NewResponse(&chatv1.FinalizeAttachmentResponse{Attachment: toProtoAttachment(attachment)}), nil
176
+ }
177
+
178
+ func (h *Handler) GetAttachment(ctx context.Context, request *connect.Request[chatv1.GetAttachmentRequest]) (*connect.Response[chatv1.GetAttachmentResponse], error) {
179
+ attachment, err := h.service.GetAttachment(ctx, request.Msg.GetAttachmentId())
180
+ if err != nil {
181
+ return nil, toConnectError(err)
182
+ }
183
+ return connect.NewResponse(&chatv1.GetAttachmentResponse{Attachment: toProtoAttachment(attachment)}), nil
184
+ }
185
+
186
+ func (h *Handler) DeleteAttachment(ctx context.Context, request *connect.Request[chatv1.DeleteAttachmentRequest]) (*connect.Response[chatv1.DeleteAttachmentResponse], error) {
187
+ if err := h.service.DeleteAttachment(ctx, request.Msg.GetAttachmentId()); err != nil {
188
+ return nil, toConnectError(err)
189
+ }
190
+ return connect.NewResponse(&chatv1.DeleteAttachmentResponse{}), nil
191
+ }
192
+
193
+ func toConnectError(err error) error {
194
+ var appErr *app.AppError
195
+ if ok := asAppError(err, &appErr); ok {
196
+ return connect.NewError(statusCodeToConnectCode(appErr.Status), err)
197
+ }
198
+ return connect.NewError(connect.CodeInternal, err)
199
+ }
200
+
201
+ func asAppError(err error, target **app.AppError) bool {
202
+ if err == nil {
203
+ return false
204
+ }
205
+ appErr, ok := err.(*app.AppError)
206
+ if ok {
207
+ *target = appErr
208
+ return true
209
+ }
210
+ return false
211
+ }
212
+
213
+ func statusCodeToConnectCode(status int) connect.Code {
214
+ switch status {
215
+ case 400:
216
+ return connect.CodeInvalidArgument
217
+ case 404:
218
+ return connect.CodeNotFound
219
+ case 409:
220
+ return connect.CodeAlreadyExists
221
+ default:
222
+ return connect.CodeInternal
223
+ }
224
+ }
225
+
226
+ func toProtoUser(user app.User) *chatv1.User {
227
+ return &chatv1.User{
228
+ Id: user.ID,
229
+ Username: user.Username,
230
+ DisplayName: user.DisplayName,
231
+ CreatedAt: user.CreatedAt,
232
+ UpdatedAt: user.UpdatedAt,
233
+ }
234
+ }
235
+
236
+ func toProtoConversation(conversation app.Conversation) *chatv1.Conversation {
237
+ participants := make([]*chatv1.User, 0, len(conversation.Participants))
238
+ for _, participant := range conversation.Participants {
239
+ participants = append(participants, toProtoUser(participant))
240
+ }
241
+ return &chatv1.Conversation{
242
+ Id: conversation.ID,
243
+ Title: conversation.Title,
244
+ CreatedByUserId: conversation.CreatedByUserID,
245
+ Participants: participants,
246
+ CreatedAt: conversation.CreatedAt,
247
+ UpdatedAt: conversation.UpdatedAt,
248
+ }
249
+ }
250
+
251
+ func toProtoMessage(message app.Message) *chatv1.Message {
252
+ attachments := make([]*chatv1.MessageAttachment, 0, len(message.Attachments))
253
+ for _, attachment := range message.Attachments {
254
+ attachments = append(attachments, &chatv1.MessageAttachment{
255
+ Id: attachment.ID,
256
+ Filename: attachment.Filename,
257
+ ContentType: attachment.ContentType,
258
+ ByteSize: attachment.ByteSize,
259
+ Status: attachment.Status,
260
+ PublicUrl: attachment.PublicURL,
261
+ })
262
+ }
263
+ return &chatv1.Message{
264
+ Id: message.ID,
265
+ ConversationId: message.ConversationID,
266
+ UserId: message.UserID,
267
+ Body: message.Body,
268
+ EditedAt: message.EditedAt,
269
+ CreatedAt: message.CreatedAt,
270
+ UpdatedAt: message.UpdatedAt,
271
+ Attachments: attachments,
66
272
  }
67
- return connect.NewResponse(&dnsv1.DeleteRecordResponse{}), nil
68
273
  }
69
274
 
70
- func toProtoRecord(record app.Record) *dnsv1.Record {
71
- return &dnsv1.Record{
72
- Id: record.ID,
73
- Type: record.Type,
74
- Name: record.Name,
75
- Content: record.Content,
76
- Ttl: int32(record.TTL),
77
- Proxied: record.Proxied,
275
+ func toProtoAttachment(attachment app.Attachment) *chatv1.Attachment {
276
+ return &chatv1.Attachment{
277
+ Id: attachment.ID,
278
+ ConversationId: attachment.ConversationID,
279
+ MessageId: attachment.MessageID,
280
+ UploadedByUserId: attachment.UploadedByUserID,
281
+ StorageBucket: attachment.StorageBucket,
282
+ StorageKey: attachment.StorageKey,
283
+ ContentType: attachment.ContentType,
284
+ ByteSize: attachment.ByteSize,
285
+ Filename: attachment.Filename,
286
+ Status: attachment.Status,
287
+ PublicUrl: attachment.PublicURL,
288
+ CreatedAt: attachment.CreatedAt,
289
+ UpdatedAt: attachment.UpdatedAt,
78
290
  }
79
291
  }
@@ -0,0 +1,216 @@
1
+ package connectapi
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "connectrpc.com/connect"
14
+ _ "github.com/jackc/pgx/v5/stdlib"
15
+ "github.com/jmoiron/sqlx"
16
+
17
+ chatv1 "{{MODULE_PATH}}/gen/chat/v1"
18
+ chatv1connect "{{MODULE_PATH}}/gen/chat/v1/chatv1connect"
19
+ "{{MODULE_PATH}}/internal/app"
20
+ )
21
+
22
+ func TestListMessagesPaginationIncludesAttachmentMetadata(t *testing.T) {
23
+ databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
24
+ if databaseURL == "" {
25
+ t.Skip("DATABASE_URL is required for integration test")
26
+ }
27
+
28
+ t.Setenv("ATTACHMENT_PUBLIC_BASE_URL", "https://storage.test")
29
+
30
+ db, err := app.OpenDatabase(context.Background(), databaseURL)
31
+ if err != nil {
32
+ t.Fatalf("open database: %v", err)
33
+ }
34
+ t.Cleanup(func() { _ = db.Close() })
35
+
36
+ if _, err := db.ExecContext(context.Background(), `
37
+ truncate table
38
+ webhook_events,
39
+ attachments,
40
+ messages,
41
+ conversation_participants,
42
+ conversations,
43
+ users
44
+ restart identity cascade`); err != nil {
45
+ t.Fatalf("truncate tables: %v", err)
46
+ }
47
+
48
+ storage := newFakeStorage()
49
+ service := app.NewChatService(db, storage, app.GenericWebhookAdapter{})
50
+ path, handler := NewHandler(service)
51
+ mux := http.NewServeMux()
52
+ mux.Handle(path, handler)
53
+ server := httptest.NewServer(mux)
54
+ t.Cleanup(server.Close)
55
+
56
+ client := chatv1connect.NewChatServiceClient(http.DefaultClient, server.URL)
57
+
58
+ createUserResponse, err := client.CreateUser(context.Background(), connect.NewRequest(&chatv1.CreateUserRequest{
59
+ Username: "alice",
60
+ DisplayName: "Alice",
61
+ }))
62
+ if err != nil {
63
+ t.Fatalf("create user: %v", err)
64
+ }
65
+ userID := createUserResponse.Msg.GetUser().GetId()
66
+
67
+ createConversationResponse, err := client.CreateConversation(context.Background(), connect.NewRequest(&chatv1.CreateConversationRequest{
68
+ CreatedByUserId: userID,
69
+ Title: "General",
70
+ ParticipantUserIds: []string{userID},
71
+ }))
72
+ if err != nil {
73
+ t.Fatalf("create conversation: %v", err)
74
+ }
75
+ conversationID := createConversationResponse.Msg.GetConversation().GetId()
76
+
77
+ messageIDs := make([]string, 0, 55)
78
+ for index := 1; index <= 55; index++ {
79
+ response, err := client.CreateMessage(context.Background(), connect.NewRequest(&chatv1.CreateMessageRequest{
80
+ ConversationId: conversationID,
81
+ UserId: userID,
82
+ Body: fmt.Sprintf("message-%d", index),
83
+ }))
84
+ if err != nil {
85
+ t.Fatalf("create message %d: %v", index, err)
86
+ }
87
+ messageIDs = append(messageIDs, response.Msg.GetMessage().GetId())
88
+ }
89
+ rewriteMessageTimestamps(t, db, messageIDs)
90
+
91
+ uploadResponse, err := client.CreateAttachmentUpload(context.Background(), connect.NewRequest(&chatv1.CreateAttachmentUploadRequest{
92
+ ConversationId: conversationID,
93
+ UserId: userID,
94
+ Filename: "photo.png",
95
+ ContentType: "image/png",
96
+ ByteSize: 1234,
97
+ }))
98
+ if err != nil {
99
+ t.Fatalf("create attachment upload: %v", err)
100
+ }
101
+ storage.set(uploadResponse.Msg.GetAttachment().GetPublicUrl(), "image/png", 1234)
102
+ if _, err := client.FinalizeAttachment(context.Background(), connect.NewRequest(&chatv1.FinalizeAttachmentRequest{
103
+ AttachmentId: uploadResponse.Msg.GetAttachment().GetId(),
104
+ MessageId: messageIDs[54],
105
+ })); err != nil {
106
+ t.Fatalf("finalize attachment: %v", err)
107
+ }
108
+
109
+ firstPage, err := client.ListMessages(context.Background(), connect.NewRequest(&chatv1.ListMessagesRequest{
110
+ ConversationId: conversationID,
111
+ }))
112
+ if err != nil {
113
+ t.Fatalf("list messages first page: %v", err)
114
+ }
115
+ if len(firstPage.Msg.GetMessages()) != 50 {
116
+ t.Fatalf("expected 50 messages, got %d", len(firstPage.Msg.GetMessages()))
117
+ }
118
+ if firstPage.Msg.GetMessages()[0].GetBody() != "message-55" {
119
+ t.Fatalf("expected newest message first, got %s", firstPage.Msg.GetMessages()[0].GetBody())
120
+ }
121
+ if firstPage.Msg.GetMessages()[49].GetBody() != "message-6" {
122
+ t.Fatalf("expected oldest message on first page to be message-6, got %s", firstPage.Msg.GetMessages()[49].GetBody())
123
+ }
124
+ if firstPage.Msg.GetNextCursor() == "" {
125
+ t.Fatal("expected next cursor on first page")
126
+ }
127
+ attachments := firstPage.Msg.GetMessages()[0].GetAttachments()
128
+ if len(attachments) != 1 {
129
+ t.Fatalf("expected attachment metadata on newest message, got %#v", attachments)
130
+ }
131
+ if attachments[0].GetPublicUrl() != uploadResponse.Msg.GetAttachment().GetPublicUrl() {
132
+ t.Fatalf("expected public_url %s, got %s", uploadResponse.Msg.GetAttachment().GetPublicUrl(), attachments[0].GetPublicUrl())
133
+ }
134
+
135
+ secondPage, err := client.ListMessages(context.Background(), connect.NewRequest(&chatv1.ListMessagesRequest{
136
+ ConversationId: conversationID,
137
+ Cursor: firstPage.Msg.GetNextCursor(),
138
+ }))
139
+ if err != nil {
140
+ t.Fatalf("list messages second page: %v", err)
141
+ }
142
+ expectedBodies := []string{"message-5", "message-4", "message-3", "message-2", "message-1"}
143
+ if len(secondPage.Msg.GetMessages()) != len(expectedBodies) {
144
+ t.Fatalf("expected %d messages on second page, got %d", len(expectedBodies), len(secondPage.Msg.GetMessages()))
145
+ }
146
+ for index, body := range expectedBodies {
147
+ if secondPage.Msg.GetMessages()[index].GetBody() != body {
148
+ t.Fatalf("expected %s at index %d, got %s", body, index, secondPage.Msg.GetMessages()[index].GetBody())
149
+ }
150
+ }
151
+ if secondPage.Msg.GetNextCursor() != "" {
152
+ t.Fatalf("expected no next cursor on final page, got %s", secondPage.Msg.GetNextCursor())
153
+ }
154
+ }
155
+
156
+ type fakeStorage struct {
157
+ metadata map[string]struct {
158
+ contentType string
159
+ byteSize int64
160
+ publicURL string
161
+ }
162
+ }
163
+
164
+ func newFakeStorage() *fakeStorage {
165
+ return &fakeStorage{metadata: map[string]struct {
166
+ contentType string
167
+ byteSize int64
168
+ publicURL string
169
+ }{}}
170
+ }
171
+
172
+ func (f *fakeStorage) CreateSignedUpload(_ context.Context, attachmentID string, conversationID string, filename string, contentType string) (string, string, app.UploadTarget, string, error) {
173
+ key := fmt.Sprintf("attachments/%s/%s/%s", conversationID, attachmentID, filename)
174
+ return "test-bucket", key, app.UploadTarget{
175
+ Method: http.MethodPut,
176
+ URL: "https://uploads.test/" + key,
177
+ Headers: map[string]string{
178
+ "Content-Type": contentType,
179
+ },
180
+ }, "https://storage.test/" + key, nil
181
+ }
182
+
183
+ func (f *fakeStorage) GetObjectMetadata(_ context.Context, bucket string, key string) (string, int64, string, error) {
184
+ entry, ok := f.metadata[bucket+"/"+key]
185
+ if !ok {
186
+ return "", 0, "", fmt.Errorf("missing metadata for %s/%s", bucket, key)
187
+ }
188
+ return entry.contentType, entry.byteSize, entry.publicURL, nil
189
+ }
190
+
191
+ func (f *fakeStorage) set(publicURL string, contentType string, byteSize int64) {
192
+ key := strings.TrimPrefix(publicURL, "https://storage.test/")
193
+ f.metadata["test-bucket/"+key] = struct {
194
+ contentType string
195
+ byteSize int64
196
+ publicURL string
197
+ }{
198
+ contentType: contentType,
199
+ byteSize: byteSize,
200
+ publicURL: publicURL,
201
+ }
202
+ }
203
+
204
+ func rewriteMessageTimestamps(t *testing.T, db *sqlx.DB, messageIDs []string) {
205
+ t.Helper()
206
+ baseTime := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
207
+ for index, messageID := range messageIDs {
208
+ createdAt := baseTime.Add(time.Duration(index+1) * time.Second)
209
+ if _, err := db.ExecContext(context.Background(), `
210
+ update messages
211
+ set created_at = $2, updated_at = $2
212
+ where id = $1`, messageID, createdAt); err != nil {
213
+ t.Fatalf("rewrite message timestamp: %v", err)
214
+ }
215
+ }
216
+ }