const path = require('path'); const dotenv = require('dotenv'); const express = require('express'); const cors = require('cors'); const Stripe = require('stripe'); // override: true ensures .env always wins over existing shell env vars dotenv.config({ path: path.join(__dirname, '.env'), override: true }); dotenv.config({ path: path.join(__dirname, '.env.local'), override: true }); dotenv.config({ path: path.join(__dirname, '..', '.env') }); dotenv.config({ path: path.join(__dirname, '..', '.env.local') }); const { closeDatabase, getDefaultDbPath, openDatabase, get, run } = require('./lib/sqlite'); const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth'); const { PlantImportValidationError, ensurePlantSchema, getPlantDiagnostics, getPlants, rebuildPlantsCatalog, } = require('./lib/plants'); const { chargeKey, consumeCreditsWithIdempotency, endpointKey, ensureBillingSchema, getAccountSnapshot, getBillingSummary, getEndpointResponse, isInsufficientCreditsError, simulatePurchase, simulateWebhook, storeEndpointResponse, } = require('./lib/billing'); const { analyzePlantHealth, getHealthModel, getScanModel, identifyPlant, isConfigured: isOpenAiConfigured, } = require('./lib/openai'); const app = express(); const port = Number(process.env.PORT || 3000); const stripeSecretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); if (!stripeSecretKey) { console.error('STRIPE_SECRET_KEY is not set. Payment endpoints will fail.'); } const stripe = new Stripe(stripeSecretKey || 'sk_test_placeholder_key_not_configured'); const resolveStripeModeFromKey = (key, livePrefix, testPrefix) => { const normalized = String(key || '').trim(); if (normalized.startsWith(livePrefix)) return 'LIVE'; if (normalized.startsWith(testPrefix)) return 'TEST'; return 'MOCK'; }; const getStripeSecretMode = () => resolveStripeModeFromKey(process.env.STRIPE_SECRET_KEY, 'sk_live_', 'sk_test_'); const getStripePublishableMode = () => resolveStripeModeFromKey( process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY, 'pk_live_', 'pk_test_', ); const SCAN_PRIMARY_COST = 1; const SCAN_REVIEW_COST = 1; const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const DEFAULT_BOOTSTRAP_PLANTS = [ { id: '1', name: 'Monstera Deliciosa', botanicalName: 'Monstera deliciosa', imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg', description: 'A popular houseplant with large, holey leaves.', categories: ['easy', 'large', 'air_purifier'], confidence: 1, careInfo: { waterIntervalDays: 7, temp: '18-27C', light: 'Indirect bright light', }, }, { id: '2', name: 'Snake Plant', botanicalName: 'Sansevieria trifasciata', imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/330px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg', description: 'A hardy indoor plant known for its upright, sword-like leaves.', categories: ['succulent', 'easy', 'low_light', 'air_purifier'], confidence: 1, careInfo: { waterIntervalDays: 21, temp: '15-30C', light: 'Low to full light', }, }, ]; let db; const parseBoolean = (value, fallbackValue) => { if (typeof value !== 'string') return fallbackValue; const normalized = value.trim().toLowerCase(); if (normalized === 'true' || normalized === '1') return true; if (normalized === 'false' || normalized === '0') return false; return fallbackValue; }; const normalizeText = (value) => { return String(value || '') .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' ') .trim(); }; const hashString = (value) => { let hash = 0; for (let i = 0; i < value.length; i += 1) { hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0; } return Math.abs(hash); }; const clamp = (value, min, max) => { return Math.min(max, Math.max(min, value)); }; const nowIso = () => new Date().toISOString(); const hasImportAdminKey = Boolean(process.env.PLANT_IMPORT_ADMIN_KEY); const isAuthorizedImport = (request) => { if (!hasImportAdminKey) return true; const provided = request.header('x-admin-key'); return provided === process.env.PLANT_IMPORT_ADMIN_KEY; }; const normalizeLanguage = (value) => { return value === 'de' || value === 'en' || value === 'es' ? value : 'en'; }; const resolveUserId = (request) => { // 1. Bearer JWT (preferred — server-side auth) const authHeader = request.header('authorization'); if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7); const payload = verifyJwt(token); if (payload && payload.sub) return String(payload.sub); } // 2. Legacy X-User-Id header (kept for backward compat) const headerUserId = request.header('x-user-id'); if (typeof headerUserId === 'string' && headerUserId.trim()) return headerUserId.trim(); const bodyUserId = typeof request.body?.userId === 'string' ? request.body.userId.trim() : ''; if (bodyUserId) return bodyUserId; return ''; }; const resolveIdempotencyKey = (request) => { const header = request.header('idempotency-key'); if (typeof header === 'string' && header.trim()) return header.trim(); return ''; }; const toPlantResult = (entry, confidence) => { return { name: entry.name, botanicalName: entry.botanicalName, confidence: clamp(confidence, 0.05, 0.99), description: entry.description || `${entry.name} identified from the plant catalog.`, careInfo: { waterIntervalDays: Math.max(1, Number(entry.careInfo?.waterIntervalDays) || 7), light: entry.careInfo?.light || 'Unknown', temp: entry.careInfo?.temp || 'Unknown', }, }; }; const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => { if (!Array.isArray(entries) || entries.length === 0) return null; const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const index = baseHash % entries.length; // Low confidence so the user knows this is a hash-based guess, not a real identification const confidence = preferHighConfidence ? 0.22 + ((baseHash % 3) / 100) : 0.18 + ((baseHash % 7) / 100); console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { plant: entries[index]?.name, confidence, imageHint: (imageUri || '').slice(0, 80), }); return toPlantResult(entries[index], confidence); }; const findCatalogMatch = (aiResult, entries) => { if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null; const aiBotanical = normalizeText(aiResult.botanicalName); const aiName = normalizeText(aiResult.name); if (!aiBotanical && !aiName) return null; const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical); if (byExactBotanical) return byExactBotanical; const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName); if (byExactName) return byExactName; if (aiBotanical) { const aiGenus = aiBotanical.split(' ')[0]; if (aiGenus) { const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `)); if (byGenus) return byGenus; } } const byContains = entries.find((entry) => { const plantName = normalizeText(entry.name); const botanical = normalizeText(entry.botanicalName); return (aiName && (plantName.includes(aiName) || aiName.includes(plantName))) || (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical))); }); if (byContains) return byContains; return null; }; const applyCatalogGrounding = (aiResult, catalogEntries) => { const matchedEntry = findCatalogMatch(aiResult, catalogEntries); if (!matchedEntry) { return { grounded: false, result: aiResult }; } return { grounded: true, result: { name: matchedEntry.name || aiResult.name, botanicalName: matchedEntry.botanicalName || aiResult.botanicalName, confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99), description: aiResult.description || matchedEntry.description || '', careInfo: { waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7), light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown', temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown', }, }, }; }; const toImportErrorPayload = (error) => { if (error instanceof PlantImportValidationError) { return { status: 422, body: { code: 'IMPORT_VALIDATION_ERROR', message: error.message, details: error.details || [], }, }; } return { status: 500, body: { code: 'INTERNAL_ERROR', message: error instanceof Error ? error.message : String(error), }, }; }; const toApiErrorPayload = (error) => { if (error && typeof error === 'object' && error.code === 'BAD_REQUEST') { return { status: 400, body: { code: 'BAD_REQUEST', message: error.message || 'Invalid request.' }, }; } if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') { return { status: 401, body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' }, }; } if (isInsufficientCreditsError(error)) { return { status: 402, body: { code: 'INSUFFICIENT_CREDITS', message: error.message || 'Insufficient credits.', details: error.metadata || undefined, }, }; } if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') { return { status: 502, body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' }, }; } if (error && typeof error === 'object' && error.code === 'TIMEOUT') { return { status: 504, body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' }, }; } return { status: 500, body: { code: 'PROVIDER_ERROR', message: error instanceof Error ? error.message : String(error), }, }; }; const ensureRequestAuth = (request) => { const userId = resolveUserId(request); if (!userId) { const error = new Error('Missing X-User-Id header.'); error.code = 'UNAUTHORIZED'; throw error; } return userId; }; const ensureNonEmptyString = (value, fieldName) => { if (typeof value === 'string' && value.trim()) return value.trim(); const error = new Error(`${fieldName} is required.`); error.code = 'BAD_REQUEST'; throw error; }; const seedBootstrapCatalogIfNeeded = async () => { const existing = await getPlants(db, { limit: 1 }); if (existing.length > 0) return; await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { source: 'bootstrap', preserveExistingIds: false, enforceUniqueImages: false, }); }; app.use(cors()); // Webhook must be BEFORE express.json() to get the raw body app.post('/api/webhook', express.raw({ type: 'application/json' }), (request, response) => { const signature = request.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent( request.body, signature, process.env.STRIPE_WEBHOOK_SECRET, ); } catch (error) { console.error(`Webhook Error: ${error.message}`); response.status(400).send(`Webhook Error: ${error.message}`); return; } switch (event.type) { case 'payment_intent.succeeded': console.log('PaymentIntent succeeded.'); break; default: console.log(`Unhandled event type: ${event.type}`); break; } response.json({ received: true }); }); app.use(express.json({ limit: '10mb' })); app.get('/', (_request, response) => { response.status(200).json({ service: 'greenlns-api', status: 'ok', endpoints: [ 'GET /health', 'POST /api/payment-sheet', 'GET /api/plants', 'POST /api/plants/rebuild', 'POST /auth/signup', 'POST /auth/login', 'GET /v1/billing/summary', 'POST /v1/scan', 'POST /v1/search/semantic', 'POST /v1/health-check', 'POST /v1/billing/simulate-purchase', 'POST /v1/billing/simulate-webhook', ], }); }); app.get('/health', (_request, response) => { const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim(); response.status(200).json({ ok: true, uptimeSec: Math.round(process.uptime()), timestamp: new Date().toISOString(), openAiConfigured: isOpenAiConfigured(), dbReady: Boolean(db), dbPath: getDefaultDbPath(), stripeConfigured: Boolean(stripeSecret), stripeMode: getStripeSecretMode(), stripePublishableMode: getStripePublishableMode(), scanModel: getScanModel(), healthModel: getHealthModel(), }); }); app.get('/api/plants', async (request, response) => { try { const query = typeof request.query.q === 'string' ? request.query.q : ''; const category = typeof request.query.category === 'string' ? request.query.category : ''; const limit = request.query.limit; const results = await getPlants(db, { query, category, limit: typeof limit === 'string' ? Number(limit) : undefined, }); response.json(results); } catch (error) { const payload = toImportErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.get('/api/plants/diagnostics', async (_request, response) => { try { const diagnostics = await getPlantDiagnostics(db); response.json(diagnostics); } catch (error) { const payload = toImportErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/api/plants/rebuild', async (request, response) => { if (!isAuthorizedImport(request)) { response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing x-admin-key.', }); return; } const payloadEntries = Array.isArray(request.body) ? request.body : request.body?.entries; const source = typeof request.body?.source === 'string' && request.body.source.trim() ? request.body.source.trim() : 'api_rebuild'; const preserveExistingIds = parseBoolean(request.body?.preserveExistingIds, true); const enforceUniqueImages = parseBoolean(request.body?.enforceUniqueImages, true); try { const summary = await rebuildPlantsCatalog(db, payloadEntries, { source, preserveExistingIds, enforceUniqueImages, }); response.status(200).json(summary); } catch (error) { const payload = toImportErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/api/payment-sheet', async (request, response) => { try { const amount = Number(request.body?.amount || 500); const currency = request.body?.currency || 'usd'; const paymentIntent = await stripe.paymentIntents.create({ amount, currency, automatic_payment_methods: { enabled: true }, }); const customer = await stripe.customers.create(); const ephemeralKey = await stripe.ephemeralKeys.create( { customer: customer.id }, { apiVersion: '2023-10-16' }, ); response.json({ paymentIntent: paymentIntent.client_secret, ephemeralKey: ephemeralKey.secret, customer: customer.id, publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key', }); } catch (error) { response.status(400).json({ code: 'PAYMENT_SHEET_ERROR', message: error instanceof Error ? error.message : String(error), }); } }); app.get('/v1/billing/summary', async (request, response) => { try { const userId = ensureRequestAuth(request); const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]); if (!userExists) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); } const summary = await getBillingSummary(db, userId); response.status(200).json(summary); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/scan', async (request, response) => { let userId = 'unknown'; try { userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri'); const language = normalizeLanguage(request.body?.language); const endpointId = endpointKey('scan', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); return; } let creditsCharged = 0; const modelPath = []; let modelUsed = null; let modelFallbackCount = 0; creditsCharged += await consumeCreditsWithIdempotency( db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST, ); const catalogEntries = await getPlants(db, { limit: 500 }); let result = pickCatalogFallback(catalogEntries, imageUri, false); let usedOpenAi = false; if (isOpenAiConfigured()) { console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel()}`); const openAiPrimary = await identifyPlant({ imageUri, language, mode: 'primary', }); modelFallbackCount = Math.max( modelFallbackCount, Math.max((openAiPrimary?.attemptedModels?.length || 0) - 1, 0), ); if (openAiPrimary?.result) { console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`); const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries); result = grounded.result; if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) }; usedOpenAi = true; modelUsed = openAiPrimary.modelUsed || modelUsed; modelPath.push('openai-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary'); } else { console.warn(`OpenAI primary identification returned null for user ${userId}`); modelPath.push('openai-primary-failed'); modelPath.push('catalog-primary-fallback'); } } else { console.log(`OpenAI not configured, using catalog fallback for user ${userId}`); modelPath.push('openai-not-configured'); modelPath.push('catalog-primary-fallback'); } if (!result) { const error = new Error('Plant catalog is empty. Unable to produce identification fallback.'); error.code = 'PROVIDER_ERROR'; throw error; } const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD; const accountSnapshot = await getAccountSnapshot(db, userId); if (shouldReview && accountSnapshot.plan === 'pro') { console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`); try { creditsCharged += await consumeCreditsWithIdempotency( db, userId, chargeKey('scan-review', userId, idempotencyKey), SCAN_REVIEW_COST, ); if (usedOpenAi) { const openAiReview = await identifyPlant({ imageUri, language, mode: 'review', }); modelFallbackCount = Math.max( modelFallbackCount, Math.max((openAiReview?.attemptedModels?.length || 0) - 1, 0), ); if (openAiReview?.result) { console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`); const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries); result = grounded.result; if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) }; modelUsed = openAiReview.modelUsed || modelUsed; modelPath.push('openai-review'); if (grounded.grounded) modelPath.push('catalog-grounded-review'); } else { console.warn(`OpenAI review identification returned null for user ${userId}`); modelPath.push('openai-review-failed'); } } else { const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); if (reviewFallback) { result = reviewFallback; } modelPath.push('catalog-review-fallback'); } } catch (error) { if (isInsufficientCreditsError(error)) { console.log(`Review skipped for user ${userId} due to insufficient credits`); modelPath.push('review-skipped-insufficient-credits'); } else { throw error; } } } else if (shouldReview) { console.log(`Review skipped for user ${userId} (plan: ${accountSnapshot.plan})`); modelPath.push('review-skipped-free-plan'); } const payload = { result, creditsCharged, modelPath, modelUsed, modelFallbackCount, billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); response.status(200).json(payload); } catch (error) { console.error(`Scan error for user ${userId}:`, error); const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/search/semantic', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const query = typeof request.body?.query === 'string' ? request.body.query.trim() : ''; const endpointId = endpointKey('semantic-search', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); return; } if (!query) { const payload = { status: 'no_results', results: [], creditsCharged: 0, billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); response.status(200).json(payload); return; } const creditsCharged = await consumeCreditsWithIdempotency( db, userId, chargeKey('semantic-search', userId, idempotencyKey), SEMANTIC_SEARCH_COST, ); const results = await getPlants(db, { query, limit: 18 }); const payload = { status: results.length > 0 ? 'success' : 'no_results', results, creditsCharged, billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/health-check', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri'); const language = normalizeLanguage(request.body?.language); const endpointId = endpointKey('health-check', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); return; } if (!isOpenAiConfigured()) { const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.'); error.code = 'PROVIDER_ERROR'; throw error; } const analysisResponse = await analyzePlantHealth({ imageUri, language, plantContext: request.body?.plantContext, }); const analysis = analysisResponse?.analysis; if (!analysis) { const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.'); error.code = 'PROVIDER_ERROR'; throw error; } const creditsCharged = await consumeCreditsWithIdempotency( db, userId, chargeKey('health-check', userId, idempotencyKey), HEALTH_CHECK_COST, ); const healthCheck = { generatedAt: nowIso(), overallHealthScore: analysis.overallHealthScore, status: analysis.status, likelyIssues: analysis.likelyIssues, actionsNow: analysis.actionsNow, plan7Days: analysis.plan7Days, creditsCharged, imageUri, }; const payload = { healthCheck, creditsCharged, modelUsed: analysisResponse?.modelUsed || null, modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0), billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/billing/simulate-purchase', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const productId = ensureNonEmptyString(request.body?.productId, 'productId'); const payload = await simulatePurchase(db, userId, idempotencyKey, productId); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/billing/simulate-webhook', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const event = ensureNonEmptyString(request.body?.event, 'event'); const payload = await simulateWebhook(db, userId, idempotencyKey, event, request.body?.payload || {}); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── Auth endpoints ──────────────────────────────────────────────────────── app.post('/auth/signup', async (request, response) => { try { const { email, name, password } = request.body || {}; if (!email || !name || !password) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'email, name and password are required.' }); } const user = await authSignUp(db, email, name, password); const token = issueToken(user.id, user.email, user.name); response.status(201).json({ userId: user.id, email: user.email, name: user.name, token }); } catch (error) { const status = error.status || 500; response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); } }); app.post('/auth/login', async (request, response) => { try { const { email, password } = request.body || {}; if (!email || !password) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' }); } const user = await authLogin(db, email, password); const token = issueToken(user.id, user.email, user.name); response.status(200).json({ userId: user.id, email: user.email, name: user.name, token }); } catch (error) { const status = error.status || 500; response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); } }); // ─── Startup ─────────────────────────────────────────────────────────────── const start = async () => { db = await openDatabase(); await ensurePlantSchema(db); await ensureBillingSchema(db); await ensureAuthSchema(db); await seedBootstrapCatalogIfNeeded(); const stripeMode = getStripeSecretMode(); const stripePublishableMode = getStripePublishableMode(); const maskKey = (key) => { const k = String(key || '').trim(); if (k.length < 12) return k ? '(too short to mask)' : '(not set)'; return `${k.slice(0, 7)}...${k.slice(-4)}`; }; console.log(`Stripe Mode: ${stripeMode} | Secret: ${maskKey(process.env.STRIPE_SECRET_KEY)}`); console.log(`Stripe Publishable Mode: ${stripePublishableMode} | Key: ${maskKey(process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY)}`); const server = app.listen(port, () => { console.log(`GreenLns server listening at http://localhost:${port}`); }); const gracefulShutdown = async () => { try { await closeDatabase(db); } catch (error) { console.error('Failed to close database', error); } finally { server.close(() => process.exit(0)); } }; process.on('SIGINT', gracefulShutdown); process.on('SIGTERM', gracefulShutdown); }; start().catch((error) => { console.error('Failed to start GreenLns server', error); process.exit(1); });