const fs = require('fs'); const path = require('path'); const dotenv = require('dotenv'); const express = require('express'); const cors = require('cors'); const loadEnvFiles = (filePaths) => { const mergedFileEnv = {}; for (const filePath of filePaths) { if (!fs.existsSync(filePath)) continue; Object.assign(mergedFileEnv, dotenv.parse(fs.readFileSync(filePath))); } for (const [key, value] of Object.entries(mergedFileEnv)) { if (process.env[key] === undefined) { process.env[key] = value; } } }; loadEnvFiles([ path.join(__dirname, '..', '.env'), path.join(__dirname, '.env'), path.join(__dirname, '..', '.env.local'), path.join(__dirname, '.env.local'), ]); const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); 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, syncRevenueCatCustomerInfo, syncRevenueCatWebhookEvent, storeEndpointResponse, } = require('./lib/billing'); const { analyzePlantHealth, getHealthModel, getScanModel, identifyPlant, isConfigured: isOpenAiConfigured, } = require('./lib/openai'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const app = express(); const port = Number(process.env.PORT || 3000); const plantsPublicDir = path.join(__dirname, 'public', 'plants'); 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', }, }, ]; const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ path.join(__dirname, 'data', 'plants_dump_utf8.json'), path.join(__dirname, '..', 'plants_dump_utf8.json'), ]; const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ path.join(__dirname, 'public', 'plants', 'manifest.json'), ]; 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 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); if (token === 'guest') return 'guest'; 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 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 isGuest = (userId) => userId === 'guest'; 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 readJsonFromCandidates = (filePaths) => { for (const filePath of filePaths) { if (!fs.existsSync(filePath)) continue; try { const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); return { parsed: JSON.parse(raw), sourcePath: filePath, }; } catch (error) { console.warn('Failed to parse bootstrap JSON file.', { filePath, error: error instanceof Error ? error.message : String(error), }); } } return null; }; const buildEntriesFromManifest = (manifest) => { const items = Array.isArray(manifest?.items) ? manifest.items : []; return items .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') .map((item) => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name: item.name.trim(), botanicalName: item.botanicalName.trim(), imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() ? item.localImageUri.trim() : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), imageStatus: item.status === 'missing' ? 'missing' : 'ok', description: '', categories: [], confidence: 1, careInfo: { waterIntervalDays: 7, light: 'Unknown', temp: 'Unknown', }, })) .filter((entry) => entry.imageUri); }; const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { const mergedByBotanical = new Map(); primaryEntries.forEach((entry) => { const botanicalKey = typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''; if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; mergedByBotanical.set(botanicalKey, { ...entry }); }); secondaryEntries.forEach((entry) => { const botanicalKey = typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''; if (!botanicalKey) return; const existing = mergedByBotanical.get(botanicalKey); if (!existing) { mergedByBotanical.set(botanicalKey, { ...entry }); return; } const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); mergedByBotanical.set(botanicalKey, { ...existing, imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, id: existing.id || entry.id, name: existing.name || entry.name, botanicalName: existing.botanicalName || entry.botanicalName, }); }); return Array.from(mergedByBotanical.values()); }; const loadFullBootstrapCatalog = () => { const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); if (mergedEntries.length === 0) return null; return { entries: mergedEntries, sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), }; }; const isMinimalBootstrapCatalog = (entries) => { if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { return false; } const botanicalNames = new Set( entries .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) .filter(Boolean), ); return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); }; const seedBootstrapCatalogIfNeeded = async () => { const fullCatalog = loadFullBootstrapCatalog(); const diagnostics = await getPlantDiagnostics(db); if (diagnostics.totalCount > 0) { if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { await rebuildPlantsCatalog(db, fullCatalog.entries, { source: 'bootstrap_upgrade_from_minimal_catalog', preserveExistingIds: false, enforceUniqueImages: false, }); console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); } } return; } if (fullCatalog) { await rebuildPlantsCatalog(db, fullCatalog.entries, { source: 'bootstrap_full_catalog', preserveExistingIds: false, enforceUniqueImages: false, }); console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); return; } await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { source: 'bootstrap_minimal_catalog', preserveExistingIds: false, enforceUniqueImages: false, }); console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); }; app.use(cors()); app.use('/plants', express.static(plantsPublicDir)); const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim(); const isAuthorizedRevenueCatWebhook = (request) => { if (!revenueCatWebhookSecret) return true; const headerValue = request.header('authorization') || request.header('Authorization') || ''; const normalized = String(headerValue).trim(); return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; }; app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { try { if (!isAuthorizedRevenueCatWebhook(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' }); } const eventPayload = request.body?.event || request.body; const result = await syncRevenueCatWebhookEvent(db, eventPayload); response.status(200).json({ received: true, syncedAt: result.syncedAt }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.use(express.json({ limit: '10mb' })); app.get('/', (_request, response) => { response.status(200).json({ service: 'greenlns-api', status: 'ok', endpoints: [ 'GET /health', 'GET /api/plants', 'POST /api/plants/rebuild', 'POST /auth/signup', 'POST /auth/login', 'GET /v1/billing/summary', 'POST /v1/billing/sync-revenuecat', 'POST /v1/scan', 'POST /v1/search/semantic', 'POST /v1/health-check', 'POST /v1/billing/simulate-purchase', 'POST /v1/billing/simulate-webhook', 'POST /v1/upload/image', 'POST /api/revenuecat/webhook', ], }); }); const getDatabaseHealthTarget = () => { const raw = getDefaultDbPath(); if (!raw) return ''; try { const parsed = new URL(raw); const databaseName = parsed.pathname.replace(/^\//, ''); return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`; } catch { return 'configured'; } }; app.get('/health', (_request, response) => { response.status(200).json({ ok: true, uptimeSec: Math.round(process.uptime()), timestamp: new Date().toISOString(), openAiConfigured: isOpenAiConfigured(), dbReady: Boolean(db), dbPath: getDatabaseHealthTarget(), 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.get('/v1/billing/summary', async (request, response) => { try { const userId = ensureRequestAuth(request); if (userId !== 'guest') { const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [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/billing/sync-revenuecat', async (request, response) => { try { const userId = ensureRequestAuth(request); if (userId === 'guest') { return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' }); } const customerInfo = request.body?.customerInfo; const source = typeof request.body?.source === 'string' ? request.body.source : undefined; if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' }); } const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source }); response.status(200).json(payload); } 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; if (!isGuest(userId)) { creditsCharged += await consumeCreditsWithIdempotency( db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST, ); } const accountSnapshot = await getAccountSnapshot(db, userId); const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; 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(scanPlan)} (plan: ${scanPlan})`); const openAiPrimary = await identifyPlant({ imageUri, language, mode: 'primary', plan: scanPlan, }); 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, language); 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; if (shouldReview && accountSnapshot.plan === 'pro') { console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`); try { if (!isGuest(userId)) { creditsCharged += await consumeCreditsWithIdempotency( db, userId, chargeKey('scan-review', userId, idempotencyKey), SCAN_REVIEW_COST, ); } if (usedOpenAi) { const openAiReview = await identifyPlant({ imageUri, language, mode: 'review', plan: scanPlan, }); 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, language); 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; } let creditsCharged = 0; if (!isGuest(userId)) { 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); } }); // ─── Image Upload ────────────────────────────────────────────────────────── app.post('/v1/upload/image', async (request, response) => { try { ensureRequestAuth(request); if (!isStorageConfigured()) { return response.status(503).json({ code: 'STORAGE_NOT_CONFIGURED', message: 'Image storage is not configured.', }); } const { imageBase64, contentType = 'image/jpeg' } = request.body || {}; if (!imageBase64 || typeof imageBase64 !== 'string') { return response.status(400).json({ code: 'BAD_REQUEST', message: 'imageBase64 is required.', }); } const { url } = await uploadImage(imageBase64, contentType); response.status(200).json({ url }); } 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(); if (isStorageConfigured()) { await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); } const server = app.listen(port, () => { console.log(`GreenLens 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 GreenLens server', error); process.exit(1); });