Initial commit for Greenlens
This commit is contained in:
195
server/index.js
195
server/index.js
@@ -1,14 +1,30 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const Stripe = require('stripe');
|
||||
|
||||
// override: true ensures .env always wins over existing shell env vars
|
||||
dotenv.config({ path: path.join(__dirname, '.env'), override: true });
|
||||
dotenv.config({ path: path.join(__dirname, '.env.local'), override: true });
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });
|
||||
const 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, run } = require('./lib/sqlite');
|
||||
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
|
||||
@@ -39,9 +55,12 @@ const {
|
||||
identifyPlant,
|
||||
isConfigured: isOpenAiConfigured,
|
||||
} = require('./lib/openai');
|
||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
||||
const { ensureStorageBucket, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
||||
const stripeSecretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
if (!stripeSecretKey) {
|
||||
console.error('STRIPE_SECRET_KEY is not set. Payment endpoints will fail.');
|
||||
@@ -112,15 +131,6 @@ const parseBoolean = (value, fallbackValue) => {
|
||||
return fallbackValue;
|
||||
};
|
||||
|
||||
const normalizeText = (value) => {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const hashString = (value) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
@@ -151,6 +161,7 @@ const resolveUserId = (request) => {
|
||||
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);
|
||||
}
|
||||
@@ -198,59 +209,6 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
|
||||
return toPlantResult(entries[index], confidence);
|
||||
};
|
||||
|
||||
const findCatalogMatch = (aiResult, entries) => {
|
||||
if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null;
|
||||
const aiBotanical = normalizeText(aiResult.botanicalName);
|
||||
const aiName = normalizeText(aiResult.name);
|
||||
if (!aiBotanical && !aiName) return null;
|
||||
|
||||
const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical);
|
||||
if (byExactBotanical) return byExactBotanical;
|
||||
|
||||
const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName);
|
||||
if (byExactName) return byExactName;
|
||||
|
||||
if (aiBotanical) {
|
||||
const aiGenus = aiBotanical.split(' ')[0];
|
||||
if (aiGenus) {
|
||||
const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `));
|
||||
if (byGenus) return byGenus;
|
||||
}
|
||||
}
|
||||
|
||||
const byContains = entries.find((entry) => {
|
||||
const plantName = normalizeText(entry.name);
|
||||
const botanical = normalizeText(entry.botanicalName);
|
||||
return (aiName && (plantName.includes(aiName) || aiName.includes(plantName)))
|
||||
|| (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical)));
|
||||
});
|
||||
if (byContains) return byContains;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyCatalogGrounding = (aiResult, catalogEntries) => {
|
||||
const matchedEntry = findCatalogMatch(aiResult, catalogEntries);
|
||||
if (!matchedEntry) {
|
||||
return { grounded: false, result: aiResult };
|
||||
}
|
||||
|
||||
return {
|
||||
grounded: true,
|
||||
result: {
|
||||
name: matchedEntry.name || aiResult.name,
|
||||
botanicalName: matchedEntry.botanicalName || aiResult.botanicalName,
|
||||
confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99),
|
||||
description: aiResult.description || matchedEntry.description || '',
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
|
||||
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
|
||||
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toImportErrorPayload = (error) => {
|
||||
if (error instanceof PlantImportValidationError) {
|
||||
return {
|
||||
@@ -331,6 +289,8 @@ const ensureRequestAuth = (request) => {
|
||||
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.`);
|
||||
@@ -350,6 +310,7 @@ const seedBootstrapCatalogIfNeeded = async () => {
|
||||
};
|
||||
|
||||
app.use(cors());
|
||||
app.use('/plants', express.static(plantsPublicDir));
|
||||
|
||||
// Webhook must be BEFORE express.json() to get the raw body
|
||||
app.post('/api/webhook', express.raw({ type: 'application/json' }), (request, response) => {
|
||||
@@ -399,6 +360,7 @@ app.get('/', (_request, response) => {
|
||||
'POST /v1/health-check',
|
||||
'POST /v1/billing/simulate-purchase',
|
||||
'POST /v1/billing/simulate-webhook',
|
||||
'POST /v1/upload/image',
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -512,9 +474,11 @@ app.post('/api/payment-sheet', async (request, response) => {
|
||||
app.get('/v1/billing/summary', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
if (userId !== 'guest') {
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
}
|
||||
}
|
||||
const summary = await getBillingSummary(db, userId);
|
||||
response.status(200).json(summary);
|
||||
@@ -543,23 +507,29 @@ app.post('/v1/scan', async (request, response) => {
|
||||
const modelPath = [];
|
||||
let modelUsed = null;
|
||||
let modelFallbackCount = 0;
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-primary', userId, idempotencyKey),
|
||||
SCAN_PRIMARY_COST,
|
||||
);
|
||||
|
||||
if (!isGuest(userId)) {
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-primary', userId, idempotencyKey),
|
||||
SCAN_PRIMARY_COST,
|
||||
);
|
||||
}
|
||||
|
||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||
const catalogEntries = await getPlants(db, { limit: 500 });
|
||||
let result = pickCatalogFallback(catalogEntries, imageUri, false);
|
||||
let usedOpenAi = false;
|
||||
|
||||
if (isOpenAiConfigured()) {
|
||||
console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel()}`);
|
||||
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,
|
||||
@@ -567,7 +537,7 @@ app.post('/v1/scan', async (request, response) => {
|
||||
);
|
||||
if (openAiPrimary?.result) {
|
||||
console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`);
|
||||
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries);
|
||||
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries, language);
|
||||
result = grounded.result;
|
||||
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
|
||||
usedOpenAi = true;
|
||||
@@ -592,22 +562,24 @@ app.post('/v1/scan', async (request, response) => {
|
||||
}
|
||||
|
||||
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
|
||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||
if (shouldReview && accountSnapshot.plan === 'pro') {
|
||||
console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`);
|
||||
try {
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-review', userId, idempotencyKey),
|
||||
SCAN_REVIEW_COST,
|
||||
);
|
||||
if (!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,
|
||||
@@ -615,7 +587,7 @@ app.post('/v1/scan', async (request, response) => {
|
||||
);
|
||||
if (openAiReview?.result) {
|
||||
console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`);
|
||||
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries);
|
||||
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries, language);
|
||||
result = grounded.result;
|
||||
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
|
||||
modelUsed = openAiReview.modelUsed || modelUsed;
|
||||
@@ -743,12 +715,15 @@ app.post('/v1/health-check', async (request, response) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('health-check', userId, idempotencyKey),
|
||||
HEALTH_CHECK_COST,
|
||||
);
|
||||
let creditsCharged = 0;
|
||||
if (!isGuest(userId)) {
|
||||
creditsCharged = await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('health-check', userId, idempotencyKey),
|
||||
HEALTH_CHECK_COST,
|
||||
);
|
||||
}
|
||||
|
||||
const healthCheck = {
|
||||
generatedAt: nowIso(),
|
||||
@@ -803,6 +778,35 @@ app.post('/v1/billing/simulate-webhook', async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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) => {
|
||||
@@ -843,6 +847,9 @@ const start = async () => {
|
||||
await ensureBillingSchema(db);
|
||||
await ensureAuthSchema(db);
|
||||
await seedBootstrapCatalogIfNeeded();
|
||||
if (isStorageConfigured()) {
|
||||
await ensureStorageBucket().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
|
||||
}
|
||||
|
||||
const stripeMode = getStripeSecretMode();
|
||||
const stripePublishableMode = getStripePublishableMode();
|
||||
@@ -855,7 +862,7 @@ const start = async () => {
|
||||
console.log(`Stripe Publishable Mode: ${stripePublishableMode} | Key: ${maskKey(process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY)}`);
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`GreenLns server listening at http://localhost:${port}`);
|
||||
console.log(`GreenLens server listening at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
@@ -873,6 +880,6 @@ const start = async () => {
|
||||
};
|
||||
|
||||
start().catch((error) => {
|
||||
console.error('Failed to start GreenLns server', error);
|
||||
console.error('Failed to start GreenLens server', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user