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