create-svc 0.1.10 → 0.1.12

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 (171) hide show
  1. package/README.md +51 -47
  2. package/index.ts +2 -2
  3. package/package.json +10 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +196 -33
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +232 -41
  14. package/src/scaffold.ts +81 -36
  15. package/src/service.test.ts +30 -0
  16. package/src/service.ts +65 -0
  17. package/src/vault.test.ts +61 -1
  18. package/src/vault.ts +77 -15
  19. package/templates/shared/.github/workflows/ci.yml +2 -1
  20. package/templates/shared/.github/workflows/deploy.yml +2 -0
  21. package/templates/shared/README.md +124 -47
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  27. package/templates/shared/scripts/cloudrun/cli.ts +329 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  29. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  30. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  31. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -44
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +402 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Makefile +14 -8
  53. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  54. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  55. package/templates/variants/bun-connectrpc/package.json +12 -5
  56. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  57. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  58. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  59. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  60. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  61. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  62. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  63. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  64. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  65. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  66. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  67. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  68. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  69. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  70. package/templates/variants/bun-hono/Makefile +14 -8
  71. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  72. package/templates/variants/bun-hono/package.json +12 -5
  73. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  74. package/templates/variants/bun-hono/src/auth.ts +181 -0
  75. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  76. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  77. package/templates/variants/bun-hono/src/index.ts +65 -180
  78. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  79. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  80. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  81. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  82. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  83. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  84. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  85. package/templates/variants/go-chi/Makefile +27 -11
  86. package/templates/variants/go-chi/atlas.hcl +8 -0
  87. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  88. package/templates/variants/go-chi/go.mod +1 -3
  89. package/templates/variants/go-chi/internal/app/service.go +202 -685
  90. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  91. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  92. package/templates/variants/go-chi/internal/config/config.go +27 -11
  93. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  94. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  95. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  96. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  97. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  98. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  99. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  100. package/templates/variants/go-chi/package.json +7 -1
  101. package/templates/variants/go-connectrpc/Makefile +26 -9
  102. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  103. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  104. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  105. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  106. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  107. package/templates/variants/go-connectrpc/go.mod +1 -1
  108. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  109. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  110. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  111. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  112. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  113. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  114. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  115. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  116. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  117. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  118. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  119. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  120. package/templates/variants/go-connectrpc/package.json +7 -1
  121. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  122. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  123. package/templates/root/.github/workflows/ci.yml +0 -26
  124. package/templates/root/.github/workflows/deploy.yml +0 -22
  125. package/templates/root/Dockerfile +0 -23
  126. package/templates/root/README.md +0 -69
  127. package/templates/root/buf.gen.yaml +0 -10
  128. package/templates/root/buf.yaml +0 -9
  129. package/templates/root/cmd/server/main.go +0 -44
  130. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  131. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  132. package/templates/root/go.mod +0 -10
  133. package/templates/root/internal/app/service.go +0 -152
  134. package/templates/root/internal/app/token_source.go +0 -50
  135. package/templates/root/internal/cloudflare/client.go +0 -160
  136. package/templates/root/internal/config/config.go +0 -55
  137. package/templates/root/internal/connectapi/handler.go +0 -79
  138. package/templates/root/internal/httpapi/routes.go +0 -93
  139. package/templates/root/internal/vault/client.go +0 -148
  140. package/templates/root/package.json +0 -12
  141. package/templates/root/protos/dns/v1/dns.proto +0 -58
  142. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  143. package/templates/root/scripts/cloudrun/config.ts +0 -50
  144. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  145. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  146. package/templates/root/service.yaml +0 -50
  147. package/templates/root/test/go.test.ts +0 -19
  148. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  149. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  150. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  151. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  152. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  153. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  154. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  155. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  156. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  157. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  158. package/templates/variants/bun-hono/src/storage.ts +0 -72
  159. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  160. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  161. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  162. package/templates/variants/go-chi/buf.yaml +0 -9
  163. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  164. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  165. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  166. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  167. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  168. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  169. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  170. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
  171. /package/bin/{create-svc.mjs → service.mjs} +0 -0
@@ -1,141 +1,67 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "context"
5
6
  "database/sql"
6
- "encoding/base64"
7
+ "encoding/csv"
7
8
  "encoding/json"
