create-svc 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -11
- package/package.json +9 -4
- package/src/cli.test.ts +29 -8
- package/src/cli.ts +103 -70
- package/src/naming.test.ts +4 -2
- package/src/naming.ts +9 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.ts +7 -28
- package/src/profiles.ts +28 -0
- package/src/scaffold.test.ts +126 -15
- package/src/scaffold.ts +94 -23
- package/src/vault.test.ts +33 -9
- package/src/vault.ts +4 -3
- package/templates/shared/README.md +135 -24
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
- package/templates/shared/scripts/cloudrun/config.ts +14 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
- package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
- package/templates/shared/scripts/cloudrun/lib.ts +88 -112
- package/templates/shared/scripts/cloudrun/neon.ts +82 -13
- package/templates/shared/service.yaml +44 -1
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-connectrpc/package.json +17 -0
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
- package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
- package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
- package/templates/variants/bun-connectrpc/src/index.ts +294 -22
- package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
- package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-hono/package.json +13 -0
- package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
- package/templates/variants/bun-hono/src/chat/service.ts +384 -0
- package/templates/variants/bun-hono/src/chat/types.ts +142 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +479 -0
- package/templates/variants/bun-hono/src/db/schema.ts +75 -0
- package/templates/variants/bun-hono/src/index.ts +254 -8
- package/templates/variants/bun-hono/src/storage.ts +72 -0
- package/templates/variants/bun-hono/src/webhooks.ts +35 -0
- package/templates/variants/bun-hono/test/app.test.ts +60 -6
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +6 -2
- package/templates/variants/go-chi/buf.gen.yaml +2 -0
- package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
- package/templates/variants/go-chi/cmd/server/main.go +16 -15
- package/templates/variants/go-chi/go.mod +3 -0
- package/templates/variants/go-chi/internal/app/service.go +763 -71
- package/templates/variants/go-chi/internal/config/config.go +22 -7
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
- package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +6 -2
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
- package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- 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
|
-
"
|
|
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
|
|
10
|
-
ID
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
238
|
+
type ChatService struct {
|
|
239
|
+
db *sqlx.DB
|
|
240
|
+
storage Storage
|
|
241
|
+
webhookAdapter WebhookAdapter
|
|
242
|
+
}
|
|
51
243
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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 *
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 *
|
|
79
|
-
|
|
80
|
-
s.
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
if
|
|
85
|
-
return
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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 *
|
|
99
|
-
_
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
+
}
|