Skip to content

Commit b534907

Browse files
Persist waitlist data securely with Deno Kv (#31)
* Vibe code wip with Cursor Added user feedback for reCAPTCHA verification failures in the waitlist form. Updated waitlist seeding to accept an array of emails instead of full entry objects, and adjusted the WaitlistEntry type to make 'created_at' optional. Minor cleanup in deno.json. * Refactor database seeding and CLI Renamed and refactored the database seeding script to 'database/cli.ts', simplifying its options and usage. Updated 'seed-data.json' to use a list of emails instead of full objects. Adjusted codegen to copy only specific source files. Improved type safety in 'addToWaitlist'. Updated Deno task and dependency versions in 'deno.json'.
1 parent f78b8a1 commit b534907

8 files changed

Lines changed: 197 additions & 41 deletions

File tree

.github/workflows/deploy.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ jobs:
3232
project: "fartlabs-dot-org"
3333
entrypoint: "main.ts"
3434
root: "generated"
35+
flags: "--unstable-kv"

codegen/codegen.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { copy, exists, expandGlob } from "@std/fs";
1+
import { copy, exists } from "@std/fs";
22
import { generateHTML } from "./html.tsx";
33
import { generateFeed } from "./feed.ts";
44

@@ -23,10 +23,24 @@ async function removeGeneratedFiles(directory: string) {
2323

2424
async function copyFiles(directory: string) {
2525
await Deno.mkdir(directory, { recursive: true });
26+
2627
await copy("deno.json", `${directory}/deno.json`, { overwrite: true });
2728
await copy("static", directory, { overwrite: true });
2829

29-
for await (const file of expandGlob("*.ts")) {
30-
await copy(file.path, `${directory}/${file.name}`, { overwrite: true });
30+
const sourceFiles = ["main.ts", "database/kv.ts"];
31+
for await (const sourceFile of sourceFiles) {
32+
try {
33+
// Create the destination directory structure if it doesn't exist.
34+
const destPath = `${directory}/${sourceFile}`;
35+
const destDir = destPath.substring(0, destPath.lastIndexOf("/"));
36+
if (destDir !== directory) {
37+
await Deno.mkdir(destDir, { recursive: true });
38+
}
39+
40+
await Deno.copyFile(sourceFile, destPath);
41+
} catch (error) {
42+
console.error(`Failed to copy ${sourceFile}:`, error);
43+
throw error;
44+
}
3145
}
3246
}

components/landing_page/waitlist_section.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ const script = `async function submitWaitlistForm(event) {
4242
body: JSON.stringify({ email, token }),
4343
});
4444
if (!response.ok) {
45-
// TODO: Handle error email already registered.
46-
// https://github.com/FartLabs/cpu.fartlabs.org/blob/6d1fbcc48efa186db592afb8b207b0ebc06132b4/components/sign-up-form.tsx#L58
4745
const { error } = await response.json();
46+
4847
if (error === "Email already registered") {
4948
formContainer.innerHTML = \`${(
5049
<WaitlistForm
@@ -53,6 +52,15 @@ const script = `async function submitWaitlistForm(event) {
5352
)}\`;
5453
return;
5554
}
55+
56+
if (error === "reCAPTCHA score too low - possible bot activity") {
57+
formContainer.innerHTML = \`${(
58+
<WaitlistForm
59+
message={<WaitlistFormMessage state="recaptcha-error" />}
60+
/>
61+
)}\`;
62+
return;
63+
}
5664
5765
formContainer.innerHTML = \`${(
5866
<WaitlistForm
@@ -130,7 +138,9 @@ function WaitlistFormButton(props: { state?: "loading" }) {
130138
}
131139

132140
function WaitlistFormMessage(
133-
props: { state: "already-registered" | "error" | "success" },
141+
props: {
142+
state: "already-registered" | "error" | "success" | "recaptcha-error";
143+
},
134144
) {
135145
switch (props?.state) {
136146
case "already-registered": {
@@ -152,6 +162,17 @@ function WaitlistFormMessage(
152162
);
153163
}
154164

165+
case "recaptcha-error": {
166+
return (
167+
<DIV class="waitlist-error" slot="message">
168+
<P style="color: red">
169+
reCAPTCHA verification failed. Please refresh the page and try
170+
again.
171+
</P>
172+
</DIV>
173+
);
174+
}
175+
155176
case "success": {
156177
// TODO: Add confetti!
157178
return (

database/cli.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { parseArgs } from "@std/cli/parse-args";
2+
import { Spinner } from "@std/cli/unstable-spinner";
3+
import { seedWaitlist } from "./kv.ts";
4+
5+
async function main() {
6+
const args = parseArgs(Deno.args, {
7+
string: ["file"],
8+
alias: {
9+
f: "file",
10+
},
11+
});
12+
13+
console.log("🌱 FartLabs Database CLI");
14+
console.log("============================\n");
15+
16+
try {
17+
const spinner = new Spinner({
18+
message: "Opening Deno KV database...",
19+
color: "yellow",
20+
});
21+
spinner.start();
22+
23+
const kv = await Deno.openKv(Deno.env.get("DENO_KV_PATH")!);
24+
25+
spinner.stop();
26+
console.log("✅ Database connection established\n");
27+
await seedDatabase(kv, args.file!);
28+
29+
// Close the database connection
30+
await kv.close();
31+
console.log("\n✅ Database operations completed successfully");
32+
} catch (error) {
33+
console.error(
34+
"\n❌ Error:",
35+
error instanceof Error ? error.message : String(error),
36+
);
37+
Deno.exit(1);
38+
}
39+
}
40+
41+
async function seedDatabase(kv: Deno.Kv, filePath: string) {
42+
try {
43+
await Deno.stat(filePath);
44+
} catch {
45+
throw new Error(`Seed file not found: ${filePath}`);
46+
}
47+
48+
const spinner = new Spinner({
49+
message: "Reading seed data...",
50+
color: "cyan",
51+
});
52+
spinner.start();
53+
54+
const fileContent = await Deno.readTextFile(filePath);
55+
const seedData: string[] = JSON.parse(fileContent);
56+
57+
spinner.stop();
58+
console.log(`📖 Loaded ${seedData.length} entries from ${filePath}\n`);
59+
60+
// Seed the database
61+
spinner.message = "Seeding database...";
62+
spinner.start();
63+
64+
await seedWaitlist(kv, seedData);
65+
66+
spinner.stop();
67+
console.log(`🌱 Successfully seeded ${seedData.length} entries`);
68+
69+
// Verify the seeding
70+
spinner.message = "Verifying seed data...";
71+
spinner.start();
72+
73+
spinner.stop();
74+
console.log(`✅ Verification complete`);
75+
}
76+
77+
// Show help if help requested
78+
if (Deno.args.includes("--help") || Deno.args.includes("-h")) {
79+
console.log(`
80+
Usage: deno run --allow-read --allow-env --unstable-kv database/cli.ts [options]
81+
82+
Options:
83+
-f, --file <path> Seed data file (default: database/seed-data.json)
84+
-h, --help Show this help message
85+
86+
Examples:
87+
deno run --allow-read --allow-env --unstable-kv database/cli.ts
88+
deno run --allow-read --allow-env --unstable-kv database/cli.ts --file custom-data.json
89+
`);
90+
Deno.exit(0);
91+
}
92+
93+
// Run the main function
94+
if (import.meta.main) {
95+
main();
96+
}

database/kv.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export interface WaitlistEntry {
2+
email: string;
3+
created_at?: string;
4+
}
5+
6+
export async function checkEmailExists(
7+
kv: Deno.Kv,
8+
email: string,
9+
): Promise<boolean> {
10+
const entry = await kv.get(["waitlist", email]);
11+
return entry.value !== null;
12+
}
13+
14+
export async function addToWaitlist(kv: Deno.Kv, email: string): Promise<void> {
15+
await kv.set(
16+
["waitlist", email],
17+
{
18+
email,
19+
created_at: new Date().toISOString(),
20+
} satisfies WaitlistEntry,
21+
);
22+
}
23+
24+
export async function seedWaitlist(
25+
kv: Deno.Kv,
26+
emails: string[],
27+
): Promise<void> {
28+
const batchOp = kv.atomic();
29+
for (const email of emails) {
30+
const entry: WaitlistEntry = {
31+
email,
32+
created_at: new Date().toISOString(),
33+
};
34+
batchOp.set(["waitlist", email], entry);
35+
}
36+
37+
await batchOp.commit();
38+
}

database/seed-data.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
"alice@example.com",
3+
"bob@example.com",
4+
"carol@example.com",
5+
"dave@example.com",
6+
"eve@example.com"
7+
]

deno.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@
88
"#/": "./",
99
"@fartlabs/css": "jsr:@fartlabs/css@^0.0.9",
1010
"@fartlabs/htx": "jsr:@fartlabs/htx@^0.0.10",
11-
"@libsql/client": "npm:@libsql/client@^0.15.12",
12-
"@libsql/linux-x64-gnu": "npm:@libsql/linux-x64-gnu@^0.5.17",
11+
"@std/cli": "jsr:@std/cli@^1.0.21",
1312
"@std/front-matter": "jsr:@std/front-matter@^1.0.9",
1413
"@std/fs": "jsr:@std/fs@^1.0.19",
1514
"@std/http": "jsr:@std/http@^1.0.20",
1615
"@std/path": "jsr:@std/path@^1.1.2",
17-
"@types/react": "npm:@types/react@^19.1.10",
16+
"@types/react": "npm:@types/react@^19.1.11",
1817
"feed": "npm:feed@^5.1.0",
1918
"highlight.js": "npm:highlight.js@^11.11.1",
2019
"markdown-it": "npm:markdown-it@^14.1.0",
@@ -27,10 +26,14 @@
2726
"generate": "deno run -A codegen/codegen.ts",
2827
"start": {
2928
"description": "Start the server",
30-
"command": "deno serve --allow-net --allow-read --allow-ffi --allow-env --env main.ts generated",
29+
"command": "deno serve --allow-net --allow-read --allow-ffi --allow-env --unstable-kv --env main.ts generated",
3130
"dependencies": ["generate"]
3231
},
33-
"outdated": "deno outdated --update --latest"
32+
"outdated": "deno outdated --update --latest",
33+
"seed": {
34+
"description": "Seed the database",
35+
"command": "deno run --env --allow-read --allow-env --unstable-kv --allow-net database/cli.ts"
36+
}
3437
},
3538
"fmt": {
3639
"exclude": ["generated"]

main.ts

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { createClient } from "@libsql/client/node";
21
import { serveDir } from "@std/http/file-server";
32
import { route } from "@std/http/unstable-route";
3+
import { addToWaitlist, checkEmailExists } from "./database/kv.ts";
44

55
export default {
66
fetch: route(
@@ -26,37 +26,13 @@ export default {
2626
throw new Error("Failed reCAPTCHA verification");
2727
}
2828

29-
const url = Deno.env.get("TURSO_DATABASE_URL");
30-
if (!url) {
31-
throw new Error("TURSO_DATABASE_URL is not set");
29+
const kv = await Deno.openKv(Deno.env.get("DENO_KV_PATH")!);
30+
const emailExists = await checkEmailExists(kv, body.email);
31+
if (emailExists) {
32+
throw new Error("Email already registered");
3233
}
3334

34-
const authToken = Deno.env.get("TURSO_AUTH_TOKEN");
35-
if (!authToken) {
36-
throw new Error("TURSO_AUTH_TOKEN is not set");
37-
}
38-
39-
const client = createClient({ url, authToken });
40-
await client
41-
.batch([
42-
"CREATE TABLE IF NOT EXISTS waitlist (email TEXT PRIMARY KEY, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)",
43-
{
44-
sql: "SELECT email FROM waitlist WHERE email = ?",
45-
args: [body.email],
46-
},
47-
], "read")
48-
.then((result) => {
49-
if (result[1].rows.length > 0) {
50-
throw new Error("Email already registered");
51-
}
52-
});
53-
54-
await client.batch([
55-
{
56-
sql: "INSERT INTO waitlist (email) VALUES (?)",
57-
args: [body.email],
58-
},
59-
], "write");
35+
await addToWaitlist(kv, body.email);
6036

6137
return new Response(null, { status: 200 });
6238
} catch (error) {

0 commit comments

Comments
 (0)