8
9
  "errors"
9
10
  "fmt"
10
- "net/http"
11
- "os"
11
+ "net/mail"
12
12
  "strings"
13
13
  "time"
14
14
 
15
- "cloud.google.com/go/storage"
16
15
  "github.com/jmoiron/sqlx"
17
16
  )
18
17
 
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"`
18
+ type WaitlistEntry struct {
19
+ ID string `json:"id" db:"id"`
20
+ Email string `json:"email" db:"email"`
21
+ Name string `json:"name,omitempty" db:"name"`
22
+ Company string `json:"company,omitempty" db:"company"`
23
+ Source string `json:"source,omitempty" db:"source"`
24
+ Status string `json:"status" db:"status"`
25
+ CreatedAt string `json:"created_at" db:"created_at"`
26
+ UpdatedAt string `json:"updated_at" db:"updated_at"`
81
27
  }
82
28
 
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"`
29
+ type WaitlistTrigger struct {
30
+ ID string `json:"id" db:"id"`
31
+ Type string `json:"type" db:"type"`
32
+ EntryID string `json:"entry_id,omitempty" db:"entry_id"`
33
+ Status string `json:"status" db:"status"`
34
+ Payload any `json:"payload"`
35
+ CreatedAt string `json:"created_at" db:"created_at"`
36
+ ProcessedAt string `json:"processed_at,omitempty" db:"processed_at"`
98
37
  }
99
38
 
100
- type CreateConversationInput struct {
101
- CreatedByUserID string `json:"created_by_user_id"`
102
- Title string `json:"title"`
103
- ParticipantUserIDs []string `json:"participant_user_ids"`
39
+ type JoinWaitlistInput struct {
40
+ Email string `json:"email"`
41
+ Name string `json:"name"`
42
+ Company string `json:"company"`
43
+ Source string `json:"source"`
104
44
  }
105
45
 
106
- type UpdateConversationInput struct {
107
- Title string `json:"title"`
46
+ type JoinWaitlistResult struct {
47
+ Entry WaitlistEntry `json:"entry"`
48
+ Created bool `json:"created"`
108
49
  }
109
50
 
110
- type CreateMessageInput struct {
111
- UserID string `json:"user_id"`
112
- Body string `json:"body"`
51
+ type RecordTriggerInput struct {
52
+ Type string `json:"type"`
53
+ EntryID string `json:"entry_id"`
54
+ Payload any `json:"payload"`
113
55
  }
114
56
 
115
- type ListMessagesInput struct {
116
- Cursor string `json:"cursor"`
57
+ type ListWaitlistEntriesInput struct {
58
+ Status string `json:"status"`
117
59
  Limit int `json:"limit"`
118
60
  }
119
61
 
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"`
62
+ type UpdateWaitlistEntryInput struct {
63
+ EntryID string `json:"entry_id"`
64
+ Status string `json:"status"`
139
65
  }
140
66
 
141
67
  type AppError struct {
@@ -146,99 +72,8 @@ type AppError struct {
146
72
 
147
73
  func (e *AppError) Error() string { return e.Err.Error() }
148
74
 
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
195
- }
196
-
197
- type GCSStorage struct {
198
- bucketName string
199
- publicBaseURL string
200
- client *storage.Client
201
- }
202
-
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}
208
- }
209
-
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
228
- }
229
-
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
234
- }
235
- return attrs.ContentType, attrs.Size, s.publicBaseURL + "/" + key, nil
236
- }
237
-
238
- type ChatService struct {
239
- db *sqlx.DB
240
- storage Storage
241
- webhookAdapter WebhookAdapter
75
+ type WaitlistService struct {
76
+ db *sqlx.DB
242
77
  }
243
78
 
244
79
  func OpenDatabase(ctx context.Context, databaseURL string) (*sqlx.DB, error) {
@@ -248,502 +83,231 @@ func OpenDatabase(ctx context.Context, databaseURL string) (*sqlx.DB, error) {
248
83
  return sqlx.ConnectContext(ctx, "pgx", databaseURL)
249
84
  }
250
85
 
251
- func NewChatService(db *sqlx.DB, storage Storage, webhookAdapter WebhookAdapter) *ChatService {
252
- return &ChatService{db: db, storage: storage, webhookAdapter: webhookAdapter}
253
- }
254
-
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
86
+ func NewWaitlistService(db *sqlx.DB) *WaitlistService {
87
+ return &WaitlistService{db: db}
297
88
  }
298
89
 
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)
90
+ func (s *WaitlistService) JoinWaitlist(ctx context.Context, input JoinWaitlistInput) (JoinWaitlistResult, error) {
91
+ email, err := normalizeEmail(input.Email)
305
92
  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
93
+ return JoinWaitlistResult{}, err
314
94
  }
315
95
 
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
- }
96
+ existing, err := s.GetEntryByEmail(ctx, email)
97
+ if err == nil {
98
+ return JoinWaitlistResult{Entry: existing, Created: false}, nil
326
99
  }
