CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Full architecture upgrade: Vercel Serverless + Engineering Quality Rules #3721

Merged⚡ AI-generatedXLccantynz wants to mergeclaude/identify-engineering-gaps-7DFa1mainopened Mar 24, 20260/7 tasks
36 changed files+3374−41
ModifiedCLAUDE.md+94−11View fileUnifiedSplit
1212- **Hours:** 24/7 including public holidays
1313- **Currency:** NZD
1414
15## Architecture (DO NOT CHANGE WITHOUT OWNER APPROVAL)
15## Architecture (OWNER-APPROVED — March 2026)
1616
17- **Frontend:** React app deployed on Vercel
18- **Backend:** FastAPI (Python) deployed on Render
19- **Database:** Neon (PostgreSQL) via asyncpg — DO NOT use MongoDB
20- **Payments:** Stripe
21- **SMS:** Twilio
17**Single-platform deployment on Vercel. No separate backend service.**
18
19- **Platform:** Vercel (frontend + serverless API routes — ONE deployment)
20- **Frontend:** React app (Create React App + Craco + Tailwind)
21- **API:** Vercel Serverless Functions (Node.js) in `/api/` directory
22- **Database:** Neon (PostgreSQL) via `@neondatabase/serverless` — DO NOT use MongoDB
23- **Payments:** Stripe (Node.js SDK)
24- **SMS:** Twilio (Node.js SDK)
2225- **Email:** Mailgun HTTP API — DO NOT use Gmail API or raw SMTP
2326- **Analytics:** PostHog
2427- **Geocoding / Autocomplete:** Google Maps API (`@react-google-maps/api`) — API key set in Vercel as `REACT_APP_GOOGLE_MAPS_API_KEY`. Falls back to plain text inputs when key is missing.
28- **Scheduled Jobs:** Vercel Cron Jobs (vercel.json) — replaces APScheduler
29
30### Why Single-Platform (No Separate Backend)
31- **Zero cold starts** — Vercel serverless functions are always warm
32- **One system to monitor** — no Render, no Docker, no second deploy pipeline
33- **Faster deployments** — push to main, everything deploys together
34- **Reduced errors** — no CORS cross-origin issues, no backend/frontend version mismatches
35- **Lower cost** — one platform instead of two
36
37### What Was Removed
38- FastAPI (Python) backend on Render — **RETIRED**
39- Docker/Dockerfile — **RETIRED**
40- render.yaml — **RETIRED**
41- APScheduler — replaced by Vercel Cron Jobs
42- asyncpg — replaced by `@neondatabase/serverless` (designed for serverless)
43
44## The 10 Rules (MANDATORY — Every AI Session)
45
46> **These are the foundation of how this system is built and maintained. Non-negotiable.**
47
48| # | Rule | What It Means |
49|---|------|---------------|
50| 1 | **Scan Before You Build** | Every session starts by checking for security issues, broken code, and problems — BEFORE doing anything new. |
51| 2 | **Auto-Repair** | If you find a bug while working on anything, fix it immediately. No "that's out of scope" excuses. |
52| 3 | **Proactive Research** | Before building anything significant, check if there's a better library, technique, or approach. No guessing. |
53| 4 | **Engineering Gap Detection** | Systematically check for features that promise something the code can't deliver, missing error handling, security gaps. |
54| 5 | **Technology Currency** | Check if our tools are up to date. If there's a newer, faster, safer version — upgrade (within approved stack). |
55| 6 | **Explain Like You're Not A Developer** | All communication in plain English. "Your users were seeing X, now they see Y" — not tech jargon. |
56| 7 | **Never Leave It Worse** | Every file you touch gets cleaned up. No leaving messes behind. |
57| 8 | **Autonomous Testing** | After changes, verify everything still works. No "it should be fine". |
58| 9 | **Mandatory Documentation** | Every change gets documented so the next session knows what happened. |
59| 10 | **No Guessing** | If you don't know, research it. If you can't find the answer, ask the owner. Never assume. |
60
61## Engineering Quality Rules (MANDATORY)
62
63> **These rules enforce The 10 Rules at the code level.**
64
65### Proactive Bug Detection (Rules 1, 2, 4)
661. **Before writing new code, scan for existing bugs** in files you're touching. Fix them.
672. **Every function must have error handling.** No bare `try/catch` that silently swallows errors.
683. **All user input must be validated and sanitized** before use in database queries, emails, or SMS.
694. **HTML-escape all user-provided data** before inserting into email templates (prevent XSS/injection).
70
71### Security — Non-Negotiable (Rules 1, 4)
725. **Never expose API keys, secrets, or tokens** in client-side code or git history.
736. **JWT_SECRET_KEY must be a real environment variable** — never generate random secrets at runtime.
747. **All admin endpoints must require authentication.** No exceptions.
758. **Password reset tokens must have expiry validation** — check `expires_at` before allowing reset.
769. **Rate limit sensitive endpoints** — booking creation, login attempts, SMS/email resend.
77
78### Performance (Rules 3, 5)
7910. **Add database indexes** on frequently queried columns (booking_ref, email, created_at, date).
8011. **Never SELECT * in list queries** — only select columns you need.
8112. **Use connection pooling** appropriate for the runtime (serverless = @neondatabase/serverless).
82
83### Frontend Reliability (Rules 2, 7, 8)
8413. **Every page component must be wrapped in an Error Boundary.** White screens are unacceptable.
8514. **All API calls must have loading states, error states, and retry logic.**
8615. **Phone number on every page must be 021 743 321.** Any other number is a bug — fix it immediately.
87
88### Code Quality (Rules 7, 9)
8916. **No dead code.** Remove unused imports, variables, and commented-out blocks.
9017. **No console.log in production.** Use proper error tracking or remove.
9118. **Consistent API response format:** `{ ok: true/false, data: ..., error: "..." }`
9219. **All dates stored in ISO 8601 UTC.** Display in NZ timezone on frontend only.
93
94### Autonomous Operation (Rules 2, 3, 4, 10)
9520. **If you find a bug while working on something else, fix it.** Don't leave it for later.
9621. **If a dependency is outdated and has known vulnerabilities, flag it.**
9722. **If an engineering gap exists (missing validation, missing error handling, missing indexes), fix it on the spot.**
9823. **Never introduce a new technology or provider without explicit owner approval.** The stack is locked.
2599
26100## Important Rules for AI Sessions
27101
39113- `frontend/public/index.html` - SEO meta tags, JSON-LD schemas
40114- `frontend/src/pages/HomePage.jsx` - Main landing page
41115- `frontend/src/pages/BookingPage.jsx` - Booking form
42- `backend/booking_routes.py` - Booking API endpoints
43- `backend/admin_routes.py` - Admin dashboard API
44- `backend/utils.py` - Email (Mailgun), SMS (Twilio), pricing engine
45- `backend/server.py` - FastAPI app setup, CORS, middleware
46- `backend/db.py` - Neon PostgreSQL connection and schema init
116- `api/` - Vercel Serverless Functions (all API endpoints)
117- `api/lib/db.js` - Neon PostgreSQL connection (serverless)
118- `api/lib/email.js` - Mailgun email utilities
119- `api/lib/sms.js` - Twilio SMS utilities
120- `api/lib/pricing.js` - Pricing engine
121- `api/lib/auth.js` - JWT authentication
122- `api/bookings.js` - Booking CRUD endpoints
123- `api/admin/` - Admin dashboard API endpoints
124
125## Legacy Files (RETIRED — Do Not Use)
126
127- `backend/` - Former FastAPI backend (retired March 2026, kept for reference only)
128- `Dockerfile` - Former Docker config for Render
129- `render.yaml` - Former Render deployment config
AddedENGINEERING_GAPS_REPORT.md+102−0View fileUnifiedSplit
1# Engineering Gaps Report — Hibiscus to Airport
2
3**Date:** 2026-03-24
4**Status:** Active — gaps being resolved in architecture migration
5
6---
7
8## CRITICAL (Must Fix)
9
10### 1. Two-System Architecture (Backend on Render)
11- **Problem:** FastAPI on Render has cold starts (30-60s), requires separate monitoring, causes CORS issues, doubles deployment complexity
12- **Fix:** Migrate all API endpoints to Vercel Serverless Functions (single platform)
13- **Status:** IN PROGRESS
14
15### 2. No Rate Limiting
16- **Problem:** Zero rate limiting on public endpoints — booking creation, SMS/email resend can be spammed
17- **Impact:** Financial damage (Twilio charges per SMS), DoS vulnerability
18- **Fix:** Add rate limiting headers + IP-based throttling in serverless functions
19
20### 3. JWT Secret Regenerates on Restart
21- **File:** `backend/auth.py:14-18`
22- **Problem:** When `JWT_SECRET_KEY` env var is not set, a random secret is generated. All admin sessions invalidated on restart.
23- **Fix:** Require JWT_SECRET_KEY as mandatory env var, fail startup if missing
24
25### 4. Password Reset Tokens Never Expire
26- **File:** `backend/booking_routes.py:950`
27- **Problem:** Token stored with `expires_at` but expiry is never checked during reset
28- **Fix:** Validate `expires_at` before allowing password reset
29
30### 5. Unescaped User Input in Email Templates
31- **File:** `backend/utils.py:424, 451, 683`
32- **Problem:** User names, addresses, notes injected directly into HTML emails without escaping
33- **Impact:** HTML injection, potential phishing via crafted booking names
34- **Fix:** HTML-escape all user-provided fields before email template insertion
35
36---
37
38## HIGH (Should Fix Soon)
39
40### 6. No Error Boundaries on Public Pages
41- **Problem:** Only admin section has ErrorBoundary. If any public component crashes, entire site shows white screen.
42- **Fix:** Wrap all page components in ErrorBoundary with fallback UI
43
44### 7. No Input Validation on Booking Model
45- **File:** `backend/booking_routes.py:191-219`
46- **Problem:** Name, email, phone have no format validation. Passengers is string but should be int.
47- **Fix:** Add Zod/regex validation on all booking fields
48
49### 8. Missing Database Indexes
50- **File:** `backend/db.py`
51- **Problem:** No indexes on booking_ref, email, phone, date, created_at
52- **Impact:** Full table scans on every query, gets worse as data grows
53- **Fix:** Add indexes on frequently queried columns
54
55### 9. Stripe Webhook Not Idempotent
56- **File:** `backend/booking_routes.py:787-828`
57- **Problem:** If webhook fires twice, sends duplicate confirmation emails
58- **Fix:** Check if payment already processed before sending notifications
59
60### 10. Race Condition in Booking Reference Generation
61- **File:** `backend/booking_routes.py:555-560`
62- **Problem:** Two simultaneous requests could generate same booking_ref
63- **Fix:** Use database sequence or SELECT FOR UPDATE
64
65### 11. N+1 Query in Admin Bookings
66- **File:** `backend/admin_routes.py:293`
67- **Problem:** `SELECT * FROM bookings LIMIT 500` fetches all columns for 500 rows
68- **Fix:** Select only needed columns for list view
69
70---
71
72## MEDIUM (Fix When Touching)
73
74### 12. No 404 Page
75- **File:** `frontend/src/App.js:299`
76- **Problem:** Unknown routes silently redirect to home. Users don't know page doesn't exist.
77
78### 13. Console.log Statements in Production
79- **Problem:** 15+ console.error calls in frontend code leak info to browser console
80
81### 14. Inconsistent API Response Format
82- **Problem:** Some endpoints return `{ok, error}`, others return `{message, booking}`
83
84### 15. Calendar Invite Import Order Bug
85- **File:** `backend/utils.py:205-220`
86- **Problem:** `timezone('Pacific/Auckland')` used at line 205 but `from pytz import timezone` at line 220
87
88### 16. Hardcoded URLs in Email Templates
89- **File:** `backend/utils.py:537`
90- **Problem:** `hibiscustoairport.co.nz/track/{booking_ref}` hardcoded instead of using env var
91
92### 17. No Audit Logging
93- **Problem:** Login attempts, password resets, admin actions not logged with IP/timestamp
94
95---
96
97## RESOLVED
98
99| Gap | Resolution | Date |
100|-----|-----------|------|
101| Architecture updated to CLAUDE.md | New rules added | 2026-03-24 |
102| Engineering quality rules added | 23 mandatory rules in CLAUDE.md | 2026-03-24 |
Addedapi/admin/bookings.js+80−0View fileUnifiedSplit
1// GET /api/admin/bookings — List bookings with optional filters (admin)
2// Also handles CSV export via ?format=csv
3
4const { getDb } = require("../lib/db");
5const { authenticateRequest } = require("../lib/auth");
6const { ok, unauthorized, serverError, methodNotAllowed, rowToBooking } = require("../lib/helpers");
7
8module.exports = async function handler(req, res) {
9 if (req.method !== "GET") return methodNotAllowed(res, ["GET"]);
10
11 const user = authenticateRequest(req);
12 if (!user) return unauthorized(res);
13
14 try {
15 const sql = getDb();
16 const { status, payment_status, format, search } = req.query;
17
18 // Build query with filters
19 let rows;
20 if (search) {
21 rows = await sql`
22 SELECT id, booking_ref, name, email, phone, pickup_address, dropoff_address,
23 date, time, passengers, notes, service_type, pricing, total_price,
24 status, payment_status, payment_method, assigned_driver_name,
25 tracking_status, created_at, updated_at
26 FROM bookings
27 WHERE name ILIKE ${"%" + search + "%"}
28 OR email ILIKE ${"%" + search + "%"}
29 OR phone ILIKE ${"%" + search + "%"}
30 OR booking_ref ILIKE ${"%" + search + "%"}
31 ORDER BY created_at DESC LIMIT 500
32 `;
33 } else if (status && payment_status) {
34 rows = await sql`
35 SELECT id, booking_ref, name, email, phone, pickup_address, dropoff_address,
36 date, time, passengers, notes, service_type, pricing, total_price,
37 status, payment_status, payment_method, assigned_driver_name,
38 tracking_status, created_at, updated_at
39 FROM bookings WHERE status = ${status} AND payment_status = ${payment_status}
40 ORDER BY created_at DESC LIMIT 500
41 `;
42 } else if (status) {
43 rows = await sql`
44 SELECT id, booking_ref, name, email, phone, pickup_address, dropoff_address,
45 date, time, passengers, notes, service_type, pricing, total_price,
46 status, payment_status, payment_method, assigned_driver_name,
47 tracking_status, created_at, updated_at
48 FROM bookings WHERE status = ${status}
49 ORDER BY created_at DESC LIMIT 500
50 `;
51 } else {
52 rows = await sql`
53 SELECT id, booking_ref, name, email, phone, pickup_address, dropoff_address,
54 date, time, passengers, notes, service_type, pricing, total_price,
55 status, payment_status, payment_method, assigned_driver_name,
56 tracking_status, created_at, updated_at
57 FROM bookings ORDER BY created_at DESC LIMIT 500
58 `;
59 }
60
61 // CSV export
62 if (format === "csv") {
63 const headers = ["Ref", "Name", "Email", "Phone", "Pickup", "Dropoff", "Date", "Time", "Passengers", "Total", "Status", "Payment"];
64 const csvRows = rows.map((r) =>
65 [r.booking_ref, r.name, r.email, r.phone, r.pickup_address, r.dropoff_address, r.date, r.time, r.passengers, r.total_price, r.status, r.payment_status]
66 .map((v) => `"${String(v || "").replace(/"/g, '""')}"`)
67 .join(",")
68 );
69 const csv = [headers.join(","), ...csvRows].join("\n");
70
71 res.setHeader("Content-Type", "text/csv");
72 res.setHeader("Content-Disposition", "attachment; filename=bookings.csv");
73 return res.status(200).send(csv);
74 }
75
76 return ok(res, { bookings: rows.map(rowToBooking), count: rows.length });
77 } catch (err) {
78 return serverError(res, err.message);
79 }
80};
Addedapi/admin/change-password.js+39−0View fileUnifiedSplit
1// POST /api/admin/change-password — Change admin password (requires auth)
2
3const { getDb } = require("../lib/db");
4const { authenticateRequest, verifyPassword, hashPassword } = require("../lib/auth");
5const { ok, badRequest, unauthorized, serverError, methodNotAllowed } = require("../lib/helpers");
6
7module.exports = async function handler(req, res) {
8 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
9
10 const user = authenticateRequest(req);
11 if (!user) return unauthorized(res);
12
13 const { current_password, new_password } = req.body || {};
14
15 if (!current_password || !new_password) {
16 return badRequest(res, "current_password and new_password are required");
17 }
18
19 if (new_password.length < 8) {
20 return badRequest(res, "New password must be at least 8 characters");
21 }
22
23 try {
24 const sql = getDb();
25 const rows = await sql`SELECT * FROM admins WHERE id = ${user.sub}`;
26 if (rows.length === 0) return unauthorized(res, "Admin not found");
27
28 const admin = rows[0];
29 const valid = await verifyPassword(current_password, admin.password);
30 if (!valid) return unauthorized(res, "Current password is incorrect");
31
32 const hashedPw = await hashPassword(new_password);
33 await sql`UPDATE admins SET password = ${hashedPw}, updated_at = ${new Date().toISOString()} WHERE id = ${user.sub}`;
34
35 return ok(res, { message: "Password changed successfully" });
36 } catch (err) {
37 return serverError(res, err.message);
38 }
39};
Addedapi/admin/login.js+59−0View fileUnifiedSplit
1// POST /api/admin/login — Admin authentication
2
3const { getDb } = require("../lib/db");
4const { createAccessToken, verifyPassword, hashPassword } = require("../lib/auth");
5const { ok, badRequest, unauthorized, serverError, methodNotAllowed, uuid } = require("../lib/helpers");
6
7module.exports = async function handler(req, res) {
8 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
9
10 const { username, password } = req.body || {};
11
12 if (!username || !password) {
13 return badRequest(res, "Username and password are required");
14 }
15
16 try {
17 const sql = getDb();
18
19 // Check if any admins exist — if not, create the initial admin
20 const admins = await sql`SELECT COUNT(*) as count FROM admins`;
21 if (parseInt(admins[0].count, 10) === 0) {
22 const initPassword = process.env.ADMIN_INIT_PASSWORD;
23 if (!initPassword) {
24 return serverError(res, "No admin accounts exist and ADMIN_INIT_PASSWORD is not set");
25 }
26 if (password !== initPassword) {
27 return unauthorized(res, "Invalid credentials");
28 }
29
30 // Create initial admin
31 const adminId = uuid();
32 const hashedPw = await hashPassword(password);
33 await sql`
34 INSERT INTO admins (id, username, password, created_at)
35 VALUES (${adminId}, ${username}, ${hashedPw}, ${new Date().toISOString()})
36 `;
37
38 const token = createAccessToken({ sub: adminId, username });
39 return ok(res, { token, username, message: "Initial admin account created" });
40 }
41
42 // Normal login
43 const rows = await sql`SELECT * FROM admins WHERE username = ${username}`;
44 if (rows.length === 0) {
45 return unauthorized(res, "Invalid credentials");
46 }
47
48 const admin = rows[0];
49 const valid = await verifyPassword(password, admin.password);
50 if (!valid) {
51 return unauthorized(res, "Invalid credentials");
52 }
53
54 const token = createAccessToken({ sub: admin.id, username: admin.username });
55 return ok(res, { token, username: admin.username });
56 } catch (err) {
57 return serverError(res, err.message);
58 }
59};
Addedapi/admin/reset-password.js+77−0View fileUnifiedSplit
1// POST /api/admin/reset-password — Request or confirm password reset
2
3const crypto = require("crypto");
4const { getDb } = require("../lib/db");
5const { hashPassword } = require("../lib/auth");
6const { sendPasswordResetEmail } = require("../lib/email");
7const { ok, badRequest, serverError, methodNotAllowed } = require("../lib/helpers");
8
9module.exports = async function handler(req, res) {
10 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
11
12 const { email, token, new_password } = req.body || {};
13
14 // If token + new_password provided, this is a reset confirmation
15 if (token && new_password) {
16 return confirmReset(req, res, token, new_password);
17 }
18
19 // Otherwise, request a reset
20 if (!email) return badRequest(res, "email is required");
21
22 try {
23 const sql = getDb();
24 const rows = await sql`SELECT * FROM admins WHERE email = ${email}`;
25 if (rows.length === 0) {
26 // Don't reveal if email exists
27 return ok(res, { message: "If that email exists, a reset link has been sent." });
28 }
29
30 const resetToken = crypto.randomBytes(32).toString("hex");
31 const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
32
33 await sql`
34 INSERT INTO password_resets (email, token, expires_at, created_at)
35 VALUES (${email}, ${resetToken}, ${expiresAt}, ${new Date().toISOString()})
36 `;
37
38 await sendPasswordResetEmail(email, resetToken);
39
40 return ok(res, { message: "If that email exists, a reset link has been sent." });
41 } catch (err) {
42 return serverError(res, err.message);
43 }
44};
45
46async function confirmReset(req, res, token, newPassword) {
47 if (newPassword.length < 8) {
48 return badRequest(res, "Password must be at least 8 characters");
49 }
50
51 try {
52 const sql = getDb();
53 const rows = await sql`SELECT * FROM password_resets WHERE token = ${token}`;
54
55 if (rows.length === 0) {
56 return badRequest(res, "Invalid or expired reset token");
57 }
58
59 const resetRecord = rows[0];
60
61 // FIXED: Actually check token expiry (was missing in old backend)
62 if (new Date(resetRecord.expires_at) < new Date()) {
63 await sql`DELETE FROM password_resets WHERE token = ${token}`;
64 return badRequest(res, "Reset token has expired. Please request a new one.");
65 }
66
67 const hashedPw = await hashPassword(newPassword);
68 await sql`UPDATE admins SET password = ${hashedPw}, updated_at = ${new Date().toISOString()} WHERE email = ${resetRecord.email}`;
69
70 // Clean up used token
71 await sql`DELETE FROM password_resets WHERE token = ${token}`;
72
73 return ok(res, { message: "Password reset successfully" });
74 } catch (err) {
75 return serverError(res, err.message);
76 }
77}
Addedapi/bookings.js+146−0View fileUnifiedSplit
1// /api/bookings
2// POST = create booking (public)
3// GET = list bookings (admin, requires auth)
4
5const { getDb } = require("./lib/db");
6const { authenticateRequest } = require("./lib/auth");
7const { sendCustomerConfirmation, sendAdminNotification } = require("./lib/email");
8const { sendCustomerSms, sendAdminSmsNotification, sendUrgentAdminSms } = require("./lib/sms");
9const { isUrgentBooking } = require("./lib/pricing");
10const {
11 ok, created, badRequest, unauthorized, serverError, methodNotAllowed,
12 uuid, isValidEmail, isValidPhone, rowToBooking, escapeHtml,
13} = require("./lib/helpers");
14
15module.exports = async function handler(req, res) {
16 if (req.method === "POST") {
17 return createBooking(req, res);
18 }
19 if (req.method === "GET") {
20 return listBookings(req, res);
21 }
22 return methodNotAllowed(res, ["GET", "POST"]);
23};
24
25async function createBooking(req, res) {
26 try {
27 const b = req.body || {};
28
29 // Input validation
30 if (!b.name || !b.email || !b.phone || !b.pickupAddress || !b.dropoffAddress || !b.date || !b.time) {
31 return badRequest(res, "Missing required fields: name, email, phone, pickupAddress, dropoffAddress, date, time");
32 }
33 if (!isValidEmail(b.email)) {
34 return badRequest(res, "Invalid email address");
35 }
36 if (!isValidPhone(b.phone)) {
37 return badRequest(res, "Invalid phone number");
38 }
39
40 const sql = getDb();
41 const bookingId = uuid();
42
43 // Generate booking reference (atomic — uses DB)
44 const lastRef = await sql`
45 SELECT booking_ref FROM bookings
46 WHERE booking_ref LIKE 'H%'
47 ORDER BY CAST(SUBSTRING(booking_ref FROM 2) AS INTEGER) DESC
48 LIMIT 1
49 `;
50 const nextNum = lastRef.length > 0 ? parseInt(lastRef[0].booking_ref.slice(1), 10) + 1 : 1;
51 const bookingRef = `H${nextNum}`;
52
53 const totalPrice = b.totalPrice != null ? b.totalPrice : (b.pricing?.totalPrice || 0);
54 const createdAt = new Date().toISOString();
55 const pricing = b.pricing || {};
56
57 await sql`
58 INSERT INTO bookings (
59 id, booking_ref, name, email, phone, pickup_address, dropoff_address,
60 date, time, passengers, notes, pricing, total_price, status, payment_status,
61 departure_flight_number, departure_time, arrival_flight_number, arrival_time,
62 service_type, vip_pickup, oversized_luggage, return_trip, additional_pickups, created_at
63 ) VALUES (
64 ${bookingId}, ${bookingRef}, ${b.name}, ${b.email}, ${b.phone},
65 ${b.pickupAddress}, ${b.dropoffAddress}, ${b.date}, ${b.time},
66 ${String(b.passengers || "1")}, ${b.notes || ""}, ${JSON.stringify(pricing)},
67 ${totalPrice}, ${b.status || "pending"}, ${b.payment_status || "unpaid"},
68 ${b.departureFlightNumber || ""}, ${b.departureTime || ""},
69 ${b.arrivalFlightNumber || ""}, ${b.arrivalTime || ""},
70 ${b.serviceType || ""}, ${b.vipPickup || false}, ${b.oversizedLuggage || false},
71 ${b.returnTrip || false}, ${JSON.stringify(b.additionalPickups || [])}, ${createdAt}
72 )
73 `;
74
75 const bookingDoc = {
76 id: bookingId,
77 booking_ref: bookingRef,
78 name: b.name,
79 email: b.email,
80 phone: b.phone,
81 pickupAddress: b.pickupAddress,
82 dropoffAddress: b.dropoffAddress,
83 date: b.date,
84 time: b.time,
85 passengers: String(b.passengers || "1"),
86 notes: b.notes || "",
87 pricing,
88 totalPrice,
89 status: b.status || "pending",
90 payment_status: b.payment_status || "unpaid",
91 departureFlightNumber: b.departureFlightNumber || "",
92 departureTime: b.departureTime || "",
93 arrivalFlightNumber: b.arrivalFlightNumber || "",
94 arrivalTime: b.arrivalTime || "",
95 };
96
97 // Send notifications (non-blocking — don't fail the booking if notifications fail)
98 try { await sendAdminNotification(bookingDoc); } catch (e) { console.error("Admin email failed:", e.message); }
99 try { await sendAdminSmsNotification(bookingDoc); } catch (e) { console.error("Admin SMS failed:", e.message); }
100
101 // Check for urgent booking
102 const { isUrgent, hoursUntil } = isUrgentBooking(b.date, b.time);
103 if (isUrgent) {
104 try { await sendUrgentAdminSms(bookingDoc, hoursUntil); } catch (e) { console.error("Urgent SMS failed:", e.message); }
105 }
106
107 // If confirmed+paid, send customer notifications
108 if (b.status === "confirmed" && b.payment_status === "paid") {
109 try { await sendCustomerConfirmation(bookingDoc); } catch (e) { console.error("Customer email failed:", e.message); }
110 try { await sendCustomerSms(bookingDoc); } catch (e) { console.error("Customer SMS failed:", e.message); }
111 }
112
113 return created(res, {
114 message: "Booking created successfully",
115 booking_id: bookingId,
116 booking_ref: bookingRef,
117 status: b.status || "pending",
118 });
119 } catch (err) {
120 return serverError(res, err.message);
121 }
122}
123
124async function listBookings(req, res) {
125 const user = authenticateRequest(req);
126 if (!user) {
127 return unauthorized(res);
128 }
129
130 try {
131 const sql = getDb();
132 const rows = await sql`
133 SELECT id, booking_ref, name, email, phone, pickup_address, dropoff_address,
134 date, time, passengers, notes, service_type, pricing, total_price,
135 status, payment_status, payment_method, assigned_driver_name,
136 tracking_status, created_at, updated_at
137 FROM bookings
138 ORDER BY created_at DESC
139 LIMIT 500
140 `;
141
142 return ok(res, { bookings: rows.map(rowToBooking) });
143 } catch (err) {
144 return serverError(res, err.message);
145 }
146}
Addedapi/bookings/[id]/cancel.js+35−0View fileUnifiedSplit
1// POST /api/bookings/:id/cancel — Cancel a booking
2
3const { getDb } = require("../../lib/db");
4const { sendCancellationEmail } = require("../../lib/email");
5const { sendCancellationSms } = require("../../lib/sms");
6const { ok, notFound, serverError, methodNotAllowed, rowToBooking } = require("../../lib/helpers");
7
8module.exports = async function handler(req, res) {
9 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
10
11 const { id } = req.query;
12
13 try {
14 const sql = getDb();
15
16 const rows = await sql`SELECT * FROM bookings WHERE id = ${id} OR booking_ref = ${id}`;
17 if (rows.length === 0) return notFound(res, "Booking not found");
18
19 const booking = rowToBooking(rows[0]);
20
21 await sql`
22 UPDATE bookings
23 SET status = 'cancelled', updated_at = ${new Date().toISOString()}
24 WHERE id = ${rows[0].id}
25 `;
26
27 // Send cancellation notifications
28 try { await sendCancellationEmail(booking); } catch (e) { console.error("Cancel email failed:", e.message); }
29 try { await sendCancellationSms(booking); } catch (e) { console.error("Cancel SMS failed:", e.message); }
30
31 return ok(res, { message: "Booking cancelled", booking_ref: booking.booking_ref });
32 } catch (err) {
33 return serverError(res, err.message);
34 }
35};
Addedapi/bookings/[id]/duplicate.js+58−0View fileUnifiedSplit
1// POST /api/bookings/:id/duplicate — Duplicate a booking (admin)
2
3const { getDb } = require("../../lib/db");
4const { authenticateRequest } = require("../../lib/auth");
5const { ok, unauthorized, notFound, serverError, methodNotAllowed, uuid } = require("../../lib/helpers");
6
7module.exports = async function handler(req, res) {
8 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
9
10 const user = authenticateRequest(req);
11 if (!user) return unauthorized(res);
12
13 const { id } = req.query;
14
15 try {
16 const sql = getDb();
17 const rows = await sql`SELECT * FROM bookings WHERE id = ${id}`;
18 if (rows.length === 0) return notFound(res, "Booking not found");
19
20 const original = rows[0];
21
22 // Generate new booking ref (atomic)
23 const lastRef = await sql`
24 SELECT booking_ref FROM bookings
25 WHERE booking_ref LIKE 'H%'
26 ORDER BY CAST(SUBSTRING(booking_ref FROM 2) AS INTEGER) DESC
27 LIMIT 1
28 `;
29 const nextNum = lastRef.length > 0 ? parseInt(lastRef[0].booking_ref.slice(1), 10) + 1 : 1;
30 const newRef = `H${nextNum}`;
31 const newId = uuid();
32 const now = new Date().toISOString();
33
34 await sql`
35 INSERT INTO bookings (
36 id, booking_ref, name, email, phone, pickup_address, dropoff_address,
37 date, time, passengers, notes, pricing, total_price, status,
38 payment_status, service_type, created_at
39 ) VALUES (
40 ${newId}, ${newRef}, ${original.name}, ${original.email}, ${original.phone},
41 ${original.pickup_address}, ${original.dropoff_address},
42 ${original.date}, ${original.time}, ${original.passengers},
43 ${"Duplicated from " + original.booking_ref + ". " + (original.notes || "")},
44 ${JSON.stringify(original.pricing)}, ${original.total_price},
45 'pending', 'unpaid', ${original.service_type}, ${now}
46 )
47 `;
48
49 return ok(res, {
50 message: "Booking duplicated",
51 original_ref: original.booking_ref,
52 new_ref: newRef,
53 new_id: newId,
54 });
55 } catch (err) {
56 return serverError(res, err.message);
57 }
58};
Addedapi/bookings/[id]/resend-email.js+36−0View fileUnifiedSplit
1// POST /api/bookings/:id/resend-email — Resend confirmation email (with cooldown)
2
3const { getDb } = require("../../lib/db");
4const { sendCustomerConfirmation } = require("../../lib/email");
5const { ok, notFound, tooManyRequests, serverError, methodNotAllowed, rowToBooking, checkCooldown } = require("../../lib/helpers");
6
7module.exports = async function handler(req, res) {
8 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
9
10 const { id } = req.query;
11 const force = req.query.force === "true";
12
13 try {
14 const sql = getDb();
15 const rows = await sql`SELECT * FROM bookings WHERE id = ${id}`;
16 if (rows.length === 0) return notFound(res, "Booking not found");
17
18 const booking = rowToBooking(rows[0]);
19
20 // Check cooldown (5 min) unless forced
21 if (!force) {
22 const waitMinutes = checkCooldown(booking.lastEmailSent);
23 if (waitMinutes > 0) {
24 return tooManyRequests(res, `Email was recently sent. Wait ${waitMinutes} minute(s).`);
25 }
26 }
27
28 await sendCustomerConfirmation(booking);
29
30 await sql`UPDATE bookings SET last_email_sent = ${new Date().toISOString()} WHERE id = ${id}`;
31
32 return ok(res, { message: `Email sent to ${booking.email}`, booking_ref: booking.booking_ref });
33 } catch (err) {
34 return serverError(res, err.message);
35 }
36};
Addedapi/bookings/[id]/resend-sms.js+35−0View fileUnifiedSplit
1// POST /api/bookings/:id/resend-sms — Resend confirmation SMS (with cooldown)
2
3const { getDb } = require("../../lib/db");
4const { sendCustomerSms } = require("../../lib/sms");
5const { ok, notFound, tooManyRequests, serverError, methodNotAllowed, rowToBooking, checkCooldown } = require("../../lib/helpers");
6
7module.exports = async function handler(req, res) {
8 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
9
10 const { id } = req.query;
11 const force = req.query.force === "true";
12
13 try {
14 const sql = getDb();
15 const rows = await sql`SELECT * FROM bookings WHERE id = ${id}`;
16 if (rows.length === 0) return notFound(res, "Booking not found");
17
18 const booking = rowToBooking(rows[0]);
19
20 if (!force) {
21 const waitMinutes = checkCooldown(booking.lastSmsSent);
22 if (waitMinutes > 0) {
23 return tooManyRequests(res, `SMS was recently sent. Wait ${waitMinutes} minute(s).`);
24 }
25 }
26
27 await sendCustomerSms(booking);
28
29 await sql`UPDATE bookings SET last_sms_sent = ${new Date().toISOString()} WHERE id = ${id}`;
30
31 return ok(res, { message: `SMS sent to ${booking.phone}`, booking_ref: booking.booking_ref });
32 } catch (err) {
33 return serverError(res, err.message);
34 }
35};
Addedapi/bookings/[id]/update-status.js+50−0View fileUnifiedSplit
1// POST /api/bookings/:id/update-status — Update booking status (admin)
2
3const { getDb } = require("../../lib/db");
4const { authenticateRequest } = require("../../lib/auth");
5const { sendCustomerConfirmation } = require("../../lib/email");
6const { sendCustomerSms } = require("../../lib/sms");
7const { ok, badRequest, unauthorized, notFound, serverError, methodNotAllowed, rowToBooking } = require("../../lib/helpers");
8
9module.exports = async function handler(req, res) {
10 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
11
12 const user = authenticateRequest(req);
13 if (!user) return unauthorized(res);
14
15 const { id } = req.query;
16 const { status, payment_status } = req.body || {};
17
18 if (!status && !payment_status) {
19 return badRequest(res, "Provide status and/or payment_status");
20 }
21
22 try {
23 const sql = getDb();
24 const now = new Date().toISOString();
25
26 // Build update
27 if (status && payment_status) {
28 await sql`UPDATE bookings SET status = ${status}, payment_status = ${payment_status}, updated_at = ${now} WHERE id = ${id}`;
29 } else if (status) {
30 await sql`UPDATE bookings SET status = ${status}, updated_at = ${now} WHERE id = ${id}`;
31 } else {
32 await sql`UPDATE bookings SET payment_status = ${payment_status}, updated_at = ${now} WHERE id = ${id}`;
33 }
34
35 const rows = await sql`SELECT * FROM bookings WHERE id = ${id}`;
36 if (rows.length === 0) return notFound(res, "Booking not found");
37
38 const booking = rowToBooking(rows[0]);
39
40 // If just confirmed+paid, send customer notifications
41 if (status === "confirmed" && (payment_status === "paid" || booking.payment_status === "paid")) {
42 try { await sendCustomerConfirmation(booking); } catch (e) { console.error("Customer email failed:", e.message); }
43 try { await sendCustomerSms(booking); } catch (e) { console.error("Customer SMS failed:", e.message); }
44 }
45
46 return ok(res, { message: "Status updated", booking });
47 } catch (err) {
48 return serverError(res, err.message);
49 }
50};
Addedapi/bookings/[ref].js+140−0View fileUnifiedSplit
1// GET /api/bookings/:ref — Fetch booking by reference or ID
2// PUT /api/bookings/:ref — Update booking (admin)
3// DELETE /api/bookings/:ref — Soft-delete booking (admin)
4
5const { getDb } = require("../lib/db");
6const { authenticateRequest } = require("../lib/auth");
7const {
8 ok, badRequest, unauthorized, notFound, serverError, methodNotAllowed,
9 rowToBooking,
10} = require("../lib/helpers");
11
12module.exports = async function handler(req, res) {
13 const { ref } = req.query;
14
15 if (req.method === "GET") return getBooking(req, res, ref);
16 if (req.method === "PUT") return updateBooking(req, res, ref);
17 if (req.method === "DELETE") return deleteBooking(req, res, ref);
18 return methodNotAllowed(res, ["GET", "PUT", "DELETE"]);
19};
20
21async function getBooking(req, res, ref) {
22 try {
23 const sql = getDb();
24
25 // Try by booking_ref first, then by id
26 let rows = await sql`SELECT * FROM bookings WHERE booking_ref = ${ref}`;
27 if (rows.length === 0) {
28 rows = await sql`SELECT * FROM bookings WHERE id = ${ref}`;
29 }
30 if (rows.length === 0) {
31 return notFound(res, "Booking not found");
32 }
33
34 return ok(res, { booking: rowToBooking(rows[0]) });
35 } catch (err) {
36 return serverError(res, err.message);
37 }
38}
39
40async function updateBooking(req, res, ref) {
41 const user = authenticateRequest(req);
42 if (!user) return unauthorized(res);
43
44 try {
45 const sql = getDb();
46 const updates = req.body || {};
47
48 // Map camelCase to snake_case
49 const fieldMap = {
50 name: "name", email: "email", phone: "phone",
51 pickupAddress: "pickup_address", dropoffAddress: "dropoff_address",
52 date: "date", time: "time", passengers: "passengers",
53 notes: "notes", totalPrice: "total_price",
54 status: "status", payment_status: "payment_status",
55 assignedDriverId: "assigned_driver_id", assignedDriverName: "assigned_driver_name",
56 driverPayout: "driver_payout", driverNotes: "driver_notes",
57 trackingStatus: "tracking_status", paymentMethod: "payment_method",
58 };
59
60 const setClauses = [];
61 const values = [];
62 let paramIdx = 1;
63
64 for (const [camel, snake] of Object.entries(fieldMap)) {
65 if (updates[camel] !== undefined) {
66 setClauses.push(`${snake} = $${paramIdx}`);
67 values.push(updates[camel]);
68 paramIdx++;
69 }
70 }
71
72 if (setClauses.length === 0) {
73 return badRequest(res, "No fields to update");
74 }
75
76 // Add updated_at
77 setClauses.push(`updated_at = $${paramIdx}`);
78 values.push(new Date().toISOString());
79 paramIdx++;
80
81 // Where clause
82 values.push(ref);
83 const query = `UPDATE bookings SET ${setClauses.join(", ")} WHERE (booking_ref = $${paramIdx} OR id = $${paramIdx}) RETURNING *`;
84
85 // Use raw query for dynamic SET
86 const { neon } = require("@neondatabase/serverless");
87 const sqlRaw = neon(process.env.DATABASE_URL);
88 const result = await sqlRaw(query, values);
89
90 if (result.length === 0) {
91 return notFound(res, "Booking not found");
92 }
93
94 return ok(res, { message: "Booking updated", booking: rowToBooking(result[0]) });
95 } catch (err) {
96 return serverError(res, err.message);
97 }
98}
99
100async function deleteBooking(req, res, ref) {
101 const user = authenticateRequest(req);
102 if (!user) return unauthorized(res);
103
104 try {
105 const sql = getDb();
106
107 // Fetch booking first
108 let rows = await sql`SELECT * FROM bookings WHERE booking_ref = ${ref} OR id = ${ref}`;
109 if (rows.length === 0) {
110 return notFound(res, "Booking not found");
111 }
112
113 const booking = rows[0];
114
115 // Soft delete — move to deleted_bookings
116 await sql`
117 INSERT INTO deleted_bookings (
118 id, booking_ref, name, email, phone, pickup_address, dropoff_address,
119 date, time, passengers, notes, service_type, pricing, total_price,
120 status, payment_status, tracking_id, assigned_driver_name,
121 created_at, updated_at, deleted_at, deleted_by, booking_data
122 ) VALUES (
123 ${booking.id}, ${booking.booking_ref}, ${booking.name}, ${booking.email},
124 ${booking.phone}, ${booking.pickup_address}, ${booking.dropoff_address},
125 ${booking.date}, ${booking.time}, ${booking.passengers}, ${booking.notes},
126 ${booking.service_type}, ${JSON.stringify(booking.pricing)}, ${booking.total_price},
127 ${booking.status}, ${booking.payment_status}, ${booking.tracking_id},
128 ${booking.assigned_driver_name}, ${booking.created_at}, ${booking.updated_at},
129 ${new Date().toISOString()}, ${user.sub || "admin"}, ${JSON.stringify(booking)}
130 )
131 `;
132
133 // Delete from bookings
134 await sql`DELETE FROM bookings WHERE id = ${booking.id}`;
135
136 return ok(res, { message: "Booking deleted", booking_ref: booking.booking_ref });
137 } catch (err) {
138 return serverError(res, err.message);
139 }
140}
Addedapi/calculate-price.js+29−0View fileUnifiedSplit
1// POST /api/calculate-price
2// Public endpoint — calculates price for a pickup/dropoff pair
3
4const { calculateDistance, calculatePrice } = require("./lib/pricing");
5const { ok, badRequest, serverError, methodNotAllowed } = require("./lib/helpers");
6
7module.exports = async function handler(req, res) {
8 if (req.method !== "POST") {
9 return methodNotAllowed(res, ["POST"]);
10 }
11
12 try {
13 const { pickupAddress, dropoffAddress, passengers = 1 } = req.body || {};
14
15 if (!pickupAddress || !dropoffAddress) {
16 return badRequest(res, "pickupAddress and dropoffAddress are required");
17 }
18
19 const distance = await calculateDistance(pickupAddress, dropoffAddress);
20 if (distance === null) {
21 return badRequest(res, "Could not calculate distance between addresses");
22 }
23
24 const pricing = calculatePrice(distance, parseInt(passengers, 10) || 1);
25 return ok(res, pricing);
26 } catch (err) {
27 return serverError(res, err.message);
28 }
29};
Addedapi/cron/reminders.js+58−0View fileUnifiedSplit
1// GET /api/cron/reminders — Vercel Cron Job: send day-before reminders
2// Configured in vercel.json to run daily at 5:00 UTC (6 PM NZDT)
3
4const { getDb } = require("../lib/db");
5const { sendReminderEmail } = require("../lib/email");
6const { sendReminderSms } = require("../lib/sms");
7const { ok, unauthorized, serverError } = require("../lib/helpers");
8
9module.exports = async function handler(req, res) {
10 // Verify this is a legitimate cron call (Vercel sets this header)
11 const cronSecret = req.headers["authorization"];
12 const expectedSecret = process.env.CRON_SECRET;
13
14 // In production, verify the cron secret
15 if (expectedSecret && cronSecret !== `Bearer ${expectedSecret}`) {
16 return unauthorized(res, "Invalid cron secret");
17 }
18
19 try {
20 const sql = getDb();
21
22 // Calculate tomorrow's date in UTC
23 const tomorrow = new Date();
24 tomorrow.setDate(tomorrow.getDate() + 1);
25 const tomorrowStr = tomorrow.toISOString().split("T")[0]; // YYYY-MM-DD
26
27 const rows = await sql`
28 SELECT * FROM bookings
29 WHERE date = ${tomorrowStr}
30 AND status = 'confirmed'
31 AND payment_status = 'paid'
32 AND (reminder_sent IS NULL OR reminder_sent = false)
33 LIMIT 100
34 `;
35
36 let sentCount = 0;
37 for (const booking of rows) {
38 try {
39 await sendReminderEmail(booking);
40 await sendReminderSms(booking);
41
42 await sql`
43 UPDATE bookings
44 SET reminder_sent = true, reminder_sent_at = ${new Date().toISOString()}
45 WHERE id = ${booking.id}
46 `;
47 sentCount++;
48 } catch (err) {
49 console.error(`Reminder failed for ${booking.booking_ref}: ${err.message}`);
50 }
51 }
52
53 console.log(`Day-before reminders: ${sentCount}/${rows.length} sent`);
54 return ok(res, { message: `Reminders sent: ${sentCount}`, total: rows.length });
55 } catch (err) {
56 return serverError(res, err.message);
57 }
58};
Addedapi/drivers.js+45−0View fileUnifiedSplit
1// /api/drivers
2// GET = list drivers (admin), POST = create driver (admin)
3
4const { getDb } = require("./lib/db");
5const { authenticateRequest } = require("./lib/auth");
6const { ok, created, badRequest, unauthorized, serverError, methodNotAllowed, uuid } = require("./lib/helpers");
7
8module.exports = async function handler(req, res) {
9 const user = authenticateRequest(req);
10 if (!user) return unauthorized(res);
11
12 if (req.method === "GET") return listDrivers(req, res);
13 if (req.method === "POST") return createDriver(req, res);
14 return methodNotAllowed(res, ["GET", "POST"]);
15};
16
17async function listDrivers(req, res) {
18 try {
19 const sql = getDb();
20 const rows = await sql`SELECT * FROM drivers ORDER BY name`;
21 return ok(res, { drivers: rows });
22 } catch (err) {
23 return serverError(res, err.message);
24 }
25}
26
27async function createDriver(req, res) {
28 const { name, phone, email, vehicle, license } = req.body || {};
29 if (!name || !phone || !email) return badRequest(res, "name, phone, and email are required");
30
31 try {
32 const sql = getDb();
33 const id = uuid();
34 const now = new Date().toISOString();
35
36 await sql`
37 INSERT INTO drivers (id, name, phone, email, vehicle, license, created_at)
38 VALUES (${id}, ${name}, ${phone}, ${email}, ${vehicle || ""}, ${license || ""}, ${now})
39 `;
40
41 return created(res, { message: "Driver created", driver: { id, name, phone, email, vehicle, license } });
42 } catch (err) {
43 return serverError(res, err.message);
44 }
45}
Addedapi/healthz.js+11−0View fileUnifiedSplit
1// GET /api/healthz — Health check endpoint
2
3const { ok } = require("./lib/helpers");
4
5module.exports = function handler(req, res) {
6 return ok(res, {
7 status: "healthy",
8 platform: "vercel-serverless",
9 timestamp: new Date().toISOString(),
10 });
11};
Addedapi/init-schema.js+22−0View fileUnifiedSplit
1// GET /api/init-schema — Initialize database schema (run once after migration)
2// Protected by ADMIN_API_KEY
3
4const { initSchema } = require("./lib/db");
5const { ok, unauthorized, serverError } = require("./lib/helpers");
6
7module.exports = async function handler(req, res) {
8 // Require API key for schema initialization
9 const apiKey = req.headers["x-api-key"] || req.query.key;
10 const expectedKey = process.env.ADMIN_API_KEY;
11
12 if (!expectedKey || apiKey !== expectedKey) {
13 return unauthorized(res, "Invalid API key");
14 }
15
16 try {
17 const result = await initSchema();
18 return ok(res, result);
19 } catch (err) {
20 return serverError(res, err.message);
21 }
22};
Addedapi/lib/auth.js+76−0View fileUnifiedSplit
1// api/lib/auth.js
2// JWT authentication for Vercel Serverless Functions
3
4const jwt = require("jsonwebtoken");
5const bcrypt = require("bcryptjs");
6
7const SECRET_KEY = process.env.JWT_SECRET_KEY;
8const ALGORITHM = "HS256";
9const TOKEN_EXPIRY = "24h";
10
11if (!SECRET_KEY && process.env.NODE_ENV === "production") {
12 throw new Error("JWT_SECRET_KEY environment variable is REQUIRED in production");
13}
14
15function getSecret() {
16 if (!SECRET_KEY) {
17 throw new Error("JWT_SECRET_KEY environment variable is not set");
18 }
19 return SECRET_KEY;
20}
21
22/**
23 * Create a JWT access token.
24 */
25function createAccessToken(payload) {
26 return jwt.sign(payload, getSecret(), {
27 algorithm: ALGORITHM,
28 expiresIn: TOKEN_EXPIRY,
29 });
30}
31
32/**
33 * Verify and decode a JWT token. Returns payload or null.
34 */
35function decodeToken(token) {
36 try {
37 return jwt.verify(token, getSecret(), { algorithms: [ALGORITHM] });
38 } catch {
39 return null;
40 }
41}
42
43/**
44 * Extract and verify bearer token from request.
45 * Returns user payload or null.
46 */
47function authenticateRequest(req) {
48 const authHeader = req.headers.authorization || req.headers.Authorization;
49 if (!authHeader || !authHeader.startsWith("Bearer ")) {
50 return null;
51 }
52 const token = authHeader.slice(7);
53 return decodeToken(token);
54}
55
56/**
57 * Hash a password with bcrypt.
58 */
59async function hashPassword(password) {
60 return bcrypt.hash(password, 12);
61}
62
63/**
64 * Verify a password against a bcrypt hash.
65 */
66async function verifyPassword(plainPassword, hashedPassword) {
67 return bcrypt.compare(plainPassword, hashedPassword);
68}
69
70module.exports = {
71 createAccessToken,
72 decodeToken,
73 authenticateRequest,
74 hashPassword,
75 verifyPassword,
76};
Addedapi/lib/db.js+200−0View fileUnifiedSplit
1// api/lib/db.js
2// Neon PostgreSQL connection for Vercel Serverless Functions
3// Uses @neondatabase/serverless — designed for serverless (HTTP-based, no persistent connections)
4
5const { neon } = require("@neondatabase/serverless");
6
7let _sql = null;
8
9function getDb() {
10 if (!process.env.DATABASE_URL) {
11 throw new Error("DATABASE_URL environment variable is not set");
12 }
13 if (!_sql) {
14 _sql = neon(process.env.DATABASE_URL);
15 }
16 return _sql;
17}
18
19// Schema initialization — run once on first deploy or via a setup endpoint
20async function initSchema() {
21 const sql = getDb();
22 await sql`
23 CREATE TABLE IF NOT EXISTS bookings (
24 id TEXT PRIMARY KEY,
25 booking_ref TEXT UNIQUE NOT NULL,
26 name TEXT NOT NULL,
27 email TEXT NOT NULL,
28 phone TEXT NOT NULL,
29 pickup_address TEXT,
30 dropoff_address TEXT,
31 date TEXT,
32 time TEXT,
33 passengers TEXT DEFAULT '1',
34 notes TEXT,
35 service_type TEXT,
36 departure_flight_number TEXT,
37 departure_time TEXT,
38 arrival_flight_number TEXT,
39 arrival_time TEXT,
40 vip_pickup BOOLEAN DEFAULT FALSE,
41 oversized_luggage BOOLEAN DEFAULT FALSE,
42 return_trip BOOLEAN DEFAULT FALSE,
43 pricing JSONB,
44 total_price NUMERIC(10,2) DEFAULT 0,
45 status TEXT DEFAULT 'pending',
46 payment_status TEXT DEFAULT 'unpaid',
47 payment_method TEXT,
48 last_email_sent TEXT,
49 last_sms_sent TEXT,
50 payment_link_sent TEXT,
51 tracking_id TEXT,
52 tracking_status TEXT,
53 assigned_driver_id TEXT,
54 assigned_driver_name TEXT,
55 driver_payout NUMERIC(10,2),
56 driver_notes TEXT,
57 acceptance_token TEXT,
58 driver_accepted BOOLEAN,
59 driver_accepted_at TEXT,
60 driver_declined_at TEXT,
61 driver_decline_reason TEXT,
62 driver_assigned_at TEXT,
63 driver_location JSONB,
64 driver_eta_minutes INTEGER,
65 auto_dispatched BOOLEAN DEFAULT FALSE,
66 reminder_sent BOOLEAN DEFAULT FALSE,
67 reminder_sent_at TEXT,
68 return_driver_id TEXT,
69 return_driver_name TEXT,
70 return_driver_payout NUMERIC(10,2),
71 return_driver_notes TEXT,
72 return_acceptance_token TEXT,
73 return_driver_accepted BOOLEAN,
74 return_tracking_status TEXT,
75 return_driver_assigned_at TEXT,
76 google_calendar_event_id TEXT,
77 additional_pickups JSONB DEFAULT '[]'::jsonb,
78 created_at TEXT,
79 updated_at TEXT
80 )
81 `;
82
83 await sql`
84 CREATE TABLE IF NOT EXISTS deleted_bookings (
85 id TEXT PRIMARY KEY,
86 booking_ref TEXT,
87 name TEXT,
88 email TEXT,
89 phone TEXT,
90 pickup_address TEXT,
91 dropoff_address TEXT,
92 date TEXT,
93 time TEXT,
94 passengers TEXT,
95 notes TEXT,
96 service_type TEXT,
97 pricing JSONB,
98 total_price NUMERIC(10,2),
99 status TEXT,
100 payment_status TEXT,
101 tracking_id TEXT,
102 assigned_driver_name TEXT,
103 created_at TEXT,
104 updated_at TEXT,
105 deleted_at TEXT,
106 deleted_by TEXT,
107 booking_data JSONB
108 )
109 `;
110
111 await sql`
112 CREATE TABLE IF NOT EXISTS admins (
113 id TEXT PRIMARY KEY,
114 username TEXT UNIQUE NOT NULL,
115 password TEXT NOT NULL,
116 email TEXT,
117 created_at TEXT,
118 updated_at TEXT
119 )
120 `;
121
122 await sql`
123 CREATE TABLE IF NOT EXISTS password_resets (
124 id SERIAL PRIMARY KEY,
125 email TEXT NOT NULL,
126 token TEXT NOT NULL,
127 expires_at TEXT NOT NULL,
128 created_at TEXT
129 )
130 `;
131
132 await sql`
133 CREATE TABLE IF NOT EXISTS drivers (
134 id TEXT PRIMARY KEY,
135 name TEXT NOT NULL,
136 phone TEXT,
137 email TEXT,
138 vehicle TEXT,
139 license TEXT,
140 status TEXT DEFAULT 'active',
141 active BOOLEAN DEFAULT TRUE,
142 created_at TEXT,
143 updated_at TEXT
144 )
145 `;
146
147 await sql`
148 CREATE TABLE IF NOT EXISTS promo_codes (
149 id TEXT PRIMARY KEY,
150 code TEXT UNIQUE NOT NULL,
151 discount_type TEXT DEFAULT 'percentage',
152 discount_value NUMERIC(10,2) DEFAULT 0,
153 min_booking_amount NUMERIC(10,2) DEFAULT 0,
154 max_uses INTEGER,
155 uses_count INTEGER DEFAULT 0,
156 expiry_date TEXT,
157 active BOOLEAN DEFAULT TRUE,
158 description TEXT,
159 created_at TEXT
160 )
161 `;
162
163 await sql`
164 CREATE TABLE IF NOT EXISTS seo_pages (
165 page_slug TEXT PRIMARY KEY,
166 page_title TEXT,
167 meta_description TEXT,
168 meta_keywords TEXT,
169 hero_heading TEXT,
170 hero_subheading TEXT,
171 cta_text TEXT,
172 created_at TEXT,
173 updated_at TEXT
174 )
175 `;
176
177 await sql`
178 CREATE TABLE IF NOT EXISTS google_calendar_tokens (
179 type TEXT PRIMARY KEY,
180 access_token TEXT,
181 refresh_token TEXT,
182 token_type TEXT,
183 expires_in INTEGER,
184 scope TEXT,
185 updated_at TEXT
186 )
187 `;
188
189 // Add indexes for performance (idempotent — IF NOT EXISTS)
190 await sql`CREATE INDEX IF NOT EXISTS idx_bookings_ref ON bookings (booking_ref)`;
191 await sql`CREATE INDEX IF NOT EXISTS idx_bookings_email ON bookings (email)`;
192 await sql`CREATE INDEX IF NOT EXISTS idx_bookings_date ON bookings (date)`;
193 await sql`CREATE INDEX IF NOT EXISTS idx_bookings_created ON bookings (created_at)`;
194 await sql`CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings (status)`;
195 await sql`CREATE INDEX IF NOT EXISTS idx_bookings_payment ON bookings (payment_status)`;
196
197 return { ok: true, message: "Schema initialized with indexes" };
198}
199
200module.exports = { getDb, initSchema };
Addedapi/lib/email.js+288−0View fileUnifiedSplit
1// api/lib/email.js
2// Mailgun HTTP API email sender for Vercel Serverless Functions
3// DO NOT replace with Gmail API, SendGrid, or raw SMTP
4
5const { escapeHtml, formatDateWithDay } = require("./helpers");
6
7/**
8 * Send email via Mailgun HTTP API.
9 */
10async function sendEmail(to, subject, htmlBody) {
11 const apiKey = process.env.MAILGUN_API_KEY;
12 const domain = process.env.MAILGUN_DOMAIN;
13 const senderEmail = process.env.SENDER_EMAIL || "noreply@bookaride.co.nz";
14
15 if (!apiKey || !domain) {
16 console.error("Mailgun not configured (set MAILGUN_API_KEY and MAILGUN_DOMAIN)");
17 return false;
18 }
19
20 const formData = new URLSearchParams();
21 formData.append("from", senderEmail);
22 formData.append("to", to);
23 formData.append("subject", subject);
24 formData.append("html", `<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">${htmlBody}</body></html>`);
25
26 try {
27 const response = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
28 method: "POST",
29 headers: {
30 Authorization: "Basic " + Buffer.from(`api:${apiKey}`).toString("base64"),
31 },
32 body: formData,
33 });
34
35 if (response.ok) {
36 console.log(`Email sent to ${to}`);
37 return true;
38 }
39 const text = await response.text();
40 console.error(`Mailgun error (${response.status}): ${text}`);
41 return false;
42 } catch (err) {
43 console.error(`Email send error: ${err.message}`);
44 return false;
45 }
46}
47
48/**
49 * Send customer booking confirmation email.
50 * All user-provided fields are HTML-escaped.
51 */
52function sendCustomerConfirmation(booking) {
53 const ref = escapeHtml(booking.booking_ref || booking.bookingRef || "N/A");
54 const name = escapeHtml(booking.name);
55 const pickup = escapeHtml(booking.pickupAddress);
56 const dropoff = escapeHtml(booking.dropoffAddress);
57 const formattedDate = formatDateWithDay(booking.date);
58 const time = escapeHtml(booking.time);
59 const passengers = escapeHtml(booking.passengers);
60 const pricing = booking.pricing || {};
61 const frontendUrl = process.env.FRONTEND_URL || "https://hibiscustoairport.co.nz";
62
63 // Flight info (escaped)
64 let flightSection = "";
65 if (booking.departureFlightNumber || booking.departureTime || booking.arrivalFlightNumber || booking.arrivalTime) {
66 const parts = [];
67 if (booking.departureFlightNumber || booking.departureTime) {
68 parts.push(`Departure: ${escapeHtml(booking.departureFlightNumber || "")} at ${escapeHtml(booking.departureTime || "")}`);
69 }
70 if (booking.arrivalFlightNumber || booking.arrivalTime) {
71 parts.push(`Arrival: ${escapeHtml(booking.arrivalFlightNumber || "")} at ${escapeHtml(booking.arrivalTime || "")}`);
72 }
73 flightSection = `
74 <div style="margin-bottom: 20px;">
75 <p style="margin: 0; color: #6b7280; font-size: 14px; font-weight: 500;">FLIGHT INFORMATION</p>
76 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px;">${parts.join(" | ")}</p>
77 </div>`;
78 }
79
80 const subject = `Your Premium Transfer is Confirmed - ${ref}`;
81
82 const body = `
83 <div style="max-width: 650px; margin: 0 auto; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f8fafc;">
84 <div style="background: linear-gradient(135deg, #1f2937 0%, #111827 100%); color: white; padding: 40px 30px; text-align: center;">
85 <h1 style="margin: 0; font-size: 24px; font-weight: 300; letter-spacing: 2px; text-transform: uppercase;">HIBISCUS TO AIRPORT</h1>
86 <p style="margin: 8px 0 0; font-size: 14px; color: #f59e0b; font-weight: 500;">PREMIUM TRANSPORTATION</p>
87 </div>
88 <div style="background: white; padding: 40px 30px;">
89 <h2 style="margin: 0; color: #1f2937; font-size: 28px; text-align: center;">Dear ${name},</h2>
90 <p style="text-align: center; color: #6b7280;">Your premium airport transfer has been confirmed.</p>
91 <div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: 25px; border-radius: 12px; text-align: center; margin: 30px 0;">
92 <h3 style="margin: 0 0 10px; font-size: 18px;">BOOKING CONFIRMATION</h3>
93 <p style="margin: 0; font-size: 24px; font-weight: bold;">${ref}</p>
94 </div>
95 <div style="background: #f8fafc; border-radius: 12px; padding: 30px; margin: 30px 0; border-left: 4px solid #f59e0b;">
96 <h3 style="margin: 0 0 20px; color: #f59e0b;">TRANSFER DETAILS</h3>
97 <div style="margin-bottom: 20px;">
98 <p style="margin: 0; color: #6b7280; font-size: 14px;">PICKUP LOCATION</p>
99 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">${pickup}</p>
100 </div>
101 <div style="margin-bottom: 20px;">
102 <p style="margin: 0; color: #6b7280; font-size: 14px;">DESTINATION</p>
103 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">${dropoff}</p>
104 </div>
105 <div style="margin-bottom: 20px;">
106 <p style="margin: 0; color: #6b7280; font-size: 14px;">DATE &amp; TIME</p>
107 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">${formattedDate} at ${time}</p>
108 </div>
109 <div style="margin-bottom: 20px;">
110 <p style="margin: 0; color: #6b7280; font-size: 14px;">PASSENGERS</p>
111 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">${passengers} guests</p>
112 </div>
113 ${flightSection}
114 </div>
115 <div style="background: white; border: 2px solid #f3f4f6; border-radius: 12px; padding: 30px; margin: 30px 0;">
116 <h3 style="margin: 0 0 20px; color: #f59e0b;">INVESTMENT BREAKDOWN</h3>
117 <table style="width: 100%; border-collapse: collapse;">
118 <tr><td style="padding: 8px 0; color: #6b7280; border-bottom: 1px solid #f3f4f6;">Distance (${pricing.distance || 0} km)</td><td style="padding: 8px 0; text-align: right; font-weight: 600; border-bottom: 1px solid #f3f4f6;">$${(pricing.basePrice || 0).toFixed(2)}</td></tr>
119 <tr><td style="padding: 8px 0; color: #6b7280; border-bottom: 1px solid #f3f4f6;">Airport Service Fee</td><td style="padding: 8px 0; text-align: right; font-weight: 600; border-bottom: 1px solid #f3f4f6;">$${(pricing.airportFee || 0).toFixed(2)}</td></tr>
120 <tr><td style="padding: 8px 0; color: #6b7280; border-bottom: 2px solid #f59e0b;">Additional Passengers</td><td style="padding: 8px 0; text-align: right; font-weight: 600; border-bottom: 2px solid #f59e0b;">$${(pricing.passengerFee || 0).toFixed(2)}</td></tr>
121 <tr><td style="padding: 15px 0 0; color: #f59e0b; font-size: 18px; font-weight: bold;">TOTAL</td><td style="padding: 15px 0 0; text-align: right; color: #f59e0b; font-size: 20px; font-weight: bold;">$${(pricing.totalPrice || 0).toFixed(2)} NZD</td></tr>
122 </table>
123 </div>
124 <div style="text-align: center; background: #f8fafc; border-radius: 12px; padding: 30px; margin: 30px 0;">
125 <p style="margin: 10px 0; color: #6b7280;"><strong>Email:</strong> <span style="color: #f59e0b;">bookings@bookaride.co.nz</span></p>
126 <p style="margin: 10px 0; color: #6b7280;"><strong>Phone:</strong> <span style="color: #f59e0b;">021 743 321</span></p>
127 <p style="margin: 10px 0; color: #6b7280;"><strong>Track:</strong> <span style="color: #f59e0b;">${escapeHtml(frontendUrl)}/tracking/${ref}</span></p>
128 </div>
129 <p style="color: #9ca3af; font-size: 12px; text-align: center; margin: 30px 0 0;">This is an automated confirmation. Please keep for your records.</p>
130 </div>
131 </div>`;
132
133 return sendEmail(booking.email, subject, body);
134}
135
136/**
137 * Send admin notification email for new booking.
138 */
139function sendAdminNotification(booking) {
140 const adminEmail = process.env.ADMIN_EMAIL || "bookings@bookaride.co.nz";
141 const ref = escapeHtml(booking.booking_ref || booking.bookingRef || "N/A");
142 const name = escapeHtml(booking.name);
143 const phone = escapeHtml(booking.phone);
144 const email = escapeHtml(booking.email);
145 const pickup = escapeHtml(booking.pickupAddress);
146 const dropoff = escapeHtml(booking.dropoffAddress);
147 const formattedDate = formatDateWithDay(booking.date);
148 const time = escapeHtml(booking.time);
149 const passengers = escapeHtml(booking.passengers);
150 const notes = escapeHtml(booking.notes || "");
151 const pricing = booking.pricing || {};
152 const totalPrice = pricing.totalPrice || booking.totalPrice || 0;
153
154 const subject = `New Booking - ${ref}`;
155
156 const body = `
157 <div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
158 <h2 style="color: #D4AF37;">New Booking Received</h2>
159 <div style="background: #f9f9f9; padding: 20px; border-radius: 8px; border-left: 4px solid #D4AF37;">
160 <p><strong>Reference:</strong> <span style="font-size: 18px; color: #D4AF37;">${ref}</span></p>
161 <hr style="border: none; border-top: 1px solid #ddd;">
162 <p><strong>Customer:</strong> ${name}</p>
163 <p><strong>Phone:</strong> ${phone}</p>
164 <p><strong>Email:</strong> ${email}</p>
165 <hr style="border: none; border-top: 1px solid #ddd;">
166 <p><strong>Pickup:</strong> ${pickup}</p>
167 <p><strong>Drop-off:</strong> ${dropoff}</p>
168 <p><strong>Date/Time:</strong> ${formattedDate} at ${time}</p>
169 <p><strong>Passengers:</strong> ${passengers}</p>
170 <hr style="border: none; border-top: 1px solid #ddd;">
171 <p><strong>Total Price:</strong> <span style="font-size: 18px; color: green;">$${Number(totalPrice).toFixed(2)} NZD</span></p>
172 <p><strong>Payment Status:</strong> ${escapeHtml(booking.payment_status || "pending")}</p>
173 ${notes ? `<p><strong>Notes:</strong> ${notes}</p>` : ""}
174 </div>
175 </div>`;
176
177 return sendEmail(adminEmail, subject, body);
178}
179
180/**
181 * Send cancellation email to customer.
182 */
183function sendCancellationEmail(booking) {
184 const ref = escapeHtml(booking.booking_ref || "N/A");
185 const name = escapeHtml(booking.name);
186 const pickup = escapeHtml(booking.pickupAddress);
187 const dropoff = escapeHtml(booking.dropoffAddress);
188 const formattedDate = formatDateWithDay(booking.date);
189 const time = escapeHtml(booking.time);
190
191 const subject = `Booking Cancelled - ${ref}`;
192
193 const body = `
194 <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
195 <div style="background: linear-gradient(135deg, #8B0000, #DC143C); color: white; padding: 30px; border-radius: 10px 10px 0 0;">
196 <h1 style="margin: 0;">Booking Cancelled</h1>
197 </div>
198 <div style="background: white; padding: 30px; border-radius: 0 0 10px 10px;">
199 <p>Your booking with Hibiscus to Airport has been cancelled.</p>
200 <div style="background: #ffe6e6; padding: 20px; border-radius: 8px; border-left: 4px solid #DC143C;">
201 <p><strong>Booking Reference:</strong> <span style="color: #DC143C; font-size: 20px; font-weight: bold;">${ref}</span></p>
202 </div>
203 <table style="width: 100%; margin: 20px 0;">
204 <tr><td style="padding: 10px 0; color: #666;"><strong>Name:</strong></td><td>${name}</td></tr>
205 <tr><td style="padding: 10px 0; color: #666;"><strong>Pickup:</strong></td><td>${pickup}</td></tr>
206 <tr><td style="padding: 10px 0; color: #666;"><strong>Drop-off:</strong></td><td>${dropoff}</td></tr>
207 <tr><td style="padding: 10px 0; color: #666;"><strong>Date &amp; Time:</strong></td><td>${formattedDate} at ${time}</td></tr>
208 </table>
209 <p style="color: #666;">Contact us: 021 743 321 | info@bookaride.co.nz</p>
210 </div>
211 </div>`;
212
213 return sendEmail(booking.email, subject, body);
214}
215
216/**
217 * Send password reset email.
218 */
219function sendPasswordResetEmail(email, resetToken) {
220 const frontendUrl = process.env.FRONTEND_URL || "https://hibiscustoairport.co.nz";
221 const resetLink = `${frontendUrl}/admin/reset-password?token=${resetToken}`;
222
223 const subject = "Password Reset - Hibiscus to Airport Admin";
224
225 const body = `
226 <div style="max-width: 600px; margin: 0 auto; font-family: 'Segoe UI', sans-serif;">
227 <div style="background: linear-gradient(135deg, #1f2937, #111827); color: white; padding: 40px 30px; text-align: center; border-radius: 10px 10px 0 0;">
228 <h1 style="margin: 0; font-size: 24px;">PASSWORD RESET</h1>
229 <p style="margin: 8px 0 0; color: #f59e0b;">HIBISCUS TO AIRPORT ADMIN</p>
230 </div>
231 <div style="background: white; padding: 40px 30px; border-radius: 0 0 10px 10px;">
232 <p>We received a request to reset your admin password.</p>
233 <div style="text-align: center; margin: 30px 0;">
234 <a href="${escapeHtml(resetLink)}" style="display: inline-block; background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 15px 40px; border-radius: 8px; text-decoration: none; font-weight: 600;">Reset Password</a>
235 </div>
236 <p style="font-size: 12px; color: #f59e0b; word-break: break-all; background: #f8fafc; padding: 15px; border-radius: 8px;">${escapeHtml(resetLink)}</p>
237 <div style="background: #fef3c7; padding: 15px; border-radius: 8px; border-left: 4px solid #f59e0b;">
238 <p style="margin: 0; font-size: 14px; color: #92400e;">This link expires in <strong>1 hour</strong>. Ignore if you didn't request this.</p>
239 </div>
240 </div>
241 </div>`;
242
243 return sendEmail(email, subject, body);
244}
245
246/**
247 * Send day-before reminder email.
248 */
249function sendReminderEmail(booking) {
250 const ref = escapeHtml(booking.booking_ref);
251 const name = escapeHtml(booking.name);
252 const pickup = escapeHtml(booking.pickup_address);
253 const dropoff = escapeHtml(booking.dropoff_address);
254 const formattedDate = formatDateWithDay(booking.date);
255 const time = escapeHtml(booking.time);
256
257 const subject = `Reminder: Your Airport Transfer Tomorrow - ${ref}`;
258
259 const body = `
260 <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
261 <div style="background: linear-gradient(135deg, #1f2937, #111827); color: #fff; padding: 30px; border-radius: 10px 10px 0 0;">
262 <h1 style="margin: 0; font-size: 24px;">Transfer Reminder</h1>
263 <p style="margin: 8px 0 0; color: #f59e0b;">Your transfer is tomorrow!</p>
264 </div>
265 <div style="background: #fff; padding: 30px; border-radius: 0 0 10px 10px; border: 1px solid #e5e7eb;">
266 <p>Hi ${name},</p>
267 <p>Just a friendly reminder that your airport transfer is scheduled for <strong>tomorrow</strong>.</p>
268 <div style="background: #f8fafc; padding: 20px; border-radius: 8px; border-left: 4px solid #f59e0b;">
269 <p><strong>Booking:</strong> ${ref}</p>
270 <p><strong>Date &amp; Time:</strong> ${formattedDate} at ${time}</p>
271 <p><strong>Pickup:</strong> ${pickup}</p>
272 <p><strong>Drop-off:</strong> ${dropoff}</p>
273 </div>
274 <p>Questions? Contact us at 021 743 321 or bookings@bookaride.co.nz</p>
275 </div>
276 </div>`;
277
278 return sendEmail(booking.email, subject, body);
279}
280
281module.exports = {
282 sendEmail,
283 sendCustomerConfirmation,
284 sendAdminNotification,
285 sendCancellationEmail,
286 sendPasswordResetEmail,
287 sendReminderEmail,
288};
Addedapi/lib/helpers.js+220−0View fileUnifiedSplit
1// api/lib/helpers.js
2// Shared helpers for Vercel Serverless Functions
3
4const crypto = require("crypto");
5
6/**
7 * Standard JSON response helper.
8 * All API responses use: { ok: true/false, data?: ..., error?: "..." }
9 */
10function jsonResponse(res, statusCode, body) {
11 res.setHeader("Content-Type", "application/json");
12 res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0, private");
13 return res.status(statusCode).json(body);
14}
15
16function ok(res, data = {}) {
17 return jsonResponse(res, 200, { ok: true, ...data });
18}
19
20function created(res, data = {}) {
21 return jsonResponse(res, 201, { ok: true, ...data });
22}
23
24function badRequest(res, error) {
25 return jsonResponse(res, 400, { ok: false, error });
26}
27
28function unauthorized(res, error = "Authentication required") {
29 return jsonResponse(res, 401, { ok: false, error });
30}
31
32function notFound(res, error = "Not found") {
33 return jsonResponse(res, 404, { ok: false, error });
34}
35
36function tooManyRequests(res, error) {
37 return jsonResponse(res, 429, { ok: false, error });
38}
39
40function serverError(res, error = "Internal server error") {
41 console.error("Server error:", error);
42 return jsonResponse(res, 500, { ok: false, error: typeof error === "string" ? error : "Internal server error" });
43}
44
45function methodNotAllowed(res, allowed = []) {
46 res.setHeader("Allow", allowed.join(", "));
47 return jsonResponse(res, 405, { ok: false, error: `Method not allowed. Use: ${allowed.join(", ")}` });
48}
49
50/**
51 * HTML-escape user-provided strings before inserting into email templates.
52 * Prevents HTML injection / XSS in emails.
53 */
54function escapeHtml(str) {
55 if (!str) return "";
56 return String(str)
57 .replace(/&/g, "&amp;")
58 .replace(/</g, "&lt;")
59 .replace(/>/g, "&gt;")
60 .replace(/"/g, "&quot;")
61 .replace(/'/g, "&#039;");
62}
63
64/**
65 * Generate a UUID v4.
66 */
67function uuid() {
68 return crypto.randomUUID();
69}
70
71/**
72 * Validate email format.
73 */
74function isValidEmail(email) {
75 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
76}
77
78/**
79 * Validate NZ phone number (basic check).
80 */
81function isValidPhone(phone) {
82 // Accept formats: 021 743 321, +64-21-743-321, 0211234567, etc.
83 const cleaned = phone.replace(/[\s\-()]+/g, "");
84 return /^(\+?64|0)\d{7,10}$/.test(cleaned);
85}
86
87/**
88 * Convert DB row (snake_case) to camelCase API response.
89 */
90function rowToBooking(row) {
91 if (!row) return null;
92 return {
93 id: row.id,
94 booking_ref: row.booking_ref,
95 name: row.name,
96 email: row.email,
97 phone: row.phone,
98 pickupAddress: row.pickup_address || "",
99 dropoffAddress: row.dropoff_address || "",
100 date: row.date || "",
101 time: row.time || "",
102 passengers: row.passengers || "1",
103 notes: row.notes || "",
104 serviceType: row.service_type || "",
105 departureFlightNumber: row.departure_flight_number || "",
106 departureTime: row.departure_time || "",
107 arrivalFlightNumber: row.arrival_flight_number || "",
108 arrivalTime: row.arrival_time || "",
109 vipPickup: row.vip_pickup || false,
110 oversizedLuggage: row.oversized_luggage || false,
111 returnTrip: row.return_trip || false,
112 pricing: row.pricing,
113 totalPrice: row.total_price != null ? parseFloat(row.total_price) : 0,
114 status: row.status || "pending",
115 payment_status: row.payment_status || "unpaid",
116 payment_method: row.payment_method,
117 lastEmailSent: row.last_email_sent,
118 lastSmsSent: row.last_sms_sent,
119 paymentLinkSent: row.payment_link_sent,
120 trackingId: row.tracking_id,
121 trackingStatus: row.tracking_status,
122 assignedDriverId: row.assigned_driver_id,
123 assignedDriverName: row.assigned_driver_name,
124 driverPayout: row.driver_payout != null ? parseFloat(row.driver_payout) : null,
125 driverNotes: row.driver_notes,
126 acceptanceToken: row.acceptance_token,
127 driverAccepted: row.driver_accepted,
128 driverAcceptedAt: row.driver_accepted_at,
129 driverDeclinedAt: row.driver_declined_at,
130 driverDeclineReason: row.driver_decline_reason,
131 driverAssignedAt: row.driver_assigned_at,
132 driverLocation: row.driver_location,
133 driverEtaMinutes: row.driver_eta_minutes,
134 autoDispatched: row.auto_dispatched || false,
135 reminderSent: row.reminder_sent || false,
136 reminderSentAt: row.reminder_sent_at,
137 returnDriverId: row.return_driver_id,
138 returnDriverName: row.return_driver_name,
139 returnDriverPayout: row.return_driver_payout != null ? parseFloat(row.return_driver_payout) : null,
140 returnDriverNotes: row.return_driver_notes,
141 returnAcceptanceToken: row.return_acceptance_token,
142 returnDriverAccepted: row.return_driver_accepted,
143 returnTrackingStatus: row.return_tracking_status,
144 returnDriverAssignedAt: row.return_driver_assigned_at,
145 googleCalendarEventId: row.google_calendar_event_id,
146 additionalPickups: row.additional_pickups || [],
147 createdAt: row.created_at,
148 updatedAt: row.updated_at,
149 };
150}
151
152/**
153 * Simple rate limiter using response headers.
154 * Returns minutes remaining if in cooldown, or 0 if can proceed.
155 */
156function checkCooldown(lastSentIso, cooldownMinutes = 5) {
157 if (!lastSentIso) return 0;
158 try {
159 const lastSent = new Date(lastSentIso);
160 const now = new Date();
161 const elapsedMinutes = (now - lastSent) / 60000;
162 if (elapsedMinutes < cooldownMinutes) {
163 return Math.ceil(cooldownMinutes - elapsedMinutes);
164 }
165 return 0;
166 } catch {
167 return 0;
168 }
169}
170
171/**
172 * Format date as DD/MM/YYYY (NZ format).
173 */
174function formatDateNz(dateStr) {
175 try {
176 const d = new Date(dateStr);
177 const day = String(d.getDate()).padStart(2, "0");
178 const month = String(d.getMonth() + 1).padStart(2, "0");
179 const year = d.getFullYear();
180 return `${day}/${month}/${year}`;
181 } catch {
182 return dateStr;
183 }
184}
185
186/**
187 * Format date as DD/MM/YYYY (DayName).
188 */
189function formatDateWithDay(dateStr) {
190 try {
191 const d = new Date(dateStr);
192 const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
193 const day = String(d.getDate()).padStart(2, "0");
194 const month = String(d.getMonth() + 1).padStart(2, "0");
195 const year = d.getFullYear();
196 return `${day}/${month}/${year} (${days[d.getDay()]})`;
197 } catch {
198 return dateStr;
199 }
200}
201
202module.exports = {
203 jsonResponse,
204 ok,
205 created,
206 badRequest,
207 unauthorized,
208 notFound,
209 tooManyRequests,
210 serverError,
211 methodNotAllowed,
212 escapeHtml,
213 uuid,
214 isValidEmail,
215 isValidPhone,
216 rowToBooking,
217 checkCooldown,
218 formatDateNz,
219 formatDateWithDay,
220};
Addedapi/lib/pricing.js+122−0View fileUnifiedSplit
1// api/lib/pricing.js
2// Pricing engine for Hibiscus to Airport
3// Matches BookaRide pricing tiers exactly
4
5/**
6 * Calculate distance between two addresses using Google Distance Matrix API.
7 * Returns distance in km or null on failure.
8 */
9async function calculateDistance(pickup, dropoff) {
10 const apiKey = process.env.GOOGLE_MAPS_API_KEY;
11 if (!apiKey) {
12 console.error("GOOGLE_MAPS_API_KEY not set");
13 return null;
14 }
15
16 try {
17 const params = new URLSearchParams({
18 origins: pickup,
19 destinations: dropoff,
20 key: apiKey,
21 units: "metric",
22 });
23
24 const response = await fetch(
25 `https://maps.googleapis.com/maps/api/distancematrix/json?${params}`
26 );
27 const data = await response.json();
28
29 if (
30 data.status === "OK" &&
31 data.rows[0]?.elements[0]?.status === "OK"
32 ) {
33 const distanceMeters = data.rows[0].elements[0].distance.value;
34 return Math.round((distanceMeters / 1000) * 100) / 100;
35 }
36 console.error("Google Maps API error:", JSON.stringify(data));
37 return null;
38 } catch (err) {
39 console.error(`Distance calculation error: ${err.message}`);
40 return null;
41 }
42}
43
44/**
45 * Calculate price based on distance bracket and passengers.
46 *
47 * IMPORTANT: The rate is based on TOTAL distance, not incremental.
48 * A 30km trip uses $5.00/km for the ENTIRE distance.
49 *
50 * Pricing tiers:
51 * - 0 - 15 km: $12.00/km
52 * - 15 - 15.8 km: $8.00/km
53 * - 15.8 - 16 km: $6.00/km
54 * - 16 - 25.5 km: $5.50/km
55 * - 25.5 - 35 km: $5.00/km
56 * - 35 - 50 km: $4.00/km
57 * - 50 - 60 km: $2.60/km
58 * - 60 - 75 km: $2.47/km
59 * - 75 - 100 km: $2.70/km
60 * - 100+ km: $3.50/km
61 */
62function calculatePrice(distanceKm, passengers = 1, vipPickup = false, oversizedLuggage = false) {
63 let ratePerKm;
64
65 if (distanceKm <= 15.0) ratePerKm = 12.0;
66 else if (distanceKm <= 15.8) ratePerKm = 8.0;
67 else if (distanceKm <= 16.0) ratePerKm = 6.0;
68 else if (distanceKm <= 25.5) ratePerKm = 5.5;
69 else if (distanceKm <= 35.0) ratePerKm = 5.0;
70 else if (distanceKm <= 50.0) ratePerKm = 4.0;
71 else if (distanceKm <= 60.0) ratePerKm = 2.6;
72 else if (distanceKm <= 75.0) ratePerKm = 2.47;
73 else if (distanceKm <= 100.0) ratePerKm = 2.7;
74 else ratePerKm = 3.5;
75
76 let basePrice = distanceKm * ratePerKm;
77 const passengerFee = Math.max(0, passengers - 1) * 5.0;
78 const airportFee = vipPickup ? 15.0 : 0.0;
79 const luggageFee = oversizedLuggage ? 25.0 : 0.0;
80
81 let totalPrice = basePrice + passengerFee + airportFee + luggageFee;
82
83 // Minimum fare of $100
84 if (totalPrice < 100.0) {
85 totalPrice = 100.0;
86 basePrice = 100.0 - passengerFee - airportFee - luggageFee;
87 }
88
89 return {
90 distance: Math.round(distanceKm * 100) / 100,
91 basePrice: Math.round(basePrice * 100) / 100,
92 airportFee: Math.round(airportFee * 100) / 100,
93 passengerFee: Math.round(passengerFee * 100) / 100,
94 oversizedLuggageFee: Math.round(luggageFee * 100) / 100,
95 totalPrice: Math.round(totalPrice * 100) / 100,
96 ratePerKm,
97 };
98}
99
100/**
101 * Check if a booking is urgent (within 24 hours).
102 * Returns { isUrgent, hoursUntil }.
103 */
104function isUrgentBooking(bookingDate, bookingTime = "00:00") {
105 try {
106 const bookingDt = new Date(`${bookingDate}T${bookingTime}:00`);
107 // Approximate NZ timezone offset (+12 or +13)
108 const nzOffsetMs = 12 * 60 * 60 * 1000;
109 const nowNz = new Date(Date.now() + nzOffsetMs);
110 const bookingNz = new Date(bookingDt.getTime() + nzOffsetMs);
111
112 const hoursUntil = (bookingNz - nowNz) / (1000 * 60 * 60);
113 return {
114 isUrgent: hoursUntil > 0 && hoursUntil <= 24,
115 hoursUntil: Math.round(hoursUntil * 10) / 10,
116 };
117 } catch {
118 return { isUrgent: false, hoursUntil: 0 };
119 }
120}
121
122module.exports = { calculateDistance, calculatePrice, isUrgentBooking };
Addedapi/lib/sms.js+130−0View fileUnifiedSplit
1// api/lib/sms.js
2// Twilio SMS sender for Vercel Serverless Functions
3
4const { escapeHtml, formatDateNz, formatDateWithDay } = require("./helpers");
5
6/**
7 * Send SMS via Twilio REST API (no SDK needed — direct HTTP).
8 * Lighter than the full Twilio SDK for serverless.
9 */
10async function sendSms(toPhone, message) {
11 const accountSid = process.env.TWILIO_ACCOUNT_SID;
12 const authToken = process.env.TWILIO_AUTH_TOKEN;
13 const fromPhone = process.env.TWILIO_PHONE_NUMBER;
14
15 if (!accountSid || !authToken || !fromPhone) {
16 console.error("Twilio not configured (set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER)");
17 return false;
18 }
19
20 const formData = new URLSearchParams();
21 formData.append("To", toPhone);
22 formData.append("From", fromPhone);
23 formData.append("Body", message);
24
25 try {
26 const response = await fetch(
27 `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
28 {
29 method: "POST",
30 headers: {
31 Authorization: "Basic " + Buffer.from(`${accountSid}:${authToken}`).toString("base64"),
32 "Content-Type": "application/x-www-form-urlencoded",
33 },
34 body: formData,
35 }
36 );
37
38 if (response.ok) {
39 console.log(`SMS sent to ${toPhone}`);
40 return true;
41 }
42 const text = await response.text();
43 console.error(`Twilio error (${response.status}): ${text}`);
44 return false;
45 } catch (err) {
46 console.error(`SMS send error: ${err.message}`);
47 return false;
48 }
49}
50
51/**
52 * Send customer booking confirmation SMS.
53 */
54function sendCustomerSms(booking) {
55 const ref = booking.booking_ref || booking.bookingRef || "N/A";
56 const formattedDate = formatDateWithDay(booking.date);
57 const totalPrice = booking.pricing?.totalPrice || booking.totalPrice || 0;
58
59 const message = `HIBISCUS TO AIRPORT\nTransfer CONFIRMED\n\nRef: ${ref}\n${formattedDate}, ${booking.time}\n${(booking.pickupAddress || "").slice(0, 50)} to ${(booking.dropoffAddress || "").slice(0, 50)}\n${booking.passengers} passengers | $${Number(totalPrice).toFixed(2)}\n\nQuestions? 021 743 321\nhibiscustoairport.co.nz`;
60
61 return sendSms(booking.phone, message);
62}
63
64/**
65 * Send admin SMS notification for new booking.
66 */
67function sendAdminSmsNotification(booking) {
68 const adminPhone = process.env.ADMIN_PHONE;
69 if (!adminPhone) {
70 console.warn("ADMIN_PHONE not set — skipping admin SMS");
71 return Promise.resolve(false);
72 }
73
74 const ref = booking.booking_ref || booking.bookingRef || "N/A";
75 const formattedDate = formatDateNz(booking.date);
76 const total = booking.pricing?.totalPrice || booking.totalPrice || 0;
77
78 const message = `NEW BOOKING!\n\nRef: ${ref}\n${booking.name}\n${formattedDate} at ${booking.time}\n${booking.passengers} pax | $${Number(total).toFixed(2)}\n\nFrom: ${(booking.pickupAddress || "").slice(0, 40)}...\nTo: ${(booking.dropoffAddress || "").slice(0, 40)}...\n\nLogin to admin to manage.`;
79
80 return sendSms(adminPhone, message);
81}
82
83/**
84 * Send cancellation SMS to customer.
85 */
86function sendCancellationSms(booking) {
87 const ref = booking.booking_ref || "N/A";
88 const formattedDate = formatDateNz(booking.date);
89
90 const message = `Hibiscus to Airport - Booking Cancelled\nRef: ${ref}\nDate: ${formattedDate} at ${booking.time}\n\nYour booking has been cancelled. Contact us: 021 743 321`;
91
92 return sendSms(booking.phone, message);
93}
94
95/**
96 * Send day-before reminder SMS.
97 */
98function sendReminderSms(booking) {
99 const ref = booking.booking_ref;
100 const formattedDate = formatDateNz(booking.date);
101
102 const message = `REMINDER: Your airport transfer is tomorrow!\nRef: ${ref}\nPickup: ${formattedDate} at ${booking.time}\nFrom: ${(booking.pickup_address || "").slice(0, 50)}\nBe ready 5-10 mins early. Questions? 021 743 321`;
103
104 return sendSms(booking.phone, message);
105}
106
107/**
108 * Send urgent booking alert SMS to admin.
109 */
110function sendUrgentAdminSms(booking, hoursUntil) {
111 const adminPhone = process.env.ADMIN_PHONE;
112 if (!adminPhone) return Promise.resolve(false);
113
114 const ref = booking.booking_ref || booking.bookingRef || "N/A";
115 const formattedDate = formatDateNz(booking.date);
116 const total = booking.pricing?.totalPrice || booking.totalPrice || 0;
117
118 const message = `URGENT BOOKING!\n\nONLY ${Math.floor(hoursUntil)}hrs NOTICE!\n\nRef: ${ref}\n${booking.name}\n${booking.phone}\n\n${formattedDate} at ${booking.time}\n${booking.passengers} pax | $${Number(total).toFixed(2)}\n\nACTION REQUIRED NOW!`;
119
120 return sendSms(adminPhone, message);
121}
122
123module.exports = {
124 sendSms,
125 sendCustomerSms,
126 sendAdminSmsNotification,
127 sendCancellationSms,
128 sendReminderSms,
129 sendUrgentAdminSms,
130};
Addedapi/promo.js+86−0View fileUnifiedSplit
1// /api/promo
2// GET = list promo codes (admin), POST = create promo (admin)
3// Also: GET /api/promo?code=xxx&validate=true — public validation
4
5const { getDb } = require("./lib/db");
6const { authenticateRequest } = require("./lib/auth");
7const { ok, created, badRequest, unauthorized, notFound, serverError, methodNotAllowed, uuid } = require("./lib/helpers");
8
9module.exports = async function handler(req, res) {
10 if (req.method === "GET") {
11 // Public promo validation
12 if (req.query.validate === "true" && req.query.code) {
13 return validatePromo(req, res);
14 }
15 // Admin list
16 const user = authenticateRequest(req);
17 if (!user) return unauthorized(res);
18 return listPromos(req, res);
19 }
20 if (req.method === "POST") {
21 const user = authenticateRequest(req);
22 if (!user) return unauthorized(res);
23 return createPromo(req, res);
24 }
25 return methodNotAllowed(res, ["GET", "POST"]);
26};
27
28async function validatePromo(req, res) {
29 try {
30 const sql = getDb();
31 const { code } = req.query;
32 const rows = await sql`SELECT * FROM promo_codes WHERE code = ${code.toUpperCase()} AND active = true`;
33
34 if (rows.length === 0) return notFound(res, "Invalid or expired promo code");
35
36 const promo = rows[0];
37
38 // Check expiry
39 if (promo.expiry_date && new Date(promo.expiry_date) < new Date()) {
40 return badRequest(res, "Promo code has expired");
41 }
42
43 // Check max uses
44 if (promo.max_uses && promo.uses_count >= promo.max_uses) {
45 return badRequest(res, "Promo code has reached maximum uses");
46 }
47
48 return ok(res, {
49 code: promo.code,
50 discount_type: promo.discount_type,
51 discount_value: parseFloat(promo.discount_value),
52 min_booking_amount: parseFloat(promo.min_booking_amount) || 0,
53 });
54 } catch (err) {
55 return serverError(res, err.message);
56 }
57}
58
59async function listPromos(req, res) {
60 try {
61 const sql = getDb();
62 const rows = await sql`SELECT * FROM promo_codes ORDER BY created_at DESC`;
63 return ok(res, { promos: rows });
64 } catch (err) {
65 return serverError(res, err.message);
66 }
67}
68
69async function createPromo(req, res) {
70 const { code, discount_type, discount_value, min_booking_amount, max_uses, expiry_date, description } = req.body || {};
71 if (!code || !discount_type || discount_value == null) {
72 return badRequest(res, "code, discount_type, and discount_value are required");
73 }
74
75 try {
76 const sql = getDb();
77 const id = uuid();
78 await sql`
79 INSERT INTO promo_codes (id, code, discount_type, discount_value, min_booking_amount, max_uses, expiry_date, description, created_at)
80 VALUES (${id}, ${code.toUpperCase()}, ${discount_type}, ${discount_value}, ${min_booking_amount || 0}, ${max_uses || null}, ${expiry_date || null}, ${description || ""}, ${new Date().toISOString()})
81 `;
82 return created(res, { message: "Promo code created", id });
83 } catch (err) {
84 return serverError(res, err.message);
85 }
86}
Addedapi/seo.js+57−0View fileUnifiedSplit
1// /api/seo
2// GET /api/seo?slug=xxx — public, get SEO data for a page
3// PUT /api/seo — admin, update SEO data
4
5const { getDb } = require("./lib/db");
6const { authenticateRequest } = require("./lib/auth");
7const { ok, badRequest, unauthorized, notFound, serverError, methodNotAllowed } = require("./lib/helpers");
8
9module.exports = async function handler(req, res) {
10 if (req.method === "GET") return getSeo(req, res);
11 if (req.method === "PUT") return updateSeo(req, res);
12 return methodNotAllowed(res, ["GET", "PUT"]);
13};
14
15async function getSeo(req, res) {
16 const { slug } = req.query;
17 if (!slug) return badRequest(res, "slug query parameter is required");
18
19 try {
20 const sql = getDb();
21 const rows = await sql`SELECT * FROM seo_pages WHERE page_slug = ${slug}`;
22 if (rows.length === 0) return notFound(res, "No SEO data for this page");
23 return ok(res, { seo: rows[0] });
24 } catch (err) {
25 return serverError(res, err.message);
26 }
27}
28
29async function updateSeo(req, res) {
30 const user = authenticateRequest(req);
31 if (!user) return unauthorized(res);
32
33 const { page_slug, page_title, meta_description, meta_keywords, hero_heading, hero_subheading, cta_text } = req.body || {};
34 if (!page_slug) return badRequest(res, "page_slug is required");
35
36 try {
37 const sql = getDb();
38 const now = new Date().toISOString();
39
40 await sql`
41 INSERT INTO seo_pages (page_slug, page_title, meta_description, meta_keywords, hero_heading, hero_subheading, cta_text, created_at, updated_at)
42 VALUES (${page_slug}, ${page_title || ""}, ${meta_description || ""}, ${meta_keywords || ""}, ${hero_heading || ""}, ${hero_subheading || ""}, ${cta_text || "Book Now"}, ${now}, ${now})
43 ON CONFLICT (page_slug) DO UPDATE SET
44 page_title = EXCLUDED.page_title,
45 meta_description = EXCLUDED.meta_description,
46 meta_keywords = EXCLUDED.meta_keywords,
47 hero_heading = EXCLUDED.hero_heading,
48 hero_subheading = EXCLUDED.hero_subheading,
49 cta_text = EXCLUDED.cta_text,
50 updated_at = EXCLUDED.updated_at
51 `;
52
53 return ok(res, { message: "SEO data saved" });
54 } catch (err) {
55 return serverError(res, err.message);
56 }
57}
Addedapi/stripe/create-session.js+59−0View fileUnifiedSplit
1// POST /api/stripe/create-session — Create Stripe Checkout session
2
3const { getDb } = require("../lib/db");
4const { ok, badRequest, notFound, serverError, methodNotAllowed } = require("../lib/helpers");
5
6module.exports = async function handler(req, res) {
7 if (req.method !== "POST") return methodNotAllowed(res, ["POST"]);
8
9 const stripeKey = process.env.STRIPE_SECRET_KEY;
10 if (!stripeKey) return serverError(res, "Stripe not configured");
11
12 const stripe = require("stripe")(stripeKey);
13 const frontendUrl = process.env.FRONTEND_URL || "https://hibiscustoairport.co.nz";
14
15 const { booking_id } = req.body || {};
16 if (!booking_id) return badRequest(res, "booking_id is required");
17
18 try {
19 const sql = getDb();
20 const rows = await sql`SELECT * FROM bookings WHERE id = ${booking_id}`;
21 if (rows.length === 0) return notFound(res, "Booking not found");
22
23 const booking = rows[0];
24 const totalPrice = parseFloat(booking.total_price) || 0;
25
26 if (totalPrice <= 0) return badRequest(res, "Invalid booking price");
27
28 const session = await stripe.checkout.sessions.create({
29 payment_method_types: ["card"],
30 line_items: [
31 {
32 price_data: {
33 currency: "nzd",
34 product_data: {
35 name: `Airport Transfer - ${booking.booking_ref}`,
36 description: `${booking.pickup_address} to ${booking.dropoff_address}`,
37 },
38 unit_amount: Math.round(totalPrice * 100),
39 },
40 quantity: 1,
41 },
42 ],
43 mode: "payment",
44 success_url: `${frontendUrl}/payment/success?booking_ref=${booking.booking_ref}&session_id={CHECKOUT_SESSION_ID}`,
45 cancel_url: `${frontendUrl}/payment/cancel?booking_ref=${booking.booking_ref}`,
46 metadata: {
47 booking_id: booking.id,
48 booking_ref: booking.booking_ref,
49 },
50 });
51
52 // Mark that payment link was sent
53 await sql`UPDATE bookings SET payment_link_sent = ${new Date().toISOString()} WHERE id = ${booking_id}`;
54
55 return ok(res, { sessionId: session.id, url: session.url });
56 } catch (err) {
57 return serverError(res, err.message);
58 }
59};
Addedapi/stripe/payment-status.js+37−0View fileUnifiedSplit
1// GET /api/stripe/payment-status?booking_id=xxx — Check payment status
2
3const { getDb } = require("../lib/db");
4const { ok, badRequest, notFound, serverError, methodNotAllowed } = require("../lib/helpers");
5
6module.exports = async function handler(req, res) {
7 if (req.method !== "GET") return methodNotAllowed(res, ["GET"]);
8
9 const { booking_id, booking_ref } = req.query;
10
11 if (!booking_id && !booking_ref) {
12 return badRequest(res, "booking_id or booking_ref is required");
13 }
14
15 try {
16 const sql = getDb();
17 let rows;
18
19 if (booking_ref) {
20 rows = await sql`SELECT id, booking_ref, status, payment_status, payment_method FROM bookings WHERE booking_ref = ${booking_ref}`;
21 } else {
22 rows = await sql`SELECT id, booking_ref, status, payment_status, payment_method FROM bookings WHERE id = ${booking_id}`;
23 }
24
25 if (rows.length === 0) return notFound(res, "Booking not found");
26
27 const b = rows[0];
28 return ok(res, {
29 booking_ref: b.booking_ref,
30 status: b.status,
31 payment_status: b.payment_status,
32 payment_method: b.payment_method,
33 });
34 } catch (err) {
35 return serverError(res, err.message);
36 }
37};
Addedapi/stripe/webhook.js+95−0View fileUnifiedSplit
1// POST /api/stripe/webhook — Stripe webhook handler
2
3const { getDb } = require("../lib/db");
4const { sendCustomerConfirmation } = require("../lib/email");
5const { sendCustomerSms } = require("../lib/sms");
6const { rowToBooking } = require("../lib/helpers");
7
8// Disable body parsing — Stripe needs raw body for signature verification
9module.exports.config = { api: { bodyParser: false } };
10
11module.exports = async function handler(req, res) {
12 if (req.method !== "POST") {
13 return res.status(405).json({ ok: false, error: "Method not allowed" });
14 }
15
16 const stripeKey = process.env.STRIPE_SECRET_KEY;
17 const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
18
19 if (!stripeKey) {
20 return res.status(500).json({ ok: false, error: "Stripe not configured" });
21 }
22
23 const stripe = require("stripe")(stripeKey);
24
25 // Read raw body
26 const chunks = [];
27 for await (const chunk of req) {
28 chunks.push(chunk);
29 }
30 const rawBody = Buffer.concat(chunks);
31
32 let event;
33 try {
34 if (webhookSecret) {
35 const sig = req.headers["stripe-signature"];
36 event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
37 } else {
38 event = JSON.parse(rawBody.toString());
39 console.warn("STRIPE_WEBHOOK_SECRET not set — webhook signature not verified");
40 }
41 } catch (err) {
42 console.error("Webhook signature verification failed:", err.message);
43 return res.status(400).json({ ok: false, error: "Invalid signature" });
44 }
45
46 if (event.type === "checkout.session.completed") {
47 const session = event.data.object;
48 const bookingId = session.metadata?.booking_id;
49
50 if (!bookingId) {
51 console.error("Webhook missing booking_id in metadata");
52 return res.status(200).json({ ok: true, message: "No booking_id in metadata" });
53 }
54
55 try {
56 const sql = getDb();
57
58 // IDEMPOTENCY CHECK: Don't process if already paid
59 const rows = await sql`SELECT * FROM bookings WHERE id = ${bookingId}`;
60 if (rows.length === 0) {
61 console.error(`Webhook: booking ${bookingId} not found`);
62 return res.status(200).json({ ok: true, message: "Booking not found" });
63 }
64
65 const booking = rows[0];
66
67 if (booking.payment_status === "paid") {
68 console.log(`Webhook: booking ${booking.booking_ref} already paid — skipping`);
69 return res.status(200).json({ ok: true, message: "Already processed" });
70 }
71
72 // Update payment status
73 await sql`
74 UPDATE bookings
75 SET status = 'confirmed', payment_status = 'paid',
76 payment_method = 'stripe', updated_at = ${new Date().toISOString()}
77 WHERE id = ${bookingId}
78 `;
79
80 // Send customer notifications
81 const bookingDoc = rowToBooking(booking);
82 bookingDoc.status = "confirmed";
83 bookingDoc.payment_status = "paid";
84
85 try { await sendCustomerConfirmation(bookingDoc); } catch (e) { console.error("Post-payment email failed:", e.message); }
86 try { await sendCustomerSms(bookingDoc); } catch (e) { console.error("Post-payment SMS failed:", e.message); }
87
88 console.log(`Payment confirmed for booking ${booking.booking_ref}`);
89 } catch (err) {
90 console.error("Webhook processing error:", err.message);
91 }
92 }
93
94 return res.status(200).json({ ok: true, received: true });
95};
Modifiedfrontend/src/App.js+10−7View fileUnifiedSplit
11import React, { lazy, Suspense } from "react";
22import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
33import { HelmetProvider } from "react-helmet-async";
4import ErrorBoundary from "./components/ErrorBoundary";
45
56// --- Eagerly loaded (critical path) ---
67import HomePage from "./pages/HomePage";
318319
319320export default function App() {
320321 return (
321 <HelmetProvider>
322 <BrowserRouter>
323 <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gold"></div></div>}>
324 <RouterSwitch />
325 </Suspense>
326 </BrowserRouter>
327 </HelmetProvider>
322 <ErrorBoundary>
323 <HelmetProvider>
324 <BrowserRouter>
325 <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gold"></div></div>}>
326 <RouterSwitch />
327 </Suspense>
328 </BrowserRouter>
329 </HelmetProvider>
330 </ErrorBoundary>
328331 );
329332}
Addedfrontend/src/components/ErrorBoundary.jsx+69−0View fileUnifiedSplit
1import React from "react";
2
3/**
4 * Public site Error Boundary.
5 * Catches React component errors and shows a friendly fallback
6 * instead of a white screen. Rule 13: Every page must have this.
7 */
8export default class ErrorBoundary extends React.Component {
9 constructor(props) {
10 super(props);
11 this.state = { hasError: false };
12 }
13
14 static getDerivedStateFromError() {
15 return { hasError: true };
16 }
17
18 componentDidCatch(error, info) {
19 // Log to error tracking (PostHog or similar)
20 if (typeof window !== "undefined" && window.posthog) {
21 window.posthog.capture("error_boundary_triggered", {
22 error: String(error),
23 componentStack: info?.componentStack,
24 page: window.location.pathname,
25 });
26 }
27 }
28
29 render() {
30 if (this.state.hasError) {
31 return (
32 <div style={{
33 minHeight: "60vh",
34 display: "flex",
35 flexDirection: "column",
36 alignItems: "center",
37 justifyContent: "center",
38 fontFamily: "'Segoe UI', system-ui, Arial, sans-serif",
39 padding: "40px 20px",
40 textAlign: "center",
41 }}>
42 <h2 style={{ color: "#1f2937", fontSize: "24px", marginBottom: "12px" }}>
43 Something went wrong
44 </h2>
45 <p style={{ color: "#6b7280", fontSize: "16px", maxWidth: "400px", marginBottom: "24px" }}>
46 We're sorry for the inconvenience. Please try refreshing the page.
47 If the problem persists, contact us at 021 743 321.
48 </p>
49 <button
50 onClick={() => window.location.reload()}
51 style={{
52 background: "linear-gradient(135deg, #f59e0b, #d97706)",
53 color: "white",
54 border: "none",
55 padding: "12px 32px",
56 borderRadius: "8px",
57 fontSize: "16px",
58 cursor: "pointer",
59 fontWeight: "600",
60 }}
61 >
62 Refresh Page
63 </button>
64 </div>
65 );
66 }
67 return this.props.children;
68 }
69}
Modifiedfrontend/src/config.js+3−3View fileUnifiedSplit
11// Centralized backend URL configuration
2// With Vercel serverless, the API lives on the same domain under /api/*.
3// REACT_APP_BACKEND_URL can override for local dev (e.g. http://localhost:8000).
4// In production, empty string means same-origin requests.
2// With Vercel Serverless Functions, API routes are on the same domain.
3// BACKEND_URL defaults to empty string so all calls go to /api/... on same origin.
4// Set REACT_APP_BACKEND_URL only for local dev pointing at a separate backend.
55export const BACKEND_URL =
66 process.env.REACT_APP_BACKEND_URL || '';
77
Modifiedfrontend/vercel.json+1−1View fileUnifiedSplit
44 { "src": "^/api/(.*)", "dest": "/api/$1" },
55 { "src": "^/(.*)", "dest": "/index.html" }
66 ]
7}
\ No newline at end of file
7}
Addedpackage-lock.json+780−0View fileUnifiedSplit
1{
2 "name": "hibiscus-to-airport-api",
3 "version": "1.0.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "hibiscus-to-airport-api",
9 "version": "1.0.0",
10 "dependencies": {
11 "@neondatabase/serverless": "^0.10.4",
12 "bcryptjs": "^2.4.3",
13 "jsonwebtoken": "^9.0.2",
14 "stripe": "^17.5.0",
15 "twilio": "^5.4.3"
16 }
17 },
18 "node_modules/@neondatabase/serverless": {
19 "version": "0.10.4",
20 "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz",
21 "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==",
22 "license": "MIT",
23 "dependencies": {
24 "@types/pg": "8.11.6"
25 }
26 },
27 "node_modules/@types/node": {
28 "version": "25.5.0",
29 "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
30 "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
31 "license": "MIT",
32 "dependencies": {
33 "undici-types": "~7.18.0"
34 }
35 },
36 "node_modules/@types/pg": {
37 "version": "8.11.6",
38 "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz",
39 "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==",
40 "license": "MIT",
41 "dependencies": {
42 "@types/node": "*",
43 "pg-protocol": "*",
44 "pg-types": "^4.0.1"
45 }
46 },
47 "node_modules/agent-base": {
48 "version": "6.0.2",
49 "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
50 "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
51 "license": "MIT",
52 "dependencies": {
53 "debug": "4"
54 },
55 "engines": {
56 "node": ">= 6.0.0"
57 }
58 },
59 "node_modules/asynckit": {
60 "version": "0.4.0",
61 "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
62 "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
63 "license": "MIT"
64 },
65 "node_modules/axios": {
66 "version": "1.13.6",
67 "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
68 "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
69 "license": "MIT",
70 "dependencies": {
71 "follow-redirects": "^1.15.11",
72 "form-data": "^4.0.5",
73 "proxy-from-env": "^1.1.0"
74 }
75 },
76 "node_modules/bcryptjs": {
77 "version": "2.4.3",
78 "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
79 "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
80 "license": "MIT"
81 },
82 "node_modules/buffer-equal-constant-time": {
83 "version": "1.0.1",
84 "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
85 "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
86 "license": "BSD-3-Clause"
87 },
88 "node_modules/call-bind-apply-helpers": {
89 "version": "1.0.2",
90 "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
91 "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
92 "license": "MIT",
93 "dependencies": {
94 "es-errors": "^1.3.0",
95 "function-bind": "^1.1.2"
96 },
97 "engines": {
98 "node": ">= 0.4"
99 }
100 },
101 "node_modules/call-bound": {
102 "version": "1.0.4",
103 "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
104 "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
105 "license": "MIT",
106 "dependencies": {
107 "call-bind-apply-helpers": "^1.0.2",
108 "get-intrinsic": "^1.3.0"
109 },
110 "engines": {
111 "node": ">= 0.4"
112 },
113 "funding": {
114 "url": "https://github.com/sponsors/ljharb"
115 }
116 },
117 "node_modules/combined-stream": {
118 "version": "1.0.8",
119 "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
120 "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
121 "license": "MIT",
122 "dependencies": {
123 "delayed-stream": "~1.0.0"
124 },
125 "engines": {
126 "node": ">= 0.8"
127 }
128 },
129 "node_modules/dayjs": {
130 "version": "1.11.20",
131 "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
132 "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
133 "license": "MIT"
134 },
135 "node_modules/debug": {
136 "version": "4.4.3",
137 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
138 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
139 "license": "MIT",
140 "dependencies": {
141 "ms": "^2.1.3"
142 },
143 "engines": {
144 "node": ">=6.0"
145 },
146 "peerDependenciesMeta": {
147 "supports-color": {
148 "optional": true
149 }
150 }
151 },
152 "node_modules/delayed-stream": {
153 "version": "1.0.0",
154 "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
155 "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
156 "license": "MIT",
157 "engines": {
158 "node": ">=0.4.0"
159 }
160 },
161 "node_modules/dunder-proto": {
162 "version": "1.0.1",
163 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
164 "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
165 "license": "MIT",
166 "dependencies": {
167 "call-bind-apply-helpers": "^1.0.1",
168 "es-errors": "^1.3.0",
169 "gopd": "^1.2.0"
170 },
171 "engines": {
172 "node": ">= 0.4"
173 }
174 },
175 "node_modules/ecdsa-sig-formatter": {
176 "version": "1.0.11",
177 "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
178 "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
179 "license": "Apache-2.0",
180 "dependencies": {
181 "safe-buffer": "^5.0.1"
182 }
183 },
184 "node_modules/es-define-property": {
185 "version": "1.0.1",
186 "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
187 "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
188 "license": "MIT",
189 "engines": {
190 "node": ">= 0.4"
191 }
192 },
193 "node_modules/es-errors": {
194 "version": "1.3.0",
195 "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
196 "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
197 "license": "MIT",
198 "engines": {
199 "node": ">= 0.4"
200 }
201 },
202 "node_modules/es-object-atoms": {
203 "version": "1.1.1",
204 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
205 "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
206 "license": "MIT",
207 "dependencies": {
208 "es-errors": "^1.3.0"
209 },
210 "engines": {
211 "node": ">= 0.4"
212 }
213 },
214 "node_modules/es-set-tostringtag": {
215 "version": "2.1.0",
216 "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
217 "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
218 "license": "MIT",
219 "dependencies": {
220 "es-errors": "^1.3.0",
221 "get-intrinsic": "^1.2.6",
222 "has-tostringtag": "^1.0.2",
223 "hasown": "^2.0.2"
224 },
225 "engines": {
226 "node": ">= 0.4"
227 }
228 },
229 "node_modules/follow-redirects": {
230 "version": "1.15.11",
231 "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
232 "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
233 "funding": [
234 {
235 "type": "individual",
236 "url": "https://github.com/sponsors/RubenVerborgh"
237 }
238 ],
239 "license": "MIT",
240 "engines": {
241 "node": ">=4.0"
242 },
243 "peerDependenciesMeta": {
244 "debug": {
245 "optional": true
246 }
247 }
248 },
249 "node_modules/form-data": {
250 "version": "4.0.5",
251 "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
252 "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
253 "license": "MIT",
254 "dependencies": {
255 "asynckit": "^0.4.0",
256 "combined-stream": "^1.0.8",
257 "es-set-tostringtag": "^2.1.0",
258 "hasown": "^2.0.2",
259 "mime-types": "^2.1.12"
260 },
261 "engines": {
262 "node": ">= 6"
263 }
264 },
265 "node_modules/function-bind": {
266 "version": "1.1.2",
267 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
268 "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
269 "license": "MIT",
270 "funding": {
271 "url": "https://github.com/sponsors/ljharb"
272 }
273 },
274 "node_modules/get-intrinsic": {
275 "version": "1.3.0",
276 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
277 "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
278 "license": "MIT",
279 "dependencies": {
280 "call-bind-apply-helpers": "^1.0.2",
281 "es-define-property": "^1.0.1",
282 "es-errors": "^1.3.0",
283 "es-object-atoms": "^1.1.1",
284 "function-bind": "^1.1.2",
285 "get-proto": "^1.0.1",
286 "gopd": "^1.2.0",
287 "has-symbols": "^1.1.0",
288 "hasown": "^2.0.2",
289 "math-intrinsics": "^1.1.0"
290 },
291 "engines": {
292 "node": ">= 0.4"
293 },
294 "funding": {
295 "url": "https://github.com/sponsors/ljharb"
296 }
297 },
298 "node_modules/get-proto": {
299 "version": "1.0.1",
300 "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
301 "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
302 "license": "MIT",
303 "dependencies": {
304 "dunder-proto": "^1.0.1",
305 "es-object-atoms": "^1.0.0"
306 },
307 "engines": {
308 "node": ">= 0.4"
309 }
310 },
311 "node_modules/gopd": {
312 "version": "1.2.0",
313 "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
314 "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
315 "license": "MIT",
316 "engines": {
317 "node": ">= 0.4"
318 },
319 "funding": {
320 "url": "https://github.com/sponsors/ljharb"
321 }
322 },
323 "node_modules/has-symbols": {
324 "version": "1.1.0",
325 "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
326 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
327 "license": "MIT",
328 "engines": {
329 "node": ">= 0.4"
330 },
331 "funding": {
332 "url": "https://github.com/sponsors/ljharb"
333 }
334 },
335 "node_modules/has-tostringtag": {
336 "version": "1.0.2",
337 "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
338 "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
339 "license": "MIT",
340 "dependencies": {
341 "has-symbols": "^1.0.3"
342 },
343 "engines": {
344 "node": ">= 0.4"
345 },
346 "funding": {
347 "url": "https://github.com/sponsors/ljharb"
348 }
349 },
350 "node_modules/hasown": {
351 "version": "2.0.2",
352 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
353 "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
354 "license": "MIT",
355 "dependencies": {
356 "function-bind": "^1.1.2"
357 },
358 "engines": {
359 "node": ">= 0.4"
360 }
361 },
362 "node_modules/https-proxy-agent": {
363 "version": "5.0.1",
364 "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
365 "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
366 "license": "MIT",
367 "dependencies": {
368 "agent-base": "6",
369 "debug": "4"
370 },
371 "engines": {
372 "node": ">= 6"
373 }
374 },
375 "node_modules/jsonwebtoken": {
376 "version": "9.0.3",
377 "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
378 "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
379 "license": "MIT",
380 "dependencies": {
381 "jws": "^4.0.1",
382 "lodash.includes": "^4.3.0",
383 "lodash.isboolean": "^3.0.3",
384 "lodash.isinteger": "^4.0.4",
385 "lodash.isnumber": "^3.0.3",
386 "lodash.isplainobject": "^4.0.6",
387 "lodash.isstring": "^4.0.1",
388 "lodash.once": "^4.0.0",
389 "ms": "^2.1.1",
390 "semver": "^7.5.4"
391 },
392 "engines": {
393 "node": ">=12",
394 "npm": ">=6"
395 }
396 },
397 "node_modules/jwa": {
398 "version": "2.0.1",
399 "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
400 "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
401 "license": "MIT",
402 "dependencies": {
403 "buffer-equal-constant-time": "^1.0.1",
404 "ecdsa-sig-formatter": "1.0.11",
405 "safe-buffer": "^5.0.1"
406 }
407 },
408 "node_modules/jws": {
409 "version": "4.0.1",
410 "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
411 "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
412 "license": "MIT",
413 "dependencies": {
414 "jwa": "^2.0.1",
415 "safe-buffer": "^5.0.1"
416 }
417 },
418 "node_modules/lodash.includes": {
419 "version": "4.3.0",
420 "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
421 "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
422 "license": "MIT"
423 },
424 "node_modules/lodash.isboolean": {
425 "version": "3.0.3",
426 "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
427 "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
428 "license": "MIT"
429 },
430 "node_modules/lodash.isinteger": {
431 "version": "4.0.4",
432 "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
433 "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
434 "license": "MIT"
435 },
436 "node_modules/lodash.isnumber": {
437 "version": "3.0.3",
438 "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
439 "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
440 "license": "MIT"
441 },
442 "node_modules/lodash.isplainobject": {
443 "version": "4.0.6",
444 "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
445 "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
446 "license": "MIT"
447 },
448 "node_modules/lodash.isstring": {
449 "version": "4.0.1",
450 "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
451 "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
452 "license": "MIT"
453 },
454 "node_modules/lodash.once": {
455 "version": "4.1.1",
456 "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
457 "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
458 "license": "MIT"
459 },
460 "node_modules/math-intrinsics": {
461 "version": "1.1.0",
462 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
463 "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
464 "license": "MIT",
465 "engines": {
466 "node": ">= 0.4"
467 }
468 },
469 "node_modules/mime-db": {
470 "version": "1.52.0",
471 "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
472 "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
473 "license": "MIT",
474 "engines": {
475 "node": ">= 0.6"
476 }
477 },
478 "node_modules/mime-types": {
479 "version": "2.1.35",
480 "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
481 "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
482 "license": "MIT",
483 "dependencies": {
484 "mime-db": "1.52.0"
485 },
486 "engines": {
487 "node": ">= 0.6"
488 }
489 },
490 "node_modules/ms": {
491 "version": "2.1.3",
492 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
493 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
494 "license": "MIT"
495 },
496 "node_modules/object-inspect": {
497 "version": "1.13.4",
498 "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
499 "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
500 "license": "MIT",
501 "engines": {
502 "node": ">= 0.4"
503 },
504 "funding": {
505 "url": "https://github.com/sponsors/ljharb"
506 }
507 },
508 "node_modules/obuf": {
509 "version": "1.1.2",
510 "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
511 "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
512 "license": "MIT"
513 },
514 "node_modules/pg-int8": {
515 "version": "1.0.1",
516 "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
517 "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
518 "license": "ISC",
519 "engines": {
520 "node": ">=4.0.0"
521 }
522 },
523 "node_modules/pg-numeric": {
524 "version": "1.0.2",
525 "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
526 "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
527 "license": "ISC",
528 "engines": {
529 "node": ">=4"
530 }
531 },
532 "node_modules/pg-protocol": {
533 "version": "1.13.0",
534 "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
535 "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
536 "license": "MIT"
537 },
538 "node_modules/pg-types": {
539 "version": "4.1.0",
540 "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz",
541 "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==",
542 "license": "MIT",
543 "dependencies": {
544 "pg-int8": "1.0.1",
545 "pg-numeric": "1.0.2",
546 "postgres-array": "~3.0.1",
547 "postgres-bytea": "~3.0.0",
548 "postgres-date": "~2.1.0",
549 "postgres-interval": "^3.0.0",
550 "postgres-range": "^1.1.1"
551 },
552 "engines": {
553 "node": ">=10"
554 }
555 },
556 "node_modules/postgres-array": {
557 "version": "3.0.4",
558 "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
559 "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
560 "license": "MIT",
561 "engines": {
562 "node": ">=12"
563 }
564 },
565 "node_modules/postgres-bytea": {
566 "version": "3.0.0",
567 "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
568 "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
569 "license": "MIT",
570 "dependencies": {
571 "obuf": "~1.1.2"
572 },
573 "engines": {
574 "node": ">= 6"
575 }
576 },
577 "node_modules/postgres-date": {
578 "version": "2.1.0",
579 "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
580 "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
581 "license": "MIT",
582 "engines": {
583 "node": ">=12"
584 }
585 },
586 "node_modules/postgres-interval": {
587 "version": "3.0.0",
588 "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
589 "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
590 "license": "MIT",
591 "engines": {
592 "node": ">=12"
593 }
594 },
595 "node_modules/postgres-range": {
596 "version": "1.1.4",
597 "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
598 "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
599 "license": "MIT"
600 },
601 "node_modules/proxy-from-env": {
602 "version": "1.1.0",
603 "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
604 "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
605 "license": "MIT"
606 },
607 "node_modules/qs": {
608 "version": "6.15.0",
609 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
610 "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
611 "license": "BSD-3-Clause",
612 "dependencies": {
613 "side-channel": "^1.1.0"
614 },
615 "engines": {
616 "node": ">=0.6"
617 },
618 "funding": {
619 "url": "https://github.com/sponsors/ljharb"
620 }
621 },
622 "node_modules/safe-buffer": {
623 "version": "5.2.1",
624 "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
625 "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
626 "funding": [
627 {
628 "type": "github",
629 "url": "https://github.com/sponsors/feross"
630 },
631 {
632 "type": "patreon",
633 "url": "https://www.patreon.com/feross"
634 },
635 {
636 "type": "consulting",
637 "url": "https://feross.org/support"
638 }
639 ],
640 "license": "MIT"
641 },
642 "node_modules/scmp": {
643 "version": "2.1.0",
644 "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
645 "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
646 "deprecated": "Just use Node.js's crypto.timingSafeEqual()",
647 "license": "BSD-3-Clause"
648 },
649 "node_modules/semver": {
650 "version": "7.7.4",
651 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
652 "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
653 "license": "ISC",
654 "bin": {
655 "semver": "bin/semver.js"
656 },
657 "engines": {
658 "node": ">=10"
659 }
660 },
661 "node_modules/side-channel": {
662 "version": "1.1.0",
663 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
664 "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
665 "license": "MIT",
666 "dependencies": {
667 "es-errors": "^1.3.0",
668 "object-inspect": "^1.13.3",
669 "side-channel-list": "^1.0.0",
670 "side-channel-map": "^1.0.1",
671 "side-channel-weakmap": "^1.0.2"
672 },
673 "engines": {
674 "node": ">= 0.4"
675 },
676 "funding": {
677 "url": "https://github.com/sponsors/ljharb"
678 }
679 },
680 "node_modules/side-channel-list": {
681 "version": "1.0.0",
682 "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
683 "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
684 "license": "MIT",
685 "dependencies": {
686 "es-errors": "^1.3.0",
687 "object-inspect": "^1.13.3"
688 },
689 "engines": {
690 "node": ">= 0.4"
691 },
692 "funding": {
693 "url": "https://github.com/sponsors/ljharb"
694 }
695 },
696 "node_modules/side-channel-map": {
697 "version": "1.0.1",
698 "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
699 "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
700 "license": "MIT",
701 "dependencies": {
702 "call-bound": "^1.0.2",
703 "es-errors": "^1.3.0",
704 "get-intrinsic": "^1.2.5",
705 "object-inspect": "^1.13.3"
706 },
707 "engines": {
708 "node": ">= 0.4"
709 },
710 "funding": {
711 "url": "https://github.com/sponsors/ljharb"
712 }
713 },
714 "node_modules/side-channel-weakmap": {
715 "version": "1.0.2",
716 "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
717 "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
718 "license": "MIT",
719 "dependencies": {
720 "call-bound": "^1.0.2",
721 "es-errors": "^1.3.0",
722 "get-intrinsic": "^1.2.5",
723 "object-inspect": "^1.13.3",
724 "side-channel-map": "^1.0.1"
725 },
726 "engines": {
727 "node": ">= 0.4"
728 },
729 "funding": {
730 "url": "https://github.com/sponsors/ljharb"
731 }
732 },
733 "node_modules/stripe": {
734 "version": "17.7.0",
735 "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
736 "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
737 "license": "MIT",
738 "dependencies": {
739 "@types/node": ">=8.1.0",
740 "qs": "^6.11.0"
741 },
742 "engines": {
743 "node": ">=12.*"
744 }
745 },
746 "node_modules/twilio": {
747 "version": "5.13.1",
748 "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.13.1.tgz",
749 "integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==",
750 "license": "MIT",
751 "dependencies": {
752 "axios": "^1.13.5",
753 "dayjs": "^1.11.9",
754 "https-proxy-agent": "^5.0.0",
755 "jsonwebtoken": "^9.0.3",
756 "qs": "^6.14.1",
757 "scmp": "^2.1.0",
758 "xmlbuilder": "^13.0.2"
759 },
760 "engines": {
761 "node": ">=14.0"
762 }
763 },
764 "node_modules/undici-types": {
765 "version": "7.18.2",
766 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
767 "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
768 "license": "MIT"
769 },
770 "node_modules/xmlbuilder": {
771 "version": "13.0.2",
772 "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
773 "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
774 "license": "MIT",
775 "engines": {
776 "node": ">=6.0"
777 }
778 }
779 }
780}
Addedpackage.json+13−0View fileUnifiedSplit
1{
2 "name": "hibiscus-to-airport-api",
3 "version": "1.0.0",
4 "private": true,
5 "description": "Vercel Serverless API for Hibiscus to Airport",
6 "dependencies": {
7 "@neondatabase/serverless": "^0.10.4",
8 "stripe": "^17.5.0",
9 "twilio": "^5.4.3",
10 "jsonwebtoken": "^9.0.2",
11 "bcryptjs": "^2.4.3"
12 }
13}
Modifiedvercel.json+12−19View fileUnifiedSplit
11{
2 "buildCommand": "cd frontend && npm install && npm run build",
2 "buildCommand": "cd frontend && yarn install && yarn build",
33 "outputDirectory": "frontend/build",
4 "functions": {
5 "api/index.py": {
6 "runtime": "@vercel/python@4.5.0",
7 "maxDuration": 30
8 }
9 },
4 "framework": null,
5 "routes": [
6 { "handle": "filesystem" },
7 { "src": "^/api/(.*)", "dest": "/api/$1" },
8 { "src": "^/(.*)", "dest": "/index.html" }
9 ],
1010 "crons": [
1111 {
1212 "path": "/api/cron/reminders",
1313 "schedule": "0 5 * * *"
14 },
15 {
16 "path": "/api/indexnow/submit",
17 "schedule": "0 3 * * 1"
1814 }
1915 ],
20 "rewrites": [
21 { "source": "/api/(.*)", "destination": "/api/index.py" },
22 { "source": "/healthz", "destination": "/api/index.py" },
23 { "source": "/debug/(.*)", "destination": "/api/index.py" },
24 { "source": "/admin/(.*)", "destination": "/api/index.py" },
25 { "source": "/agent-cockpit", "destination": "/api/index.py" },
26 { "source": "/(.*)", "destination": "/index.html" }
27 ]
16 "functions": {
17 "api/**/*.js": {
18 "maxDuration": 30
19 }
20 }
2821}
2922
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts