create-svc 0.1.53 → 0.1.54

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 (38) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/src/scaffold.test.ts +30 -8
  4. package/src/scaffold.ts +23 -0
  5. package/src/service-runtime/cloudrun/cli.ts +10 -0
  6. package/src/service-runtime/cloudrun/config.ts +3 -0
  7. package/src/service-runtime/cloudrun/observability.ts +18 -0
  8. package/templates/shared/.github/workflows/deploy.yml +1 -1
  9. package/templates/shared/.github/workflows/preview.yml +4 -3
  10. package/templates/shared/README.md +37 -0
  11. package/templates/shared/service.jsonc +8 -0
  12. package/templates/variants/bun-connectrpc/Makefile +4 -1
  13. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +10 -0
  14. package/templates/variants/bun-connectrpc/package.json +1 -0
  15. package/templates/variants/bun-connectrpc/src/db/repository.ts +52 -3
  16. package/templates/variants/bun-connectrpc/src/db/schema.ts +13 -0
  17. package/templates/variants/bun-connectrpc/src/index.ts +47 -4
  18. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +22 -0
  19. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +16 -0
  20. package/templates/variants/bun-hono/Makefile +4 -1
  21. package/templates/variants/bun-hono/migrations/0000_init.sql +10 -0
  22. package/templates/variants/bun-hono/package.json +1 -0
  23. package/templates/variants/bun-hono/src/db/repository.ts +52 -3
  24. package/templates/variants/bun-hono/src/db/schema.ts +13 -0
  25. package/templates/variants/bun-hono/src/index.ts +34 -8
  26. package/templates/variants/bun-hono/src/waitlist/service.ts +22 -0
  27. package/templates/variants/bun-hono/src/waitlist/types.ts +16 -0
  28. package/templates/variants/bun-hono/test/app.test.ts +13 -0
  29. package/templates/variants/go-chi/Makefile +4 -1
  30. package/templates/variants/go-chi/internal/app/service.go +96 -0
  31. package/templates/variants/go-chi/internal/httpapi/routes.go +56 -7
  32. package/templates/variants/go-chi/migrations/0000_init.sql +10 -0
  33. package/templates/variants/go-chi/migrations/atlas.sum +2 -2
  34. package/templates/variants/go-connectrpc/Makefile +4 -1
  35. package/templates/variants/go-connectrpc/internal/app/service.go +96 -0
  36. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +56 -7
  37. package/templates/variants/go-connectrpc/migrations/0000_init.sql +10 -0
  38. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -2
@@ -1,7 +1,7 @@
1
- import { desc, eq } from "drizzle-orm";
1
+ import { and, desc, eq } from "drizzle-orm";
2
2
  import type { createDb } from "./client";
3
- import { waitlistEntries, waitlistTriggers } from "./schema";
4
- import type { WaitlistEntry, WaitlistTrigger } from "../waitlist/types";
3
+ import { waitlistEntries, waitlistTriggers, webhookEvents } from "./schema";
4
+ import type { WaitlistEntry, WaitlistTrigger, WebhookEvent } from "../waitlist/types";
5
5
 
6
6
  type Database = ReturnType<typeof createDb>;
7
7
  type WaitlistEntryRow = typeof waitlistEntries.$inferSelect;
@@ -21,6 +21,14 @@ type CreateTriggerRecord = {
21
21
  payload: unknown;
22
22
  };
23
23
 
