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
|
@@ -13,7 +13,7 @@ import (
|
|
|
13
13
|
"{{MODULE_PATH}}/internal/app"
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
func RegisterRoutes(router chi.Router, service *app.
|
|
16
|
+
func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
|
|
17
17
|
router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
18
18
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
19
19
|
})
|
|
@@ -23,212 +23,105 @@ func RegisterRoutes(router chi.Router, service *app.ChatService) {
|
|
|
23
23
|
router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
|
24
24
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
25
25
|
"service": "{{SERVICE_NAME}}",
|
|
26
|
-
"domain": "
|
|
26
|
+
"domain": "waitlist",
|
|
27
27
|
"apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
|
|
28
28
|
})
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
router.Post("/v1/
|
|
32
|
-
var input app.
|
|
31
|
+
router.Post("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
32
|
+
var input app.JoinWaitlistInput
|
|
33
33
|
if err := decodeJSON(request, &input); err != nil {
|
|
34
34
|
writeError(w, err)
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
result, err := service.JoinWaitlist(request.Context(), input)
|
|
38
38
|
if err != nil {
|
|
39
39
|
writeError(w, err)
|
|
40
40
|
return
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
router.Get("/v1/users/{userID}", func(w http.ResponseWriter, request *http.Request) {
|
|
46
|
-
user, err := service.GetUser(request.Context(), chi.URLParam(request, "userID"))
|
|
47
|
-
if err != nil {
|
|
48
|
-
writeError(w, err)
|
|
49
|
-
return
|
|
42
|
+
status := http.StatusOK
|
|
43
|
+
if result.Created {
|
|
44
|
+
status = http.StatusCreated
|
|
50
45
|
}
|
|
51
|
-
writeJSON(w,
|
|
46
|
+
writeJSON(w, status, result)
|
|
52
47
|
})
|
|
53
48
|
|
|
54
|
-
router.Get("/v1/
|
|
55
|
-
|
|
49
|
+
router.Get("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
50
|
+
entry, err := service.GetEntryByEmail(request.Context(), request.URL.Query().Get("email"))
|
|
56
51
|
if err != nil {
|
|
57
52
|
writeError(w, err)
|
|
58
53
|
return
|
|
59
54
|
}
|
|
60
|
-
writeJSON(w, http.StatusOK, map[string]any{"
|
|
55
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
61
56
|
})
|
|
62
57
|
|
|
63
|
-
router.
|
|
64
|
-
|
|
65
|
-
if err := decodeJSON(request, &input); err != nil {
|
|
66
|
-
writeError(w, err)
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
conversation, err := service.CreateConversation(request.Context(), input)
|
|
58
|
+
router.Get("/v1/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
|
|
59
|
+
entry, err := service.GetEntry(request.Context(), chi.URLParam(request, "entryID"))
|
|
70
60
|
if err != nil {
|
|
71
61
|
writeError(w, err)
|
|
72
62
|
return
|
|
73
63
|
}
|
|
74
|
-
writeJSON(w, http.
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
router.Get("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
|
|
78
|
-
conversation, err := service.GetConversation(request.Context(), chi.URLParam(request, "conversationID"))
|
|
79
|
-
if err != nil {
|
|
80
|
-
writeError(w, err)
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
router.Patch("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
|
|
87
|
-
var input app.UpdateConversationInput
|
|
88
|
-
if err := decodeJSON(request, &input); err != nil {
|
|
89
|
-
writeError(w, err)
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
conversation, err := service.UpdateConversation(request.Context(), chi.URLParam(request, "conversationID"), input)
|
|
93
|
-
if err != nil {
|
|
94
|
-
writeError(w, err)
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
router.Delete("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
|
|
101
|
-
if err := service.DeleteConversation(request.Context(), chi.URLParam(request, "conversationID")); err != nil {
|
|
102
|
-
writeError(w, err)
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
w.WriteHeader(http.StatusNoContent)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
router.Post("/v1/conversations/{conversationID}/participants", func(w http.ResponseWriter, request *http.Request) {
|
|
109
|
-
var input struct {
|
|
110
|
-
UserID string `json:"user_id"`
|
|
111
|
-
}
|
|
112
|
-
if err := decodeJSON(request, &input); err != nil {
|
|
113
|
-
writeError(w, err)
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
conversation, err := service.AddParticipant(request.Context(), chi.URLParam(request, "conversationID"), input.UserID)
|
|
117
|
-
if err != nil {
|
|
118
|
-
writeError(w, err)
|
|
119
|
-
return
|
|
120
|
-
}
|
|
121
|
-
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
router.Delete("/v1/conversations/{conversationID}/participants/{userID}", func(w http.ResponseWriter, request *http.Request) {
|
|
125
|
-
if err := service.RemoveParticipant(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "userID")); err != nil {
|
|
126
|
-
writeError(w, err)
|
|
127
|
-
return
|
|
128
|
-
}
|
|
129
|
-
w.WriteHeader(http.StatusNoContent)
|
|
64
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
130
65
|
})
|
|
131
66
|
|
|
132
|
-
router.Get("/v1/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if err != nil {
|
|
137
|
-
writeError(w, &app.AppError{Status: http.StatusBadRequest, Code: "invalid_limit", Err: errors.New("limit must be a positive integer")})
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
limit = parsedLimit
|
|
141
|
-
}
|
|
142
|
-
result, err := service.ListMessages(request.Context(), chi.URLParam(request, "conversationID"), app.ListMessagesInput{
|
|
143
|
-
Cursor: strings.TrimSpace(request.URL.Query().Get("cursor")),
|
|
144
|
-
Limit: limit,
|
|
67
|
+
router.Get("/v1/admin/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
68
|
+
entries, err := service.ListEntries(request.Context(), app.ListWaitlistEntriesInput{
|
|
69
|
+
Status: request.URL.Query().Get("status"),
|
|
70
|
+
Limit: optionalInt(request.URL.Query().Get("limit")),
|
|
145
71
|
})
|
|
146
72
|
if err != nil {
|
|
147
73
|
writeError(w, err)
|
|
148
74
|
return
|
|
149
75
|
}
|
|
150
|
-
writeJSON(w, http.StatusOK,
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
router.Post("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
|
|
154
|
-
var input app.CreateMessageInput
|
|
155
|
-
if err := decodeJSON(request, &input); err != nil {
|
|
156
|
-
writeError(w, err)
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
message, err := service.CreateMessage(request.Context(), chi.URLParam(request, "conversationID"), input)
|
|
160
|
-
if err != nil {
|
|
161
|
-
writeError(w, err)
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
writeJSON(w, http.StatusCreated, map[string]any{"message": message})
|
|
76
|
+
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
|
165
77
|
})
|
|
166
78
|
|
|
167
|
-
router.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
message, err := service.UpdateMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID"), input)
|
|
79
|
+
router.Get("/v1/admin/waitlist/export", func(w http.ResponseWriter, request *http.Request) {
|
|
80
|
+
csv, err := service.ExportEntries(request.Context(), app.ListWaitlistEntriesInput{
|
|
81
|
+
Status: request.URL.Query().Get("status"),
|
|
82
|
+
Limit: optionalInt(request.URL.Query().Get("limit")),
|
|
83
|
+
})
|
|
174
84
|
if err != nil {
|
|
175
85
|
writeError(w, err)
|
|
176
86
|
return
|
|
177
87
|
}
|
|
178
|
-
|
|
88
|
+
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
89
|
+
w.Header().Set("Content-Disposition", `attachment; filename="waitlist.csv"`)
|
|
90
|
+
w.WriteHeader(http.StatusOK)
|
|
91
|
+
_, _ = w.Write([]byte(csv))
|
|
179
92
|
})
|
|
180
93
|
|
|
181
|
-
router.
|
|
182
|
-
|
|
183
|
-
writeError(w, err)
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
w.WriteHeader(http.StatusNoContent)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
router.Post("/v1/attachments/uploads", func(w http.ResponseWriter, request *http.Request) {
|
|
190
|
-
var input app.CreateAttachmentUploadInput
|
|
94
|
+
router.Patch("/v1/admin/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
|
|
95
|
+
var input app.UpdateWaitlistEntryInput
|
|
191
96
|
if err := decodeJSON(request, &input); err != nil {
|
|
192
97
|
writeError(w, err)
|
|
193
98
|
return
|
|
194
99
|
}
|
|
195
|
-
|
|
100
|
+
input.EntryID = chi.URLParam(request, "entryID")
|
|
101
|
+
entry, err := service.UpdateEntry(request.Context(), input)
|
|
196
102
|
if err != nil {
|
|
197
103
|
writeError(w, err)
|
|
198
104
|
return
|
|
199
105
|
}
|
|
200
|
-
writeJSON(w, http.
|
|
106
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
201
107
|
})
|
|
202
108
|
|
|
203
|
-
router.Post("/v1/
|
|
204
|
-
var
|
|
205
|
-
if err := decodeOptionalJSON(request, &
|
|
109
|
+
router.Post("/v1/triggers/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
110
|
+
var payload map[string]any
|
|
111
|
+
if err := decodeOptionalJSON(request, &payload); err != nil {
|
|
206
112
|
writeError(w, err)
|
|
207
113
|
return
|
|
208
114
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
router.Get("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
|
|
218
|
-
attachment, err := service.GetAttachment(request.Context(), chi.URLParam(request, "attachmentID"))
|
|
115
|
+
trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
116
|
+
Type: stringValue(payload, "type", "manual"),
|
|
117
|
+
EntryID: stringValue(payload, "entry_id", ""),
|
|
118
|
+
Payload: payload,
|
|
119
|
+
})
|
|
219
120
|
if err != nil {
|
|
220
121
|
writeError(w, err)
|
|
221
122
|
return
|
|
222
123
|
}
|
|
223
|
-
writeJSON(w, http.
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
router.Delete("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
|
|
227
|
-
if err := service.DeleteAttachment(request.Context(), chi.URLParam(request, "attachmentID")); err != nil {
|
|
228
|
-
writeError(w, err)
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
w.WriteHeader(http.StatusNoContent)
|
|
124
|
+
writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
|
|
232
125
|
})
|
|
233
126
|
|
|
234
127
|
router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
|
|
@@ -237,16 +130,18 @@ func RegisterRoutes(router chi.Router, service *app.ChatService) {
|
|
|
237
130
|
writeError(w, err)
|
|
238
131
|
return
|
|
239
132
|
}
|
|
240
|
-
|
|
133
|
+
trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
134
|
+
Type: "webhook." + chi.URLParam(request, "provider"),
|
|
135
|
+
Payload: map[string]any{
|
|
136
|
+
"headers": request.Header,
|
|
137
|
+
"rawBody": string(rawBody),
|
|
138
|
+
},
|
|
139
|
+
})
|
|
241
140
|
if err != nil {
|
|
242
141
|
writeError(w, err)
|
|
243
142
|
return
|
|
244
143
|
}
|
|
245
|
-
|
|
246
|
-
if duplicate {
|
|
247
|
-
status = http.StatusOK
|
|
248
|
-
}
|
|
249
|
-
writeJSON(w, status, map[string]any{"event": event, "duplicate": duplicate})
|
|
144
|
+
writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
|
|
250
145
|
})
|
|
251
146
|
|
|
252
147
|
router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
|
|
@@ -288,8 +183,34 @@ func writeError(w http.ResponseWriter, err error) {
|
|
|
288
183
|
}
|
|
289
184
|
|
|
290
185
|
status := http.StatusInternalServerError
|
|
291
|
-
if
|
|
186
|
+
if strings.Contains(strings.ToLower(err.Error()), "json") {
|
|
292
187
|
status = http.StatusBadRequest
|
|
293
188
|
}
|
|
294
189
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
295
190
|
}
|
|
191
|
+
|
|
192
|
+
func stringValue(values map[string]any, key string, fallback string) string {
|
|
193
|
+
if values == nil {
|
|
194
|
+
return fallback
|
|
195
|
+
}
|
|
196
|
+
value, ok := values[key]
|
|
197
|
+
if !ok && key == "entry_id" {
|
|
198
|
+
value, ok = values["entryId"]
|
|
199
|
+
}
|
|
200
|
+
if !ok {
|
|
201
|
+
return fallback
|
|
202
|
+
}
|
|
203
|
+
text, ok := value.(string)
|
|
204
|
+
if !ok {
|
|
205
|
+
return fallback
|
|
206
|
+
}
|
|
207
|
+
return strings.TrimSpace(text)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func optionalInt(value string) int {
|
|
211
|
+
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
212
|
+
if err != nil {
|
|
213
|
+
return 0
|
|
214
|
+
}
|
|
215
|
+
return parsed
|
|
216
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
package httpapi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"os"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
|
|
14
|
+
"github.com/go-chi/chi/v5"
|
|
15
|
+
_ "github.com/jackc/pgx/v5/stdlib"
|
|
16
|
+
|
|
17
|
+
"{{MODULE_PATH}}/internal/app"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestWaitlistJoinIsIdempotentAndRecordsTriggers(t *testing.T) {
|
|
21
|
+
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
|
22
|
+
if databaseURL == "" {
|
|
23
|
+
t.Skip("DATABASE_URL is required for integration test")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
db, err := app.OpenDatabase(context.Background(), databaseURL)
|
|
27
|
+
if err != nil {
|
|
28
|
+
t.Fatalf("open database: %v", err)
|
|
29
|
+
}
|
|
30
|
+
t.Cleanup(func() { _ = db.Close() })
|
|
31
|
+
|
|
32
|
+
if _, err := db.ExecContext(context.Background(), `
|
|
33
|
+
truncate table
|
|
34
|
+
waitlist_triggers,
|
|
35
|
+
waitlist_entries
|
|
36
|
+
restart identity cascade`); err != nil {
|
|
37
|
+
t.Fatalf("truncate tables: %v", err)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
service := app.NewWaitlistService(db)
|
|
41
|
+
router := chi.NewRouter()
|
|
42
|
+
RegisterRoutes(router, service)
|
|
43
|
+
server := httptest.NewServer(router)
|
|
44
|
+
t.Cleanup(server.Close)
|
|
45
|
+
|
|
46
|
+
first := joinWaitlist(t, server.URL, "Founder@Example.com")
|
|
47
|
+
if first.Created != true {
|
|
48
|
+
t.Fatal("expected first join to create entry")
|
|
49
|
+
}
|
|
50
|
+
if first.Entry.Email != "founder@example.com" {
|
|
51
|
+
t.Fatalf("expected normalized email, got %s", first.Entry.Email)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
second := joinWaitlist(t, server.URL, "founder@example.com")
|
|
55
|
+
if second.Created {
|
|
56
|
+
t.Fatal("expected second join to be idempotent")
|
|
57
|
+
}
|
|
58
|
+
if second.Entry.ID != first.Entry.ID {
|
|
59
|
+
t.Fatalf("expected same entry id, got %s and %s", first.Entry.ID, second.Entry.ID)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
trigger := recordTrigger(t, server.URL, first.Entry.ID)
|
|
63
|
+
if trigger.Trigger.Type != "cron.digest" {
|
|
64
|
+
t.Fatalf("expected cron.digest trigger, got %s", trigger.Trigger.Type)
|
|
65
|
+
}
|
|
66
|
+
if trigger.Trigger.EntryID != first.Entry.ID {
|
|
67
|
+
t.Fatalf("expected trigger entry id %s, got %s", first.Entry.ID, trigger.Trigger.EntryID)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
updated := updateEntry(t, server.URL, first.Entry.ID, "invited")
|
|
71
|
+
if updated.Entry.Status != "invited" {
|
|
72
|
+
t.Fatalf("expected invited status, got %s", updated.Entry.Status)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
list := listEntries(t, server.URL, "invited")
|
|
76
|
+
if len(list.Entries) != 1 || list.Entries[0].ID != first.Entry.ID {
|
|
77
|
+
t.Fatalf("expected one invited entry %s, got %+v", first.Entry.ID, list.Entries)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
exported := exportEntries(t, server.URL, "invited")
|
|
81
|
+
if !strings.Contains(exported, "founder@example.com") {
|
|
82
|
+
t.Fatalf("expected exported csv to contain email, got %s", exported)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type joinResponse struct {
|
|
87
|
+
Entry app.WaitlistEntry `json:"entry"`
|
|
88
|
+
Created bool `json:"created"`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type triggerResponse struct {
|
|
92
|
+
Trigger app.WaitlistTrigger `json:"trigger"`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type entryResponse struct {
|
|
96
|
+
Entry app.WaitlistEntry `json:"entry"`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type listResponse struct {
|
|
100
|
+
Entries []app.WaitlistEntry `json:"entries"`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func joinWaitlist(t *testing.T, baseURL string, email string) joinResponse {
|
|
104
|
+
t.Helper()
|
|
105
|
+
body := bytes.NewBufferString(`{"email":` + quoteJSON(email) + `,"name":"Founder","company":"Example Co","source":"homepage"}`)
|
|
106
|
+
response, err := http.Post(baseURL+"/v1/waitlist", "application/json", body)
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatalf("join waitlist: %v", err)
|
|
109
|
+
}
|
|
110
|
+
defer response.Body.Close()
|
|
111
|
+
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusOK {
|
|
112
|
+
t.Fatalf("expected success, got %d", response.StatusCode)
|
|
113
|
+
}
|
|
114
|
+
var payload joinResponse
|
|
115
|
+
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
|
116
|
+
t.Fatalf("decode join response: %v", err)
|
|
117
|
+
}
|
|
118
|
+
return payload
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func recordTrigger(t *testing.T, baseURL string, entryID string) triggerResponse {
|
|
122
|
+
t.Helper()
|
|
123
|
+
body := bytes.NewBufferString(`{"type":"cron.digest","entry_id":` + quoteJSON(entryID) + `}`)
|
|
124
|
+
response, err := http.Post(baseURL+"/v1/triggers/waitlist", "application/json", body)
|
|
125
|
+
if err != nil {
|
|
126
|
+
t.Fatalf("record trigger: %v", err)
|
|
127
|
+
}
|
|
128
|
+
defer response.Body.Close()
|
|
129
|
+
if response.StatusCode != http.StatusAccepted {
|
|
130
|
+
t.Fatalf("expected 202, got %d", response.StatusCode)
|
|
131
|
+
}
|
|
132
|
+
var payload triggerResponse
|
|
133
|
+
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
|
134
|
+
t.Fatalf("decode trigger response: %v", err)
|
|
135
|
+
}
|
|
136
|
+
return payload
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func updateEntry(t *testing.T, baseURL string, entryID string, status string) entryResponse {
|
|
140
|
+
t.Helper()
|
|
141
|
+
body := bytes.NewBufferString(`{"status":` + quoteJSON(status) + `}`)
|
|
142
|
+
request, err := http.NewRequest(http.MethodPatch, baseURL+"/v1/admin/waitlist/"+entryID, body)
|
|
143
|
+
if err != nil {
|
|
144
|
+
t.Fatalf("build update entry request: %v", err)
|
|
145
|
+
}
|
|
146
|
+
request.Header.Set("Content-Type", "application/json")
|
|
147
|
+
response, err := http.DefaultClient.Do(request)
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("update entry: %v", err)
|
|
150
|
+
}
|
|
151
|
+
defer response.Body.Close()
|
|
152
|
+
if response.StatusCode != http.StatusOK {
|
|
153
|
+
t.Fatalf("expected 200, got %d", response.StatusCode)
|
|
154
|
+
}
|
|
155
|
+
var payload entryResponse
|
|
156
|
+
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
|
157
|
+
t.Fatalf("decode update response: %v", err)
|
|
158
|
+
}
|
|
159
|
+
return payload
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func listEntries(t *testing.T, baseURL string, status string) listResponse {
|
|
163
|
+
t.Helper()
|
|
164
|
+
response, err := http.Get(baseURL + "/v1/admin/waitlist?status=" + status)
|
|
165
|
+
if err != nil {
|
|
166
|
+
t.Fatalf("list entries: %v", err)
|
|
167
|
+
}
|
|
168
|
+
defer response.Body.Close()
|
|
169
|
+
if response.StatusCode != http.StatusOK {
|
|
170
|
+
t.Fatalf("expected 200, got %d", response.StatusCode)
|
|
171
|
+
}
|
|
172
|
+
var payload listResponse
|
|
173
|
+
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
|
174
|
+
t.Fatalf("decode list response: %v", err)
|
|
175
|
+
}
|
|
176
|
+
return payload
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func exportEntries(t *testing.T, baseURL string, status string) string {
|
|
180
|
+
t.Helper()
|
|
181
|
+
response, err := http.Get(baseURL + "/v1/admin/waitlist/export?status=" + status)
|
|
182
|
+
if err != nil {
|
|
183
|
+
t.Fatalf("export entries: %v", err)
|
|
184
|
+
}
|
|
185
|
+
defer response.Body.Close()
|
|
186
|
+
if response.StatusCode != http.StatusOK {
|
|
187
|
+
t.Fatalf("expected 200, got %d", response.StatusCode)
|
|
188
|
+
}
|
|
189
|
+
raw, err := io.ReadAll(response.Body)
|
|
190
|
+
if err != nil {
|
|
191
|
+
t.Fatalf("read export response: %v", err)
|
|
192
|
+
}
|
|
193
|
+
return string(raw)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func quoteJSON(value string) string {
|
|
197
|
+
encoded, _ := json.Marshal(value)
|
|
198
|
+
return string(encoded)
|
|
199
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package temporalapp
|
|
2
|
+
|
|
3
|
+
import "context"
|
|
4
|
+
|
|
5
|
+
type WaitlistFollowUpInput struct {
|
|
6
|
+
TriggerID string
|
|
7
|
+
Email string
|
|
8
|
+
Type string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type WaitlistFollowUpResult struct {
|
|
12
|
+
Status string
|
|
13
|
+
TriggerID string
|
|
14
|
+
Email string
|
|
15
|
+
Type string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Activities struct{}
|
|
19
|
+
|
|
20
|
+
func (a *Activities) RecordWaitlistFollowUp(ctx context.Context, input WaitlistFollowUpInput) (WaitlistFollowUpResult, error) {
|
|
21
|
+
return WaitlistFollowUpResult{
|
|
22
|
+
Status: "queued",
|
|
23
|
+
TriggerID: input.TriggerID,
|
|
24
|
+
Email: input.Email,
|
|
25
|
+
Type: input.Type,
|
|
26
|
+
}, nil
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package temporalapp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"go.temporal.io/sdk/client"
|
|
5
|
+
"go.temporal.io/sdk/worker"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
type WorkerConfig struct {
|
|
9
|
+
Address string
|
|
10
|
+
Namespace string
|
|
11
|
+
TaskQueue string
|
|
12
|
+
APIKey string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func StartWorker(cfg WorkerConfig) (func(), error) {
|
|
16
|
+
options := client.Options{
|
|
17
|
+
HostPort: cfg.Address,
|
|
18
|
+
Namespace: cfg.Namespace,
|
|
19
|
+
}
|
|
20
|
+
if cfg.APIKey != "" {
|
|
21
|
+
options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
temporalClient, err := client.Dial(options)
|
|
25
|
+
if err != nil {
|
|
26
|
+
return nil, err
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
temporalWorker := worker.New(temporalClient, cfg.TaskQueue, worker.Options{})
|
|
30
|
+
temporalWorker.RegisterWorkflow(WaitlistFollowUpWorkflow)
|
|
31
|
+
temporalWorker.RegisterActivity(&Activities{})
|
|
32
|
+
|
|
33
|
+
if err := temporalWorker.Start(); err != nil {
|
|
34
|
+
temporalClient.Close()
|
|
35
|
+
return nil, err
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return func() {
|
|
39
|
+
temporalWorker.Stop()
|
|
40
|
+
temporalClient.Close()
|
|
41
|
+
}, nil
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package temporalapp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"go.temporal.io/sdk/workflow"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func WaitlistFollowUpWorkflow(ctx workflow.Context, input WaitlistFollowUpInput) (WaitlistFollowUpResult, error) {
|
|
10
|
+
options := workflow.ActivityOptions{
|
|
11
|
+
StartToCloseTimeout: time.Minute,
|
|
12
|
+
}
|
|
13
|
+
ctx = workflow.WithActivityOptions(ctx, options)
|
|
14
|
+
|
|
15
|
+
var result WaitlistFollowUpResult
|
|
16
|
+
err := workflow.ExecuteActivity(ctx, "RecordWaitlistFollowUp", input).Get(ctx, &result)
|
|
17
|
+
return result, err
|
|
18
|
+
}
|
|
@@ -1,63 +1,20 @@
|
|
|
1
|
-
create table if not exists
|
|
1
|
+
create table if not exists waitlist_entries (
|
|
2
2
|
id text primary key,
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
email text not null unique,
|
|
4
|
+
name text,
|
|
5
|
+
company text,
|
|
6
|
+
source text,
|
|
7
|
+
status text not null default 'joined',
|
|
5
8
|
created_at timestamptz not null default now(),
|
|
6
9
|
updated_at timestamptz not null default now()
|
|
7
10
|
);
|
|
8
11
|
|
|
9
|
-
create table if not exists
|
|
12
|
+
create table if not exists waitlist_triggers (
|
|
10
13
|
id text primary key,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
created_at timestamptz not null default now(),
|
|
15
|
-
updated_at timestamptz not null default now()
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
create table if not exists conversation_participants (
|
|
19
|
-
conversation_id text not null references conversations(id),
|
|
20
|
-
user_id text not null references users(id),
|
|
21
|
-
joined_at timestamptz not null default now(),
|
|
22
|
-
primary key (conversation_id, user_id)
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
create table if not exists messages (
|
|
26
|
-
id text primary key,
|
|
27
|
-
conversation_id text not null references conversations(id),
|
|
28
|
-
user_id text not null references users(id),
|
|
29
|
-
body text not null,
|
|
30
|
-
edited_at timestamptz,
|
|
31
|
-
deleted_at timestamptz,
|
|
32
|
-
created_at timestamptz not null default now(),
|
|
33
|
-
updated_at timestamptz not null default now()
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
create table if not exists attachments (
|
|
37
|
-
id text primary key,
|
|
38
|
-
conversation_id text not null references conversations(id),
|
|
39
|
-
message_id text references messages(id),
|
|
40
|
-
uploaded_by_user_id text not null references users(id),
|
|
41
|
-
storage_bucket text not null,
|
|
42
|
-
storage_key text not null,
|
|
43
|
-
content_type text not null,
|
|
44
|
-
byte_size bigint not null,
|
|
45
|
-
filename text not null,
|
|
46
|
-
status text not null,
|
|
47
|
-
deleted_at timestamptz,
|
|
48
|
-
created_at timestamptz not null default now(),
|
|
49
|
-
updated_at timestamptz not null default now()
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
create table if not exists webhook_events (
|
|
53
|
-
id text primary key,
|
|
54
|
-
provider text not null,
|
|
55
|
-
external_event_id text not null,
|
|
56
|
-
event_type text not null,
|
|
57
|
-
signature_valid text not null,
|
|
58
|
-
status text not null,
|
|
14
|
+
type text not null,
|
|
15
|
+
entry_id text references waitlist_entries(id),
|
|
16
|
+
status text not null default 'queued',
|
|
59
17
|
payload_json text not null,
|
|
60
|
-
|
|
61
|
-
processed_at timestamptz
|
|
62
|
-
unique (provider, external_event_id)
|
|
18
|
+
created_at timestamptz not null default now(),
|
|
19
|
+
processed_at timestamptz
|
|
63
20
|
);
|