Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/app/api/bounties/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';

export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
Expand All @@ -17,6 +18,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json(rows[0]);
} catch (e) {
console.error(e);
return NextResponse.json({ error: 'Failed to fetch bounty' }, { status: 500 });
return dbErrorResponse(e, 'Failed to fetch bounty');
}
}
5 changes: 3 additions & 2 deletions apps/web/app/api/bounties/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { createCoinPayClient } from '@profullstack/stack/coinpay';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';
import { getSessionDid } from '@/lib/auth';
import { generatePublicId } from '@/lib/id';

Expand All @@ -23,7 +24,7 @@ export async function GET() {
return NextResponse.json(bounties);
} catch (e) {
console.error(e);
return NextResponse.json({ error: 'Failed to fetch bounties' }, { status: 500 });
return dbErrorResponse(e, 'Failed to fetch bounties');
}
}

Expand Down Expand Up @@ -91,6 +92,6 @@ export async function POST(req: NextRequest) {
}, { status: 201 });
} catch (e) {
console.error('Failed to create bounty:', e);
return NextResponse.json({ error: 'Failed to create bounty. Please try again.' }, { status: 500 });
return dbErrorResponse(e, 'Failed to create bounty. Please try again.');
}
}
3 changes: 2 additions & 1 deletion apps/web/app/api/coupons/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';

export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
Expand All @@ -15,6 +16,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json(rows[0]);
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to fetch coupon' }, { status: 500 });
return dbErrorResponse(err, 'Failed to fetch coupon');
}
}
5 changes: 3 additions & 2 deletions apps/web/app/api/coupons/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';
import { getSessionDid } from '@/lib/auth';
import type { DiscountType } from '@/lib/types';

Expand All @@ -20,7 +21,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json(coupons);
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to fetch coupons' }, { status: 500 });
return dbErrorResponse(err, 'Failed to fetch coupons');
}
}

Expand Down Expand Up @@ -128,6 +129,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true }, { status: 201 });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to create coupon' }, { status: 500 });
return dbErrorResponse(err, 'Failed to create coupon');
}
}
3 changes: 2 additions & 1 deletion apps/web/app/api/coupons/vote/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSessionDid } from '@/lib/auth';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';

export async function POST(req: NextRequest) {
const did = await getSessionDid();
Expand Down Expand Up @@ -33,6 +34,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, votes: rows[0]?.votes ?? 0 });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to vote' }, { status: 500 });
return dbErrorResponse(err, 'Failed to vote');
}
}
3 changes: 2 additions & 1 deletion apps/web/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';

export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
Expand All @@ -24,6 +25,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json(results);
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
return dbErrorResponse(err, 'Search failed');
}
}
3 changes: 2 additions & 1 deletion apps/web/app/api/stores/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';

export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
Expand All @@ -16,6 +17,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ slu
return NextResponse.json({ store, coupons });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to fetch store' }, { status: 500 });
return dbErrorResponse(err, 'Failed to fetch store');
}
}
5 changes: 3 additions & 2 deletions apps/web/app/api/stores/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { dbErrorResponse } from '@/lib/api-error';
import { getSessionDid } from '@/lib/auth';

export async function GET() {
Expand All @@ -15,7 +16,7 @@ export async function GET() {
return NextResponse.json(stores);
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to fetch stores' }, { status: 500 });
return dbErrorResponse(err, 'Failed to fetch stores');
}
}

Expand All @@ -37,6 +38,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true }, { status: 201 });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Failed to create store' }, { status: 500 });
return dbErrorResponse(err, 'Failed to create store');
}
}
30 changes: 30 additions & 0 deletions apps/web/lib/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { isDbPaused } from './db';

/**
* What a caller sees when the database node is parked. It names the cause
* rather than the fix: the dashboard restart is an operator action, and the
* person hitting the API can only wait.
*/
export const DB_PAUSED_MESSAGE =
'The coupon database is temporarily unavailable and should be back shortly.';

/**
* Map an error caught in a route handler onto a response.
*
* A paused database is a transient infrastructure state, not a bad request and
* not a bug, so it answers 503 + Retry-After instead of a blanket 500. Anything
* else keeps the route's own 500 and message, so a genuine defect still reads
* as a defect. `code` is stable for clients to branch on — the CLI prints its
* own wording for `database_paused` rather than echoing a raw HTTP status.
*/
export function dbErrorResponse(err: unknown, fallback: string): NextResponse {
if (isDbPaused(err)) {
return NextResponse.json(
{ error: DB_PAUSED_MESSAGE, code: 'database_paused' },
{ status: 503, headers: { 'Retry-After': '60' } }
);
}
return NextResponse.json({ error: fallback }, { status: 500 });
}
14 changes: 14 additions & 0 deletions apps/web/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ function isDisconnect(err: unknown): boolean {
);
}

// SQLite Cloud parks a free-tier node after a stretch of inactivity. Every
// query then fails with error 10010 until someone restarts it from the
// dashboard, and the node refuses new sockets too — so reconnecting cannot fix
// it. That is why this is deliberately NOT folded into isDisconnect() above:
// retrying a paused node just pays the connection cost twice before failing
// identically. Callers use it to tell "c0upons is down" (transient, 503) apart
// from "c0upons is broken" (a real 500).
export function isDbPaused(err: unknown): boolean {
const code = (err as { errorCode?: string | number } | null)?.errorCode;
if (code != null && String(code) === '10010') return true;
const msg = err instanceof Error ? err.message : String(err);
return /node has been paused|paused due to inactivity/i.test(msg);
}

async function runSql(args: unknown[]): Promise<unknown> {
if (!client) client = createClient();
try {
Expand Down
51 changes: 44 additions & 7 deletions apps/web/public/cli/c0upons
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

set -euo pipefail

VERSION="1.1.0"
VERSION="1.2.0"
BASE_URL="${C0UPONS_API:-https://c0upons.com/api}"
# Website root (for login + share links). Defaults to BASE_URL without /api.
WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}"
Expand Down Expand Up @@ -90,6 +90,35 @@ api_post() {
exit 1
}

# api_get <path> — fetches a public endpoint.
# On success, leaves the response body in $API_BODY. On failure, prints the
# server's own message and exits. The read commands used to pipe `curl -fsSL`
# straight into jq, so an outage surfaced as `curl: (22) The requested URL
# returned error: 500` — which says nothing about whose fault it is or whether
# retrying helps. The API distinguishes those cases now; this reads them out.
api_get() {
local path="$1" resp code
# -L keeps the redirect-following the previous `curl -fsSL` had, so a
# canonical-host redirect still resolves instead of surfacing as a bare 301.
resp=$(curl -sSL -w $'\n%{http_code}' "${BASE_URL}${path}") \
|| { echo -e "${RED}Network error.${RESET} Could not reach ${BASE_URL}."; exit 1; }
code=$(printf '%s' "$resp" | tail -n1)
API_BODY=$(printf '%s' "$resp" | sed '$d')
if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
return 0
fi
local msg
msg=$(printf '%s' "$API_BODY" | jq -r '.error // empty' 2>/dev/null || true)
# 503 is the API saying "down, not broken" — worth its own wording so people
# retry rather than reinstall the CLI or file a bug.
if [ "$code" = "503" ]; then
echo -e "${ORANGE}c0upons is temporarily unavailable.${RESET} ${msg:-Please try again shortly.}"
else
echo -e "${RED}Error (${code}):${RESET} ${msg:-request failed}"
fi
exit 1
}

urlencode() {
local raw="$1"
if command -v python3 &>/dev/null; then
Expand Down Expand Up @@ -130,7 +159,8 @@ cmd_search() {
local encoded
encoded=$(urlencode "$q")
local results
results=$(curl -fsSL "${BASE_URL}/search?q=${encoded}")
api_get "/search?q=${encoded}"
results="$API_BODY"
local count
count=$(echo "$results" | jq 'length')
if [ "$count" -eq 0 ]; then
Expand All @@ -142,29 +172,35 @@ cmd_search() {
while IFS= read -r row; do
print_coupon "$row"
i=$((i + 1))
[ "$i" -ge 20 ] && break
# `[ … ] && break` would leave its own status 1 behind on the last pass
# through the loop, and that becomes the script's exit code — so a search
# that printed results perfectly still reported failure to `&&`/CI.
if [ "$i" -ge 20 ]; then break; fi
done < <(echo "$results" | jq -c '.[]')
}

cmd_latest() {
require_jq
local results
results=$(curl -fsSL "${BASE_URL}/coupons")
api_get "/coupons"
results="$API_BODY"
local count
count=$(echo "$results" | jq 'length')
echo -e "${BOLD}Latest ${count} coupon(s)${RESET}\n"
local i=0
while IFS= read -r row; do
print_coupon "$row"
i=$((i + 1))
[ "$i" -ge 10 ] && break
# Same trailing-status trap as cmd_search above.
if [ "$i" -ge 10 ]; then break; fi
done < <(echo "$results" | jq -c '.[]')
}

cmd_stores() {
require_jq
local results
results=$(curl -fsSL "${BASE_URL}/stores")
api_get "/stores"
results="$API_BODY"
local count
count=$(echo "$results" | jq 'length')
echo -e "${BOLD}${count} stores${RESET}\n"
Expand All @@ -179,7 +215,8 @@ cmd_store() {
exit 1
fi
local result
result=$(curl -fsSL "${BASE_URL}/stores/${slug}")
api_get "/stores/${slug}"
result="$API_BODY"
local name
name=$(echo "$result" | jq -r '.name // "Unknown"')
local coupons
Expand Down
Loading