24
+ type CreateWebhookEventRecord = {
25
+ id: string;
26
+ provider: string;
27
+ externalEventId: string;
28
+ payload: unknown;
29
+ headers: Record<string, string>;
30
+ };
31
+
24
32
  export class WaitlistRepository {
25
33
  constructor(private readonly db: Database) {}
26
34
 
@@ -91,6 +99,36 @@ export class WaitlistRepository {
91
99
  .returning();
92
100
  return toWaitlistTrigger(row);
93
101
  }
102
+
103
+ async recordWebhookEvent(input: CreateWebhookEventRecord): Promise<{ event: WebhookEvent; duplicate: boolean }> {
104
+ const [inserted] = await this.db
105
+ .insert(webhookEvents)
106
+ .values({
107
+ id: input.id,
108
+ provider: input.provider,
109
+ externalEventId: input.externalEventId,
110
+ payloadJson: JSON.stringify(input.payload ?? {}),
111
+ headersJson: JSON.stringify(input.headers),
112
+ receivedAt: new Date(),
113
+ })
114
+ .onConflictDoNothing({
115
+ target: [webhookEvents.provider, webhookEvents.externalEventId],
116
+ })
117
+ .returning();
118
+ if (inserted) {
119
+ return { event: toWebhookEvent(inserted), duplicate: false };
120
+ }
121
+
122
+ const [row] = await this.db
123
+ .select()
124
+ .from(webhookEvents)
125
+ .where(and(eq(webhookEvents.provider, input.provider), eq(webhookEvents.externalEventId, input.externalEventId)))
126
+ .limit(1);
127
+ if (!row) {
128
+ throw new Error("webhook event was not inserted and could not be read");
129
+ }
130
+ return { event: toWebhookEvent(row), duplicate: true };
131
+ }
94
132
  }
95
133
 
96
134
  function clampLimit(value: number | null | undefined) {
@@ -124,3 +162,14 @@ function toWaitlistTrigger(row: typeof waitlistTriggers.$inferSelect): WaitlistT
124
162
  processedAt: row.processedAt?.toISOString() ?? null,
125
163
  };
126
164
  }
165
+
166
+ function toWebhookEvent(row: typeof webhookEvents.$inferSelect): WebhookEvent {
167
+ return {
168
+ id: row.id,
169
+ provider: row.provider,
170
+ externalEventId: row.externalEventId,
171
+ payload: JSON.parse(row.payloadJson),
172
+ headers: JSON.parse(row.headersJson),
173
+ receivedAt: row.receivedAt.toISOString(),
174
+ };
175
+ }
@@ -24,3 +24,16 @@ export const waitlistTriggers = pgTable("waitlist_triggers", {
24
24
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
25
25
  processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
26
26
  });
