feat: initialize project with docker-compose infrastructure and server application logic

This commit is contained in:
2026-04-08 00:11:24 +02:00
parent 1b40f1eb1b
commit 8d90d97182
3 changed files with 343 additions and 324 deletions

View File

@@ -41,7 +41,8 @@ services:
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} 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_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}

View File

@@ -1,8 +1,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const loadEnvFiles = (filePaths) => { const loadEnvFiles = (filePaths) => {
const mergedFileEnv = {}; const mergedFileEnv = {};
@@ -25,7 +25,7 @@ loadEnvFiles([
path.join(__dirname, '.env.local'), 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 { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
const { const {
PlantImportValidationError, PlantImportValidationError,
@@ -34,34 +34,34 @@ const {
getPlants, getPlants,
rebuildPlantsCatalog, rebuildPlantsCatalog,
} = require('./lib/plants'); } = require('./lib/plants');
const { const {
chargeKey, chargeKey,
consumeCreditsWithIdempotency, consumeCreditsWithIdempotency,
endpointKey, endpointKey,
ensureBillingSchema, ensureBillingSchema,
getAccountSnapshot, getAccountSnapshot,
getBillingSummary, getBillingSummary,
getEndpointResponse, getEndpointResponse,
isInsufficientCreditsError, isInsufficientCreditsError,
simulatePurchase, simulatePurchase,
simulateWebhook, simulateWebhook,
syncRevenueCatCustomerInfo, syncRevenueCatCustomerInfo,
syncRevenueCatWebhookEvent, syncRevenueCatWebhookEvent,
storeEndpointResponse, storeEndpointResponse,
} = require('./lib/billing'); } = require('./lib/billing');
const { const {
analyzePlantHealth, analyzePlantHealth,
getHealthModel, getHealthModel,
getScanModel, getScanModel,
identifyPlant, identifyPlant,
isConfigured: isOpenAiConfigured, isConfigured: isOpenAiConfigured,
} = require('./lib/openai'); } = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants'); const plantsPublicDir = path.join(__dirname, 'public', 'plants');
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1; const SCAN_REVIEW_COST = 1;
@@ -69,7 +69,7 @@ const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
const DEFAULT_BOOTSTRAP_PLANTS = [ const DEFAULT_BOOTSTRAP_PLANTS = [
{ {
id: '1', id: '1',
name: 'Monstera Deliciosa', name: 'Monstera Deliciosa',
@@ -97,18 +97,18 @@ const DEFAULT_BOOTSTRAP_PLANTS = [
temp: '15-30C', temp: '15-30C',
light: 'Low to full light', light: 'Low to full light',
}, },
}, },
]; ];
const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [
path.join(__dirname, 'data', 'plants_dump_utf8.json'), path.join(__dirname, 'data', 'plants_dump_utf8.json'),
path.join(__dirname, '..', 'plants_dump_utf8.json'), path.join(__dirname, '..', 'plants_dump_utf8.json'),
]; ];
const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [
path.join(__dirname, 'public', 'plants', 'manifest.json'), path.join(__dirname, 'public', 'plants', 'manifest.json'),
]; ];
let db; let db;
const parseBoolean = (value, fallbackValue) => { const parseBoolean = (value, fallbackValue) => {
if (typeof value !== 'string') return 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; if (!Array.isArray(entries) || entries.length === 0) return null;
const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
const index = baseHash % entries.length; const index = baseHash % entries.length;
@@ -188,11 +188,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
const confidence = preferHighConfidence const confidence = preferHighConfidence
? 0.22 + ((baseHash % 3) / 100) ? 0.22 + ((baseHash % 3) / 100)
: 0.18 + ((baseHash % 7) / 100); : 0.18 + ((baseHash % 7) / 100);
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { if (!silent) {
plant: entries[index]?.name, console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
confidence, plant: entries[index]?.name,
imageHint: (imageUri || '').slice(0, 80), confidence,
}); imageHint: (imageUri || '').slice(0, 80),
});
}
return toPlantResult(entries[index], confidence); return toPlantResult(entries[index], confidence);
}; };
@@ -278,238 +280,238 @@ const ensureRequestAuth = (request) => {
const isGuest = (userId) => userId === 'guest'; const isGuest = (userId) => userId === 'guest';
const ensureNonEmptyString = (value, fieldName) => { const ensureNonEmptyString = (value, fieldName) => {
if (typeof value === 'string' && value.trim()) return value.trim(); if (typeof value === 'string' && value.trim()) return value.trim();
const error = new Error(`${fieldName} is required.`); const error = new Error(`${fieldName} is required.`);
error.code = 'BAD_REQUEST'; error.code = 'BAD_REQUEST';
throw error; throw error;
}; };
const readJsonFromCandidates = (filePaths) => { const readJsonFromCandidates = (filePaths) => {
for (const filePath of filePaths) { for (const filePath of filePaths) {
if (!fs.existsSync(filePath)) continue; if (!fs.existsSync(filePath)) continue;
try { try {
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return { return {
parsed: JSON.parse(raw), parsed: JSON.parse(raw),
sourcePath: filePath, sourcePath: filePath,
}; };
} catch (error) { } catch (error) {
console.warn('Failed to parse bootstrap JSON file.', { console.warn('Failed to parse bootstrap JSON file.', {
filePath, filePath,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
} }
} }
return null; return null;
}; };
const buildEntriesFromManifest = (manifest) => { const buildEntriesFromManifest = (manifest) => {
const items = Array.isArray(manifest?.items) ? manifest.items : []; const items = Array.isArray(manifest?.items) ? manifest.items : [];
return items return items
.filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string')
.map((item) => ({ .map((item) => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
name: item.name.trim(), name: item.name.trim(),
botanicalName: item.botanicalName.trim(), botanicalName: item.botanicalName.trim(),
imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim()
? item.localImageUri.trim() ? item.localImageUri.trim()
: (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''),
imageStatus: item.status === 'missing' ? 'missing' : 'ok', imageStatus: item.status === 'missing' ? 'missing' : 'ok',
description: '', description: '',
categories: [], categories: [],
confidence: 1, confidence: 1,
careInfo: { careInfo: {
waterIntervalDays: 7, waterIntervalDays: 7,
light: 'Unknown', light: 'Unknown',
temp: 'Unknown', temp: 'Unknown',
}, },
})) }))
.filter((entry) => entry.imageUri); .filter((entry) => entry.imageUri);
}; };
const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => {
const mergedByBotanical = new Map(); const mergedByBotanical = new Map();
primaryEntries.forEach((entry) => { primaryEntries.forEach((entry) => {
const botanicalKey = typeof entry?.botanicalName === 'string' const botanicalKey = typeof entry?.botanicalName === 'string'
? entry.botanicalName.trim().toLowerCase() ? entry.botanicalName.trim().toLowerCase()
: ''; : '';
if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return;
mergedByBotanical.set(botanicalKey, { ...entry }); mergedByBotanical.set(botanicalKey, { ...entry });
}); });
secondaryEntries.forEach((entry) => { secondaryEntries.forEach((entry) => {
const botanicalKey = typeof entry?.botanicalName === 'string' const botanicalKey = typeof entry?.botanicalName === 'string'
? entry.botanicalName.trim().toLowerCase() ? entry.botanicalName.trim().toLowerCase()
: ''; : '';
if (!botanicalKey) return; if (!botanicalKey) return;
const existing = mergedByBotanical.get(botanicalKey); const existing = mergedByBotanical.get(botanicalKey);
if (!existing) { if (!existing) {
mergedByBotanical.set(botanicalKey, { ...entry }); mergedByBotanical.set(botanicalKey, { ...entry });
return; return;
} }
const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/');
mergedByBotanical.set(botanicalKey, { mergedByBotanical.set(botanicalKey, {
...existing, ...existing,
imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri,
imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus,
id: existing.id || entry.id, id: existing.id || entry.id,
name: existing.name || entry.name, name: existing.name || entry.name,
botanicalName: existing.botanicalName || entry.botanicalName, botanicalName: existing.botanicalName || entry.botanicalName,
}); });
}); });
return Array.from(mergedByBotanical.values()); return Array.from(mergedByBotanical.values());
}; };
const loadFullBootstrapCatalog = () => { const loadFullBootstrapCatalog = () => {
const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES);
const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES);
const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : [];
const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : [];
const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries);
if (mergedEntries.length === 0) return null; if (mergedEntries.length === 0) return null;
return { return {
entries: mergedEntries, entries: mergedEntries,
sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '),
}; };
}; };
const isMinimalBootstrapCatalog = (entries) => { const isMinimalBootstrapCatalog = (entries) => {
if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) {
return false; return false;
} }
const botanicalNames = new Set( const botanicalNames = new Set(
entries entries
.map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''))
.filter(Boolean), .filter(Boolean),
); );
return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase()));
}; };
const seedBootstrapCatalogIfNeeded = async () => { const seedBootstrapCatalogIfNeeded = async () => {
const fullCatalog = loadFullBootstrapCatalog(); const fullCatalog = loadFullBootstrapCatalog();
const diagnostics = await getPlantDiagnostics(db); const diagnostics = await getPlantDiagnostics(db);
if (diagnostics.totalCount > 0) { if (diagnostics.totalCount > 0) {
if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) {
const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 });
if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) {
await rebuildPlantsCatalog(db, fullCatalog.entries, { await rebuildPlantsCatalog(db, fullCatalog.entries, {
source: 'bootstrap_upgrade_from_minimal_catalog', source: 'bootstrap_upgrade_from_minimal_catalog',
preserveExistingIds: false, preserveExistingIds: false,
enforceUniqueImages: false, enforceUniqueImages: false,
}); });
console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`);
} }
} }
return; return;
} }
if (fullCatalog) { if (fullCatalog) {
await rebuildPlantsCatalog(db, fullCatalog.entries, { await rebuildPlantsCatalog(db, fullCatalog.entries, {
source: 'bootstrap_full_catalog', source: 'bootstrap_full_catalog',
preserveExistingIds: false, preserveExistingIds: false,
enforceUniqueImages: false, enforceUniqueImages: false,
}); });
console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`);
return; return;
} }
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
source: 'bootstrap_minimal_catalog', source: 'bootstrap_minimal_catalog',
preserveExistingIds: false, preserveExistingIds: false,
enforceUniqueImages: false, enforceUniqueImages: false,
}); });
console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.');
}; };
app.use(cors()); app.use(cors());
app.use('/plants', express.static(plantsPublicDir)); app.use('/plants', express.static(plantsPublicDir));
const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim(); const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim();
const isAuthorizedRevenueCatWebhook = (request) => { const isAuthorizedRevenueCatWebhook = (request) => {
if (!revenueCatWebhookSecret) return true; if (!revenueCatWebhookSecret) return true;
const headerValue = request.header('authorization') || request.header('Authorization') || ''; const headerValue = request.header('authorization') || request.header('Authorization') || '';
const normalized = String(headerValue).trim(); const normalized = String(headerValue).trim();
return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`;
}; };
app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => {
try { try {
if (!isAuthorizedRevenueCatWebhook(request)) { if (!isAuthorizedRevenueCatWebhook(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' }); return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' });
} }
const eventPayload = request.body?.event || request.body; const eventPayload = request.body?.event || request.body;
const result = await syncRevenueCatWebhookEvent(db, eventPayload); const result = await syncRevenueCatWebhookEvent(db, eventPayload);
response.status(200).json({ received: true, syncedAt: result.syncedAt }); response.status(200).json({ received: true, syncedAt: result.syncedAt });
} catch (error) { } catch (error) {
const payload = toApiErrorPayload(error); const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body); response.status(payload.status).json(payload.body);
} }
}); });
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.get('/', (_request, response) => { app.get('/', (_request, response) => {
response.status(200).json({ response.status(200).json({
service: 'greenlns-api', service: 'greenlns-api',
status: 'ok', status: 'ok',
endpoints: [ endpoints: [
'GET /health', 'GET /health',
'GET /api/plants', 'GET /api/plants',
'POST /api/plants/rebuild', 'POST /api/plants/rebuild',
'POST /auth/signup', 'POST /auth/signup',
'POST /auth/login', 'POST /auth/login',
'GET /v1/billing/summary', 'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat', 'POST /v1/billing/sync-revenuecat',
'POST /v1/scan', 'POST /v1/scan',
'POST /v1/search/semantic', 'POST /v1/search/semantic',
'POST /v1/health-check', 'POST /v1/health-check',
'POST /v1/billing/simulate-purchase', 'POST /v1/billing/simulate-purchase',
'POST /v1/billing/simulate-webhook', 'POST /v1/billing/simulate-webhook',
'POST /v1/upload/image', 'POST /v1/upload/image',
'POST /api/revenuecat/webhook', 'POST /api/revenuecat/webhook',
], ],
}); });
}); });
const getDatabaseHealthTarget = () => { const getDatabaseHealthTarget = () => {
const raw = getDefaultDbPath(); const raw = getDefaultDbPath();
if (!raw) return ''; if (!raw) return '';
try { try {
const parsed = new URL(raw); const parsed = new URL(raw);
const databaseName = parsed.pathname.replace(/^\//, ''); const databaseName = parsed.pathname.replace(/^\//, '');
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`; return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`;
} catch { } catch {
return 'configured'; return 'configured';
} }
}; };
app.get('/health', (_request, response) => { app.get('/health', (_request, response) => {
response.status(200).json({ response.status(200).json({
ok: true, ok: true,
uptimeSec: Math.round(process.uptime()), uptimeSec: Math.round(process.uptime()),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openAiConfigured: isOpenAiConfigured(), openAiConfigured: isOpenAiConfigured(),
dbReady: Boolean(db), dbReady: Boolean(db),
dbPath: getDatabaseHealthTarget(), dbPath: getDatabaseHealthTarget(),
scanModel: getScanModel(), scanModel: getScanModel(),
healthModel: getHealthModel(), healthModel: getHealthModel(),
}); });
}); });
app.get('/api/plants', async (request, response) => { app.get('/api/plants', async (request, response) => {
try { try {
@@ -569,43 +571,43 @@ app.post('/api/plants/rebuild', async (request, response) => {
} }
}); });
app.get('/v1/billing/summary', async (request, response) => { app.get('/v1/billing/summary', async (request, response) => {
try { try {
const userId = ensureRequestAuth(request); const userId = ensureRequestAuth(request);
if (userId !== 'guest') { if (userId !== 'guest') {
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]); const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]);
if (!userExists) { if (!userExists) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
} }
} }
const summary = await getBillingSummary(db, userId); const summary = await getBillingSummary(db, userId);
response.status(200).json(summary); response.status(200).json(summary);
} catch (error) { } catch (error) {
const payload = toApiErrorPayload(error); const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body); response.status(payload.status).json(payload.body);
} }
}); });
app.post('/v1/billing/sync-revenuecat', async (request, response) => { app.post('/v1/billing/sync-revenuecat', async (request, response) => {
try { try {
const userId = ensureRequestAuth(request); const userId = ensureRequestAuth(request);
if (userId === 'guest') { if (userId === 'guest') {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' }); return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' });
} }
const customerInfo = request.body?.customerInfo; const customerInfo = request.body?.customerInfo;
const source = typeof request.body?.source === 'string' ? request.body.source : undefined; const source = typeof request.body?.source === 'string' ? request.body.source : undefined;
if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) { if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' }); return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' });
} }
const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source }); const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source });
response.status(200).json(payload); response.status(200).json(payload);
} catch (error) { } catch (error) {
const payload = toApiErrorPayload(error); const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body); response.status(payload.status).json(payload.body);
} }
}); });
app.post('/v1/scan', async (request, response) => { app.post('/v1/scan', async (request, response) => {
let userId = 'unknown'; let userId = 'unknown';
try { try {
userId = ensureRequestAuth(request); userId = ensureRequestAuth(request);
@@ -637,7 +639,7 @@ app.post('/v1/scan', async (request, response) => {
const accountSnapshot = await getAccountSnapshot(db, userId); const accountSnapshot = await getAccountSnapshot(db, userId);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
const catalogEntries = await getPlants(db, { limit: 500 }); const catalogEntries = await getPlants(db, { limit: 500 });
let result = pickCatalogFallback(catalogEntries, imageUri, false); let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
let usedOpenAi = false; let usedOpenAi = false;
if (isOpenAiConfigured()) { if (isOpenAiConfigured()) {
@@ -662,7 +664,10 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-primary'); modelPath.push('openai-primary');
if (grounded.grounded) modelPath.push('catalog-grounded-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary');
} else { } 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('openai-primary-failed');
modelPath.push('catalog-primary-fallback'); modelPath.push('catalog-primary-fallback');
} }
@@ -711,11 +716,13 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-review'); modelPath.push('openai-review');
if (grounded.grounded) modelPath.push('catalog-grounded-review'); if (grounded.grounded) modelPath.push('catalog-grounded-review');
} else { } 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'); modelPath.push('openai-review-failed');
} }
} else { } else {
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true });
if (reviewFallback) { if (reviewFallback) {
result = reviewFallback; result = reviewFallback;
} }
@@ -827,7 +834,13 @@ app.post('/v1/health-check', async (request, response) => {
}); });
const analysis = analysisResponse?.analysis; const analysis = analysisResponse?.analysis;
if (!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'; error.code = 'PROVIDER_ERROR';
throw error; throw error;
} }
@@ -958,19 +971,19 @@ app.post('/auth/login', async (request, response) => {
// ─── Startup ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────
const start = async () => { const start = async () => {
db = await openDatabase(); db = await openDatabase();
await ensurePlantSchema(db); await ensurePlantSchema(db);
await ensureBillingSchema(db); await ensureBillingSchema(db);
await ensureAuthSchema(db); await ensureAuthSchema(db);
await seedBootstrapCatalogIfNeeded(); await seedBootstrapCatalogIfNeeded();
if (isStorageConfigured()) { if (isStorageConfigured()) {
await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
} }
const server = app.listen(port, () => { const server = app.listen(port, () => {
console.log(`GreenLens server listening at http://localhost:${port}`); console.log(`GreenLens server listening at http://localhost:${port}`);
}); });
const gracefulShutdown = async () => { const gracefulShutdown = async () => {
try { try {

View File

@@ -137,15 +137,16 @@ const normalizeHealthAnalysis = (raw, language) => {
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { // Use safe defaults instead of returning null — bad/partial JSON falls through
return null; // 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' const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical')
? statusRaw ? statusRaw
: 'watch'; : 'watch';
const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : [];
const likelyIssues = issuesRaw const likelyIssues = issuesInput
.map((entry) => { .map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const title = getString(entry.title); const title = getString(entry.title);
@@ -168,7 +169,7 @@ const normalizeHealthAnalysis = (raw, language) => {
? 'La IA no pudo extraer senales de salud estables.' ? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.'; : 'AI could not extract stable health signals.';
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
likelyIssues: [ likelyIssues: [
{ {
@@ -191,7 +192,7 @@ const normalizeHealthAnalysis = (raw, language) => {
} }
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
likelyIssues, likelyIssues,
actionsNow: actionsNowRaw, actionsNow: actionsNowRaw,
@@ -305,10 +306,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
if (!response.ok) { if (!response.ok) {
const body = await response.text(); const body = await response.text();
let parsedError = {};
try { parsedError = JSON.parse(body); } catch {}
console.warn('OpenAI request HTTP error.', { console.warn('OpenAI request HTTP error.', {
status: response.status, status: response.status,
model, model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL, endpoint: OPENAI_CHAT_COMPLETIONS_URL,
openAiCode: parsedError?.error?.code,
openAiMessage: parsedError?.error?.message,
image: summarizeImageUri(imageUri), image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300), bodyPreview: body.slice(0, 300),
}); });