Skip to content

Commit 343fd21

Browse files
committed
feat: initialize core application architecture, database schema, API routes, and global error handling
1 parent dc59f1f commit 343fd21

20 files changed

Lines changed: 1044 additions & 220 deletions

File tree

.github/workflows/backup.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Supabase Scheduled Database Backup
2+
3+
on:
4+
schedule:
5+
# Run every Sunday at 02:00 UTC
6+
- cron: '0 2 * * 0'
7+
workflow_dispatch: # Allows manual trigger from GitHub UI
8+
9+
jobs:
10+
backup:
11+
name: Database Dump Backup
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Checkout Code
15+
uses: actions/checkout@v4
16+
17+
- name: Install PostgreSQL Client
18+
run: |
19+
sudo apt-get update
20+
sudo apt-get install -y postgresql-client
21+
22+
- name: Run Database Dump
23+
env:
24+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
25+
run: |
26+
if [ -z "$DATABASE_URL" ]; then
27+
echo "DATABASE_URL secret is not defined. Skipping database dump."
28+
exit 0
29+
fi
30+
31+
mkdir -p backups
32+
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
33+
BACKUP_FILE="backups/clientecho_backup_${TIMESTAMP}.sql"
34+
35+
echo "Starting pg_dump..."
36+
pg_dump "$DATABASE_URL" --no-owner --no-acl -f "$BACKUP_FILE"
37+
gzip "$BACKUP_FILE"
38+
39+
echo "Database backup completed: ${BACKUP_FILE}.gz"
40+
41+
- name: Upload Backup Artifact
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: db-backup-${{ github.run_id }}
45+
path: backups/*.sql.gz
46+
retention-days: 30

.github/workflows/keepalive.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Supabase Database Keepalive
2+
3+
on:
4+
schedule:
5+
# Run every 3 days at 04:00 UTC
6+
- cron: '0 4 */3 * *'
7+
workflow_dispatch: # Allows manual trigger from GitHub UI
8+
9+
jobs:
10+
keepalive:
11+
name: Ping Database Keepalive
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Health Check Ping
15+
run: |
16+
if [ -n "${{ secrets.APP_URL }}" ]; then
17+
echo "Pinging health endpoint at ${{ secrets.APP_URL }}/api/health..."
18+
curl -s -f "${{ secrets.APP_URL }}/api/health" || echo "Warning: Health endpoint returned non-200"
19+
else
20+
echo "APP_URL secret not set. Skipping HTTP ping."
21+
fi
22+
23+
- name: Direct DB Keepalive Query (if DATABASE_URL provided)
24+
if: "${{ env.DATABASE_URL != '' }}"
25+
env:
26+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
27+
run: |
28+
echo "Executing lightweight keepalive query..."
29+
# Uses psql with SSL mode to perform a lightweight query that resets Supabase pause timer
30+
psql "$DATABASE_URL" -c "SELECT 1 as keepalive_ping;" || echo "Warning: Direct query failed"

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
105105
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
106106
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
107107
108-
# Database Connection
109-
DATABASE_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
108+
# Database Connection (IMPORTANT: In serverless / production Vercel environments, use Supabase Transaction Pooler port 6543)
109+
DATABASE_URL=postgresql://postgres.your-project-ref:password@aws-0-region.pooler.supabase.com:6543/postgres?pgbouncer=true
110+
# (Direct connection port 5432 should only be used for migrations/local dev, NOT production serverless)
110111
111112
# Upstash Redis Rate Limiting & Cache
112113
UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io