327
-
328
- if err := tx.Commit(); err != nil {
329
- return Conversation{}, err
100
+ var appErr *AppError
101
+ if !errors.As(err, &appErr) || appErr.Code != "entry_not_found" {
102
+ return JoinWaitlistResult{}, err
330
103
  }
331
- return s.GetConversation(ctx, conversationID)
332
- }
333
104
 
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)
105
+ id := fmt.Sprintf("entry_%d", time.Now().UnixNano())
106
+ var entry WaitlistEntry
107
+ err = s.db.GetContext(ctx, &entry, `
108
+ insert into waitlist_entries (id, email, name, company, source, status)
109
+ values ($1, $2, nullif($3, ''), nullif($4, ''), nullif($5, ''), 'joined')
110
+ returning id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
111
+ `, id, email, strings.TrimSpace(input.Name), strings.TrimSpace(input.Company), strings.TrimSpace(input.Source))
340
112
  if err != nil {
341
- return Conversation{}, notFoundIfNoRows(err, "conversation_not_found", "conversation not found")
113
+ return JoinWaitlistResult{}, err
342
114
  }
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
- }
355
115
 
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
116
+ if _, err := s.RecordTrigger(ctx, RecordTriggerInput{
117
+ Type: "waitlist.joined",
118
+ EntryID: entry.ID,
119
+ Payload: map[string]any{"email": entry.Email, "source": entry.Source},
120
+ }); err != nil {
121
+ return JoinWaitlistResult{}, err
359
122
  }
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
363
- }
364
- return s.GetConversation(ctx, conversationID)
365
- }
366
-
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
- }
375
123
 
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)
124
+ return JoinWaitlistResult{Entry: entry, Created: true}, nil
389
125
  }
390
126
 
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)
127
+ func (s *WaitlistService) GetEntry(ctx context.Context, entryID string) (WaitlistEntry, error) {
128
+ var entry WaitlistEntry
129
+ err := s.db.GetContext(ctx, &entry, `
130
+ select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
131
+ from waitlist_entries
132
+ where id = $1`, strings.TrimSpace(entryID))
402
133
  if err != nil {
403
- return ListMessagesResult{}, err
404
- }
405
- cursor, err := parseMessageCursor(input.Cursor)
406
- if err != nil {
407
- return ListMessagesResult{}, err
408
- }
409
-
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
134
+ return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
437
135
  }
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
136
+ return entry, nil
444
137
  }
445
138
 
446
- func (s *ChatService) CreateMessage(ctx context.Context, conversationID string, input CreateMessageInput) (Message, error) {
447
- conversation, err := s.GetConversation(ctx, conversationID)
139
+ func (s *WaitlistService) GetEntryByEmail(ctx context.Context, rawEmail string) (WaitlistEntry, error) {
140
+ email, err := normalizeEmail(rawEmail)
448
141
  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")}
142
+ return WaitlistEntry{}, err
475
143
  }
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)
144
+ var entry WaitlistEntry
145
+ err = s.db.GetContext(ctx, &entry, `
146
+ select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
147
+ from waitlist_entries
148
+ where email = $1`, email)
483
149
  if err != nil {
484
- return Message{}, notFoundIfNoRows(err, "message_not_found", "message not found")
150
+ return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
485
151
  }
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
152
+ return entry, nil
493
153
  }
