Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

View File

@@ -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);
});