Skip to content
Open
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
16 changes: 16 additions & 0 deletions .github/workflows/deploy-api-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ jobs:
echo "API healthy (${IMAGE_TAG})"
REMOTE

- name: Run database migration
env:
SSH_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }}
SSH_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
APP_DIR: ${{ secrets.PRODUCTION_APP_DIR || '/opt/buywhere' }}
run: |
ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${SSH_HOST}" \
env APP_DIR="${APP_DIR}" \
bash -s <<'REMOTE'
set -euo pipefail
echo "Running database migration..."
cd "${APP_DIR}"
docker compose exec -T api node dist/migrate.js
echo "Migration complete."
REMOTE

- name: Smoke test agent-readiness headers
run: |
sleep 3
Expand Down
101 changes: 101 additions & 0 deletions .github/workflows/inject-posthog-vm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Inject PostHog Key to Production VM

on:
workflow_dispatch:

permissions:
contents: read

jobs:
inject-posthog-vm:
name: Inject POSTHOG_API_KEY to Production VM
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }}

- name: Trust production host
run: |
mkdir -p ~/.ssh
ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts

- name: Detect service and inject POSTHOG_API_KEY
env:
DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}
DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY_PRODUCTION }}
run: |
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" << 'EOF'
set -euo pipefail

echo "=== Detecting service management ==="

POSTHOG_KEY="${POSTHOG_KEY}"

# Check for systemd service
if systemctl list-unit-files 2>/dev/null | grep -qiE 'buywhere|fastapi|uvicorn'; then
echo "Detected: systemd-managed"
SVC=$(systemctl list-units --type=service --all 2>/dev/null | grep -iE 'buywhere|fastapi|uvicorn' | awk '{print $1}' | head -1)
SVC="${SVC:-buywhere-api}"

# Update env file
ENV_FILE="/etc/systemd/system/${SVC}.d/override.conf"
mkdir -p "$(dirname $ENV_FILE)"
if ! grep -q "POSTHOG_API_KEY=" "$ENV_FILE" 2>/dev/null; then
echo "POSTHOG_API_KEY=$POSTHOG_KEY" >> "$ENV_FILE"
fi
systemctl daemon-reload
systemctl restart "$SVC" || true
echo "Done: systemd updated and service restarted"

# Check for PM2
elif command -v pm2 &>/dev/null && pm2 list 2>/dev/null | grep -qiE 'buywhere|api|fastapi'; then
echo "Detected: PM2-managed"
PM2_NAME=$(pm2 list 2>/dev/null | grep -iE 'buywhere|api|fastapi' | awk '{print $2}' | head -1)
if [ -n "$PM2_NAME" ]; then
export POSTHOG_API_KEY="$POSTHOG_KEY"
pm2 restart "$PM2_NAME" || true
echo "Done: PM2 process restarted"
fi

# Check for Docker
elif command -v docker &>/dev/null && docker ps 2>/dev/null | grep -qiE 'buywhere|api'; then
echo "Detected: Docker-managed"
CONTAINER=$(docker ps 2>/dev/null | grep -iE 'buywhere|api' | awk '{print $1}' | head -1)
if [ -n "$CONTAINER" ]; then
docker exec "$CONTAINER" env POSTHOG_API_KEY="$POSTHOG_KEY" sh -c 'echo "POSTHOG_API_KEY set"' 2>/dev/null || true
docker restart "$CONTAINER" 2>/dev/null || true
echo "Done: Docker container restarted"
fi

# Check for raw process
elif pgrep -f "uvicorn\|gunicorn" &>/dev/null; then
echo "Detected: Raw process (uvicorn/gunicorn)"
# Add to /etc/environment as fallback
if ! grep -q "POSTHOG_API_KEY=" /etc/environment 2>/dev/null; then
echo "POSTHOG_API_KEY=$POSTHOG_KEY" >> /etc/environment
fi
export POSTHOG_API_KEY="$POSTHOG_KEY"
PID=$(pgrep -f "uvicorn\|gunicorn" | head -1)
echo "Process PID: $PID — killed for restart"
kill -TERM "$PID" 2>/dev/null || true
sleep 2
# Restart hint
echo "WARNING: Manual restart required for raw process"
else
echo "No known service found — adding to /etc/environment"
if ! grep -q "POSTHOG_API_KEY=" /etc/environment 2>/dev/null; then
echo "POSTHOG_API_KEY=$POSTHOG_KEY" >> /etc/environment
fi
echo "Added to /etc/environment — verify service picks it up"
fi

