diff --git a/greenlns-landing/docker-compose.yml b/greenlns-landing/docker-compose.yml index 8e54702..b1ee536 100644 --- a/greenlns-landing/docker-compose.yml +++ b/greenlns-landing/docker-compose.yml @@ -41,7 +41,8 @@ services: MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage} OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} - OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini} + OPENAI_SCAN_MODEL_PRO: ${OPENAI_SCAN_MODEL_PRO:-gpt-5.4} + OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini} REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} diff --git a/server/index.js b/server/index.js index 70c09ca..044d385 100644 --- a/server/index.js +++ b/server/index.js @@ -1,8 +1,8 @@ -const fs = require('fs'); -const path = require('path'); -const dotenv = require('dotenv'); -const express = require('express'); -const cors = require('cors'); +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 = {}; @@ -25,7 +25,7 @@ loadEnvFiles([ path.join(__dirname, '.env.local'), ]); -const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); +const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth'); const { PlantImportValidationError, @@ -34,34 +34,34 @@ const { getPlants, rebuildPlantsCatalog, } = require('./lib/plants'); -const { - chargeKey, - consumeCreditsWithIdempotency, - endpointKey, - ensureBillingSchema, +const { + chargeKey, + consumeCreditsWithIdempotency, + endpointKey, + ensureBillingSchema, getAccountSnapshot, getBillingSummary, getEndpointResponse, - isInsufficientCreditsError, - simulatePurchase, - simulateWebhook, - syncRevenueCatCustomerInfo, - syncRevenueCatWebhookEvent, - storeEndpointResponse, -} = require('./lib/billing'); -const { - analyzePlantHealth, - getHealthModel, - getScanModel, - identifyPlant, + 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'); +} = 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 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; @@ -69,7 +69,7 @@ const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; -const DEFAULT_BOOTSTRAP_PLANTS = [ +const DEFAULT_BOOTSTRAP_PLANTS = [ { id: '1', name: 'Monstera Deliciosa', @@ -97,18 +97,18 @@ const DEFAULT_BOOTSTRAP_PLANTS = [ 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 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; @@ -180,7 +180,7 @@ const toPlantResult = (entry, confidence) => { }; }; -const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => { +const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false, { silent = false } = {}) => { if (!Array.isArray(entries) || entries.length === 0) return null; const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const index = baseHash % entries.length; @@ -188,11 +188,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => 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), - }); + if (!silent) { + 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); }; @@ -278,238 +280,238 @@ const ensureRequestAuth = (request) => { 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.'); -}; +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' })); +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', + 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', + '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', - ], - }); -}); + '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(), - }); -}); +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 { @@ -569,43 +571,43 @@ app.post('/api/plants/rebuild', async (request, response) => { } }); -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.' }); - } +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) => { + } +}); + +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); @@ -637,7 +639,7 @@ app.post('/v1/scan', async (request, response) => { 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 result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true }); let usedOpenAi = false; if (isOpenAiConfigured()) { @@ -662,7 +664,10 @@ app.post('/v1/scan', async (request, response) => { modelPath.push('openai-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary'); } else { - console.warn(`OpenAI primary identification returned null for user ${userId}`); + console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, { + attemptedModels: openAiPrimary?.attemptedModels, + plant: result?.name, + }); modelPath.push('openai-primary-failed'); modelPath.push('catalog-primary-fallback'); } @@ -711,11 +716,13 @@ app.post('/v1/scan', async (request, response) => { modelPath.push('openai-review'); if (grounded.grounded) modelPath.push('catalog-grounded-review'); } else { - console.warn(`OpenAI review identification returned null for user ${userId}`); + console.warn(`OpenAI review identification returned null for user ${userId}.`, { + attemptedModels: openAiReview?.attemptedModels, + }); modelPath.push('openai-review-failed'); } } else { - const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); + const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true }); if (reviewFallback) { result = reviewFallback; } @@ -827,7 +834,13 @@ app.post('/v1/health-check', async (request, response) => { }); const analysis = analysisResponse?.analysis; if (!analysis) { - const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.'); + console.warn('Health check analysis was null — all models returned unusable output.', { + attemptedModels: analysisResponse?.attemptedModels, + modelUsed: analysisResponse?.modelUsed, + }); + const error = new Error( + `Health check AI failed. Tried: ${(analysisResponse?.attemptedModels || []).join(', ')}. Verify API key, model access, and network.` + ); error.code = 'PROVIDER_ERROR'; throw error; } @@ -958,19 +971,19 @@ app.post('/auth/login', async (request, response) => { // ─── 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 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 { diff --git a/server/lib/openai.js b/server/lib/openai.js index 0b64a75..ab9acd7 100644 --- a/server/lib/openai.js +++ b/server/lib/openai.js @@ -137,15 +137,16 @@ const normalizeHealthAnalysis = (raw, language) => { const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); - if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { - return null; - } - - const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical' + // Use safe defaults instead of returning null — bad/partial JSON falls through + // to the graceful "Uncertain analysis" fallback at line 164 rather than + // propagating null → PROVIDER_ERROR to the caller. + const score = scoreRaw ?? 50; + const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical') ? statusRaw : 'watch'; + const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : []; - const likelyIssues = issuesRaw + const likelyIssues = issuesInput .map((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; const title = getString(entry.title); @@ -168,7 +169,7 @@ const normalizeHealthAnalysis = (raw, language) => { ? 'La IA no pudo extraer senales de salud estables.' : 'AI could not extract stable health signals.'; return { - overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + overallHealthScore: Math.round(clamp(score, 0, 100)), status, likelyIssues: [ { @@ -191,7 +192,7 @@ const normalizeHealthAnalysis = (raw, language) => { } return { - overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + overallHealthScore: Math.round(clamp(score, 0, 100)), status, likelyIssues, actionsNow: actionsNowRaw, @@ -305,10 +306,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature if (!response.ok) { const body = await response.text(); + let parsedError = {}; + try { parsedError = JSON.parse(body); } catch {} console.warn('OpenAI request HTTP error.', { status: response.status, model, endpoint: OPENAI_CHAT_COMPLETIONS_URL, + openAiCode: parsedError?.error?.code, + openAiMessage: parsedError?.error?.message, image: summarizeImageUri(imageUri), bodyPreview: body.slice(0, 300), });