1721 lines
63 KiB
JavaScript
1721 lines
63 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
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 {
|
|
deleteAccount: authDeleteAccount,
|
|
ensureAuthSchema,
|
|
signUp: authSignUp,
|
|
login: authLogin,
|
|
signInWithApple: authSignInWithApple,
|
|
issueToken,
|
|
verifyJwt,
|
|
} = require('./lib/auth');
|
|
const {
|
|
PlantImportValidationError,
|
|
ensurePlantSchema,
|
|
getPlantDiagnostics,
|
|
getPlants,
|
|
rebuildPlantsCatalog,
|
|
} = require('./lib/plants');
|
|
const {
|
|
chargeKey,
|
|
consumeCreditsWithIdempotency,
|
|
endpointKey,
|
|
ensureBillingSchema,
|
|
getAccountSnapshot,
|
|
getBillingSummary,
|
|
getEndpointResponse,
|
|
ensureSufficientCredits,
|
|
isInsufficientCreditsError,
|
|
claimNotificationOnce,
|
|
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 { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
|
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
|
const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord');
|
|
const {
|
|
exchangeCodeForTokens: exchangeTiktokCode,
|
|
getTiktokTokens,
|
|
refreshTiktokTokens,
|
|
saveTiktokTokens,
|
|
assertExpectedTiktokAccount,
|
|
} = require('./lib/tiktok');
|
|
|
|
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 = 0;
|
|
const SEMANTIC_SEARCH_COST = 2;
|
|
const HEALTH_CHECK_COST = 2;
|
|
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
|
// Below this the app should treat the identification as uncertain and nudge
|
|
// the user toward a clearer photo instead of presenting the name as settled.
|
|
const LOW_CONFIDENCE_RESULT_THRESHOLD = 0.6;
|
|
|
|
let catalogCache = null;
|
|
|
|
const getCachedCatalogEntries = async (db) => {
|
|
if (catalogCache) return catalogCache;
|
|
catalogCache = await getPlants(db, { limit: 500 });
|
|
return catalogCache;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
// Same admin secret, but also accepted as a query param since browser
|
|
// navigation to /api/tiktok/connect can't set a custom header.
|
|
const isAuthorizedAdminNavigation = (request) => {
|
|
if (!hasImportAdminKey) return true;
|
|
const provided = request.header('x-admin-key') || request.query?.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 ensureNotGuest = (userId, requiredCredits) => {
|
|
// Guests may use the limited pre-auth demo identification scan. Other
|
|
// endpoints must not consume credits from the shared 'guest' account.
|
|
if (isGuest(userId)) {
|
|
const error = new Error('Sign in to use scan credits.');
|
|
error.code = 'INSUFFICIENT_CREDITS';
|
|
error.status = 402;
|
|
error.metadata = { required: requiredCredits, available: 0 };
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
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'
|
|
&& Number.isInteger(error.status)
|
|
&& error.status >= 400
|
|
&& error.status < 500
|
|
&& typeof error.code === 'string'
|
|
) {
|
|
return {
|
|
status: error.status,
|
|
body: { code: error.code, message: error.message || 'Request failed.' },
|
|
};
|
|
}
|
|
|
|
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 === 'NOT_A_PLANT') {
|
|
return {
|
|
status: 422,
|
|
body: { code: 'NOT_A_PLANT', message: error.message || 'Image does not contain a plant.' },
|
|
};
|
|
}
|
|
|
|
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);
|
|
if (isPurchaseEventType(eventPayload?.type)) {
|
|
// RevenueCat delivers webhooks at-least-once; dedupe notifications by
|
|
// event id so redeliveries don't ping the sales channel twice.
|
|
const eventId = String(eventPayload?.id || eventPayload?.transaction_id || '').trim();
|
|
const isFirstDelivery = eventId
|
|
? await claimNotificationOnce(db, `discord-purchase-event:${eventId}`)
|
|
: true;
|
|
if (isFirstDelivery) {
|
|
// RevenueCat's `price` is always USD; only `price_in_purchased_currency`
|
|
// matches the `currency` field.
|
|
const hasLocalPrice = typeof eventPayload?.price_in_purchased_currency === 'number';
|
|
notifyPurchase({
|
|
productId: eventPayload?.product_id,
|
|
price: hasLocalPrice ? eventPayload.price_in_purchased_currency : eventPayload?.price,
|
|
currency: hasLocalPrice ? eventPayload?.currency : 'USD',
|
|
store: eventPayload?.store,
|
|
isTrial: String(eventPayload?.period_type || '').toUpperCase() === 'TRIAL',
|
|
isRenewal: String(eventPayload?.type || '').toUpperCase() === 'RENEWAL',
|
|
isSandbox: String(eventPayload?.environment || '').toUpperCase() === 'SANDBOX',
|
|
});
|
|
}
|
|
}
|
|
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',
|
|
'POST /auth/apple',
|
|
'DELETE /auth/account',
|
|
'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',
|
|
'GET /api/tiktok/connect',
|
|
'GET /api/tiktok/callback',
|
|
'GET /api/tiktok/status',
|
|
'GET /api/tiktok/token',
|
|
'POST /api/tiktok/upload/video',
|
|
'POST /api/tiktok/upload/photo',
|
|
'GET /api/tiktok/upload/status',
|
|
'GET /api/tiktok/analytics',
|
|
],
|
|
});
|
|
});
|
|
|
|
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;
|
|
|
|
const [accountSnapshot, catalogEntries] = await Promise.all([
|
|
getAccountSnapshot(db, userId),
|
|
getCachedCatalogEntries(db),
|
|
]);
|
|
if (isGuest(userId)) {
|
|
modelPath.push('guest-demo-no-credit');
|
|
} else {
|
|
creditsCharged += await consumeCreditsWithIdempotency(
|
|
db,
|
|
userId,
|
|
chargeKey('scan-primary', userId, idempotencyKey),
|
|
SCAN_PRIMARY_COST,
|
|
);
|
|
}
|
|
|
|
// Free tier gets the same model quality; quantity (3 credits/month) is the differentiator.
|
|
const scanPlan = 'pro';
|
|
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
|
let usedOpenAi = false;
|
|
let rawPrimaryResult = null;
|
|
|
|
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}`);
|
|
rawPrimaryResult = openAiPrimary.result;
|
|
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries, language);
|
|
result = grounded.result;
|
|
usedOpenAi = true;
|
|
modelUsed = openAiPrimary.modelUsed || modelUsed;
|
|
modelPath.push('openai-primary');
|
|
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
|
|
} else {
|
|
if (isGuest(userId)) {
|
|
const error = new Error('AI demo scan failed. Please try again with a clearer plant photo.');
|
|
error.code = 'PROVIDER_ERROR';
|
|
error.status = 502;
|
|
throw error;
|
|
}
|
|
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 {
|
|
if (isGuest(userId)) {
|
|
const error = new Error('AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.');
|
|
error.code = 'PROVIDER_ERROR';
|
|
error.status = 502;
|
|
throw error;
|
|
}
|
|
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 agrees = reviewAgreesWithPrimary(rawPrimaryResult, openAiReview.result);
|
|
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries, language);
|
|
const decision = decideReviewOutcome({ primaryResult: result, reviewResult: grounded.result, agrees });
|
|
if (decision.accept) {
|
|
// modelUsed and the grounding marker describe the RESULT the user
|
|
// gets, so they only change when the review actually replaces it.
|
|
if (decision.replace) {
|
|
result = grounded.result;
|
|
modelUsed = openAiReview.modelUsed || modelUsed;
|
|
if (grounded.grounded) modelPath.push('catalog-grounded-review');
|
|
}
|
|
if (decision.confidence != null) {
|
|
// Cross-model agreement bonus: both models named the same species.
|
|
result = { ...result, confidence: decision.confidence };
|
|
}
|
|
modelPath.push('openai-review');
|
|
modelPath.push(decision.reason);
|
|
} else {
|
|
console.log(`OpenAI review disagreed at lower confidence for user ${userId} (${grounded.result.name} ${grounded.result.confidence} vs ${result.name} ${result.confidence}) — keeping primary result.`);
|
|
modelPath.push(decision.reason);
|
|
}
|
|
} 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,
|
|
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,
|
|
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 accountSnapshot = await getAccountSnapshot(db, userId);
|
|
ensureNotGuest(userId, SEMANTIC_SEARCH_COST);
|
|
|
|
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;
|
|
}
|
|
|
|
const accountSnapshot = await getAccountSnapshot(db, userId);
|
|
ensureNotGuest(userId, HEALTH_CHECK_COST);
|
|
// Balance pre-check: the paid AI analysis below runs BEFORE the charge
|
|
// (failed analyses are intentionally not charged), so without this check a
|
|
// user with insufficient credits could trigger unlimited free analyses.
|
|
ensureSufficientCredits(accountSnapshot, HEALTH_CHECK_COST);
|
|
|
|
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',
|
|
analysisSummary: unavailableIssue,
|
|
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,
|
|
analysisSummary: analysis.analysisSummary,
|
|
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);
|
|
notifyNewUser({
|
|
provider: 'email',
|
|
platform: request.header('x-app-platform'),
|
|
appVersion: request.header('x-app-version'),
|
|
});
|
|
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 });
|
|
}
|
|
});
|
|
|
|
app.post('/auth/apple', async (request, response) => {
|
|
try {
|
|
const { identityToken, appleUser, email, name } = request.body || {};
|
|
if (!identityToken) {
|
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
|
|
}
|
|
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
|
|
const token = issueToken(user.id, user.email, user.name);
|
|
if (user.isNewUser) {
|
|
notifyNewUser({
|
|
provider: 'apple',
|
|
platform: request.header('x-app-platform'),
|
|
appVersion: request.header('x-app-version'),
|
|
});
|
|
}
|
|
response.status(200).json({
|
|
userId: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
token,
|
|
isNewUser: Boolean(user.isNewUser),
|
|
});
|
|
} catch (error) {
|
|
const status = error.status || 500;
|
|
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
|
}
|
|
});
|
|
|
|
// ─── TikTok (Hermes Agent posting) ─────────────────────────────────────────
|
|
|
|
const getTiktokEnv = () => {
|
|
const clientKey = (process.env.TIKTOK_CLIENT_KEY || '').trim();
|
|
const clientSecret = (process.env.TIKTOK_CLIENT_SECRET || '').trim();
|
|
const redirectUri = (process.env.TIKTOK_REDIRECT_URI || `${process.env.SITE_URL || ''}/api/tiktok/callback`).trim();
|
|
const expectedOpenId = (process.env.TIKTOK_EXPECTED_OPEN_ID || '').trim();
|
|
return { clientKey, clientSecret, redirectUri, expectedOpenId };
|
|
};
|
|
|
|
const TIKTOK_STATE_COOKIE = 'tiktok_oauth_state';
|
|
|
|
const readCookie = (request, name) => {
|
|
const header = request.headers.cookie || '';
|
|
for (const part of header.split(';')) {
|
|
const [key, ...rest] = part.trim().split('=');
|
|
if (key === name) return decodeURIComponent(rest.join('='));
|
|
}
|
|
return '';
|
|
};
|
|
|
|
// Plain text keeps attacker-controlled query params (error_description) from
|
|
// being interpreted as HTML by the browser.
|
|
const sendTiktokText = (response, status, message) => {
|
|
response.clearCookie(TIKTOK_STATE_COOKIE);
|
|
response.status(status).type('text/plain').send(message);
|
|
};
|
|
|
|
app.get('/api/tiktok/connect', (request, response) => {
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
const { clientKey, redirectUri } = getTiktokEnv();
|
|
if (!clientKey) {
|
|
return response.status(500).json({ code: 'SERVER_ERROR', message: 'TIKTOK_CLIENT_KEY is not configured.' });
|
|
}
|
|
|
|
const oauthState = crypto.randomUUID();
|
|
|
|
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
|
|
authUrl.searchParams.set('client_key', clientKey);
|
|
authUrl.searchParams.set('scope', 'user.info.basic,user.info.stats,video.publish,video.upload,video.list');
|
|
authUrl.searchParams.set('response_type', 'code');
|
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
authUrl.searchParams.set('state', oauthState);
|
|
|
|
response.cookie(TIKTOK_STATE_COOKIE, oauthState, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 10 * 60 * 1000,
|
|
});
|
|
response.redirect(authUrl.toString());
|
|
});
|
|
|
|
app.get('/api/tiktok/callback', async (request, response) => {
|
|
const { code, state, error, error_description: errorDescription } = request.query;
|
|
const savedState = readCookie(request, TIKTOK_STATE_COOKIE);
|
|
|
|
if (error) {
|
|
return sendTiktokText(response, 400, `TikTok authorization failed: ${errorDescription || error}`);
|
|
}
|
|
if (!code || typeof code !== 'string') {
|
|
return sendTiktokText(response, 400, 'Missing authorization code.');
|
|
}
|
|
if (!state || typeof state !== 'string' || !savedState || state !== savedState) {
|
|
return sendTiktokText(response, 403, 'Invalid OAuth state. Start over at /api/tiktok/connect.');
|
|
}
|
|
|
|
const { clientKey, clientSecret, redirectUri } = getTiktokEnv();
|
|
if (!clientKey || !clientSecret) {
|
|
return sendTiktokText(response, 500, 'TikTok client credentials are not configured.');
|
|
}
|
|
|
|
try {
|
|
const tokens = await exchangeTiktokCode({ code, clientKey, clientSecret, redirectUri });
|
|
assertExpectedTiktokAccount(tokens.open_id, getTiktokEnv().expectedOpenId);
|
|
await saveTiktokTokens(db, tokens);
|
|
sendTiktokText(response, 200, 'TikTok account connected. You can close this tab.');
|
|
} catch (err) {
|
|
console.error('TikTok callback error', err);
|
|
sendTiktokText(response, err.status || 500, `Failed to connect TikTok account: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
// Access tokens stay server-side. Hermes must use the protected upload routes.
|
|
const TIKTOK_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
app.get('/api/tiktok/token', async (request, response) => {
|
|
if (!hasImportAdminKey) {
|
|
// This endpoint hands out live credentials — never expose it without a key.
|
|
return response.status(500).json({
|
|
code: 'SERVER_ERROR',
|
|
message: 'PLANT_IMPORT_ADMIN_KEY must be configured to expose TikTok tokens.',
|
|
});
|
|
}
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
response.status(410).json({
|
|
code: 'TOKEN_HANDOFF_DISABLED',
|
|
message: 'TikTok tokens are server-managed. Use the GreenLens TikTok upload routes.',
|
|
});
|
|
});
|
|
|
|
app.get('/api/tiktok/status', async (request, response) => {
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
const tokens = await getTiktokTokens(db);
|
|
const { expectedOpenId } = getTiktokEnv();
|
|
if (!tokens) {
|
|
return response.json({ connected: false, brand: 'greenlens', accountMatch: null, requiresReconnect: true });
|
|
}
|
|
const refreshExpiresAt = tokens.refresh_token_expires_at
|
|
? new Date(tokens.refresh_token_expires_at).getTime()
|
|
: null;
|
|
const accountMatch = expectedOpenId ? tokens.open_id === expectedOpenId : null;
|
|
const openIdHash = tokens.open_id
|
|
? crypto.createHash('sha256').update(tokens.open_id).digest('hex').slice(0, 12)
|
|
: null;
|
|
response.json({
|
|
connected: true,
|
|
brand: 'greenlens',
|
|
openIdHash,
|
|
accountMatch,
|
|
scope: tokens.scope,
|
|
accessTokenExpiresAt: tokens.access_token_expires_at,
|
|
refreshTokenExpiresAt: tokens.refresh_token_expires_at,
|
|
requiresReconnect: refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now()),
|
|
});
|
|
});
|
|
|
|
// ─── TikTok Upload Helpers ─────────────────────────────────────────────────
|
|
|
|
const assertConfiguredTiktokAccount = (tokens) => {
|
|
assertExpectedTiktokAccount(tokens?.open_id, getTiktokEnv().expectedOpenId);
|
|
return tokens;
|
|
};
|
|
|
|
const refreshLiveTiktokTokens = async () => {
|
|
const { clientKey, clientSecret } = getTiktokEnv();
|
|
if (!clientKey || !clientSecret) {
|
|
const error = new Error('TikTok client credentials are not configured.');
|
|
error.status = 500;
|
|
throw error;
|
|
}
|
|
return assertConfiguredTiktokAccount(await refreshTiktokTokens(db, { clientKey, clientSecret }));
|
|
};
|
|
|
|
const getLiveTiktokAccessToken = async () => {
|
|
const tokens = await getTiktokTokens(db);
|
|
if (!tokens) {
|
|
const error = new Error('No TikTok account connected.');
|
|
error.status = 404;
|
|
throw error;
|
|
}
|
|
|
|
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
|
|
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
|
|
return refreshLiveTiktokTokens();
|
|
}
|
|
|
|
return assertConfiguredTiktokAccount(tokens);
|
|
};
|
|
|
|
const tiktokApi = async (url, options = {}) => {
|
|
const tokens = await getLiveTiktokAccessToken();
|
|
const accessToken = tokens.access_token;
|
|
|
|
const fetchOptions = {
|
|
...options,
|
|
headers: {
|
|
...(options.headers || {}),
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json; charset=UTF-8',
|
|
},
|
|
};
|
|
|
|
const readTiktokResponse = async (res) => {
|
|
const text = await res.text();
|
|
try {
|
|
return { res, data: JSON.parse(text) };
|
|
} catch {
|
|
return { res, data: { raw: text } };
|
|
}
|
|
};
|
|
|
|
let result = await readTiktokResponse(await fetch(url, fetchOptions));
|
|
if (result.res.status === 401 && result.data?.error?.code === 'access_token_invalid') {
|
|
const refreshed = await refreshLiveTiktokTokens();
|
|
fetchOptions.headers.Authorization = `Bearer ${refreshed.access_token}`;
|
|
result = await readTiktokResponse(await fetch(url, fetchOptions));
|
|
}
|
|
const { res, data } = result;
|
|
|
|
if (!res.ok || data?.error?.code !== 'ok') {
|
|
const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`;
|
|
const error = new Error(message);
|
|
error.status = res.status;
|
|
error.body = data;
|
|
throw error;
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
const uploadBinaryToTiktok = async (uploadUrl, buffer, mimeType = 'video/mp4') => {
|
|
const res = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': mimeType,
|
|
'Content-Length': String(buffer.length),
|
|
},
|
|
body: buffer,
|
|
});
|
|
|
|
const text = await res.text();
|
|
if (!res.ok && res.status !== 201) {
|
|
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
|
|
error.status = res.status;
|
|
throw error;
|
|
}
|
|
|
|
return { status: res.status, body: text };
|
|
};
|
|
|
|
// ─── TikTok Video Upload ────────────────────────────────────────────────────
|
|
|
|
app.post('/api/tiktok/upload/video', async (request, response) => {
|
|
try {
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
const { videoBuffer, mimeType = 'video/mp4' } = request.body || {};
|
|
if (!Buffer.isBuffer(videoBuffer)) {
|
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBuffer must be a binary buffer.' });
|
|
}
|
|
|
|
const videoSize = videoBuffer.length;
|
|
const initBody = {
|
|
source_info: {
|
|
source: 'FILE_UPLOAD',
|
|
video_size: videoSize,
|
|
chunk_size: videoSize,
|
|
total_chunk_count: 1,
|
|
},
|
|
};
|
|
|
|
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', {
|
|
method: 'POST',
|
|
body: Buffer.from(JSON.stringify(initBody)),
|
|
});
|
|
|
|
const uploadUrl = initResult?.data?.upload_url;
|
|
const publishId = initResult?.data?.publish_id;
|
|
if (!uploadUrl || !publishId) {
|
|
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
|
|
}
|
|
|
|
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType || 'video/mp4');
|
|
|
|
response.status(200).json({
|
|
publish_id: publishId,
|
|
upload_url: uploadUrl,
|
|
status: 'INITIATED',
|
|
});
|
|
} catch (error) {
|
|
const payload = toApiErrorPayload(error);
|
|
response.status(payload.status).json(payload.body);
|
|
}
|
|
});
|
|
|
|
// ─── TikTok Photo/Carousel Upload ───────────────────────────────────────────
|
|
|
|
app.post('/api/tiktok/upload/photo', async (request, response) => {
|
|
try {
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
let files = Array.isArray(request.body?.files)
|
|
? request.body.files
|
|
: Array.isArray(request.body?.photos)
|
|
? request.body.photos.map((photo) => {
|
|
if (Buffer.isBuffer(photo?.buffer)) {
|
|
return { buffer: photo.buffer, contentType: photo.mimeType || photo.contentType || 'image/jpeg' };
|
|
}
|
|
if (typeof photo?.url === 'string' && /^https?:\/\//i.test(photo.url)) {
|
|
return { buffer: Buffer.from(photo.url), contentType: photo.mimeType || photo.contentType || 'image/jpeg', url: photo.url };
|
|
}
|
|
return null;
|
|
}).filter(Boolean)
|
|
: [];
|
|
|
|
if (!files.length) {
|
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' });
|
|
}
|
|
|
|
if (files.length > 35) {
|
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'TikTok allows up to 35 photos per carousel post.' });
|
|
}
|
|
|
|
const photoUrls = [];
|
|
for (const file of files) {
|
|
// Photos given as public URLs are passed to TikTok directly; buffers
|
|
// are first uploaded to MinIO so TikTok can pull them by URL.
|
|
if (typeof file.url === 'string' && /^https?:\/\//i.test(file.url)) {
|
|
photoUrls.push(file.url);
|
|
continue;
|
|
}
|
|
|
|
const buffer = file.buffer || file.data;
|
|
const mimeType = file.contentType || file.mimeType || 'image/jpeg';
|
|
|
|
if (!Buffer.isBuffer(buffer)) {
|
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Each file must include a binary buffer or a public url.' });
|
|
}
|
|
|
|
const uploadRes = await uploadImage(buffer.toString('base64'), mimeType);
|
|
photoUrls.push(uploadRes.url);
|
|
}
|
|
|
|
const title = typeof request.body?.title === 'string' ? request.body.title.trim() : '';
|
|
const description = typeof request.body?.description === 'string' ? request.body.description.trim() : '';
|
|
|
|
const initBody = {
|
|
media_type: 'PHOTO',
|
|
// MEDIA_UPLOAD = draft in the creator's TikTok inbox (posting policy is
|
|
// upload/draft only) and only needs the video.upload scope.
|
|
post_mode: 'MEDIA_UPLOAD',
|
|
post_info: {
|
|
...(title ? { title } : {}),
|
|
...(description ? { description } : {}),
|
|
},
|
|
source_info: {
|
|
source: 'PULL_FROM_URL',
|
|
photo_cover_index: 0,
|
|
photo_images: photoUrls,
|
|
},
|
|
};
|
|
|
|
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/content/init/', {
|
|
method: 'POST',
|
|
body: Buffer.from(JSON.stringify(initBody)),
|
|
});
|
|
|
|
response.status(200).json({
|
|
publish_id: initResult?.data?.publish_id,
|
|
status: initResult?.data?.status || 'INITIATED',
|
|
});
|
|
} catch (error) {
|
|
const payload = toApiErrorPayload(error);
|
|
response.status(payload.status).json(payload.body);
|
|
}
|
|
});
|
|
|
|
// ─── TikTok Upload Status ───────────────────────────────────────────────────
|
|
|
|
app.get('/api/tiktok/upload/status', async (request, response) => {
|
|
try {
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
const publishId = String(request.query.publish_id || '').trim();
|
|
if (!publishId) {
|
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'publish_id is required.' });
|
|
}
|
|
|
|
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
|
|
method: 'POST',
|
|
body: Buffer.from(JSON.stringify({ publish_id: publishId })),
|
|
});
|
|
|
|
response.status(200).json(result);
|
|
} catch (error) {
|
|
const payload = toApiErrorPayload(error);
|
|
response.status(payload.status).json(payload.body);
|
|
}
|
|
});
|
|
|
|
// ─── TikTok Analytics ───────────────────────────────────────────────────────
|
|
// Live read-only stats from the Display API. Requires the user.info.stats and
|
|
// video.list scopes — accounts connected before the scope change must
|
|
// re-authorize via /api/tiktok/connect.
|
|
|
|
const TIKTOK_USER_FIELDS = 'display_name,follower_count,following_count,likes_count,video_count';
|
|
const TIKTOK_VIDEO_FIELDS = 'id,title,video_description,duration,create_time,share_url,view_count,like_count,comment_count,share_count';
|
|
const TIKTOK_VIDEO_PAGE_SIZE = 20; // Display API maximum per page
|
|
|
|
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
|
|
const summarizeTiktokVideos = (videos) => {
|
|
const enriched = videos.map((video) => {
|
|
const views = Number(video.view_count) || 0;
|
|
const likes = Number(video.like_count) || 0;
|
|
const comments = Number(video.comment_count) || 0;
|
|
const shares = Number(video.share_count) || 0;
|
|
const engagements = likes + comments + shares;
|
|
const postedAt = video.create_time ? new Date(Number(video.create_time) * 1000) : null;
|
|
|
|
return {
|
|
...video,
|
|
engagement_rate: views > 0 ? Number((engagements / views).toFixed(4)) : null,
|
|
posted_at: postedAt ? postedAt.toISOString() : null,
|
|
posted_weekday_utc: postedAt ? WEEKDAY_NAMES[postedAt.getUTCDay()] : null,
|
|
posted_hour_utc: postedAt ? postedAt.getUTCHours() : null,
|
|
};
|
|
});
|
|
|
|
const viewCounts = enriched.map((video) => Number(video.view_count) || 0).sort((a, b) => a - b);
|
|
const total = (key) => enriched.reduce((sum, video) => sum + (Number(video[key]) || 0), 0);
|
|
const median = viewCounts.length
|
|
? viewCounts.length % 2
|
|
? viewCounts[(viewCounts.length - 1) / 2]
|
|
: (viewCounts[viewCounts.length / 2 - 1] + viewCounts[viewCounts.length / 2]) / 2
|
|
: 0;
|
|
|
|
const summary = {
|
|
video_count: enriched.length,
|
|
total_views: total('view_count'),
|
|
total_likes: total('like_count'),
|
|
total_comments: total('comment_count'),
|
|
total_shares: total('share_count'),
|
|
average_views: enriched.length ? Math.round(total('view_count') / enriched.length) : 0,
|
|
median_views: median,
|
|
};
|
|
|
|
return { videos: enriched, summary };
|
|
};
|
|
|
|
app.get('/api/tiktok/analytics', async (request, response) => {
|
|
try {
|
|
if (!isAuthorizedAdminNavigation(request)) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
|
|
}
|
|
|
|
const maxVideos = Math.min(Math.max(Number(request.query.max_videos) || 50, 1), 200);
|
|
|
|
const userResult = await tiktokApi(
|
|
`https://open.tiktokapis.com/v2/user/info/?fields=${TIKTOK_USER_FIELDS}`,
|
|
{ method: 'GET' },
|
|
);
|
|
|
|
const videos = [];
|
|
let cursor;
|
|
let hasMore = true;
|
|
while (hasMore && videos.length < maxVideos) {
|
|
const pageBody = { max_count: Math.min(TIKTOK_VIDEO_PAGE_SIZE, maxVideos - videos.length) };
|
|
if (cursor) pageBody.cursor = cursor;
|
|
|
|
const page = await tiktokApi(
|
|
`https://open.tiktokapis.com/v2/video/list/?fields=${TIKTOK_VIDEO_FIELDS}`,
|
|
{ method: 'POST', body: Buffer.from(JSON.stringify(pageBody)) },
|
|
);
|
|
|
|
const pageVideos = Array.isArray(page?.data?.videos) ? page.data.videos : [];
|
|
videos.push(...pageVideos);
|
|
cursor = page?.data?.cursor;
|
|
hasMore = Boolean(page?.data?.has_more) && pageVideos.length > 0;
|
|
}
|
|
|
|
const { videos: enrichedVideos, summary } = summarizeTiktokVideos(videos);
|
|
|
|
response.status(200).json({
|
|
user: userResult?.data?.user || null,
|
|
summary,
|
|
videos: enrichedVideos,
|
|
});
|
|
} catch (error) {
|
|
const payload = toApiErrorPayload(error);
|
|
response.status(payload.status).json(payload.body);
|
|
}
|
|
});
|
|
|
|
// ─── Startup ───────────────────────────────────────────────────────────────
|
|
|
|
app.delete('/auth/account', async (request, response) => {
|
|
try {
|
|
const authHeader = request.header('authorization') || request.header('Authorization') || '';
|
|
if (!authHeader.startsWith('Bearer ')) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' });
|
|
}
|
|
|
|
const payload = verifyJwt(authHeader.slice(7));
|
|
if (!payload?.sub) {
|
|
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' });
|
|
}
|
|
|
|
await authDeleteAccount(db, String(payload.sub));
|
|
response.status(204).send();
|
|
} catch (error) {
|
|
const status = error.status || 500;
|
|
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|