494
154
 
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
155
+ func (s *WaitlistService) ListEntries(ctx context.Context, input ListWaitlistEntriesInput) ([]WaitlistEntry, error) {
156
+ limit := clampLimit(input.Limit)
157
+ status := strings.TrimSpace(input.Status)
158
+ var entries []WaitlistEntry
159
+ var err error
160
+ if status != "" {
161
+ status, err = normalizeStatus(status)
162
+ if err != nil {
163
+ return nil, err
164
+ }
165
+ err = s.db.SelectContext(ctx, &entries, `
166
+ select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
167
+ from waitlist_entries
168
+ where status = $1
169
+ order by created_at desc
170
+ limit $2`, status, limit)
171
+ } else {
172
+ err = s.db.SelectContext(ctx, &entries, `
173
+ select id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
174
+ from waitlist_entries
175
+ order by created_at desc
176
+ limit $1`, limit)
509
177
  }
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
178
  if err != nil {
517
- return CreateAttachmentUploadResult{}, err
179
+ return nil, err
518
180
  }
519
- attachment.PublicURL = publicURL
520
- return CreateAttachmentUploadResult{Attachment: attachment, Upload: upload}, nil
181
+ return entries, nil
521
182
  }
522
183
 
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
184
+ func (s *WaitlistService) UpdateEntry(ctx context.Context, input UpdateWaitlistEntryInput) (WaitlistEntry, error) {
185
+ entryID := strings.TrimSpace(input.EntryID)
186
+ if entryID == "" {
187
+ return WaitlistEntry{}, &AppError{Status: 400, Code: "invalid_entry_id", Err: errors.New("entry id is required")}
534
188
  }
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)
189
+ status, err := normalizeStatus(input.Status)
540
190
  if err != nil {
541
- return Attachment{}, err
191
+ return WaitlistEntry{}, err
542
192
  }
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()
193
+ var entry WaitlistEntry
194
+ err = s.db.GetContext(ctx, &entry, `
195
+ update waitlist_entries
196
+ set status = $2, updated_at = now()
563
197
  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)
198
+ returning id, email, coalesce(name, '') as name, coalesce(company, '') as company, coalesce(source, '') as source, status, created_at::text, updated_at::text
199
+ `, entryID, status)
566
200
  if err != nil {
567
- return Attachment{}, err
201
+ return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
568
202
  }
569
- updated.PublicURL = publicURL
570
- return updated, nil
203
+ return entry, nil
571
204
  }
572
205
 
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)
206
+ func (s *WaitlistService) ExportEntries(ctx context.Context, input ListWaitlistEntriesInput) (string, error) {
207
+ entries, err := s.ListEntries(ctx, input)
581
208
  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
209
+ return "", err
590
210
  }
591
- if !errors.Is(err, sql.ErrNoRows) {
592
- return WebhookEvent{}, false, err
211
+ var buffer bytes.Buffer
212
+ writer := csv.NewWriter(&buffer)
213
+ _ = writer.Write([]string{"id", "email", "name", "company", "source", "status", "created_at", "updated_at"})
214
+ for _, entry := range entries {
215
+ _ = writer.Write([]string{entry.ID, entry.Email, entry.Name, entry.Company, entry.Source, entry.Status, entry.CreatedAt, entry.UpdatedAt})
593
216
  }
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
217
+ writer.Flush()
218
+ return buffer.String(), writer.Error()
606
219
  }
607
220
 
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]
221
+ func (s *WaitlistService) RecordTrigger(ctx context.Context, input RecordTriggerInput) (WaitlistTrigger, error) {
222
+ triggerType := strings.TrimSpace(input.Type)
223
+ if triggerType == "" {
224
+ return WaitlistTrigger{}, &AppError{Status: 400, Code: "invalid_trigger_type", Err: errors.New("trigger type is required")}
618
225
  }
619
226
 
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
227
+ entryID := strings.TrimSpace(input.EntryID)
228
+ if entryID != "" {
229
+ if _, err := s.GetEntry(ctx, entryID); err != nil {
230
+ return WaitlistTrigger{}, err
648
231
  }
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
- })
657
232
  }
658
233
 
659
- return nil
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")}
234
+ payloadBytes, err := json.Marshal(input.Payload)
235
+ if err != nil {
236
+ return WaitlistTrigger{}, err
671
237
  }
