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.
Files changed (163) hide show
  1. package/README.md +138 -16
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +19 -11
  4. package/src/cli.test.ts +46 -7
  5. package/src/cli.ts +282 -84
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +5 -2
  9. package/src/naming.ts +32 -1
  10. package/src/neon.ts +10 -8
  11. package/src/post-scaffold.test.ts +19 -0
  12. package/src/post-scaffold.ts +18 -26
  13. package/src/profiles.ts +25 -0
  14. package/src/scaffold.test.ts +320 -18
  15. package/src/scaffold.ts +154 -28
  16. package/src/vault.test.ts +94 -10
  17. package/src/vault.ts +81 -18
  18. package/templates/shared/.github/workflows/ci.yml +2 -1
  19. package/templates/shared/.github/workflows/deploy.yml +2 -0
  20. package/templates/shared/README.md +217 -29
  21. package/templates/shared/docker-compose.yml +19 -0
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
  27. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +21 -19
  29. package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
  30. package/templates/shared/scripts/cloudrun/lib.ts +232 -123
  31. package/templates/shared/scripts/cloudrun/neon.ts +127 -13
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -1
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  53. package/templates/variants/bun-connectrpc/Makefile +17 -8
  54. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  55. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
  56. package/templates/variants/bun-connectrpc/package.json +25 -1
  57. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  58. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  59. package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
  60. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  61. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  62. package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
  63. package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
  64. package/templates/variants/bun-connectrpc/src/index.ts +194 -22
  65. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  66. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  67. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  68. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  69. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  70. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  71. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  72. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  73. package/templates/variants/bun-hono/Makefile +17 -8
  74. package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
  75. package/templates/variants/bun-hono/package.json +21 -1
  76. package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
  77. package/templates/variants/bun-hono/src/auth.ts +181 -0
  78. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  79. package/templates/variants/bun-hono/src/db/repository.ts +126 -0
  80. package/templates/variants/bun-hono/src/db/schema.ts +26 -0
  81. package/templates/variants/bun-hono/src/index.ts +141 -10
  82. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  83. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  84. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  85. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  86. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  87. package/templates/variants/bun-hono/test/app.test.ts +90 -5
  88. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  89. package/templates/variants/bun-hono/tsconfig.json +1 -0
  90. package/templates/variants/go-chi/Makefile +30 -10
  91. package/templates/variants/go-chi/atlas.hcl +8 -0
  92. package/templates/variants/go-chi/cmd/server/main.go +25 -13
  93. package/templates/variants/go-chi/go.mod +3 -2
  94. package/templates/variants/go-chi/internal/app/service.go +279 -70
  95. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  96. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  97. package/templates/variants/go-chi/internal/config/config.go +38 -7
  98. package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
  99. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  100. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  101. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  102. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  103. package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
  104. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  105. package/templates/variants/go-chi/package.json +7 -1
  106. package/templates/variants/go-chi/test/go.test.ts +4 -1
  107. package/templates/variants/go-connectrpc/Makefile +29 -8
  108. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  109. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  110. package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
  111. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  112. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  113. package/templates/variants/go-connectrpc/go.mod +4 -0
  114. package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
  115. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  116. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  117. package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
  118. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
  119. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  120. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
  121. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  122. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  123. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  124. package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
  125. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  126. package/templates/variants/go-connectrpc/package.json +7 -1
  127. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  128. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  129. package/templates/root/.github/workflows/ci.yml +0 -26
  130. package/templates/root/.github/workflows/deploy.yml +0 -22
  131. package/templates/root/Dockerfile +0 -23
  132. package/templates/root/README.md +0 -69
  133. package/templates/root/buf.gen.yaml +0 -10
  134. package/templates/root/buf.yaml +0 -9
  135. package/templates/root/cmd/server/main.go +0 -44
  136. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  137. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  138. package/templates/root/go.mod +0 -10
  139. package/templates/root/internal/app/service.go +0 -152
  140. package/templates/root/internal/app/token_source.go +0 -50
  141. package/templates/root/internal/cloudflare/client.go +0 -160
  142. package/templates/root/internal/config/config.go +0 -55
  143. package/templates/root/internal/connectapi/handler.go +0 -79
  144. package/templates/root/internal/httpapi/routes.go +0 -93
  145. package/templates/root/internal/vault/client.go +0 -148
  146. package/templates/root/package.json +0 -12
  147. package/templates/root/protos/dns/v1/dns.proto +0 -58
  148. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  149. package/templates/root/scripts/cloudrun/config.ts +0 -50
  150. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  151. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  152. package/templates/root/service.yaml +0 -50
  153. package/templates/root/test/go.test.ts +0 -19
  154. package/templates/shared/.env.example +0 -10
  155. package/templates/variants/go-chi/buf.gen.yaml +0 -10
  156. package/templates/variants/go-chi/buf.yaml +0 -9
  157. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  158. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  159. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  160. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  161. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  162. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  163. 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, "/waitlist.v1.WaitlistService/JoinWaitlist", 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 "os"
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "strings"
7
+ )
4
8
 