scripts/load-test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* ClientEcho Free-Tier Connection Pool & Concurrency Load Test Script
3+
*
4+
* Simulates concurrent bursts of HTTP requests against the application
5+
* to verify database connection pooler performance (PgBouncer port 6543)
6+
* without exceeding connection limits or causing unhandled server crashes.
7+
*
8+
* Usage:
9+
* node scripts/load-test.js [targetUrl] [concurrency] [totalRequests]
10+
* Example:
11+
* node scripts/load-test.js http://localhost:3000 20 100
12+
*/
13+
14+
const targetUrl = process.argv[2] || "http://localhost:3000";
15+
const concurrency = parseInt(process.argv[3] || "15", 10);
16+
const totalRequests = parseInt(process.argv[4] || "60", 10);
17+
18+
console.log("==================================================");
19+
console.log("🚀 ClientEcho Connection Pool & Concurrency Load Test");
20+
console.log(`Target URL: ${targetUrl}`);
21+
console.log(`Concurrency: ${concurrency} concurrent workers`);
22+
console.log(`Total Requests: ${totalRequests}`);
23+
console.log("==================================================\n");
24+
25+
let completedRequests = 0;
26+
let successfulRequests = 0;
27+
let failedRequests = 0;
28+
const latencies = [];
29+
30+
async function sendRequest(id) {
31+
const url = `${targetUrl}/api/health`;
32+
const start = Date.now();
33+
34+
try {
35+
const res = await fetch(url, {
36+
headers: { "User-Agent": "ClientEcho-LoadTest/1.0" },
37+
});
38+
const latency = Date.now() - start;
39+
latencies.push(latency);
40+
completedRequests++;
41+
42+
if (res.ok) {
43+
successfulRequests++;
44+
process.stdout.write(`\r[${completedRequests}/${totalRequests}] OK: ${res.status} (${latency}ms)`);
45+
} else {
46+
failedRequests++;
47+
process.stdout.write(`\r[${completedRequests}/${totalRequests}] FAIL: ${res.status} (${latency}ms)`);
48+
}
49+
} catch (err) {
50+
const latency = Date.now() - start;
51+
latencies.push(latency);
52+
completedRequests++;
53+
failedRequests++;
54+
process.stdout.write(`\r[${completedRequests}/${totalRequests}] ERR: ${err.message} (${latency}ms)`);
55+
}
56+
}
57+
58+
async function runLoadTest() {
59+
const overallStart = Date.now();
60+
let currentIndex = 0;
61+
62+
async function worker() {
63+
while (currentIndex < totalRequests) {
64+
const id = ++currentIndex;
65+
await sendRequest(id);
66+
}
67+
}
68+
69+
const workers = Array.from({ length: concurrency }, () => worker());
70+
await Promise.all(workers);
71+
72+
const totalDuration = (Date.now() - overallStart) / 1000;
73+
const avgLatency = latencies.length > 0 ? (latencies.reduce((a, b) => a + b, 0) / latencies.length).toFixed(1) : 0;
74+
const minLatency = latencies.length > 0 ? Math.min(...latencies) : 0;
75+
const maxLatency = latencies.length > 0 ? Math.max(...latencies) : 0;
76+
const rps = (completedRequests / totalDuration).toFixed(1);
77+
78+
console.log("\n\n==================================================");
79+
console.log("📊 Load Test Summary Results");
80+
console.log("==================================================");
81+
console.log(`Total Requests: ${completedRequests}`);
82+
console.log(`Success Count (2xx): ${successfulRequests}`);
83+
console.log(`Failure Count: ${failedRequests}`);
84+
console.log(`Total Duration: ${totalDuration.toFixed(2)}s`);
85+
console.log(`Throughput: ${rps} req/sec`);
86+
console.log(`Latency (Min/Avg/Max): ${minLatency}ms / ${avgLatency}ms / ${maxLatency}ms`);
87+
console.log("==================================================");
88+
89+
if (failedRequests === 0) {
90+
console.log("✅ PASSED: All requests succeeded without connection pool exhaustion.");
91+
} else {
92+
console.log("⚠️ WARNING: Some requests encountered errors. Check server logs.");
93+
}
94+
}
95+
96+
runLoadTest();