27
+
28
+ export const webhookEvents = pgTable(
29
+ "webhook_events",
30
+ {
31
+ id: text("id").primaryKey(),
32
+ provider: text("provider").notNull(),
33
+ externalEventId: text("external_event_id").notNull(),
34
+ payloadJson: text("payload_json").notNull(),
35
+ headersJson: text("headers_json").notNull(),
36
+ receivedAt: timestamp("received_at", { withTimezone: true, mode: "date" }).notNull().defaultNow(),
37
+ },
38
+ (table) => [uniqueIndex("webhook_events_provider_external_event_id_key").on(table.provider, table.externalEventId)]
39
+ );
@@ -110,15 +110,26 @@ export function createApp(service: WaitlistService) {
110
110
  app.post("/webhooks/:provider", async (context) => {
111
111
  try {
112
112
  const rawBody = await context.req.text();
113
- const trigger = await service.recordTrigger({
114
- type: `webhook.${context.req.param("provider")}`,
115
- entryId: null,
116
- payload: {
117
- headers: Object.fromEntries(context.req.raw.headers),
118
- rawBody,
119
- },
113
+ const provider = context.req.param("provider");
114
+ const headers = Object.fromEntries(context.req.raw.headers);
115
+ const payload = parseWebhookPayload(rawBody);
116
+ const result = await service.recordWebhookEvent({
117
+ provider,
118
+ externalEventId: webhookEventId(payload, context.req.raw.headers),
119
+ payload,
120
+ headers,
120
121
  });
121
- return context.json({ trigger }, 202);
122
+ if (!result.duplicate) {
123
+ await service.recordTrigger({
124
+ type: `webhook.${provider}`,
125
+ entryId: null,
126
+ payload: {
127
+ headers,
128
+ rawBody,
129
+ },
130
+ });
131
+ }
132
+ return context.json(result, result.duplicate ? 200 : 202);
122
133
  } catch (error) {
123
134
  return writeError(context, error);
124
135
  }
@@ -142,6 +153,21 @@ function parseOptionalNumber(value: string | undefined) {
142
153
  return value ? Number(value) : null;
143
154
  }
144
155
 
156
+ function parseWebhookPayload(rawBody: string) {
157
+ try {
158
+ return rawBody ? JSON.parse(rawBody) : {};
159
+ } catch {
160
+ return { rawBody };
161
+ }
162
+ }
163
+
164
+ function webhookEventId(payload: unknown, headers: Headers) {
165
+ if (payload && typeof payload === "object" && "id" in payload && typeof payload.id === "string") {
166
+ return payload.id;
167
+ }
168
+ return headers.get("x-webhook-event-id") ?? crypto.randomUUID();
169
+ }
170
+
145
171
  if (import.meta.main) {
146
172
  const temporalWorker = await startTemporalWorker();
147
173
  if (temporalWorker) {
@@ -3,8 +3,10 @@ import { WaitlistRepository } from "../db/repository";
3
3
  import type {
4
4
  JoinWaitlistInput,
5
5
  ListWaitlistEntriesInput,
6
+ RecordWebhookEventInput,
6
7
  RecordTriggerInput,
7
8
  UpdateWaitlistEntryInput,
9
+ WebhookEvent,
8
10
  WaitlistEntry,
9
11
  WaitlistEntryStatus,
10
12
  } from "./types";
@@ -27,6 +29,7 @@ export type WaitlistService = {
27
29
  updateWaitlistEntry(input: UpdateWaitlistEntryInput): Promise<WaitlistEntry>;
28
30
  exportWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<string>;
29
31
  recordTrigger(input: RecordTriggerInput): Promise<unknown>;
32
+ recordWebhookEvent(input: RecordWebhookEventInput): Promise<{ event: WebhookEvent; duplicate: boolean }>;
30
33
  };
31
34
 
32
35
  export class DefaultWaitlistService implements WaitlistService {
@@ -113,6 +116,25 @@ export class DefaultWaitlistService implements WaitlistService {
113
116
  payload: input.payload ?? {},
114
117
  });
115
118
  }
119
+
120
+ async recordWebhookEvent(input: RecordWebhookEventInput) {
121
+ const provider = input.provider.trim();
122
+ const externalEventId = input.externalEventId.trim();
123
+ if (!provider) {
124
+ throw new AppError(400, "invalid_webhook_provider", "webhook provider is required");
125
+ }
126
+ if (!externalEventId) {
127
+ throw new AppError(400, "invalid_webhook_event_id", "webhook event id is required");
128
+ }
129
+
130
+ return this.repository.recordWebhookEvent({
131
+ id: crypto.randomUUID(),
132
+ provider,
133
+ externalEventId,
134
+ payload: input.payload ?? {},
135
+ headers: input.headers,
136
+ });
137
+ }
116
138
  }
117
139
 
118
140
  export function createDefaultWaitlistService() {
@@ -19,6 +19,15 @@ export type WaitlistTrigger = {
19
19
  processedAt: string | null;
20
20
  };
21
21
 
22
+ export type WebhookEvent = {
23
+ id: string;
24
+ provider: string;
25
+ externalEventId: string;
26
+ payload: unknown;
27
+ headers: Record<string, string>;
28
+ receivedAt: string;
29
+ };
30
+
22
31
  export type JoinWaitlistInput = {
23
32
  email: string;
24
33
  name?: string | null;
@@ -48,3 +57,10 @@ export type RecordTriggerInput = {
48
57
  entryId?: string | null;
49
58
  payload?: unknown;
50
59
  };
60
+
61
+ export type RecordWebhookEventInput = {
62
+ provider: string;
63
+ externalEventId: string;
64
+ payload: unknown;
65
+ headers: Record<string, string>;
66
+ };
@@ -93,5 +93,18 @@ function createMockService(): WaitlistService {
93
93
  processedAt: null,
94
94
  };
95
95
  },
96
+ async recordWebhookEvent() {
97
+ return {
98
+ duplicate: false,
99
+ event: {
100
+ id: "webhook_1",
101
+ provider: "generic",
102
+ externalEventId: "evt_1",
103
+ payload: {},
104
+ headers: {},
105
+ receivedAt: "2026-01-01T00:00:00.000Z",
106
+ },
107
+ };
108
+ },
96
109
  };
97
110
  }
@@ -1,4 +1,4 @@
1
- .PHONY: dev migrate migrate-lint gen lint test create deploy dashboards auth destroy
1
+ .PHONY: dev migrate migrate-lint gen lint test create deploy dashboards observability-bootstrap auth destroy
2
2
 
3
3
  SERVICE := service
4
4
  WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
@@ -38,6 +38,9 @@ deploy:
38
38
  dashboards:
39
39
  $(SERVICE) dashboards
40
40
 
41
+ observability-bootstrap:
42
+ $(SERVICE) observability-bootstrap
43
+
41
44
  auth:
42
45
  $(SERVICE) auth $(ARGS)
43
46
 
@@ -36,6 +36,15 @@ type WaitlistTrigger struct {
36
36
  ProcessedAt string `json:"processed_at,omitempty" db:"processed_at"`
37
37
  }
38
38
 
39
+ type WebhookEvent struct {
40
+ ID string `json:"id" db:"id"`
41
+ Provider string `json:"provider" db:"provider"`
42
+ ExternalEventID string `json:"external_event_id" db:"external_event_id"`
43
+ Payload map[string]any `json:"payload"`
44
+ Headers map[string]any `json:"headers"`
45
+ ReceivedAt string `json:"received_at" db:"received_at"`
46
+ }
47
+
39
48
  type JoinWaitlistInput struct {
40
49
  Email string `json:"email"`
41
50
  Name string `json:"name"`
@@ -54,6 +63,18 @@ type RecordTriggerInput struct {
54
63
  Payload any `json:"payload"`
55
64
  }
56
65
 
66
+ type RecordWebhookEventInput struct {
67
+ Provider string `json:"provider"`
68
+ ExternalEventID string `json:"external_event_id"`
69
+ Payload map[string]any `json:"payload"`
70
+ Headers map[string]any `json:"headers"`
71
+ }
72
+
73
+ type RecordWebhookEventResult struct {
74
+ Event WebhookEvent `json:"event"`
75
+ Duplicate bool `json:"duplicate"`
76
+ }
77
+
57
78
  type ListWaitlistEntriesInput struct {
58
79
  Status string `json:"status"`
59
80
  Limit int `json:"limit"`
@@ -252,6 +273,53 @@ returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, cr
252
273
  return row.toTrigger()
253
274
  }
254
275
 
276
+ func (s *WaitlistService) RecordWebhookEvent(ctx context.Context, input RecordWebhookEventInput) (RecordWebhookEventResult, error) {
277
+ provider := strings.TrimSpace(input.Provider)
278
+ if provider == "" {
279
+ return RecordWebhookEventResult{}, &AppError{Status: 400, Code: "invalid_webhook_provider", Err: errors.New("webhook provider is required")}
280
+ }
281
+ externalEventID := strings.TrimSpace(input.ExternalEventID)
282
+ if externalEventID == "" {
283
+ return RecordWebhookEventResult{}, &AppError{Status: 400, Code: "invalid_webhook_event_id", Err: errors.New("webhook event id is required")}
284
+ }
285
+
286
+ payloadBytes, err := json.Marshal(input.Payload)
287
+ if err != nil {
288
+ return RecordWebhookEventResult{}, err
289
+ }
290
+ headersBytes, err := json.Marshal(input.Headers)
291
+ if err != nil {
292
+ return RecordWebhookEventResult{}, err
293
+ }
294
+
295
+ id := fmt.Sprintf("wh_%d", time.Now().UnixNano())
296
+ var row webhookEventRow
297
+ err = s.db.GetContext(ctx, &row, `
298
+ insert into webhook_events (id, provider, external_event_id, payload_json, headers_json)
299
+ values ($1, $2, $3, $4, $5)
300
+ on conflict (provider, external_event_id) do nothing
301
+ returning id, provider, external_event_id, payload_json, headers_json, received_at::text
302
+ `, id, provider, externalEventID, string(payloadBytes), string(headersBytes))
303
+ if err == nil {
304
+ event, err := row.toWebhookEvent()
305
+ return RecordWebhookEventResult{Event: event, Duplicate: false}, err
306
+ }
307
+ if !errors.Is(err, sql.ErrNoRows) {
308
+ return RecordWebhookEventResult{}, err
309
+ }
310
+
311
+ err = s.db.GetContext(ctx, &row, `
312
+ select id, provider, external_event_id, payload_json, headers_json, received_at::text
313
+ from webhook_events
314
+ where provider = $1 and external_event_id = $2
315
+ `, provider, externalEventID)
316
+ if err != nil {
317
+ return RecordWebhookEventResult{}, err
318
+ }
319
+ event, err := row.toWebhookEvent()
320
+ return RecordWebhookEventResult{Event: event, Duplicate: true}, err
321
+ }
322
+
255
323
  type waitlistTriggerRow struct {
256
324
  ID string `db:"id"`
257
325
  Type string `db:"type"`
@@ -262,6 +330,34 @@ type waitlistTriggerRow struct {
262
330
  ProcessedAt string `db:"processed_at"`
263
331
  }
264
332
 
333
+ type webhookEventRow struct {
334
+ ID string `db:"id"`
335
+ Provider string `db:"provider"`
336
+ ExternalEventID string `db:"external_event_id"`
337
+ PayloadJSON string `db:"payload_json"`
338
+ HeadersJSON string `db:"headers_json"`
339
+ ReceivedAt string `db:"received_at"`
340
+ }
341
+
342
+ func (r webhookEventRow) toWebhookEvent() (WebhookEvent, error) {
343
+ var payload map[string]any
344
+ if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
345
+ return WebhookEvent{}, err
346
+ }
347
+ var headers map[string]any
348
+ if err := json.Unmarshal([]byte(r.HeadersJSON), &headers); err != nil {
349
+ return WebhookEvent{}, err
350
+ }
351
+ return WebhookEvent{
352
+ ID: r.ID,
353
+ Provider: r.Provider,
354
+ ExternalEventID: r.ExternalEventID,
355
+ Payload: payload,
356
+ Headers: headers,
357
+ ReceivedAt: r.ReceivedAt,
358
+ }, nil
359
+ }
360
+
265
361
  func (r waitlistTriggerRow) toTrigger() (WaitlistTrigger, error) {
266
362
  var payload any
267
363
  if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
@@ -3,10 +3,12 @@ package httpapi
3
3
  import (
4
4
  "encoding/json"
5
5
  "errors"
6
+ "fmt"
6
7
  "io"
7
8
  "net/http"
8
9
  "strconv"
9
10
  "strings"
11
+ "time"
10
12
 
11
13
  "github.com/go-chi/chi/v5"
12
14
 
@@ -130,18 +132,35 @@ func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
130
132
  writeError(w, err)
131
133
  return
132
134
  }
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
- },
135
+ provider := chi.URLParam(request, "provider")
136
+ payload := parseWebhookPayload(rawBody)
137
+ result, err := service.RecordWebhookEvent(request.Context(), app.RecordWebhookEventInput{
138
+ Provider: provider,
139
+ ExternalEventID: webhookEventID(payload, request.Header),
140
+ Payload: payload,
141
+ Headers: headersPayload(request.Header),
139
142
  })
140
143
  if err != nil {
141
144
  writeError(w, err)
142
145
  return
143
146
  }
144
- writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
147
+ if !result.Duplicate {
148
+ if _, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
149
+ Type: "webhook." + provider,
150
+ Payload: map[string]any{
151
+ "headers": headersPayload(request.Header),
152
+ "rawBody": string(rawBody),
153
+ },
154
+ }); err != nil {
155
+ writeError(w, err)
156
+ return
157
+ }
158
+ }
159
+ if result.Duplicate {
160
+ writeJSON(w, http.StatusOK, result)
161
+ return
162
+ }
163
+ writeJSON(w, http.StatusAccepted, result)
145
164
  })
146
165
 
147
166
  router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
@@ -157,6 +176,36 @@ func decodeJSON(request *http.Request, out any) error {
157
176
  return json.NewDecoder(request.Body).Decode(out)
158
177
  }
159
178
 
179
+ func parseWebhookPayload(rawBody []byte) map[string]any {
180
+ var payload map[string]any
181
+ if len(rawBody) == 0 || json.Unmarshal(rawBody, &payload) != nil {
182
+ return map[string]any{"rawBody": string(rawBody)}
183
+ }
184
+ return payload
185
+ }
186
+
187
+ func webhookEventID(payload map[string]any, headers http.Header) string {
188
+ if id, ok := payload["id"].(string); ok && id != "" {
189
+ return id
190
+ }
191
+ if id := headers.Get("X-Webhook-Event-Id"); id != "" {
192
+ return id
193
+ }
194
+ return fmt.Sprintf("evt_%d", time.Now().UnixNano())
195
+ }
196
+
197
+ func headersPayload(headers http.Header) map[string]any {
198
+ out := make(map[string]any, len(headers))
199
+ for key, values := range headers {
200
+ if len(values) == 1 {
201
+ out[key] = values[0]
202
+ continue
203
+ }
204
+ out[key] = values
205
+ }
206
+ return out
207
+ }
208
+
160
209
  func decodeOptionalJSON(request *http.Request, out any) error {
161
210
  defer request.Body.Close()
162
211
  decoder := json.NewDecoder(request.Body)
@@ -18,3 +18,13 @@ create table if not exists waitlist_triggers (
18
18
  created_at timestamptz not null default now(),
19
19
  processed_at timestamptz
20
20
  );
21
+
22
+ create table if not exists webhook_events (
23
+ id text primary key,
24
+ provider text not null,
25
+ external_event_id text not null,
26
+ payload_json text not null,
27
+ headers_json text not null,
28
+ received_at timestamptz not null default now(),
29
+ unique (provider, external_event_id)
30
+ );
@@ -1,2 +1,2 @@
1
- h1:D9GgvtEf0xxnRDaTb/C3cPtRbncVXsK6Ef0KYQuLMRw=
2
- 0000_init.sql h1:dBpLGyoeZNRpjT6RvQXiqx3p2ICAChKy2jVqCIYuWQg=
1
+ h1:AhMVwNnmHR10XivPa/5MXyIZrG7LPmekS6SpgjTGJyA=
2
+ 0000_init.sql h1:9Sm7GI+kmYAsnRTFdZkIZl0bsQN5EUlN/AopryCCRgs=
@@ -1,4 +1,4 @@
1
- .PHONY: dev migrate migrate-lint gen lint test create deploy dashboards auth destroy
1
+ .PHONY: dev migrate migrate-lint gen lint test create deploy dashboards observability-bootstrap auth destroy
2
2
 
3
3
  SERVICE := service
4
4
  WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
@@ -39,6 +39,9 @@ deploy:
39
39
  dashboards:
40
40
  $(SERVICE) dashboards
41
41
 
42
+ observability-bootstrap:
43
+ $(SERVICE) observability-bootstrap
44
+
42
45
  auth:
43
46
  $(SERVICE) auth $(ARGS)
44
47
 
@@ -36,6 +36,15 @@ type WaitlistTrigger struct {
36
36
  ProcessedAt string `json:"processed_at,omitempty" db:"processed_at"`
37
37
  }
38
38
 
39
+ type WebhookEvent struct {
40
+ ID string `json:"id" db:"id"`
41
+ Provider string `json:"provider" db:"provider"`
42
+ ExternalEventID string `json:"external_event_id" db:"external_event_id"`
43
+ Payload map[string]any `json:"payload"`
44
+ Headers map[string]any `json:"headers"`
45
+ ReceivedAt string `json:"received_at" db:"received_at"`
46
+ }
47
+
39
48
  type JoinWaitlistInput struct {
40
49
  Email string `json:"email"`
41
50
  Name string `json:"name"`
@@ -54,6 +63,18 @@ type RecordTriggerInput struct {
54
63
  Payload any `json:"payload"`
55
64
  }
56
65
 
66
+ type RecordWebhookEventInput struct {
67
+ Provider string `json:"provider"`
68
+ ExternalEventID string `json:"external_event_id"`
69
+ Payload map[string]any `json:"payload"`
70
+ Headers map[string]any `json:"headers"`
71
+ }
72
+
73
+ type RecordWebhookEventResult struct {
74
+ Event WebhookEvent `json:"event"`
75
+ Duplicate bool `json:"duplicate"`
76
+ }
77
+
57
78
  type ListWaitlistEntriesInput struct {
58
79
  Status string `json:"status"`
59
80
  Limit int `json:"limit"`
@@ -252,6 +273,53 @@ returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, cr
252
273
  return row.toTrigger()
253
274
  }
254
275
 
276
+ func (s *WaitlistService) RecordWebhookEvent(ctx context.Context, input RecordWebhookEventInput) (RecordWebhookEventResult, error) {
277
+ provider := strings.TrimSpace(input.Provider)
278
+ if provider == "" {
279
+ return RecordWebhookEventResult{}, &AppError{Status: 400, Code: "invalid_webhook_provider", Err: errors.New("webhook provider is required")}
280
+ }
281
+ externalEventID := strings.TrimSpace(input.ExternalEventID)
282
+ if externalEventID == "" {
283
+ return RecordWebhookEventResult{}, &AppError{Status: 400, Code: "invalid_webhook_event_id", Err: errors.New("webhook event id is required")}
284
+ }
285
+
286
+ payloadBytes, err := json.Marshal(input.Payload)
287
+ if err != nil {
288
+ return RecordWebhookEventResult{}, err
289
+ }
290
+ headersBytes, err := json.Marshal(input.Headers)
291
+ if err != nil {
292
+ return RecordWebhookEventResult{}, err
293
+ }
294
+
295
+ id := fmt.Sprintf("wh_%d", time.Now().UnixNano())
296
+ var row webhookEventRow
297
+ err = s.db.GetContext(ctx, &row, `
298
+ insert into webhook_events (id, provider, external_event_id, payload_json, headers_json)
299
+ values ($1, $2, $3, $4, $5)
300
+ on conflict (provider, external_event_id) do nothing
301
+ returning id, provider, external_event_id, payload_json, headers_json, received_at::text
302
+ `, id, provider, externalEventID, string(payloadBytes), string(headersBytes))
303
+ if err == nil {
304
+ event, err := row.toWebhookEvent()
305
+ return RecordWebhookEventResult{Event: event, Duplicate: false}, err
306
+ }
307
+ if !errors.Is(err, sql.ErrNoRows) {
308
+ return RecordWebhookEventResult{}, err
309
+ }
310
+
311
+ err = s.db.GetContext(ctx, &row, `
312
+ select id, provider, external_event_id, payload_json, headers_json, received_at::text
313
+ from webhook_events
314
+ where provider = $1 and external_event_id = $2
315
+ `, provider, externalEventID)
316
+ if err != nil {
317
+ return RecordWebhookEventResult{}, err
318
+ }
319
+ event, err := row.toWebhookEvent()
320
+ return RecordWebhookEventResult{Event: event, Duplicate: true}, err
321
+ }
322
+
255
323
  type waitlistTriggerRow struct {
256
324
  ID string `db:"id"`
257
325
  Type string `db:"type"`
@@ -262,6 +330,34 @@ type waitlistTriggerRow struct {
262
330
  ProcessedAt string `db:"processed_at"`
263
331
  }
264
332
 
333
+ type webhookEventRow struct {
334
+ ID string `db:"id"`
335
+ Provider string `db:"provider"`
336
+ ExternalEventID string `db:"external_event_id"`
337
+ PayloadJSON string `db:"payload_json"`
338
+ HeadersJSON string `db:"headers_json"`
339
+ ReceivedAt string `db:"received_at"`
340
+ }
341
+
342
+ func (r webhookEventRow) toWebhookEvent() (WebhookEvent, error) {
343
+ var payload map[string]any
344
+ if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
345
+ return WebhookEvent{}, err
346
+ }
347
+ var headers map[string]any
348
+ if err := json.Unmarshal([]byte(r.HeadersJSON), &headers); err != nil {
349
+ return WebhookEvent{}, err
350
+ }
351
+ return WebhookEvent{
352
+ ID: r.ID,
353
+ Provider: r.Provider,
354
+ ExternalEventID: r.ExternalEventID,
355
+ Payload: payload,
356
+ Headers: headers,
357
+ ReceivedAt: r.ReceivedAt,
358
+ }, nil
359
+ }
360
+
265
361
  func (r waitlistTriggerRow) toTrigger() (WaitlistTrigger, error) {
266
362
  var payload any
267
363
  if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {