Skip to content

Commit ddc960c

Browse files
committed
Contact - Form submissions
1 parent 14e3a7b commit ddc960c

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

api/schema/forms_d1.sql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- D1 schema for small church form data
2+
-- Apply with:
3+
-- npx wrangler d1 execute zion_ministry_db --remote --file=./schema/forms_d1.sql
4+
5+
CREATE TABLE IF NOT EXISTS small_group_registrations (
6+
id INTEGER PRIMARY KEY AUTOINCREMENT,
7+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
8+
name TEXT NOT NULL,
9+
area TEXT,
10+
phone TEXT,
11+
message TEXT
12+
);
13+
14+
CREATE INDEX IF NOT EXISTS idx_small_group_created_at
15+
ON small_group_registrations (created_at DESC);
16+
17+
CREATE TABLE IF NOT EXISTS volunteer_signups (
18+
id INTEGER PRIMARY KEY AUTOINCREMENT,
19+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
20+
name TEXT NOT NULL,
21+
email TEXT NOT NULL,
22+
ministry TEXT,
23+
message TEXT
24+
);
25+
26+
CREATE INDEX IF NOT EXISTS idx_volunteer_created_at
27+
ON volunteer_signups (created_at DESC);

api/worker.js

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ function jsonResponse(data, status, corsHeaders) {
6868
});
6969
}
7070