src/app/(admin)/admin/page.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,21 +129,21 @@ export default async function TechAdminDashboardPage() {
129129
</div>
130130
</div>
131131

132-
{/* Card 3: Stripe Webhook Status */}
132+
{/* Card 3: Billing Subsystem Status */}
133133
<div className="bg-ink-800 p-4 md:p-5 rounded-2xl border border-surface-white/10 space-y-3">
134134
<div className="flex items-center justify-between">
135135
<div className="text-[10px] font-mono font-semibold text-surface-white/50 uppercase tracking-wider">
136-
Stripe Billing
136+
Billing Subsystem
137137
</div>
138138
<div className="w-7 h-7 rounded-lg bg-surface-white/8 flex items-center justify-center">
139139
<DollarSign className="w-3.5 h-3.5 text-surface-white/60" />
140140
</div>
141141
</div>
142-
<div className={`font-display text-xl font-bold ${stripeConnected ? "text-emerald-400" : "text-rose-400"}`}>
143-
{stripeConnected ? "Connected" : "Not Set"}
142+
<div className="font-display text-xl font-bold text-amber-400">
143+
Paused
144144
</div>
145145
<div className="text-[10px] text-surface-white/40 font-mono">
146-
{stripeConnected ? "Webhook listener active" : "STRIPE_SECRET_KEY missing"}
146+
Flat free tier active &middot; Webhooks dormant
147147
</div>
148148
</div>
149149

src/app/(dashboard)/billing/page.tsx

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,9 @@ export default function BillingPage() {
7474
</p>
7575
</div>
7676

77-
<span className={`inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-mono font-bold tracking-wide uppercase ${
78-
isPro
79-
? "bg-ink-900 text-surface-white"
80-
: "bg-surface-white text-ink-900 border border-ink-900/20"
81-
}`}>
82-
{isPro ? <Crown className="w-4 h-4" /> : <Zap className="w-4 h-4 text-ink-900" />}
83-
<span>{isPro ? "Pro Workspace" : "Starter Free Plan"}</span>
77+
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-mono font-bold tracking-wide uppercase bg-surface-white text-ink-900 border border-ink-900/20">
78+
<Zap className="w-4 h-4 text-ink-900" />
79+
<span>Standard Workspace</span>
8480
</span>
8581
</div>
8682

@@ -134,6 +130,17 @@ export default function BillingPage() {
134130
</div>
135131
) : (
136132
<div className="space-y-8">
133+
{/* Billing Notice Banner */}
134+
<div className="p-5 bg-surface-white rounded-3xl border border-ink-900/10 shadow-sm space-y-2">
135+
<div className="flex items-center gap-2 text-xs font-mono font-bold uppercase text-ink-900">
136+
<ShieldCheck className="w-4 h-4 text-ink-900" />
137+
<span>Billing Status: Paused (Free All-Features Access)</span>
138+
</div>
139+
<p className="text-xs text-ink-800/70 leading-relaxed">
140+
Paid subscriptions are currently paused. All workspaces have complete access to custom typography, widget accent colors, carousel & rotator layouts, and custom CSS without subscription fees.
141+
</p>
142+
</div>
143+
137144
{/* Plan Usage & Capability Cards */}
138145
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
139146
{/* Widget Limit Meter */}
@@ -143,19 +150,17 @@ export default function BillingPage() {
143150
Active Widget Limit
144151
</span>
145152
<span className="text-xs font-mono font-bold text-ink-900">
146-
{widgetCount} of {isPro ? "Unlimited" : "1 Limit"}
153+
{widgetCount} of 1 Cap
147154
</span>
148155
</div>
149156
<div className="w-full bg-surface-light h-3 rounded-full overflow-hidden border border-ink-900/10">
150157
<div
151158
className="bg-ink-900 h-full transition-all duration-300"
152-
style={{ width: isPro ? "25%" : `${Math.min(100, (widgetCount / 1) * 100)}%` }}
159+
style={{ width: `${Math.min(100, (widgetCount / 1) * 100)}%` }}
153160
/>
154161
</div>
155162
<p className="text-xs text-ink-800/70 leading-relaxed">
156-
{isPro
157-
? "Pro workspaces can create and embed unlimited widgets across client sites."
158-
: "Starter Free plan is limited to 1 active widget. Upgrade to Pro for unlimited scale."}
163+
Standard workspaces can configure and embed 1 live active testimonial widget at a time.
159164
</p>
160165
</div>
161166

@@ -166,19 +171,17 @@ export default function BillingPage() {
166171
Approved Testimonials
167172
</span>
168173
<span className="text-xs font-mono font-bold text-ink-900">
169-
{testimonialCount} of {isPro ? "Unlimited" : "25 Limit"}
174+
{testimonialCount} of 25 Cap
170175
</span>
171176
</div>
172177
<div className="w-full bg-surface-light h-3 rounded-full overflow-hidden border border-ink-900/10">
173178
<div
174179
className="bg-ink-900 h-full transition-all duration-300"
175-
style={{ width: isPro ? "20%" : `${Math.min(100, (testimonialCount / 25) * 100)}%` }}
180+
style={{ width: `${Math.min(100, (testimonialCount / 25) * 100)}%` }}
176181
/>
177182
</div>
178183
<p className="text-xs text-ink-800/70 leading-relaxed">
179-
{isPro
180-
? "Pro workspaces enjoy unlimited testimonial moderation and magic link approvals."
181-
: "Starter Free plan allows up to 25 approved testimonials."}
184+
Up to 25 approved testimonials per workspace across 1-click magic links, public forms, and manual imports.
182185
</p>
183186
</div>
184187
</div>
@@ -188,31 +191,21 @@ export default function BillingPage() {
188191
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-ink-900/10 pb-6">
189192
<div>
190193
<h2 className="font-display text-xl font-bold text-ink-900">
191-
{isPro ? `Pro Workspace Subscription (${PRO_PLAN.priceDisplay}/mo)` : "Starter Free Plan ($0/forever)"}
194+
ClientEcho Standard Plan ($0 / forever)
192195
</h2>
193196
<p className="text-xs text-ink-800/70 mt-1">
194-
{isPro
195-
? "Billed monthly via Stripe. Cancel or update payment method anytime."
196-
: "Ideal for testing magic link approvals and public submission forms."}
197+
Full access to magic link verification, verification seals, and custom widget styling.
197198
</p>
198199
</div>
199200

200-
{isPro ? (
201+
{isPro && (
201202
<button
202203
onClick={handleManageStripePortal}
203204
className="px-5 py-2.5 bg-ink-900 hover:bg-ink-800 text-surface-white text-xs font-semibold rounded-xl transition shadow-sm inline-flex items-center gap-2"
204205
>
205-
<span>Manage Subscription</span>
206+
<span>Manage Billing Portal</span>
206207
<ExternalLink className="w-4 h-4" />
207208
</button>
208-
) : (
209-
<button
210-
onClick={() => setShowUpgradeModal(true)}
211-
className="px-5 py-2.5 bg-ink-900 hover:bg-ink-800 text-surface-white text-xs font-semibold rounded-xl transition shadow-sm inline-flex items-center gap-2"
212-
>
213-
<Crown className="w-4 h-4" />
214-
<span>Upgrade to Pro ({PRO_PLAN.priceDisplay}/mo)</span>
215-
</button>
216209
)}
217210
</div>
218211

@@ -236,39 +229,26 @@ export default function BillingPage() {
236229
<div className="w-5 h-5 bg-ink-900 text-surface-white rounded-lg flex items-center justify-center flex-shrink-0">
237230
<Check className="w-3.5 h-3.5" />
238231
</div>
239-
<span className="text-ink-900 font-medium">
240-
{isPro ? "Unlimited Active Widgets" : "1 Active Widget Cap"}
241-
</span>
232+
<span className="text-ink-900 font-medium">Custom Typography, Colors & Layout Variants</span>
242233
</div>
243234

244235
<div className="flex items-center gap-2.5">
245236
<div className="w-5 h-5 bg-ink-900 text-surface-white rounded-lg flex items-center justify-center flex-shrink-0">
246237
<Check className="w-3.5 h-3.5" />
247238
</div>
248-
<span className="text-ink-900 font-medium">
249-
{isPro ? "Remove ClientEcho Branding" : "ClientEcho Footer Branding Included"}
250-
</span>
239+
<span className="text-ink-900 font-medium">Dedicated Verification Pages & Trust Badges</span>
251240
</div>
252241
</div>
253242
</div>
254243

255244
<div className="p-4 bg-surface-light rounded-2xl border border-ink-900/10 text-xs font-mono text-ink-800/70 flex items-center gap-2">
256245
<ShieldCheck className="w-4 h-4 text-ink-900 flex-shrink-0" />
257246
<span>
258-
All payment transactions are handled securely via PCI-compliant Stripe Checkout and Customer Portal. ClientEcho never stores raw credit card details.
247+
All transactions remain PCI-compliant via Stripe integration. No subscription charges will occur while billing is paused.
259248
</span>
260249
</div>
261250
</div>
262251
)}
263-
264-
{/* Upgrade Modal */}
265-
<UpgradeModal
266-
isOpen={showUpgradeModal}
267-
onClose={() => setShowUpgradeModal(false)}
268-
title="Upgrade to Pro Workspace Plan"
269-
featureName="Unlimited Widgets & Pro Features"
270-
description="Unlock unlimited widgets, remove ClientEcho branding, custom Google Fonts, accent colors, and carousel presentation layouts for $19/month."
271-
/>
272252
</div>
273253
);
274254
}

0 commit comments

Comments
 (0)