create-svc 0.1.9 → 0.1.11
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 +138 -16
- package/bin/create-service.mjs +2 -0
- package/package.json +19 -11
- package/src/cli.test.ts +46 -7
- package/src/cli.ts +282 -84
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +5 -2
- package/src/naming.ts +32 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +18 -26
- package/src/profiles.ts +25 -0
- package/src/scaffold.test.ts +320 -18
- package/src/scaffold.ts +154 -28
- package/src/vault.test.ts +94 -10
- package/src/vault.ts +81 -18
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +217 -29
- package/templates/shared/docker-compose.yml +19 -0
- 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 +24 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +21 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
- package/templates/shared/scripts/cloudrun/lib.ts +232 -123
- package/templates/shared/scripts/cloudrun/neon.ts +127 -13
- 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 -1
- 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 +397 -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/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +17 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-connectrpc/package.json +25 -1
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
- package/templates/variants/bun-connectrpc/src/index.ts +194 -22
- 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 +14 -13
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +17 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-hono/package.json +21 -1
- package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +126 -0
- package/templates/variants/bun-hono/src/db/schema.ts +26 -0
- package/templates/variants/bun-hono/src/index.ts +141 -10
- 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 +90 -5
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +30 -10
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +25 -13
- package/templates/variants/go-chi/go.mod +3 -2
- package/templates/variants/go-chi/internal/app/service.go +279 -70
- 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 +38 -7
- package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
- 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 +20 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +29 -8
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
- 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 +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
- 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 +38 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
- 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 +20 -0
- 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/.env.example +0 -10
- package/templates/variants/go-chi/buf.gen.yaml +0 -10
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestMiddlewareRejectsProtectedPathWithoutBearerToken(t *testing.T) {
|
|
10
|
+
handler := Middleware(Config{
|
|
11
|
+
Enabled: true,
|
|
12
|
+
Issuer: "https://auth.anmho.com",
|
|
13
|
+
Audience: "api://{{SERVICE_ID}}",
|
|
14
|
+
JWKSURL: "https://auth.anmho.com/api/auth/jwks",
|
|
15
|
+
})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
16
|
+
w.WriteHeader(http.StatusNoContent)
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
response := httptest.NewRecorder()
|
|
20
|
+
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/waitlist", nil))
|
|
21
|
+
|
|
22
|
+
if response.Code != http.StatusUnauthorized {
|
|
23
|
+
t.Fatalf("expected 401, got %d", response.Code)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestMiddlewareLeavesHealthPublic(t *testing.T) {
|
|
28
|
+
handler := Middleware(Config{Enabled: true})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
29
|
+
w.WriteHeader(http.StatusNoContent)
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
response := httptest.NewRecorder()
|
|
33
|
+
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
34
|
+
|
|
35
|
+
if response.Code != http.StatusNoContent {
|
|
36
|
+
t.Fatalf("expected health to pass through, got %d", response.Code)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
package config
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"os"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
4
8
|
|
|
5
9
|
type Config struct {
|
|
6
|
-
Port
|
|
7
|
-
DatabaseURL
|
|
10
|
+
Port string
|
|
11
|
+
DatabaseURL string
|
|
12
|
+
TemporalEnabled bool
|
|
13
|
+
TemporalAddress string
|
|
14
|
+
TemporalNamespace string
|
|
15
|
+
TemporalTaskQueue string
|
|
16
|
+
TemporalAPIKey string
|
|
17
|
+
AuthEnabled bool
|
|
18
|
+
AuthIssuer string
|
|
19
|
+
AuthAudience string
|
|
20
|
+
AuthJWKSURL string
|
|
8
21
|
}
|
|
9
22
|
|
|
10
23
|
func Load() (Config, error) {
|
|
11
|
-
|
|
12
|
-
Port:
|
|
13
|
-
DatabaseURL:
|
|
14
|
-
|
|
24
|
+
cfg := Config{
|
|
25
|
+
Port: envOr("PORT", "8080"),
|
|
26
|
+
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
|
27
|
+
TemporalEnabled: envBool("TEMPORAL_ENABLED"),
|
|
28
|
+
TemporalAddress: envOr("TEMPORAL_ADDRESS", "localhost:7233"),
|
|
29
|
+
TemporalNamespace: envOr("TEMPORAL_NAMESPACE", "default"),
|
|
30
|
+
TemporalTaskQueue: envOr("TEMPORAL_TASK_QUEUE", "{{SERVICE_NAME}}"),
|
|
31
|
+
TemporalAPIKey: strings.TrimSpace(os.Getenv("TEMPORAL_API_KEY")),
|
|
32
|
+
AuthEnabled: envBool("AUTH_ENABLED"),
|
|
33
|
+
AuthIssuer: envOr("AUTH_ISSUER", "{{AUTH_ISSUER}}"),
|
|
34
|
+
AuthAudience: envOr("AUTH_AUDIENCE", "{{AUTH_AUDIENCE}}"),
|
|
35
|
+
AuthJWKSURL: envOr("AUTH_JWKS_URL", "{{AUTH_JWKS_URL}}"),
|
|
36
|
+
}
|
|
37
|
+
if cfg.DatabaseURL == "" {
|
|
38
|
+
return Config{}, errors.New("DATABASE_URL is required")
|
|
39
|
+
}
|
|
40
|
+
return cfg, nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func envBool(key string) bool {
|
|
44
|
+
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
|
45
|
+
return value == "1" || value == "true" || value == "yes" || value == "on"
|
|
15
46
|
}
|
|
16
47
|
|
|
17
48
|
func envOr(key string, fallback string) string {
|
|
@@ -3,6 +3,7 @@ package httpapi
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"errors"
|
|
6
|
+
"io"
|
|
6
7
|
"net/http"
|
|
7
8
|
"strconv"
|
|
8
9
|
"strings"
|
|
@@ -12,67 +13,154 @@ import (
|
|
|
12
13
|
"{{MODULE_PATH}}/internal/app"
|
|
13
14
|
)
|
|
14
15
|
|
|
15
|
-
func RegisterRoutes(router chi.Router, service *app.
|
|
16
|
+
func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
|
|
16
17
|
router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
17
18
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
18
19
|
})
|
|
20
|
+
router.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
|
21
|
+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
22
|
+
})
|
|
23
|
+
router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
|
24
|
+
writeJSON(w, http.StatusOK, map[string]string{
|
|
25
|
+
"service": "{{SERVICE_NAME}}",
|
|
26
|
+
"domain": "waitlist",
|
|
27
|
+
"apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
router.Post("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
32
|
+
var input app.JoinWaitlistInput
|
|
33
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
34
|
+
writeError(w, err)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
result, err := service.JoinWaitlist(request.Context(), input)
|
|
38
|
+
if err != nil {
|
|
39
|
+
writeError(w, err)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
status := http.StatusOK
|
|
43
|
+
if result.Created {
|
|
44
|
+
status = http.StatusCreated
|
|
45
|
+
}
|
|
46
|
+
writeJSON(w, status, result)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
router.Get("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
50
|
+
entry, err := service.GetEntryByEmail(request.Context(), request.URL.Query().Get("email"))
|
|
51
|
+
if err != nil {
|
|
52
|
+
writeError(w, err)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.Get("/v1/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
|
|
59
|
+
entry, err := service.GetEntry(request.Context(), chi.URLParam(request, "entryID"))
|
|
60
|
+
if err != nil {
|
|
61
|
+
writeError(w, err)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
65
|
+
})
|
|
66
|
+
|
|
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")),
|
|
71
|
+
})
|
|
72
|
+
if err != nil {
|
|
73
|
+
writeError(w, err)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
|
77
|
+
})
|
|
78
|
+
|
|
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
|
+
})
|
|
84
|
+
if err != nil {
|
|
85
|
+
writeError(w, err)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
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))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
router.Patch("/v1/admin/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
|
|
95
|
+
var input app.UpdateWaitlistEntryInput
|
|
96
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
97
|
+
writeError(w, err)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
input.EntryID = chi.URLParam(request, "entryID")
|
|
101
|
+
entry, err := service.UpdateEntry(request.Context(), input)
|
|
102
|
+
if err != nil {
|
|
103
|
+
writeError(w, err)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
107
|
+
})
|
|
19
108
|
|
|
20
|
-
router.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
112
|
+
writeError(w, err)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
116
|
+
Type: stringValue(payload, "type", "manual"),
|
|
117
|
+
EntryID: stringValue(payload, "entry_id", ""),
|
|
118
|
+
Payload: payload,
|
|
28
119
|
})
|
|
120
|
+
if err != nil {
|
|
121
|
+
writeError(w, err)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
|
|
125
|
+
})
|
|
29
126
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
writeJSON(w, http.StatusCreated, map[string]any{"record": record})
|
|
127
|
+
router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
|
|
128
|
+
rawBody, err := io.ReadAll(request.Body)
|
|
129
|
+
if err != nil {
|
|
130
|
+
writeError(w, err)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
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
|
+
},
|
|
43
139
|
})
|
|
140
|
+
if err != nil {
|
|
141
|
+
writeError(w, err)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
|
|
145
|
+
})
|
|
44
146
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
writeError(w, err)
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
record, err := service.UpdateRecord(request.Context(), chi.URLParam(request, "recordID"), input)
|
|
54
|
-
if err != nil {
|
|
55
|
-
writeError(w, err)
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
writeJSON(w, http.StatusOK, map[string]any{"record": record})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
r.Delete("/", func(w http.ResponseWriter, request *http.Request) {
|
|
62
|
-
if err := service.DeleteRecord(request.Context(), chi.URLParam(request, "recordID")); err != nil {
|
|
63
|
-
writeError(w, err)
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
w.WriteHeader(http.StatusNoContent)
|
|
67
|
-
})
|
|
147
|
+
router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
|
|
148
|
+
writeJSON(w, http.StatusOK, map[string]string{
|
|
149
|
+
"status": "ok",
|
|
150
|
+
"provider": chi.URLParam(request, "provider"),
|
|
68
151
|
})
|
|
69
152
|
})
|
|
70
153
|
}
|
|
71
154
|
|
|
72
155
|
func decodeJSON(request *http.Request, out any) error {
|
|
73
156
|
defer request.Body.Close()
|
|
157
|
+
return json.NewDecoder(request.Body).Decode(out)
|
|
158
|
+
}
|
|
74
159
|
|
|
75
|
-
|
|
160
|
+
func decodeOptionalJSON(request *http.Request, out any) error {
|
|
161
|
+
defer request.Body.Close()
|
|
162
|
+
decoder := json.NewDecoder(request.Body)
|
|
163
|
+
if err := decoder.Decode(out); err != nil && !errors.Is(err, io.EOF) {
|
|
76
164
|
return err
|
|
77
165
|
}
|
|
78
166
|
return nil
|
|
@@ -85,9 +173,44 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
|
85
173
|
}
|
|
86
174
|
|
|
87
175
|
func writeError(w http.ResponseWriter, err error) {
|
|
176
|
+
var appErr *app.AppError
|
|
177
|
+
if errors.As(err, &appErr) {
|
|
178
|
+
writeJSON(w, appErr.Status, map[string]string{
|
|
179
|
+
"error": appErr.Error(),
|
|
180
|
+
"code": appErr.Code,
|
|
181
|
+
})
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
88
185
|
status := http.StatusInternalServerError
|
|
89
|
-
if
|
|
186
|
+
if strings.Contains(strings.ToLower(err.Error()), "json") {
|
|
90
187
|
status = http.StatusBadRequest
|
|
91
188
|
}
|
|
92
189
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
93
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
create table if not exists waitlist_entries (
|
|
2
|
+
id text primary key,
|
|
3
|
+
email text not null unique,
|
|
4
|
+
name text,
|
|
5
|
+
company text,
|
|
6
|
+
source text,
|
|
7
|
+
status text not null default 'joined',
|
|
8
|
+
created_at timestamptz not null default now(),
|
|
9
|
+
updated_at timestamptz not null default now()
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
create table if not exists waitlist_triggers (
|
|
13
|
+
id text primary key,
|
|
14
|
+
type text not null,
|
|
15
|
+
entry_id text references waitlist_entries(id),
|
|
16
|
+
status text not null default 'queued',
|
|
17
|
+
payload_json text not null,
|
|
18
|
+
created_at timestamptz not null default now(),
|
|
19
|
+
processed_at timestamptz
|
|
20
|
+
);
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
|
-
"
|
|
6
|
+
"service": "./scripts/cloudrun/cli.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"service": "bun run ./scripts/cloudrun/cli.ts",
|
|
10
|
+
"auth": "bun run ./scripts/cloudrun/cli.ts auth",
|
|
11
|
+
"dashboards": "bun run ./scripts/cloudrun/cli.ts dashboards"
|
|
7
12
|
},
|
|
8
13
|
"dependencies": {
|
|
14
|
+
"@anmho/authctl": "0.1.1",
|
|
9
15
|
"@clack/prompts": "^1.2.0",
|
|
10
16
|
"@neondatabase/api-client": "^2.7.1"
|
|
11
17
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
test("go test ./...", { timeout: 60_000 }, () => {
|
|
4
|
-
const
|
|
4
|
+
const go = Bun.which("go");
|
|
5
|
+
expect(go, "go must be installed and available on PATH").toBeString();
|
|
6
|
+
|
|
7
|
+
const result = Bun.spawnSync([go, "test", "./..."], {
|
|
5
8
|
cwd: process.cwd(),
|
|
6
9
|
stdout: "pipe",
|
|
7
10
|
stderr: "pipe",
|