diff --git a/.gitignore b/.gitignore index 86ce498..0c5622a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /out/ /.open-next/ /.wrangler/ +.wrangler-secrets.tmp.json # production /build diff --git a/README.md b/README.md index 57e5ba8..7ccf8c6 100644 --- a/README.md +++ b/README.md @@ -66,26 +66,94 @@ Locales: | `npm run lint` | ESLint | | `npm test` | Unit tests (Vitest) | | `npm run typecheck` | TypeScript (`tsc --noEmit`) | +| `npm run db:migrate` | Apply SQL migrations to Neon (uses `.env.local` → `.env.prod` → `.env`) | +| `npm run db:migrate:prod` | Apply migrations to **production** Neon (`MIGRATE_ENV=prod`, reads `.env.prod` only) | +| `npm run db:reset:prod-payments` | Dry-run cleanup of sandbox rows in prod Neon; pass `--confirm` to apply | | `npm run preview` | Build OpenNext worker and preview in `workerd` via Wrangler | | `npm run build:worker` | Build the Cloudflare Worker bundle only | | `npm run deploy` | Build + deploy to Cloudflare Workers | -| `npm run deploy:live` | Build + deploy current branch/commit as the **live** production Worker (tagged) | +| `npm run deploy:live` | Full production deploy — see [Deploy to production](#deploy-to-production) | +| `npm run cf:secrets` | Upload Worker secrets from `.env.prod` without redeploying | | `npm run cf:whoami` | Show Cloudflare auth account | | `npm run cf:status` | Show the live Workers deployment status | -### Deploy from your machine +### Environment files + +| File | Purpose | +| --- | --- | +| `.env.local` | Local development (gitignored). Copy from `.env.example`. | +| `.env.prod` | Production secrets and config (gitignored). Used by deploy and prod migrations. | +| `.env.example` | Template with placeholder keys — safe to commit. | + +Never commit `.env.local` or `.env.prod`. + +### Database migrations + +Migrations live in `migrations/*.sql` and are applied in filename order. + +```bash +# Local / dev Neon +npm run db:migrate + +# Production Neon (uses DATABASE_URL from .env.prod) +npm run db:migrate:prod +``` + +The app also runs `ensureSchema()` on first DB access, which applies any pending migration files automatically. + +### Deploy to production You do not need GitHub Actions or the Cloudflare dashboard to ship: ```bash -# one-time: log in if needed +# One-time: log in to Cloudflare npx wrangler login -# ship the current branch as production +# Create .env.prod with production values (see .env.example) +# Then ship: npm run deploy:live ``` -`deploy:live` tags the Worker version with the current git branch and short SHA (and marks `-dirty` if you have uncommitted changes), then rolls it out to 100% production traffic. +#### What `npm run deploy:live` does + +Runs `scripts/deploy-live.sh`, which: + +1. **Checks auth** — exits if `wrangler whoami` fails. +2. **Build env** — copies `.env.prod` → `.env.production.local` so `NEXT_PUBLIC_*` (e.g. site URL) is baked into the Next.js build, not localhost from `.env.local`. +3. **Sync secrets** — calls `scripts/sync-cloudflare-env.mjs` to upload every key in `.env.prod` to the `stackwise-technologies` Worker via `wrangler secret bulk`. +4. **Build** — runs `npm run build:worker` (OpenNext + Next.js production build). +5. **Deploy** — publishes to Cloudflare Workers at 100% traffic, tagged with git branch + short SHA (e.g. `main@abc1234`). + +#### Sync secrets only (no redeploy) + +After editing `.env.prod`, push new secrets without rebuilding: + +```bash +npm run cf:secrets +# equivalent to: node scripts/sync-cloudflare-env.mjs .env.prod +``` + +Requires `wrangler` login. Uploads all non-comment `KEY=value` lines from the file as encrypted Worker secrets. + +#### Script reference (`scripts/`) + +| File | Invoked by | Description | +| --- | --- | --- | +| `scripts/deploy-live.sh` | `npm run deploy:live` | End-to-end production deploy: env, secrets, build, publish. | +| `scripts/sync-cloudflare-env.mjs` | `npm run cf:secrets`, `deploy-live.sh` | Parses an env file and runs `wrangler secret bulk` for Worker `stackwise-technologies`. Optional arg: path to env file (default `.env.prod`). | +| `scripts/migrate.mjs` | `npm run db:migrate`, `npm run db:migrate:prod` | Loads env files, connects to Neon, runs all `migrations/*.sql` in order. Set `MIGRATE_ENV=prod` to force `.env.prod`. | +| `scripts/db-reset-prod-payments.mjs` | `npm run db:reset:prod-payments` | Removes test/sandbox payment links from production Neon and marks remaining drafts as live (`kpay_is_test = false`). Dry-run by default; `--confirm` to apply. | + +### Admin & payments (local) + +- Admin dashboard: [http://localhost:3000/admin](http://localhost:3000/admin) +- Payment admin uses Neon Postgres, KPay, and SMTP — configure in `.env.local`. +- For local payment E2E, set `NEXT_PUBLIC_SITE_URL=http://localhost:3000` in `.env.local`. +- See [docs/PAYMENTS.md](docs/PAYMENTS.md) for payment flow, receipts, and legal notices. + +### Payments legal & compliance + +Customer payment pages (`/pay/*`) include a legal notice linking to [Payment processing & data](/pay/legal). See [docs/PAYMENTS.md](docs/PAYMENTS.md) for how payments, receipts, and personal data are handled. --- diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md new file mode 100644 index 0000000..bb993f8 --- /dev/null +++ b/docs/PAYMENTS.md @@ -0,0 +1,61 @@ +# Payments — legal, receipts, and customer flow + +## Customer payment pages + +Public routes under `/pay/[slug]`: + +| Route | Purpose | +| --- | --- | +| `/pay/[slug]` | Invoice + pay (Card / Mobile Money via KPay) | +| `/pay/[slug]/return` | KPay return handler | +| `/pay/[slug]/receipt` | **Paid only** — printable receipt (A4) | +| `/pay/legal` | Payment processing & data notice | + +Every pay page includes a **PaymentLegalNotice** footer linking to `/pay/legal`. + +## Payment processor + +- **KPay** handles checkout (card + Mobile Money). +- Stackwise stores invoice metadata and payment status in Neon Postgres. +- Card numbers and mobile-money PINs are never stored by Stackwise. + +## Audit logging + +Customer interactions on pay pages log IP address and user agent to `payment_audit_logs`: + +| Event | Trigger | +| --- | --- | +| `PAY_PAGE_VIEW` | Invoice page load | +| `PAYMENT_INIT` | Pay button / KPay redirect | +| `RETURN_CALLBACK` | KPay return URL | +| `RECEIPT_VIEW` | Receipt page (paid only) | + +Logs are visible at `/admin/audit-logs` (latest 100 events) and on each payment-link detail page. Apply migration `0003_payment_audit_logs.sql` (or `npm run db:migrate`). + +## Receipt access + +Receipts are gated by payment status: + +- `canAccessReceipt(link)` returns true only when `status === "PAID"`. +- Unpaid links redirect from `/pay/[slug]/receipt` to the pay page. +- Admin “View receipt” is shown only for paid links. + +## Receipt email + +When payment completes (webhook or return URL sync), `finalizePaidPayment`: + +1. Marks the link `PAID` +2. Sends receipt email to the customer via `sendPaymentReceiptEmails` +3. Notifies admins listed in `ADMIN_NOTIFY_EMAILS` + +The email includes a **receipt URL** (`/pay/[slug]/receipt`) for viewing and printing. + +## Admin dashboard + +- Live links: `kpay_is_test = false` only (production DB filter) +- Tables support **search** (invoice, customer, email) and **status filter** +- **10 rows per page** pagination + +## Environment variables + +See `.env.example` for `KPAY_*`, `DATABASE_URL`, `SMTP_*`, and `ADMIN_EMAILS`. diff --git a/migrations/0001_neon_init.sql b/migrations/0001_neon_init.sql new file mode 100644 index 0000000..f811ed3 --- /dev/null +++ b/migrations/0001_neon_init.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS admin_otps ( + id UUID PRIMARY KEY, + subject_hash VARCHAR(64) NOT NULL, + otp VARCHAR(6) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_admin_otps_subject ON admin_otps(subject_hash); + +CREATE TABLE IF NOT EXISTS otp_verify_attempts ( + subject_hash VARCHAR(64) PRIMARY KEY, + failed_count INT NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS otp_send_throttle ( + subject_hash VARCHAR(64) PRIMARY KEY, + last_attempt_at TIMESTAMPTZ NULL, + last_sent_at TIMESTAMPTZ NULL +); + +CREATE TABLE IF NOT EXISTS payment_links ( + id UUID PRIMARY KEY, + slug VARCHAR(32) UNIQUE NOT NULL, + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'XAF', + amount_usd NUMERIC(12, 2) NOT NULL, + amount_local NUMERIC(14, 2) NOT NULL, + exchange_rate NUMERIC(16, 8) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + line_items JSONB NOT NULL, + notes TEXT, + kpay_payment_id TEXT, + kpay_reference TEXT, + gateway_url TEXT, + invoice_number VARCHAR(32) UNIQUE NOT NULL, + sent_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + receipt_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_payment_links_slug ON payment_links(slug); +CREATE INDEX IF NOT EXISTS idx_payment_links_status ON payment_links(status); +CREATE INDEX IF NOT EXISTS idx_payment_links_created ON payment_links(created_at DESC); diff --git a/migrations/0002_kpay_is_test.sql b/migrations/0002_kpay_is_test.sql new file mode 100644 index 0000000..5b3846a --- /dev/null +++ b/migrations/0002_kpay_is_test.sql @@ -0,0 +1 @@ +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS kpay_is_test BOOLEAN; diff --git a/migrations/0003_payment_audit_logs.sql b/migrations/0003_payment_audit_logs.sql new file mode 100644 index 0000000..44368ae --- /dev/null +++ b/migrations/0003_payment_audit_logs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS payment_audit_logs ( + id UUID PRIMARY KEY, + payment_link_id UUID NOT NULL REFERENCES payment_links(id) ON DELETE CASCADE, + event VARCHAR(32) NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_payment_audit_link ON payment_audit_logs(payment_link_id, created_at DESC); diff --git a/migrations/0004_allowed_payment_methods.sql b/migrations/0004_allowed_payment_methods.sql new file mode 100644 index 0000000..27072b7 --- /dev/null +++ b/migrations/0004_allowed_payment_methods.sql @@ -0,0 +1 @@ +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS allowed_payment_methods VARCHAR(20) NOT NULL DEFAULT 'BOTH'; diff --git a/migrations/0005_manual_payment_fields.sql b/migrations/0005_manual_payment_fields.sql new file mode 100644 index 0000000..709c616 --- /dev/null +++ b/migrations/0005_manual_payment_fields.sql @@ -0,0 +1,7 @@ +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_source VARCHAR(20); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_method VARCHAR(32); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS amount_received_usd NUMERIC(12, 2); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS amount_received_local NUMERIC(14, 2); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS collected_at TIMESTAMPTZ; +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_reference TEXT; +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_notes TEXT; diff --git a/package-lock.json b/package-lock.json index 131e365..7ef5bd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "stackwise-technologies-limited", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stackwise-technologies-limited", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { + "@neondatabase/serverless": "^1.0.2", "next": "16.2.6", + "nodemailer": "^7.0.6", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -16,6 +18,7 @@ "@opennextjs/cloudflare": "^1.20.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^7.0.1", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -2970,6 +2973,15 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@next/env": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", @@ -4640,6 +4652,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-80vKwiIsVSyFA1rRovH59jNPLBOuc6dRZIHEu40gXTkBkZnQv8vog1xSGEb9j5q/tdMAs5ivvDR2pLTU0hGHXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -10137,6 +10159,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/package.json b/package.json index b642739..02093ec 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.2.0", "private": true, "scripts": { + "db:migrate": "node scripts/migrate.mjs", "dev": "next dev", "build": "next build", "start": "next start", @@ -13,15 +14,21 @@ "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "deploy:live": "bash scripts/deploy-live.sh", + "cf:secrets": "node scripts/sync-cloudflare-env.mjs .env.prod", + "db:migrate:prod": "MIGRATE_ENV=prod node scripts/migrate.mjs", + "db:reset:prod-payments": "MIGRATE_ENV=prod node scripts/db-reset-prod-payments.mjs", "cf:whoami": "wrangler whoami", "cf:status": "wrangler deployments status --name stackwise-technologies" }, "dependencies": { + "@neondatabase/serverless": "^1.0.2", "next": "16.2.6", + "nodemailer": "^7.0.6", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@types/nodemailer": "^7.0.1", "@opennextjs/cloudflare": "^1.20.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", diff --git a/scripts/db-reset-prod-payments.mjs b/scripts/db-reset-prod-payments.mjs new file mode 100644 index 0000000..40a61d9 --- /dev/null +++ b/scripts/db-reset-prod-payments.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Remove sandbox/test payment links from production Neon and mark remaining drafts as live. + * + * Usage: + * node scripts/db-reset-prod-payments.mjs # dry run (default) + * node scripts/db-reset-prod-payments.mjs --confirm # apply changes + * + * Requires DATABASE_URL in .env.prod (MIGRATE_ENV=prod). + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { neon } from "@neondatabase/serverless"; + +function loadEnvFile(filename, { override = false } = {}) { + const envPath = path.join(process.cwd(), filename); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (override || !process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(".env.prod", { override: true }); + +const confirm = process.argv.includes("--confirm"); +const url = process.env.DATABASE_URL; + +if (!url) { + console.error("DATABASE_URL is not set (.env.prod)"); + process.exit(1); +} + +const sql = neon(url); + +const flaggedTest = await sql` + SELECT id, invoice_number, status, kpay_is_test + FROM payment_links + WHERE kpay_is_test = true + ORDER BY created_at DESC +`; + +const legacySandbox = await sql` + SELECT id, invoice_number, status, kpay_is_test + FROM payment_links + WHERE kpay_is_test IS NULL AND kpay_payment_id IS NOT NULL + ORDER BY created_at DESC +`; + +const draftsToMarkLive = await sql` + SELECT id, invoice_number, status, kpay_is_test + FROM payment_links + WHERE kpay_is_test IS NULL AND kpay_payment_id IS NULL + ORDER BY created_at DESC +`; + +const toDelete = [...flaggedTest, ...legacySandbox]; + +console.log("Production payment link cleanup"); +console.log("================================"); +console.log(`Mode: ${confirm ? "APPLY" : "DRY RUN (pass --confirm to apply)"}`); +console.log(""); +console.log(`Delete (test / legacy sandbox): ${toDelete.length}`); +for (const row of toDelete) { + console.log(` - ${row.invoice_number} (${row.status}, kpay_is_test=${row.kpay_is_test})`); +} +console.log(""); +console.log(`Mark as live (drafts with no KPay id): ${draftsToMarkLive.length}`); +for (const row of draftsToMarkLive) { + console.log(` - ${row.invoice_number} (${row.status}) → kpay_is_test = false`); +} + +if (!confirm) { + console.log(""); + console.log("No changes made. Re-run with --confirm to apply."); + process.exit(0); +} + +if (toDelete.length > 0) { + const ids = toDelete.map((row) => row.id); + await sql` + DELETE FROM payment_links + WHERE id = ANY(${ids}::uuid[]) + `; + console.log(`Deleted ${toDelete.length} payment link(s).`); +} + +if (draftsToMarkLive.length > 0) { + const draftIds = draftsToMarkLive.map((row) => row.id); + await sql` + UPDATE payment_links + SET kpay_is_test = false, updated_at = NOW() + WHERE id = ANY(${draftIds}::uuid[]) + `; + console.log(`Marked ${draftsToMarkLive.length} draft link(s) as live.`); +} + +const remaining = await sql` + SELECT + COUNT(*) FILTER (WHERE kpay_is_test = false) AS live_count, + COUNT(*) FILTER (WHERE kpay_is_test = true) AS test_count, + COUNT(*) FILTER (WHERE kpay_is_test IS NULL) AS unset_count + FROM payment_links +`; + +console.log(""); +console.log("Remaining rows:", remaining[0]); +console.log("Done."); diff --git a/scripts/deploy-live.sh b/scripts/deploy-live.sh index 0d904ef..f28774f 100755 --- a/scripts/deploy-live.sh +++ b/scripts/deploy-live.sh @@ -38,6 +38,32 @@ echo " tag: ${TAG}" echo " message: ${MESSAGE}" echo +ENV_PROD="${ROOT}/.env.prod" +PROD_LOCAL="${ROOT}/.env.production.local" +PROD_LOCAL_BACKUP="" +if [[ -f "$ENV_PROD" ]]; then + echo "==> Applying .env.prod for Next.js production build" + if [[ -f "$PROD_LOCAL" ]]; then + PROD_LOCAL_BACKUP="${PROD_LOCAL}.deploy-backup" + cp "$PROD_LOCAL" "$PROD_LOCAL_BACKUP" + fi + cp "$ENV_PROD" "$PROD_LOCAL" +else + echo "warning: .env.prod not found — NEXT_PUBLIC_* may default to localhost" >&2 +fi + +cleanup_prod_local() { + if [[ -n "$PROD_LOCAL_BACKUP" && -f "$PROD_LOCAL_BACKUP" ]]; then + mv "$PROD_LOCAL_BACKUP" "$PROD_LOCAL" + elif [[ -f "$PROD_LOCAL" && -f "$ENV_PROD" ]]; then + rm -f "$PROD_LOCAL" + fi +} +trap cleanup_prod_local EXIT + +echo "==> Syncing Cloudflare Worker secrets from .env.prod" +node scripts/sync-cloudflare-env.mjs .env.prod + echo "==> Building OpenNext Cloudflare worker" npm run build:worker diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..b777fdf --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Apply SQL migrations in migrations/*.sql (sorted) to Neon Postgres. + * + * Usage: + * node scripts/migrate.mjs # .env.local → .env.prod → .env + * MIGRATE_ENV=prod node scripts/migrate.mjs # .env.prod only + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { neon } from "@neondatabase/serverless"; + +function loadEnvFile(filename, { override = false } = {}) { + const envPath = path.join(process.cwd(), filename); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (override || !process.env[key]) process.env[key] = value; + } +} + +if (process.env.MIGRATE_ENV === "prod") { + loadEnvFile(".env.prod", { override: true }); +} else { + loadEnvFile(".env.local"); + loadEnvFile(".env.prod"); + loadEnvFile(".env"); + loadEnvFile(".env.example"); +} + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("DATABASE_URL is required"); + process.exit(1); +} + +const sql = neon(url); +const migrationsDir = path.join(process.cwd(), "migrations"); +const files = fs + .readdirSync(migrationsDir) + .filter((name) => name.endsWith(".sql")) + .sort(); + +for (const file of files) { + const migrationPath = path.join(migrationsDir, file); + const migration = fs.readFileSync(migrationPath, "utf8"); + const statements = migration + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + await sql.query(statement); + console.log(`[${file}]`, statement.split("\n")[0].slice(0, 80)); + } +} + +console.log("Migration complete:", files.join(", ")); diff --git a/scripts/sync-cloudflare-env.mjs b/scripts/sync-cloudflare-env.mjs new file mode 100644 index 0000000..343fefb --- /dev/null +++ b/scripts/sync-cloudflare-env.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Upload production env vars from .env.prod to the Cloudflare Worker as secrets. + * Usage: node scripts/sync-cloudflare-env.mjs [.env.prod] + */ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +const WORKER_NAME = "stackwise-technologies"; +const envFile = process.argv[2] ?? ".env.prod"; +const envPath = path.join(process.cwd(), envFile); + +if (!fs.existsSync(envPath)) { + console.error(`Missing env file: ${envPath}`); + process.exit(1); +} + +const secrets = {}; +for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key) secrets[key] = value; +} + +if (Object.keys(secrets).length === 0) { + console.error("No variables found in env file"); + process.exit(1); +} + +const tmpPath = path.join(process.cwd(), ".wrangler-secrets.tmp.json"); +fs.writeFileSync(tmpPath, JSON.stringify(secrets, null, 2)); + +console.log(`Uploading ${Object.keys(secrets).length} secrets to Worker "${WORKER_NAME}" from ${envFile}…`); +console.log(`Keys: ${Object.keys(secrets).join(", ")}`); + +const result = spawnSync( + "npx", + ["wrangler", "secret", "bulk", tmpPath, "--name", WORKER_NAME], + { stdio: "inherit", cwd: process.cwd() }, +); + +fs.unlinkSync(tmpPath); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +console.log("Cloudflare secrets updated."); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index bc9b61e..56ca763 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { Geist, Geist_Mono } from "next/font/google"; import { notFound } from "next/navigation"; +import { ClientProviders } from "@/components/ClientProviders"; +import { createPageMetadata } from "@/lib/metadata"; import { isLocale, locales, type Locale } from "@/lib/content"; import "../../styles/globals.css"; @@ -15,46 +17,16 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { - icons: [ - { - rel: "icon", - type: "image/svg+xml", - url: "/favicon.svg", - }, - ], - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - openGraph: { - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - url: "https://stackwisetechnologies.com", - siteName: "Stackwise Technologies Ltd", - images: [ - { - url: "https://stackwisetechnologies.com/images/og-landing-1200x630.png", - width: 1200, - height: 630, - }, - { - url: "https://stackwisetechnologies.com/images/og-landing-1200x1200.png", - width: 1200, - height: 1200, - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - images: ["https://stackwisetechnologies.com/image/og-landing-1200x630.png"], - }, +const MARKETING_TITLE = + "Stackwise Technologies LTD | Your Engineering Partner for Product Development"; +const MARKETING_DESCRIPTION = + "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery."; +export const metadata: Metadata = { + ...createPageMetadata({ + title: MARKETING_TITLE, + description: MARKETING_DESCRIPTION, + }), keywords: [ "Stackwise Technologies", "Engineering Partner", @@ -110,6 +82,24 @@ export const metadata: Metadata = { "Software Engineering Solutions", "Product Development Services", ], + openGraph: { + ...createPageMetadata({ + title: MARKETING_TITLE, + description: MARKETING_DESCRIPTION, + }).openGraph, + images: [ + { + url: "https://stackwisetechnologies.com/images/og-landing-1200x630.png", + width: 1200, + height: 630, + }, + { + url: "https://stackwisetechnologies.com/images/og-landing-1200x1200.png", + width: 1200, + height: 1200, + }, + ], + }, }; export function generateStaticParams() { @@ -133,7 +123,7 @@ export default async function LocaleLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} >
- {children} +