echo "=== PostHog key injection complete ==="
EOF
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ yarn-error.log*
# local env files
.env*.local

# private keys and secrets
/private/

# vercel
.vercel

Expand Down
32 changes: 32 additions & 0 deletions api/dist/analytics/posthog.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.trackApiQuery = trackApiQuery;
exports.trackAffiliateClick = trackAffiliateClick;
exports.trackRegistration = trackRegistration;
exports.trackProductSearch = trackProductSearch;
exports.trackProductView = trackProductView;
exports.trackComparePageView = trackComparePageView;
exports.trackCompareRetailerClick = trackCompareRetailerClick;
exports.shutdownPostHog = shutdownPostHog;
Expand Down Expand Up @@ -77,6 +79,36 @@ function trackRegistration(apiKey, agentName, signupChannel, utmSource) {
},
});
}
function trackProductSearch(event) {
const ph = getClient();
if (!ph)
return;
ph.capture({
distinctId: event.apiKey,
event: 'product_search',
properties: {
query_text: event.queryText,
result_count: event.resultCount,
response_time_ms: event.responseTimeMs,
source_page: event.sourcePage,
},
});
}
function trackProductView(event) {
const ph = getClient();
if (!ph)
return;
ph.capture({
distinctId: event.apiKey || 'anonymous',
event: 'product_view',
properties: {
product_id: event.productId,
retailer: event.retailer,
category: event.category,
source: event.source,
},
});
}
function trackComparePageView(event) {
const ph = getClient();
if (!ph)
Expand Down
12 changes: 4 additions & 8 deletions api/dist/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,18 @@ const pg_1 = require("pg");
const ioredis_1 = __importDefault(require("ioredis"));
exports.db = new pg_1.Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/buywhere',
// With PgBouncer in transaction mode in front of Postgres, we can safely
// allow more client-side connections — PgBouncer caps the actual DB
// connections at DEFAULT_POOL_SIZE (20). Without PgBouncer, keep this ≤20
// to avoid Postgres shared-memory exhaustion under concurrent load (BUY-1841).
max: parseInt(process.env.PG_POOL_MAX || '50'),
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: 10000,
});
exports.redis = new ioredis_1.default({
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT || '6380'),
// maxRetriesPerRequest: null hangs commands indefinitely when Redis is down.
// 0 = fail fast so HTTP request handlers can catch and fail open.
maxRetriesPerRequest: 0,
maxRetriesPerRequest: 1,
commandTimeout: 500,
enableOfflineQueue: false,
retryStrategy: (times) => Math.min(times * 500, 10000),
retryStrategy: (times) => Math.min(times * 100, 1000),
});
// Suppress unhandled-error crashes from Redis reconnect attempts
exports.redis.on('error', (err) => {
Expand Down
71 changes: 71 additions & 0 deletions api/dist/email.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendVerificationEmail = sendVerificationEmail;
const client_ses_1 = require("@aws-sdk/client-ses");
const SES_REGION = process.env.SES_REGION || 'ap-southeast-1';
const SES_FROM = process.env.SES_FROM_EMAIL || 'hello@buywhere.ai';
const VERIFY_BASE = process.env.VERIFY_BASE_URL || 'https://api.buywhere.ai';
let sesClient = null;
function getSesClient() {
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
if (!sesClient) {
sesClient = new client_ses_1.SESClient({
region: SES_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
}
return sesClient;
}
return null;
}
async function sendVerificationEmail(email, token, name) {
const verifyUrl = `${VERIFY_BASE}/v1/auth/verify?token=${encodeURIComponent(token)}`;
const greeting = name ? `Hi ${name},` : 'Hi there,';
const htmlBody = [
`<!DOCTYPE html>`,
`<html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:560px;margin:0 auto;padding:24px">`,
`<div style="background:#f8fafc;border-radius:16px;padding:32px">`,
`<p style="font-size:18px;font-weight:600;color:#0f172a;margin:0 0 16px">Verify your BuyWhere API key</p>`,
`<p style="font-size:14px;line-height:1.6;color:#334155;margin:0 0 24px">`,
`${greeting} Thanks for signing up for BuyWhere. Click the button below to verify your email and activate your free tier (60 req/min, 1,000 req/day).`,
`</p>`,
`<a href="${verifyUrl}" style="display:inline-block;background:#4f46e5;color:#fff;font-size:14px;font-weight:600;padding:12px 28px;border-radius:12px;text-decoration:none">Verify email address</a>`,
`<p style="font-size:12px;color:#64748b;margin:24px 0 0;line-height:1.5">`,
`Or paste this link into your browser:<br/>`,
`<a href="${verifyUrl}" style="color:#4f46e5">${verifyUrl}</a>`,
`</p>`,
`<p style="font-size:12px;color:#94a3b8;margin:24px 0 0;line-height:1.5">`,
`This link expires in 24 hours. If you didn't sign up for BuyWhere, you can ignore this email.`,
`</p>`,
`</div></body></html>`,
].join('\n');
const client = getSesClient();
if (client) {
try {
await client.send(new client_ses_1.SendEmailCommand({
Source: SES_FROM,
Destination: { ToAddresses: [email] },
Message: {
Subject: { Data: 'Verify your BuyWhere API key', Charset: 'UTF-8' },
Body: {
Html: { Data: htmlBody, Charset: 'UTF-8' },
Text: {
Data: `Verify your BuyWhere API key\n\n${greeting}\n\nClick this link to verify your email:\n${verifyUrl}\n\nThis link expires in 24 hours.`,
Charset: 'UTF-8',
},
},
},
}));
return true;
}
catch (err) {
console.error('[email] SES send failed:', err);
return false;
}
}
console.log(`[email-stub] Would send verification to ${email} with token ${token}`);
return true;
}
18 changes: 18 additions & 0 deletions api/dist/jobs/kpiSnapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.takeKpiSnapshot = takeKpiSnapshot;
const config_1 = require("../config");
async function takeKpiSnapshot() {
const result = await config_1.db.query(`
SELECT
(SELECT COUNT(*) FROM products WHERE is_active = true)::int AS product_count,
(SELECT COUNT(*) FROM merchants)::int AS merchant_count,
(SELECT COUNT(DISTINCT source) FROM products WHERE is_active = true)::int AS platform_count
`);
const { product_count, merchant_count, platform_count } = result.rows[0];
const snapshot_at = new Date();
await config_1.db.query(`INSERT INTO kpi_snapshots (product_count, merchant_count, platform_count, snapshot_at)
VALUES ($1, $2, $3, $4)`, [product_count, merchant_count, platform_count, snapshot_at]);
console.log(`[kpi-snapshot] Recorded — products: ${product_count}, merchants: ${merchant_count}, platforms: ${platform_count}`);
return { product_count, merchant_count, platform_count, snapshot_at };
}
54 changes: 54 additions & 0 deletions api/dist/jobs/kpiSnapshotRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("../config");
const kpiSnapshot_1 = require("./kpiSnapshot");
const HOUR_UTC = parseInt(process.env.KPI_SNAPSHOT_HOUR_UTC ?? '17', 10);
const MIN_UTC = parseInt(process.env.KPI_SNAPSHOT_MIN_UTC ?? '0', 10);
function msUntilNext(hourUtc, minUtc) {
const now = new Date();
const next = new Date(now);
next.setUTCHours(hourUtc, minUtc, 0, 0);
if (next <= now) {
next.setUTCDate(next.getUTCDate() + 1);
}
return next.getTime() - now.getTime();
}
function formatDelay(ms) {
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
return `${h}h ${m}m`;
}
async function tick() {
console.log('[kpi-snapshot-runner] Job triggered');
try {
const snapshot = await (0, kpiSnapshot_1.takeKpiSnapshot)();
console.log(`[kpi-snapshot-runner] Completed — products: ${snapshot.product_count}, ` +
`merchants: ${snapshot.merchant_count}, platforms: ${snapshot.platform_count}`);
}
catch (err) {
console.error('[kpi-snapshot-runner] Unhandled job error:', err);
}
schedule();
}
function schedule() {
const delay = msUntilNext(HOUR_UTC, MIN_UTC);
console.log(`[kpi-snapshot-runner] Next run at ${HOUR_UTC.toString().padStart(2, '0')}:${MIN_UTC.toString().padStart(2, '0')} UTC ` +
`(01:00 SGT) — in ${formatDelay(delay)}`);
setTimeout(tick, delay);
}
async function main() {
console.log(`[kpi-snapshot-runner] Starting. Schedule: daily ${HOUR_UTC.toString().padStart(2, '0')}:${MIN_UTC.toString().padStart(2, '0')} UTC (01:00 SGT)`);
const shutdown = async (sig) => {
console.log(`[kpi-snapshot-runner] Received ${sig}, shutting down`);
await config_1.db.end().catch(() => { });
config_1.redis.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
schedule();
}
main().catch((err) => {
console.error('[kpi-snapshot-runner] Fatal startup error:', err);
process.exit(1);
});
Loading