672
- if limit > maxMessagePageSize {
673
- return 0, &AppError{Status: 400, Code: "invalid_limit", Err: fmt.Errorf("limit must be at most %d", maxMessagePageSize)}
238
+ if string(payloadBytes) == "null" {
239
+ payloadBytes = []byte("{}")
674
240
  }
675
- return limit, nil
676
- }
677
-
678
- type messageCursor struct {
679
- CreatedAt time.Time `json:"created_at"`
680
- ID string `json:"id"`
681
- }
682
241
 
683
- func parseMessageCursor(raw string) (*messageCursor, error) {
684
- if strings.TrimSpace(raw) == "" {
685
- return nil, nil
686
- }
687
- decoded, err := base64.RawURLEncoding.DecodeString(raw)
242
+ id := fmt.Sprintf("trg_%d", time.Now().UnixNano())
243
+ var row waitlistTriggerRow
244
+ err = s.db.GetContext(ctx, &row, `
245
+ insert into waitlist_triggers (id, type, entry_id, status, payload_json)
246
+ values ($1, $2, nullif($3, ''), 'queued', $4)
247
+ returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, created_at::text, coalesce(processed_at::text, '') as processed_at
248
+ `, id, triggerType, entryID, string(payloadBytes))
688
249
  if err != nil {
689
- return nil, &AppError{Status: 400, Code: "invalid_cursor", Err: errors.New("cursor is invalid")}
690
- }
250
+ return WaitlistTrigger{}, err
251
+ }
252
+ return row.toTrigger()
253
+ }
254
+
255
+ type waitlistTriggerRow struct {
256
+ ID string `db:"id"`
257
+ Type string `db:"type"`
258
+ EntryID string `db:"entry_id"`
259
+ Status string `db:"status"`
260
+ PayloadJSON string `db:"payload_json"`
261
+ CreatedAt string `db:"created_at"`
262
+ ProcessedAt string `db:"processed_at"`
263
+ }
264
+
265
+ func (r waitlistTriggerRow) toTrigger() (WaitlistTrigger, error) {
266
+ var payload any
267
+ if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
268
+ return WaitlistTrigger{}, err
269
+ }
270
+ return WaitlistTrigger{
271
+ ID: r.ID,
272
+ Type: r.Type,
273
+ EntryID: r.EntryID,
274
+ Status: r.Status,
275
+ Payload: payload,
276
+ CreatedAt: r.CreatedAt,
277
+ ProcessedAt: r.ProcessedAt,
278
+ }, nil
279
+ }
691
280
 
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")}
281
+ func normalizeEmail(value string) (string, error) {
282
+ email := strings.ToLower(strings.TrimSpace(value))
283
+ if email == "" {
284
+ return "", &AppError{Status: 400, Code: "invalid_email", Err: errors.New("valid email is required")}
698
285
  }
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")}
286
+ parsed, err := mail.ParseAddress(email)
287
+ if err != nil || parsed.Address != email {
288
+ return "", &AppError{Status: 400, Code: "invalid_email", Err: errors.New("valid email is required")}
701
289
  }
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)
290
+ return email, nil
715
291
  }
716
292
 
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
293
+ func normalizeStatus(value string) (string, error) {
294
+ status := strings.ToLower(strings.TrimSpace(value))
295
+ switch status {
296
+ case "joined", "invited", "converted", "archived":
297
+ return status, nil
298
+ default:
299
+ return "", &AppError{Status: 400, Code: "invalid_status", Err: errors.New("status must be one of joined, invited, converted, archived")}
721
300
  }
722
- return strings.TrimRight(base, "/") + "/" + key
723
301
  }
724
302
 
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",
303
+ func clampLimit(value int) int {
304
+ if value <= 0 {
305
+ return 100
732
306
  }
733
- for _, layout := range layouts {
734
- parsed, err := time.Parse(layout, value)
735
- if err == nil {
736
- return parsed, nil
737
- }
307
+ if value > 500 {
308
+ return 500
738
309
  }
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"
310
+ return value
747
311
  }
748
312
 
749
313
  func notFoundIfNoRows(err error, code string, message string) error {
@@ -752,50 +316,3 @@ func notFoundIfNoRows(err error, code string, message string) error {
752
316
  }
753
317
  return err
754
318
  }
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
- }