71+
function csvResponse(content, fileName, corsHeaders) {
72+
return new Response(content, {
73+
status: 200,
74+
headers: {
75+
"Content-Type": "text/csv; charset=utf-8",
76+
"Content-Disposition": `attachment; filename=\"${fileName}\"`,
77+
...corsHeaders
78+
}
79+
});
80+
}
81+
7182
function validatePayload(body) {
7283
const name = String(body.name || "").trim();
7384
const email = String(body.email || "").trim();
@@ -89,6 +100,142 @@ function validatePayload(body) {
89100
return { payload: { name, email, subject, message } };
90101
}
91102

103+
function validateSmallGroupPayload(body) {
104+
const name = String(body.name || "").trim();
105+
const area = String(body.area || "").trim();
106+
const phone = String(body.phone || "").trim();
107+
const message = String(body.message || "").trim();
108+
109+
if (!name) return { error: "Name is required." };
110+
if (name.length > 120) return { error: "Name is too long." };
111+
if (area.length > 120) return { error: "Preferred area is too long." };
112+
if (phone.length > 50) return { error: "Phone is too long." };
113+
if (message.length > 3000) return { error: "Comments are too long." };
114+
115+
return { payload: { name, area, phone, message } };
116+
}
117+
118+
function validateVolunteerPayload(body) {
119+
const name = String(body.name || "").trim();
120+
const email = String(body.email || "").trim();
121+
const ministry = String(body.ministry || "").trim();
122+
const message = String(body.message || "").trim();
123+
124+
if (!name || !email) {
125+
return { error: "Name and email are required." };
126+
}
127+
128+
if (!/^\S+@\S+\.\S+$/.test(email)) {
129+
return { error: "Invalid email address." };
130+
}
131+
132+
if (name.length > 120) return { error: "Name is too long." };
133+
if (ministry.length > 120) return { error: "Ministry interest is too long." };
134+
if (message.length > 3000) return { error: "Notes are too long." };
135+
136+
return { payload: { name, email, ministry, message } };
137+
}
138+
139+
function requireDb(env) {
140+
if (!env.DB) {
141+
throw new Error("D1 binding DB is not configured.");
142+
}
143+
return env.DB;
144+
}
145+
146+
async function saveSmallGroupRegistration(env, payload) {
147+
const db = requireDb(env);
148+
await db.prepare(
149+
`INSERT INTO small_group_registrations (name, area, phone, message)
150+
VALUES (?1, ?2, ?3, ?4)`
151+
)
152+
.bind(payload.name, payload.area, payload.phone, payload.message)
153+
.run();
154+
}
155+
156+
async function saveVolunteerSignup(env, payload) {
157+
const db = requireDb(env);
158+
await db.prepare(
159+
`INSERT INTO volunteer_signups (name, email, ministry, message)
160+
VALUES (?1, ?2, ?3, ?4)`
161+
)
162+
.bind(payload.name, payload.email, payload.ministry, payload.message)
163+
.run();
164+
}
165+
166+
function csvEscape(value) {
167+
const raw = String(value == null ? "" : value);
168+
const escaped = raw.replace(/"/g, '""');
169+
return `"${escaped}"`;
170+
}
171+
172+
function makeCsv(header, rows) {
173+
const lines = [header.map(csvEscape).join(",")];
174+
rows.forEach((row) => {
175+
lines.push(row.map(csvEscape).join(","));
176+
});
177+
return lines.join("\n");
178+
}
179+
180+
async function exportRowsAsCsv(env, type) {
181+
const db = requireDb(env);
182+
183+
if (type === "small_group") {
184+
const result = await db.prepare(
185+
`SELECT id, created_at, name, area, phone, message
186+
FROM small_group_registrations
187+
ORDER BY created_at DESC`
188+
).all();
189+
190+
const rows = (result.results || []).map((r) => [
191+
r.id,
192+
r.created_at,
193+
r.name,
194+
r.area,
195+
r.phone,
196+
r.message
197+
]);
198+
199+
return {
200+
fileName: "small-group-registrations.csv",
201+
content: makeCsv(["id", "created_at", "name", "area", "phone", "message"], rows)
202+
};
203+
}
204+
205+
if (type === "volunteer") {
206+
const result = await db.prepare(
207+
`SELECT id, created_at, name, email, ministry, message
208+
FROM volunteer_signups
209+
ORDER BY created_at DESC`
210+
).all();
211+
212+
const rows = (result.results || []).map((r) => [
213+
r.id,
214+
r.created_at,
215+
r.name,
216+
r.email,
217+
r.ministry,
218+
r.message
219+
]);
220+
221+
return {
222+
fileName: "volunteer-signups.csv",
223+
content: makeCsv(["id", "created_at", "name", "email", "ministry", "message"], rows)
224+
};
225+
}
226+
227+
throw new Error("Unsupported export type.");
228+
}
229+
230+
function isExportAuthorized(request, env, url) {
231+
const expected = String(env.EXPORT_TOKEN || "").trim();
232+
if (!expected) return false;
233+
234+
const headerToken = String(request.headers.get("x-admin-token") || "").trim();
235+
const queryToken = String(url.searchParams.get("token") || "").trim();
236+
return headerToken === expected || queryToken === expected;
237+
}
238+
92239
async function sendEmailViaResend(env, payload) {
93240
const apiKey = env.RESEND_API_KEY;
94241
if (!apiKey) throw new Error("RESEND_API_KEY is not configured.");
@@ -157,6 +304,32 @@ export default {
157304
return jsonResponse({ ok: true }, 200, corsHeaders);
158305
}
159306

307+
// GET /api/export?type=small_group|volunteer&format=csv
308+
if (method === "GET" && path === "/api/export") {
309+
if (!isExportAuthorized(request, env, url)) {
310+
return jsonResponse({ ok: false, error: "Unauthorized." }, 401, corsHeaders);
311+
}
312+
313+
const type = String(url.searchParams.get("type") || "").trim();
314+
const format = String(url.searchParams.get("format") || "csv").trim().toLowerCase();
315+
316+
if (format !== "csv") {
317+
return jsonResponse({ ok: false, error: "Only csv format is supported." }, 400, corsHeaders);
318+
}
319+
320+
if (type !== "small_group" && type !== "volunteer") {
321+
return jsonResponse({ ok: false, error: "Invalid export type." }, 400, corsHeaders);
322+
}
323+
324+
try {
325+
const exported = await exportRowsAsCsv(env, type);
326+
return csvResponse(exported.content, exported.fileName, corsHeaders);
327+
} catch (err) {
328+
console.error("CSV export failed:", err.message);
329+
return jsonResponse({ ok: false, error: "Unable to export data." }, 500, corsHeaders);
330+
}
331+
}
332+
160333
// POST /api/contact
161334
if (method === "POST" && path === "/api/contact") {
162335
let body;
@@ -180,6 +353,52 @@ export default {
180353
}
181354
}
182355

356+
// POST /api/small-group
357+
if (method === "POST" && path === "/api/small-group") {
358+
let body;
359+
try {
360+
body = await request.json();
361+
} catch (_) {
362+
return jsonResponse({ ok: false, error: "Invalid JSON body." }, 400, corsHeaders);
363+
}
364+
365+
const { payload, error } = validateSmallGroupPayload(body);
366+
if (error) {
367+
return jsonResponse({ ok: false, error }, 400, corsHeaders);
368+
}
369+
370+
try {
371+
await saveSmallGroupRegistration(env, payload);
372+
return jsonResponse({ ok: true }, 200, corsHeaders);
373+
} catch (err) {
374+
console.error("Small group save failed:", err.message);
375+
return jsonResponse({ ok: false, error: "Unable to save registration." }, 500, corsHeaders);
376+
}
377+
}
378+
379+
// POST /api/volunteer
380+
if (method === "POST" && path === "/api/volunteer") {
381+
let body;
382+
try {
383+
body = await request.json();
384+
} catch (_) {
385+
return jsonResponse({ ok: false, error: "Invalid JSON body." }, 400, corsHeaders);
386+
}
387+
388+
const { payload, error } = validateVolunteerPayload(body);
389+
if (error) {
390+
return jsonResponse({ ok: false, error }, 400, corsHeaders);
391+
}
392+
393+
try {
394+
await saveVolunteerSignup(env, payload);
395+
return jsonResponse({ ok: true }, 200, corsHeaders);
396+
} catch (err) {
397+
console.error("Volunteer save failed:", err.message);
398+
return jsonResponse({ ok: false, error: "Unable to save signup." }, 500, corsHeaders);
399+
}
400+
}
401+
183402
return jsonResponse({ ok: false, error: "Not found." }, 404, corsHeaders);
184403
}
185404
};

api/wrangler.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ compatibility_date = "2025-01-01"
1414
# wrangler secret put CONTACT_TO
1515
# wrangler secret put FROM_EMAIL
1616
# wrangler secret put ALLOWED_ORIGINS
17+
# wrangler secret put EXPORT_TOKEN
1718
#
1819
# Or set them in Cloudflare Dashboard:
1920
# Workers & Pages → zion-contact-api → Settings → Variables and Secrets
2021

2122
[vars]
2223
# Non-secret defaults (can be overridden by dashboard variables)
2324
ALLOWED_ORIGINS = "https://ziongospelministry.org,https://www.ziongospelministry.org"
25+
26+
[[d1_databases]]
27+
binding = "DB"
28+
database_name = "zion_ministry_db"
29+
database_id = "924048ce-eb91-4d8b-8f2b-cf73b24417e8"

0 commit comments

Comments
 (0)