create-svc 0.1.53 → 0.1.55
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 +6 -0
- package/package.json +1 -1
- package/src/git-bootstrap.test.ts +49 -1
- package/src/git-bootstrap.ts +13 -1
- package/src/scaffold.test.ts +38 -8
- package/src/scaffold.ts +28 -1
- package/src/service-runtime/cloudrun/cli.ts +10 -0
- package/src/service-runtime/cloudrun/config.ts +3 -0
- package/src/service-runtime/cloudrun/lib.ts +0 -3
- package/src/service-runtime/cloudrun/observability.ts +18 -0
- package/templates/shared/.env.example +40 -0
- package/templates/shared/.github/workflows/deploy.yml +2 -1
- package/templates/shared/.github/workflows/preview.yml +5 -3
- package/templates/shared/README.md +37 -0
- package/templates/shared/_gitignore +13 -0
- package/templates/shared/service.jsonc +8 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +10 -0
- package/templates/variants/bun-connectrpc/package.json +1 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +52 -3
- package/templates/variants/bun-connectrpc/src/db/schema.ts +13 -0
- package/templates/variants/bun-connectrpc/src/index.ts +47 -4
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +22 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +16 -0
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +10 -0
- package/templates/variants/bun-hono/package.json +1 -0
- package/templates/variants/bun-hono/src/db/repository.ts +52 -3
- package/templates/variants/bun-hono/src/db/schema.ts +13 -0
- package/templates/variants/bun-hono/src/index.ts +34 -8
- package/templates/variants/bun-hono/src/waitlist/service.ts +22 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +16 -0
- package/templates/variants/bun-hono/test/app.test.ts +13 -0
- package/templates/variants/go-chi/Dockerfile +0 -1
- package/templates/variants/go-chi/Makefile +4 -1
- package/templates/variants/go-chi/internal/app/service.go +96 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +56 -7
- package/templates/variants/go-chi/migrations/0000_init.sql +10 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -2
- package/templates/variants/go-connectrpc/Makefile +4 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +96 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +56 -7
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +10 -0
- 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
|
payloadJson: string;
|
|
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
|
+
);
|
|
@@ -96,11 +96,21 @@ export function createHandler(service: WaitlistService) {
|
|
|
96
96
|
try {
|
|
97
97
|
const provider = path.split("/").filter(Boolean)[1] ?? "generic";
|
|
98
98
|
const rawBody = await readRawBody(request);
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
const headers = headersPayload(request.headers);
|
|
100
|
+
const payload = parseWebhookPayload(rawBody);
|
|
101
|
+
const result = await service.recordWebhookEvent({
|
|
102
|
+
provider,
|
|
103
|
+
externalEventId: webhookEventId(payload, request.headers),
|
|
104
|
+
payload,
|
|
105
|
+
headers,
|
|
102
106
|
});
|
|
103
|
-
|
|
107
|
+
if (!result.duplicate) {
|
|
108
|
+
await service.recordTrigger({
|
|
109
|
+
type: `webhook.${provider}`,
|
|
110
|
+
payloadJson: JSON.stringify({ headers, rawBody }),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
respondJson(response, result.duplicate ? 200 : 202, result);
|
|
104
114
|
} catch (error) {
|
|
105
115
|
respondAppError(response, error);
|
|
106
116
|
}
|
|
@@ -192,6 +202,39 @@ function readRawBody(request: Parameters<FallbackHandler>[0]) {
|
|
|
192
202
|
});
|
|
193
203
|
}
|
|
194
204
|
|
|
205
|
+
function parseWebhookPayload(rawBody: string) {
|
|
206
|
+
try {
|
|
207
|
+
return rawBody ? JSON.parse(rawBody) : {};
|
|
208
|
+
} catch {
|
|
209
|
+
return { rawBody };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function webhookEventId(payload: unknown, headers: Parameters<FallbackHandler>[0]["headers"]) {
|
|
214
|
+
if (payload && typeof payload === "object" && "id" in payload && typeof payload.id === "string") {
|
|
215
|
+
return payload.id;
|
|
216
|
+
}
|
|
217
|
+
const header = headers["x-webhook-event-id"];
|
|
218
|
+
if (Array.isArray(header)) {
|
|
219
|
+
return header[0] ?? crypto.randomUUID();
|
|
220
|
+
}
|
|
221
|
+
return header ?? crypto.randomUUID();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function headersPayload(headers: Parameters<FallbackHandler>[0]["headers"]) {
|
|
225
|
+
const out: Record<string, string> = {};
|
|
226
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
out[key] = value.join(", ");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (typeof value === "string") {
|
|
232
|
+
out[key] = value;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
195
238
|
if (import.meta.main) {
|
|
196
239
|
const temporalWorker = await startTemporalWorker();
|
|
197
240
|
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
|
payloadJson: normalizePayloadJson(input.payloadJson),
|
|
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;
|
|
@@ -43,3 +52,10 @@ export type RecordTriggerInput = {
|
|
|
43
52
|
entryId?: string | null;
|
|
44
53
|
payloadJson?: string | null;
|
|
45
54
|
};
|
|
55
|
+
|
|
56
|
+
export type RecordWebhookEventInput = {
|
|
57
|
+
provider: string;
|
|
58
|
+
externalEventId: string;
|
|
59
|
+
payload: unknown;
|
|
60
|
+
headers: Record<string, string>;
|
|
61
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate gen lint test create deploy dashboards auth destroy
|
|
1
|
+
.PHONY: dev migrate gen lint test create deploy dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
|
|
@@ -26,6 +26,9 @@ deploy:
|
|
|
26
26
|
dashboards:
|
|
27
27
|
$(SERVICE) dashboards
|
|
28
28
|
|
|
29
|
+
observability-bootstrap:
|
|
30
|
+
$(SERVICE) observability-bootstrap
|
|
31
|
+
|
|
29
32
|
auth:
|
|
30
33
|
$(SERVICE) auth $(ARGS)
|
|
31
34
|
|
|
@@ -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,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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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 {
|