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.
- package/README.md +51 -47
- package/index.ts +2 -2
- package/package.json +10 -9
- package/src/cli.test.ts +28 -10
- package/src/cli.ts +196 -33
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +1 -0
- package/src/naming.ts +23 -0
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +17 -4
- package/src/profiles.ts +2 -5
- package/src/scaffold.test.ts +232 -41
- package/src/scaffold.ts +81 -36
- package/src/service.test.ts +30 -0
- package/src/service.ts +65 -0
- package/src/vault.test.ts +61 -1
- package/src/vault.ts +77 -15
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +124 -47
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
- package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
- package/templates/shared/scripts/cloudrun/cli.ts +329 -7
- package/templates/shared/scripts/cloudrun/config.ts +11 -4
- package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
- package/templates/shared/scripts/cloudrun/lib.ts +174 -41
- package/templates/shared/scripts/cloudrun/neon.ts +45 -0
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -44
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +402 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Makefile +14 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-connectrpc/package.json +12 -5
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
- package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
- package/templates/variants/bun-connectrpc/src/index.ts +76 -176
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-hono/Makefile +14 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-hono/package.json +12 -5
- package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/repository.ts +68 -421
- package/templates/variants/bun-hono/src/db/schema.ts +15 -64
- package/templates/variants/bun-hono/src/index.ts +65 -180
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +72 -41
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/go-chi/Makefile +27 -11
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +21 -10
- package/templates/variants/go-chi/go.mod +1 -3
- package/templates/variants/go-chi/internal/app/service.go +202 -685
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +27 -11
- package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-connectrpc/Makefile +26 -9
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
- package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +1 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
- package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
- package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
- package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
- package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
- package/templates/variants/bun-hono/src/chat/service.ts +0 -384
- package/templates/variants/bun-hono/src/chat/types.ts +0 -142
- package/templates/variants/bun-hono/src/storage.ts +0 -72
- package/templates/variants/bun-hono/src/webhooks.ts +0 -35
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
- package/templates/variants/go-chi/buf.gen.yaml +0 -12
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
- /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/
|
|
7
|
+
"encoding/csv"
|
|
7
8
|
"encoding/json"
|
|
8
9
|
"errors"
|
|
9
10
|
"fmt"
|
|
10
|
-
"net/
|
|
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
|
|
20
|
-
ID
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
84
|
-
ID
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
107
|
-
|
|
46
|
+
type JoinWaitlistResult struct {
|
|
47
|
+
Entry WaitlistEntry `json:"entry"`
|
|
48
|
+
Created bool `json:"created"`
|
|
108
49
|
}
|
|
109
50
|
|
|
110
|
-
type
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
|
|
57
|
+
type ListWaitlistEntriesInput struct {
|
|
58
|
+
Status string `json:"status"`
|
|
117
59
|
Limit int `json:"limit"`
|
|
118
60
|
}
|
|
119
61
|
|
|
120
|
-
type
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
150
|
-
|
|
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
|
|
252
|
-
return &
|
|
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 *
|
|
300
|
-
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
329
|
-
return
|
|
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
|
-
|
|
335
|
-
var
|
|
336
|
-
err
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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 *
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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 *
|
|
447
|
-
|
|
139
|
+
func (s *WaitlistService) GetEntryByEmail(ctx context.Context, rawEmail string) (WaitlistEntry, error) {
|
|
140
|
+
email, err := normalizeEmail(rawEmail)
|
|
448
141
|
if err != nil {
|
|
449
|
-
return
|
|
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
|
|
477
|
-
err
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
where
|
|
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
|
|
150
|
+
return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
|
|
485
151
|
}
|
|
486
|
-
return
|
|
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 *
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
179
|
+
return nil, err
|
|
518
180
|
}
|
|
519
|
-
|
|
520
|
-
return CreateAttachmentUploadResult{Attachment: attachment, Upload: upload}, nil
|
|
181
|
+
return entries, nil
|
|
521
182
|
}
|
|
522
183
|
|
|
523
|
-
func (s *
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
|
191
|
+
return WaitlistEntry{}, err
|
|
542
192
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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,
|
|
565
|
-
`,
|
|
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
|
|
201
|
+
return WaitlistEntry{}, notFoundIfNoRows(err, "entry_not_found", "waitlist entry not found")
|
|
568
202
|
}
|
|
569
|
-
|
|
570
|
-
return updated, nil
|
|
203
|
+
return entry, nil
|
|
571
204
|
}
|
|
572
205
|
|
|
573
|
-
func (s *
|
|
574
|
-
|
|
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
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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 *
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
|
673
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
if err == nil {
|
|
736
|
-
return parsed, nil
|
|
737
|
-
}
|
|
307
|
+
if value > 500 {
|
|
308
|
+
return 500
|
|
738
309
|
}
|
|
739
|
-
return
|
|
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
|
-
}
|