5
9
  type Config struct {
6
- Port string
7
- DatabaseURL string
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
- return Config{
12
- Port: envOr("PORT", "8080"),
13
- DatabaseURL: envOr("DATABASE_URL", ""),
14
- }, nil
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 {
@@ -2,78 +2,167 @@ package connectapi
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
6
+ "errors"
5
7
  "net/http"
6
8
 
7
9
  "connectrpc.com/connect"
8
10
 
9
- dnsv1 "{{MODULE_PATH}}/gen/dns/v1"
10
- dnsv1connect "{{MODULE_PATH}}/gen/dns/v1/dnsv1connect"
11
+ waitlistv1 "{{MODULE_PATH}}/gen/waitlist/v1"
12
+ waitlistv1connect "{{MODULE_PATH}}/gen/waitlist/v1/waitlistv1connect"
11
13
  "{{MODULE_PATH}}/internal/app"
12
14
  )
13
15
 
14
16
  type Handler struct {
15
- service *app.DNSService
17
+ service *app.WaitlistService
16
18
  }
17
19
 
18
- func NewHandler(service *app.DNSService) (string, http.Handler) {
19
- return dnsv1connect.NewDNSServiceHandler(&Handler{service: service})
20
+ func NewHandler(service *app.WaitlistService) (string, http.Handler) {
21
+ return waitlistv1connect.NewWaitlistServiceHandler(&Handler{service: service})
20
22
  }
21
23
 
22
- func (h *Handler) ListRecords(ctx context.Context, _ *connect.Request[dnsv1.ListRecordsRequest]) (*connect.Response[dnsv1.ListRecordsResponse], error) {
23
- records, err := h.service.ListRecords(ctx)
24
+ func (h *Handler) JoinWaitlist(ctx context.Context, request *connect.Request[waitlistv1.JoinWaitlistRequest]) (*connect.Response[waitlistv1.JoinWaitlistResponse], error) {
25
+ result, err := h.service.JoinWaitlist(ctx, app.JoinWaitlistInput{
26
+ Email: request.Msg.GetEmail(),
27
+ Name: request.Msg.GetName(),
28
+ Company: request.Msg.GetCompany(),
29
+ Source: request.Msg.GetSource(),
30
+ })
31
+ if err != nil {
32
+ return nil, toConnectError(err)
33
+ }
34
+ return connect.NewResponse(&waitlistv1.JoinWaitlistResponse{
35
+ Entry: toProtoEntry(result.Entry),
36
+ Created: result.Created,
37
+ }), nil
38
+ }
39
+
40
+ func (h *Handler) GetWaitlistEntry(ctx context.Context, request *connect.Request[waitlistv1.GetWaitlistEntryRequest]) (*connect.Response[waitlistv1.GetWaitlistEntryResponse], error) {
41
+ entry, err := h.service.GetEntry(ctx, request.Msg.GetEntryId())
42
+ if err != nil {
43
+ return nil, toConnectError(err)
44
+ }
45
+ return connect.NewResponse(&waitlistv1.GetWaitlistEntryResponse{Entry: toProtoEntry(entry)}), nil
46
+ }
47
+
48
+ func (h *Handler) GetWaitlistEntryByEmail(ctx context.Context, request *connect.Request[waitlistv1.GetWaitlistEntryByEmailRequest]) (*connect.Response[waitlistv1.GetWaitlistEntryResponse], error) {
49
+ entry, err := h.service.GetEntryByEmail(ctx, request.Msg.GetEmail())
24
50
  if err != nil {
25
- return nil, connect.NewError(connect.CodeInternal, err)
51
+ return nil, toConnectError(err)
26
52
  }
53
+ return connect.NewResponse(&waitlistv1.GetWaitlistEntryResponse{Entry: toProtoEntry(entry)}), nil
54
+ }
27
55
 
28
- response := &dnsv1.ListRecordsResponse{Records: make([]*dnsv1.Record, 0, len(records))}
29
- for _, record := range records {
30
- response.Records = append(response.Records, toProtoRecord(record))
56
+ func (h *Handler) ListWaitlistEntries(ctx context.Context, request *connect.Request[waitlistv1.ListWaitlistEntriesRequest]) (*connect.Response[waitlistv1.ListWaitlistEntriesResponse], error) {
57
+ entries, err := h.service.ListEntries(ctx, app.ListWaitlistEntriesInput{
58
+ Status: request.Msg.GetStatus(),
59
+ Limit: int(request.Msg.GetLimit()),
60
+ })
61
+ if err != nil {
62
+ return nil, toConnectError(err)
63
+ }
64
+ response := &waitlistv1.ListWaitlistEntriesResponse{Entries: make([]*waitlistv1.WaitlistEntry, 0, len(entries))}
65
+ for _, entry := range entries {
66
+ response.Entries = append(response.Entries, toProtoEntry(entry))
31
67
  }
32
68
  return connect.NewResponse(response), nil
33
69
  }
34
70
 
35
- func (h *Handler) CreateRecord(ctx context.Context, request *connect.Request[dnsv1.CreateRecordRequest]) (*connect.Response[dnsv1.CreateRecordResponse], error) {
36
- record, err := h.service.CreateRecord(ctx, app.CreateRecordInput{
37
- Type: request.Msg.GetType(),
38
- Name: request.Msg.GetName(),
39
- Content: request.Msg.GetContent(),
40
- TTL: int(request.Msg.GetTtl()),
41
- Proxied: request.Msg.GetProxied(),
71
+ func (h *Handler) UpdateWaitlistEntry(ctx context.Context, request *connect.Request[waitlistv1.UpdateWaitlistEntryRequest]) (*connect.Response[waitlistv1.GetWaitlistEntryResponse], error) {
72
+ entry, err := h.service.UpdateEntry(ctx, app.UpdateWaitlistEntryInput{
73
+ EntryID: request.Msg.GetEntryId(),
74
+ Status: request.Msg.GetStatus(),
75
+ })
76
+ if err != nil {
77
+ return nil, toConnectError(err)
78
+ }
79
+ return connect.NewResponse(&waitlistv1.GetWaitlistEntryResponse{Entry: toProtoEntry(entry)}), nil
80
+ }
81
+
82
+ func (h *Handler) ExportWaitlistEntries(ctx context.Context, request *connect.Request[waitlistv1.ExportWaitlistEntriesRequest]) (*connect.Response[waitlistv1.ExportWaitlistEntriesResponse], error) {
83
+ csv, err := h.service.ExportEntries(ctx, app.ListWaitlistEntriesInput{
84
+ Status: request.Msg.GetStatus(),
85
+ Limit: int(request.Msg.GetLimit()),
42
86
  })
43
87
  if err != nil {
44
- return nil, connect.NewError(connect.CodeInternal, err)
88
+ return nil, toConnectError(err)
45
89
  }
46
- return connect.NewResponse(&dnsv1.CreateRecordResponse{Record: toProtoRecord(record)}), nil
90
+ return connect.NewResponse(&waitlistv1.ExportWaitlistEntriesResponse{Csv: csv}), nil
47
91
  }
48
92
 
49
- func (h *Handler) UpdateRecord(ctx context.Context, request *connect.Request[dnsv1.UpdateRecordRequest]) (*connect.Response[dnsv1.UpdateRecordResponse], error) {
50
- record, err := h.service.UpdateRecord(ctx, request.Msg.GetId(), app.UpdateRecordInput{
93
+ func (h *Handler) RecordTrigger(ctx context.Context, request *connect.Request[waitlistv1.RecordTriggerRequest]) (*connect.Response[waitlistv1.RecordTriggerResponse], error) {
94
+ trigger, err := h.service.RecordTrigger(ctx, app.RecordTriggerInput{
51
95
  Type: request.Msg.GetType(),
52
- Name: request.Msg.GetName(),
53
- Content: request.Msg.GetContent(),
54
- TTL: int(request.Msg.GetTtl()),
55
- Proxied: request.Msg.GetProxied(),
96
+ EntryID: request.Msg.GetEntryId(),
97
+ Payload: jsonPayload(request.Msg.GetPayloadJson()),
56
98
  })
57
99
  if err != nil {
58
- return nil, connect.NewError(connect.CodeInternal, err)
100
+ return nil, toConnectError(err)
101
+ }
102
+ return connect.NewResponse(&waitlistv1.RecordTriggerResponse{Trigger: toProtoTrigger(trigger)}), nil
103
+ }
104
+
105
+ func toConnectError(err error) error {
106
+ var appErr *app.AppError
107
+ if errors.As(err, &appErr) {
108
+ return connect.NewError(statusCodeToConnectCode(appErr.Status), err)
109
+ }
110
+ return connect.NewError(connect.CodeInternal, err)
111
+ }
112
+
113
+ func statusCodeToConnectCode(status int) connect.Code {
114
+ switch status {
115
+ case 400:
116
+ return connect.CodeInvalidArgument
117
+ case 404:
118
+ return connect.CodeNotFound
119
+ case 409:
120
+ return connect.CodeAlreadyExists
121
+ default:
122
+ return connect.CodeInternal
59
123
  }
60
- return connect.NewResponse(&dnsv1.UpdateRecordResponse{Record: toProtoRecord(record)}), nil
61
124
  }
62
125
 
63
- func (h *Handler) DeleteRecord(ctx context.Context, request *connect.Request[dnsv1.DeleteRecordRequest]) (*connect.Response[dnsv1.DeleteRecordResponse], error) {
64
- if err := h.service.DeleteRecord(ctx, request.Msg.GetId()); err != nil {
65
- return nil, connect.NewError(connect.CodeInternal, err)
126
+ func toProtoEntry(entry app.WaitlistEntry) *waitlistv1.WaitlistEntry {
127
+ return &waitlistv1.WaitlistEntry{
128
+ Id: entry.ID,
129
+ Email: entry.Email,
130
+ Name: entry.Name,
131
+ Company: entry.Company,
132
+ Source: entry.Source,
133
+ Status: entry.Status,
134
+ CreatedAt: entry.CreatedAt,
135
+ UpdatedAt: entry.UpdatedAt,
66
136
  }
67
- return connect.NewResponse(&dnsv1.DeleteRecordResponse{}), nil
68
137
  }
69
138
 
70
- func toProtoRecord(record app.Record) *dnsv1.Record {
71
- return &dnsv1.Record{
72
- Id: record.ID,
73
- Type: record.Type,
74
- Name: record.Name,
75
- Content: record.Content,
76
- Ttl: int32(record.TTL),
77
- Proxied: record.Proxied,
139
+ func toProtoTrigger(trigger app.WaitlistTrigger) *waitlistv1.WaitlistTrigger {
140
+ return &waitlistv1.WaitlistTrigger{
141
+ Id: trigger.ID,
142
+ Type: trigger.Type,
143
+ EntryId: trigger.EntryID,
144
+ Status: trigger.Status,
145
+ PayloadJson: payloadToJSON(trigger.Payload),
146
+ CreatedAt: trigger.CreatedAt,
147
+ ProcessedAt: trigger.ProcessedAt,
148
+ }
149
+ }
150
+
151
+ func jsonPayload(value string) any {
152
+ if value == "" {
153
+ return map[string]any{}
154
+ }
155
+ var payload any
156
+ if err := json.Unmarshal([]byte(value), &payload); err != nil {
157
+ return map[string]any{}
158
+ }
159
+ return payload
160
+ }
161
+
162
+ func payloadToJSON(value any) string {
163
+ encoded, err := json.Marshal(value)
164
+ if err != nil {
165
+ return "{}"
78
166
  }
167
+ return string(encoded)
79
168
  }
@@ -0,0 +1,122 @@
1
+ package connectapi
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "strings"
9
+ "testing"
10
+
11
+ "connectrpc.com/connect"
12
+ _ "github.com/jackc/pgx/v5/stdlib"
13
+
14
+ waitlistv1 "{{MODULE_PATH}}/gen/waitlist/v1"
15
+ waitlistv1connect "{{MODULE_PATH}}/gen/waitlist/v1/waitlistv1connect"
16
+ "{{MODULE_PATH}}/internal/app"
17
+ )
18
+
19
+ func TestWaitlistRPCJoinIsIdempotentAndRecordsTriggers(t *testing.T) {
20
+ databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
21
+ if databaseURL == "" {
22
+ t.Skip("DATABASE_URL is required for integration test")
23
+ }
24
+
25
+ db, err := app.OpenDatabase(context.Background(), databaseURL)
26
+ if err != nil {
27
+ t.Fatalf("open database: %v", err)
28
+ }
29
+ t.Cleanup(func() { _ = db.Close() })
30
+
31
+ if _, err := db.ExecContext(context.Background(), `
32
+ truncate table
33
+ waitlist_triggers,
34
+ waitlist_entries
35
+ restart identity cascade`); err != nil {
36
+ t.Fatalf("truncate tables: %v", err)
37
+ }
38
+
39
+ service := app.NewWaitlistService(db)
40
+ path, handler := NewHandler(service)
41
+ mux := http.NewServeMux()
42
+ mux.Handle(path, handler)
43
+ server := httptest.NewServer(mux)
44
+ t.Cleanup(server.Close)
45
+
46
+ client := waitlistv1connect.NewWaitlistServiceClient(http.DefaultClient, server.URL)
47
+
48
+ first, err := client.JoinWaitlist(context.Background(), connect.NewRequest(&waitlistv1.JoinWaitlistRequest{
49
+ Email: "Founder@Example.com",
50
+ Name: "Founder",
51
+ Company: "Example Co",
52
+ Source: "homepage",
53
+ }))
54
+ if err != nil {
55
+ t.Fatalf("join waitlist: %v", err)
56
+ }
57
+ if !first.Msg.GetCreated() {
58
+ t.Fatal("expected first join to create entry")
59
+ }
60
+ if first.Msg.GetEntry().GetEmail() != "founder@example.com" {
61
+ t.Fatalf("expected normalized email, got %s", first.Msg.GetEntry().GetEmail())
62
+ }
63
+
64
+ second, err := client.JoinWaitlist(context.Background(), connect.NewRequest(&waitlistv1.JoinWaitlistRequest{
65
+ Email: "founder@example.com",
66
+ }))
67
+ if err != nil {
68
+ t.Fatalf("join waitlist again: %v", err)
69
+ }
70
+ if second.Msg.GetCreated() {
71
+ t.Fatal("expected second join to be idempotent")
72
+ }
73
+ if second.Msg.GetEntry().GetId() != first.Msg.GetEntry().GetId() {
74
+ t.Fatalf("expected same entry id, got %s and %s", first.Msg.GetEntry().GetId(), second.Msg.GetEntry().GetId())
75
+ }
76
+
77
+ trigger, err := client.RecordTrigger(context.Background(), connect.NewRequest(&waitlistv1.RecordTriggerRequest{
78
+ Type: "cron.digest",
79
+ EntryId: first.Msg.GetEntry().GetId(),
80
+ PayloadJson: "{}",
81
+ }))
82
+ if err != nil {
83
+ t.Fatalf("record trigger: %v", err)
84
+ }
85
+ if trigger.Msg.GetTrigger().GetType() != "cron.digest" {
86
+ t.Fatalf("expected cron.digest trigger, got %s", trigger.Msg.GetTrigger().GetType())
87
+ }
88
+ if trigger.Msg.GetTrigger().GetEntryId() != first.Msg.GetEntry().GetId() {
89
+ t.Fatalf("expected entry id %s, got %s", first.Msg.GetEntry().GetId(), trigger.Msg.GetTrigger().GetEntryId())
90
+ }
91
+
92
+ updated, err := client.UpdateWaitlistEntry(context.Background(), connect.NewRequest(&waitlistv1.UpdateWaitlistEntryRequest{
93
+ EntryId: first.Msg.GetEntry().GetId(),
94
+ Status: "invited",
95
+ }))
96
+ if err != nil {
97
+ t.Fatalf("update waitlist entry: %v", err)
98
+ }
99
+ if updated.Msg.GetEntry().GetStatus() != "invited" {
100
+ t.Fatalf("expected invited status, got %s", updated.Msg.GetEntry().GetStatus())
101
+ }
102
+
103
+ list, err := client.ListWaitlistEntries(context.Background(), connect.NewRequest(&waitlistv1.ListWaitlistEntriesRequest{
104
+ Status: "invited",
105
+ }))
106
+ if err != nil {
107
+ t.Fatalf("list waitlist entries: %v", err)
108
+ }
109
+ if len(list.Msg.GetEntries()) != 1 || list.Msg.GetEntries()[0].GetId() != first.Msg.GetEntry().GetId() {
110
+ t.Fatalf("expected one invited entry %s, got %+v", first.Msg.GetEntry().GetId(), list.Msg.GetEntries())
111
+ }
112
+
113
+ exported, err := client.ExportWaitlistEntries(context.Background(), connect.NewRequest(&waitlistv1.ExportWaitlistEntriesRequest{
114
+ Status: "invited",
115
+ }))
116
+ if err != nil {
117
+ t.Fatalf("export waitlist entries: %v", err)
118
+ }
119
+ if !strings.Contains(exported.Msg.GetCsv(), "founder@example.com") {
120
+ t.Fatalf("expected exported csv to contain email, got %s", exported.Msg.GetCsv())
121
+ }
122
+ }
@@ -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.DNSService) {
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.Route("/v1/dns/records", func(r chi.Router) {
21
- r.Get("/", func(w http.ResponseWriter, request *http.Request) {
22
- records, err := service.ListRecords(request.Context())
23
- if err != nil {
24
- writeError(w, err)
25
- return
26
- }
27
- writeJSON(w, http.StatusOK, map[string]any{"records": records})
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
- r.Post("/", func(w http.ResponseWriter, request *http.Request) {
31
- var input app.CreateRecordInput
32
- if err := decodeJSON(request, &input); err != nil {
33
- writeError(w, err)
34
- return
35
- }
36
-
37
- record, err := service.CreateRecord(request.Context(), input)
38
- if err != nil {
39
- writeError(w, err)
40
- return
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
- r.Route("/{recordID}", func(r chi.Router) {
46
- r.Put("/", func(w http.ResponseWriter, request *http.Request) {
47
- var input app.UpdateRecordInput
48
- if err := decodeJSON(request, &input); err != nil {
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
- if err := json.NewDecoder(request.Body).Decode(out); err != nil {
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 errors.Is(err, strconv.ErrSyntax) || strings.Contains(strings.ToLower(err.Error()), "json") {
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
+ }