Files
Greenlens/server/index.js

1037 lines
37 KiB
JavaScript

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, { silent = 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);
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);
};
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, { silent: true });
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} — using catalog fallback.`, {
attemptedModels: openAiPrimary?.attemptedModels,
plant: result?.name,
});
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}.`, {
attemptedModels: openAiReview?.attemptedModels,
});
modelPath.push('openai-review-failed');
}
} else {
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: 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) {
// All models in the chain failed (timeout, quota, network) — return a graceful
// "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert.
// Credits are NOT charged. Response is NOT cached so the user can retry.
console.warn('Health check analysis was null — all models returned unusable output.', {
attemptedModels: analysisResponse?.attemptedModels,
modelUsed: analysisResponse?.modelUsed,
});
const unavailableIssue = language === 'de'
? 'Die KI-Analyse ist gerade nicht verfügbar. Bitte versuche es in einem Moment erneut.'
: language === 'es'
? 'El análisis de IA no está disponible ahora. Inténtalo de nuevo en un momento.'
: 'AI analysis is temporarily unavailable. Please try again in a moment.';
const unavailableAction = language === 'de'
? 'Erneut scannen wenn die Verbindung stabil ist.'
: language === 'es'
? 'Volver a escanear cuando la conexión sea estable.'
: 'Try scanning again when your connection is stable.';
const fallbackHealthCheck = {
generatedAt: nowIso(),
overallHealthScore: 50,
status: 'watch',
likelyIssues: [{
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
confidence: 0.1,
details: unavailableIssue,
}],
actionsNow: [unavailableAction],
plan7Days: [unavailableAction],
creditsCharged: 0,
imageUri,
};
const fallbackPayload = {
healthCheck: fallbackHealthCheck,
creditsCharged: 0,
modelUsed: null,
modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0),
billing: await getBillingSummary(db, userId),
};
response.status(200).json(fallbackPayload);
return;
}
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);
});