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,166 @@
|
|
|
1
|
+
import { createDb } from "../db/client";
|
|
2
|
+
import { WaitlistRepository } from "../db/repository";
|
|
3
|
+
import type {
|
|
4
|
+
JoinWaitlistInput,
|
|
5
|
+
ListWaitlistEntriesInput,
|
|
6
|
+
RecordTriggerInput,
|
|
7
|
+
UpdateWaitlistEntryInput,
|
|
8
|
+
WaitlistEntry,
|
|
9
|
+
WaitlistEntryStatus,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export class AppError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
readonly status: number,
|
|
15
|
+
readonly code: string,
|
|
16
|
+
message: string
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type WaitlistService = {
|
|
23
|
+
joinWaitlist(input: JoinWaitlistInput): Promise<{ entry: WaitlistEntry; created: boolean }>;
|
|
24
|
+
getWaitlistEntry(entryId: string): Promise<WaitlistEntry>;
|
|
25
|
+
getWaitlistEntryByEmail(email: string): Promise<WaitlistEntry>;
|
|
26
|
+
listWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<WaitlistEntry[]>;
|
|
27
|
+
updateWaitlistEntry(input: UpdateWaitlistEntryInput): Promise<WaitlistEntry>;
|
|
28
|
+
exportWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<string>;
|
|
29
|
+
recordTrigger(input: RecordTriggerInput): Promise<unknown>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class DefaultWaitlistService implements WaitlistService {
|
|
33
|
+
constructor(private readonly repository: WaitlistRepository) {}
|
|
34
|
+
|
|
35
|
+
async joinWaitlist(input: JoinWaitlistInput) {
|
|
36
|
+
const email = normalizeEmail(input.email);
|
|
37
|
+
const existing = await this.repository.getEntryByEmail(email);
|
|
38
|
+
if (existing) {
|
|
39
|
+
return { entry: existing, created: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entry = await this.repository.createEntry({
|
|
43
|
+
id: crypto.randomUUID(),
|
|
44
|
+
email,
|
|
45
|
+
name: normalizeNullableText(input.name),
|
|
46
|
+
company: normalizeNullableText(input.company),
|
|
47
|
+
source: normalizeNullableText(input.source),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await this.repository.createTrigger({
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
type: "waitlist.joined",
|
|
53
|
+
entryId: entry.id,
|
|
54
|
+
payload: { email: entry.email, source: entry.source },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { entry, created: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getWaitlistEntry(entryId: string) {
|
|
61
|
+
const entry = await this.repository.getEntryById(entryId.trim());
|
|
62
|
+
if (!entry) {
|
|
63
|
+
throw new AppError(404, "entry_not_found", `waitlist entry ${entryId} not found`);
|
|
64
|
+
}
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getWaitlistEntryByEmail(email: string) {
|
|
69
|
+
const entry = await this.repository.getEntryByEmail(normalizeEmail(email));
|
|
70
|
+
if (!entry) {
|
|
71
|
+
throw new AppError(404, "entry_not_found", `waitlist entry for ${email} not found`);
|
|
72
|
+
}
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async listWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
|
|
77
|
+
return this.repository.listEntries({
|
|
78
|
+
status: input.status ? normalizeStatus(input.status) : null,
|
|
79
|
+
limit: input.limit,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async updateWaitlistEntry(input: UpdateWaitlistEntryInput) {
|
|
84
|
+
const entryId = input.entryId.trim();
|
|
85
|
+
if (!entryId) {
|
|
86
|
+
throw new AppError(400, "invalid_entry_id", "entry id is required");
|
|
87
|
+
}
|
|
88
|
+
const entry = await this.repository.updateEntryStatus(entryId, normalizeStatus(input.status));
|
|
89
|
+
if (!entry) {
|
|
90
|
+
throw new AppError(404, "entry_not_found", `waitlist entry ${input.entryId} not found`);
|
|
91
|
+
}
|
|
92
|
+
return entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async exportWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
|
|
96
|
+
return entriesToCsv(await this.listWaitlistEntries(input));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async recordTrigger(input: RecordTriggerInput) {
|
|
100
|
+
const type = input.type.trim();
|
|
101
|
+
if (!type) {
|
|
102
|
+
throw new AppError(400, "invalid_trigger_type", "trigger type is required");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (input.entryId) {
|
|
106
|
+
await this.getWaitlistEntry(input.entryId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return this.repository.createTrigger({
|
|
110
|
+
id: crypto.randomUUID(),
|
|
111
|
+
type,
|
|
112
|
+
entryId: input.entryId?.trim() || null,
|
|
113
|
+
payload: input.payload ?? {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createDefaultWaitlistService() {
|
|
119
|
+
return new DefaultWaitlistService(new WaitlistRepository(createDb()));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeEmail(value: string) {
|
|
123
|
+
const email = value.trim().toLowerCase();
|
|
124
|
+
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
125
|
+
throw new AppError(400, "invalid_email", "valid email is required");
|
|
126
|
+
}
|
|
127
|
+
return email;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeNullableText(value: string | null | undefined) {
|
|
131
|
+
const normalized = value?.trim();
|
|
132
|
+
return normalized ? normalized : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeStatus(value: string): WaitlistEntryStatus {
|
|
136
|
+
const status = value.trim().toLowerCase();
|
|
137
|
+
if (status === "joined" || status === "invited" || status === "converted" || status === "archived") {
|
|
138
|
+
return status;
|
|
139
|
+
}
|
|
140
|
+
throw new AppError(400, "invalid_status", "status must be one of joined, invited, converted, archived");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function entriesToCsv(entries: WaitlistEntry[]) {
|
|
144
|
+
const headers = ["id", "email", "name", "company", "source", "status", "created_at", "updated_at"];
|
|
145
|
+
return [
|
|
146
|
+
headers.join(","),
|
|
147
|
+
...entries.map((entry) =>
|
|
148
|
+
[
|
|
149
|
+
entry.id,
|
|
150
|
+
entry.email,
|
|
151
|
+
entry.name ?? "",
|
|
152
|
+
entry.company ?? "",
|
|
153
|
+
entry.source ?? "",
|
|
154
|
+
entry.status,
|
|
155
|
+
entry.createdAt,
|
|
156
|
+
entry.updatedAt,
|
|
157
|
+
]
|
|
158
|
+
.map(csvCell)
|
|
159
|
+
.join(",")
|
|
160
|
+
),
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function csvCell(value: string) {
|
|
165
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type WaitlistEntry = {
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
name: string | null;
|
|
5
|
+
company: string | null;
|
|
6
|
+
source: string | null;
|
|
7
|
+
status: WaitlistEntryStatus;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type WaitlistTrigger = {
|
|
13
|
+
id: string;
|
|
14
|
+
type: string;
|
|
15
|
+
entryId: string | null;
|
|
16
|
+
status: "queued" | "processed" | "failed";
|
|
17
|
+
payload: unknown;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
processedAt: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type JoinWaitlistInput = {
|
|
23
|
+
email: string;
|
|
24
|
+
name?: string | null;
|
|
25
|
+
company?: string | null;
|
|
26
|
+
source?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type JoinWaitlistResult = {
|
|
30
|
+
entry: WaitlistEntry;
|
|
31
|
+
created: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type WaitlistEntryStatus = "joined" | "invited" | "converted" | "archived";
|
|
35
|
+
|
|
36
|
+
export type ListWaitlistEntriesInput = {
|
|
37
|
+
status?: string | null;
|
|
38
|
+
limit?: number | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type UpdateWaitlistEntryInput = {
|
|
42
|
+
entryId: string;
|
|
43
|
+
status: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type RecordTriggerInput = {
|
|
47
|
+
type: string;
|
|
48
|
+
entryId?: string | null;
|
|
49
|
+
payload?: unknown;
|
|
50
|
+
};
|
|
@@ -1,12 +1,97 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { createApp } from "../src/index";
|
|
3
|
+
import type { WaitlistService } from "../src/waitlist/service";
|
|
4
|
+
import type { WaitlistEntry } from "../src/waitlist/types";
|
|
3
5
|
|
|
4
6
|
test("health endpoint returns ok", async () => {
|
|
5
|
-
const response = await createApp().request("/healthz");
|
|
7
|
+
const response = await createApp(createMockService()).request("/healthz");
|
|
6
8
|
expect(response.status).toBe(200);
|
|
7
|
-
expect(await response.json()).toEqual({
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
expect(await response.json()).toEqual({ status: "ok" });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("webhook health endpoint returns ok", async () => {
|
|
13
|
+
const response = await createApp(createMockService()).request("/webhooks/generic/health");
|
|
14
|
+
expect(response.status).toBe(200);
|
|
15
|
+
expect(await response.json()).toEqual({ status: "ok", provider: "generic" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("waitlist join returns created entry", async () => {
|
|
19
|
+
const response = await createApp(createMockService()).request("/v1/waitlist", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ email: "founder@example.com", source: "test" }),
|
|
11
23
|
});
|
|
24
|
+
expect(response.status).toBe(201);
|
|
25
|
+
expect(await response.json()).toMatchObject({
|
|
26
|
+
created: true,
|
|
27
|
+
entry: {
|
|
28
|
+
email: "founder@example.com",
|
|
29
|
+
status: "joined",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("waitlist api requires a bearer token when service auth is enabled", async () => {
|
|
35
|
+
const previous = Bun.env.AUTH_ENABLED;
|
|
36
|
+
Bun.env.AUTH_ENABLED = "true";
|
|
37
|
+
try {
|
|
38
|
+
const response = await createApp(createMockService()).request("/v1/waitlist", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify({ email: "founder@example.com" }),
|
|
42
|
+
});
|
|
43
|
+
expect(response.status).toBe(401);
|
|
44
|
+
expect(await response.json()).toEqual({ error: "missing bearer token", code: "unauthorized" });
|
|
45
|
+
} finally {
|
|
46
|
+
if (previous === undefined) {
|
|
47
|
+
delete Bun.env.AUTH_ENABLED;
|
|
48
|
+
} else {
|
|
49
|
+
Bun.env.AUTH_ENABLED = previous;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
12
52
|
});
|
|
53
|
+
|
|
54
|
+
function createMockService(): WaitlistService {
|
|
55
|
+
const entry: WaitlistEntry = {
|
|
56
|
+
id: "entry_1",
|
|
57
|
+
email: "founder@example.com",
|
|
58
|
+
name: null,
|
|
59
|
+
company: null,
|
|
60
|
+
source: "test",
|
|
61
|
+
status: "joined",
|
|
62
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
63
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async joinWaitlist() {
|
|
68
|
+
return { entry, created: true };
|
|
69
|
+
},
|
|
70
|
+
async getWaitlistEntry() {
|
|
71
|
+
return entry;
|
|
72
|
+
},
|
|
73
|
+
async getWaitlistEntryByEmail() {
|
|
74
|
+
return entry;
|
|
75
|
+
},
|
|
76
|
+
async listWaitlistEntries() {
|
|
77
|
+
return [entry];
|
|
78
|
+
},
|
|
79
|
+
async updateWaitlistEntry() {
|
|
80
|
+
return { ...entry, status: "invited" };
|
|
81
|
+
},
|
|
82
|
+
async exportWaitlistEntries() {
|
|
83
|
+
return "id,email,name,company,source,status,created_at,updated_at\nentry_1,founder@example.com,,,test,joined,2026-01-01T00:00:00.000Z,2026-01-01T00:00:00.000Z";
|
|
84
|
+
},
|
|
85
|
+
async recordTrigger() {
|
|
86
|
+
return {
|
|
87
|
+
id: "trigger_1",
|
|
88
|
+
type: "manual",
|
|
89
|
+
entryId: null,
|
|
90
|
+
status: "queued",
|
|
91
|
+
payload: {},
|
|
92
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
93
|
+
processedAt: null,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { createApp } from "../src/index";
|
|
4
|
+
import { DefaultWaitlistService } from "../src/waitlist/service";
|
|
5
|
+
import { WaitlistRepository } from "../src/db/repository";
|
|
6
|
+
import { createDb } from "../src/db/client";
|
|
7
|
+
|
|
8
|
+
const databaseUrl = Bun.env.DATABASE_URL?.trim();
|
|
9
|
+
const integrationTest = databaseUrl ? test : test.skip;
|
|
10
|
+
|
|
11
|
+
let sql: SQL | null = null;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
if (!databaseUrl) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
sql = new SQL(databaseUrl);
|
|
18
|
+
await sql.unsafe(`
|
|
19
|
+
truncate table
|
|
20
|
+
waitlist_triggers,
|
|
21
|
+
waitlist_entries
|
|
22
|
+
restart identity cascade
|
|
23
|
+
`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await sql?.end();
|
|
28
|
+
sql = null;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
integrationTest("waitlist join is idempotent and records triggers", async () => {
|
|
32
|
+
const app = createApp(new DefaultWaitlistService(new WaitlistRepository(createDb(databaseUrl))));
|
|
33
|
+
|
|
34
|
+
const first = await requestJson(app, "/v1/waitlist", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: {
|
|
37
|
+
email: "Founder@Example.com",
|
|
38
|
+
name: "Founder",
|
|
39
|
+
company: "Example Co",
|
|
40
|
+
source: "homepage",
|
|
41
|
+
},
|
|
42
|
+
expectedStatus: 201,
|
|
43
|
+
});
|
|
44
|
+
expect(first.entry.email).toBe("founder@example.com");
|
|
45
|
+
expect(first.created).toBe(true);
|
|
46
|
+
|
|
47
|
+
const second = await requestJson(app, "/v1/waitlist", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: {
|
|
50
|
+
email: "founder@example.com",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
expect(second.entry.id).toBe(first.entry.id);
|
|
54
|
+
expect(second.created).toBe(false);
|
|
55
|
+
|
|
56
|
+
const trigger = await requestJson(app, "/v1/triggers/waitlist", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
body: {
|
|
59
|
+
type: "cron.digest",
|
|
60
|
+
entry_id: first.entry.id,
|
|
61
|
+
},
|
|
62
|
+
expectedStatus: 202,
|
|
63
|
+
});
|
|
64
|
+
expect(trigger.trigger).toMatchObject({
|
|
65
|
+
type: "cron.digest",
|
|
66
|
+
entryId: first.entry.id,
|
|
67
|
+
status: "queued",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const updated = await requestJson(app, `/v1/admin/waitlist/${first.entry.id}`, {
|
|
71
|
+
method: "PATCH",
|
|
72
|
+
body: { status: "invited" },
|
|
73
|
+
});
|
|
74
|
+
expect(updated.entry).toMatchObject({ id: first.entry.id, status: "invited" });
|
|
75
|
+
|
|
76
|
+
const list = await requestJson(app, "/v1/admin/waitlist?status=invited");
|
|
77
|
+
expect(list.entries).toHaveLength(1);
|
|
78
|
+
expect(list.entries[0]).toMatchObject({ id: first.entry.id, status: "invited" });
|
|
79
|
+
|
|
80
|
+
const exportResponse = await app.request("/v1/admin/waitlist/export?status=invited");
|
|
81
|
+
expect(exportResponse.status).toBe(200);
|
|
82
|
+
expect(exportResponse.headers.get("content-type")).toContain("text/csv");
|
|
83
|
+
expect(await exportResponse.text()).toContain("founder@example.com");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
async function requestJson(
|
|
87
|
+
app: ReturnType<typeof createApp>,
|
|
88
|
+
path: string,
|
|
89
|
+
input: {
|
|
90
|
+
method?: string;
|
|
91
|
+
body?: unknown;
|
|
92
|
+
expectedStatus?: number;
|
|
93
|
+
} = {}
|
|
94
|
+
) {
|
|
95
|
+
const response = await app.request(path, {
|
|
96
|
+
method: input.method ?? "GET",
|
|
97
|
+
headers: input.body ? { "Content-Type": "application/json" } : undefined,
|
|
98
|
+
body: input.body ? JSON.stringify(input.body) : undefined,
|
|
99
|
+
});
|
|
100
|
+
expect(response.status).toBe(input.expectedStatus ?? 200);
|
|
101
|
+
return response.json();
|
|
102
|
+
}
|
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
.PHONY: dev gen lint test
|
|
1
|
+
.PHONY: dev migrate migrate-lint gen lint test create deploy dashboards auth destroy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SERVICE := npx --no-install service
|
|
4
|
+
WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
|
|
5
|
+
ATLAS ?= atlas
|
|
4
6
|
|
|
5
7
|
dev:
|
|
6
|
-
go run ./cmd/server
|
|
8
|
+
@bun run ./scripts/dev.ts go run ./cmd/server
|
|
9
|
+
|
|
10
|
+
migrate:
|
|
11
|
+
@bun run ./scripts/ensure-local-db.ts
|
|
12
|
+
@bun run ./scripts/wait-for-db.ts
|
|
13
|
+
@$(WITH_ENV) $(ATLAS) migrate apply --env local
|
|
14
|
+
|
|
15
|
+
migrate-lint:
|
|
16
|
+
@bun run ./scripts/ensure-local-db.ts
|
|
17
|
+
@bun run ./scripts/wait-for-db.ts
|
|
18
|
+
@$(WITH_ENV) $(ATLAS) migrate lint --env local --latest 1
|
|
7
19
|
|
|
8
20
|
gen:
|
|
9
|
-
|
|
21
|
+
@echo "no generated code for go + chi"
|
|
10
22
|
|
|
11
23
|
lint:
|
|
12
24
|
go vet ./...
|
|
13
|
-
|
|
25
|
+
@bun run ./scripts/ensure-local-db.ts
|
|
26
|
+
@bun run ./scripts/wait-for-db.ts
|
|
27
|
+
@$(WITH_ENV) $(ATLAS) migrate lint --env local --latest 1
|
|
14
28
|
|
|
15
29
|
test:
|
|
16
30
|
bun test ./test
|
|
17
31
|
|
|
18
|
-
|
|
19
|
-
$(
|
|
32
|
+
create:
|
|
33
|
+
$(SERVICE) create
|
|
20
34
|
|
|
21
35
|
deploy:
|
|
22
|
-
$(
|
|
36
|
+
$(SERVICE) deploy $(ARGS)
|
|
37
|
+
|
|
38
|
+
dashboards:
|
|
39
|
+
$(SERVICE) dashboards
|
|
40
|
+
|
|
41
|
+
auth:
|
|
42
|
+
$(SERVICE) auth $(ARGS)
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
$(
|
|
44
|
+
destroy:
|
|
45
|
+
$(SERVICE) destroy $(ARGS)
|
|
@@ -7,13 +7,15 @@ import (
|
|
|
7
7
|
"time"
|
|
8
8
|
|
|
9
9
|
"github.com/go-chi/chi/v5"
|
|
10
|
+
_ "github.com/jackc/pgx/v5/stdlib"
|
|
10
11
|
"golang.org/x/net/http2"
|
|
11
12
|
"golang.org/x/net/http2/h2c"
|
|
12
13
|
|
|
13
14
|
"{{MODULE_PATH}}/internal/app"
|
|
15
|
+
"{{MODULE_PATH}}/internal/auth"
|
|
14
16
|
"{{MODULE_PATH}}/internal/config"
|
|
15
|
-
"{{MODULE_PATH}}/internal/connectapi"
|
|
16
17
|
"{{MODULE_PATH}}/internal/httpapi"
|
|
18
|
+
temporalapp "{{MODULE_PATH}}/internal/temporal"
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
func main() {
|
|
@@ -22,25 +24,35 @@ func main() {
|
|
|
22
24
|
log.Fatal(err)
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
if
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
db, err := app.OpenDatabase(context.Background(), cfg.DatabaseURL)
|
|
28
|
+
if err != nil {
|
|
29
|
+
log.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
service := app.NewWaitlistService(db)
|
|
33
|
+
if cfg.TemporalEnabled {
|
|
34
|
+
stopTemporal, err := temporalapp.StartWorker(temporalapp.WorkerConfig{
|
|
35
|
+
Address: cfg.TemporalAddress,
|
|
36
|
+
Namespace: cfg.TemporalNamespace,
|
|
37
|
+
TaskQueue: cfg.TemporalTaskQueue,
|
|
38
|
+
APIKey: cfg.TemporalAPIKey,
|
|
39
|
+
})
|
|
40
|
+
if err != nil {
|
|
34
41
|
log.Fatal(err)
|
|
35
42
|
}
|
|
43
|
+
defer stopTemporal()
|
|
44
|
+
log.Printf("Temporal worker polling %s", cfg.TemporalTaskQueue)
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
router := chi.NewRouter()
|
|
48
|
+
router.Use(auth.Middleware(auth.Config{
|
|
49
|
+
Enabled: cfg.AuthEnabled,
|
|
50
|
+
Issuer: cfg.AuthIssuer,
|
|
51
|
+
Audience: cfg.AuthAudience,
|
|
52
|
+
JWKSURL: cfg.AuthJWKSURL,
|
|
53
|
+
}))
|
|
39
54
|
httpapi.RegisterRoutes(router, service)
|
|
40
55
|
|
|
41
|
-
connectPath, connectHandler := connectapi.NewHandler(service)
|
|
42
|
-
router.Mount(connectPath, connectHandler)
|
|
43
|
-
|
|
44
56
|
server := &http.Server{
|
|
45
57
|
Addr: ":" + cfg.Port,
|
|
46
58
|
ReadHeaderTimeout: 10 * time.Second,
|
|
@@ -3,8 +3,9 @@ module {{MODULE_PATH}}
|
|
|
3
3
|
go 1.25.4
|
|
4
4
|
|
|
5
5
|
require (
|
|
6
|
-
connectrpc.com/connect v1.19.1
|
|
7
6
|
github.com/go-chi/chi/v5 v5.2.2
|
|
7
|
+
github.com/jackc/pgx/v5 v5.7.5
|
|
8
|
+
github.com/jmoiron/sqlx v1.4.0
|
|
9
|
+
go.temporal.io/sdk v1.43.0
|
|
8
10
|
golang.org/x/net v0.42.0
|
|
9
|
-
google.golang.org/protobuf v1.36.10
|
|
10
11
|
)
|