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