Onboarding
This commit is contained in:
@@ -25,8 +25,9 @@ loadEnvFiles([
|
||||
path.join(__dirname, '.env.local'),
|
||||
]);
|
||||
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||
const {
|
||||
deleteAccount: authDeleteAccount,
|
||||
ensureAuthSchema,
|
||||
signUp: authSignUp,
|
||||
login: authLogin,
|
||||
@@ -70,10 +71,10 @@ 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;
|
||||
const SEMANTIC_SEARCH_COST = 2;
|
||||
const HEALTH_CHECK_COST = 2;
|
||||
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;
|
||||
|
||||
let catalogCache = null;
|
||||
@@ -525,6 +526,7 @@ app.get('/', (_request, response) => {
|
||||
'POST /auth/signup',
|
||||
'POST /auth/login',
|
||||
'POST /auth/apple',
|
||||
'DELETE /auth/account',
|
||||
'GET /v1/billing/summary',
|
||||
'POST /v1/billing/sync-revenuecat',
|
||||
'POST /v1/scan',
|
||||
@@ -909,11 +911,12 @@ app.post('/v1/health-check', async (request, response) => {
|
||||
: 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',
|
||||
likelyIssues: [{
|
||||
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,
|
||||
@@ -944,11 +947,12 @@ app.post('/v1/health-check', async (request, response) => {
|
||||
);
|
||||
}
|
||||
|
||||
const healthCheck = {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: analysis.overallHealthScore,
|
||||
status: analysis.status,
|
||||
likelyIssues: analysis.likelyIssues,
|
||||
const healthCheck = {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: analysis.overallHealthScore,
|
||||
status: analysis.status,
|
||||
analysisSummary: analysis.analysisSummary,
|
||||
likelyIssues: analysis.likelyIssues,
|
||||
actionsNow: analysis.actionsNow,
|
||||
plan7Days: analysis.plan7Days,
|
||||
creditsCharged,
|
||||
@@ -1081,7 +1085,27 @@ app.post('/auth/apple', async (request, response) => {
|
||||
|
||||
// ─── Startup ───────────────────────────────────────────────────────────────
|
||||
|
||||
const start = async () => {
|
||||
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);
|
||||
|
||||
@@ -291,4 +291,55 @@ const signInWithApple = async (db, identityToken, profile = {}) => {
|
||||
return { id, email: normalizedEmail, name, isNewUser: true };
|
||||
};
|
||||
|
||||
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };
|
||||
const runInTransaction = async (db, worker) => {
|
||||
const client = typeof db.connect === 'function' ? await db.connect() : db;
|
||||
const release = typeof client.release === 'function' ? () => client.release() : () => {};
|
||||
|
||||
await run(client, 'BEGIN');
|
||||
try {
|
||||
const result = await worker(client);
|
||||
await run(client, 'COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
await run(client, 'ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
console.error('Failed to rollback account deletion transaction.', rollbackError);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async (db, userId) => {
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
const err = new Error('Valid user id is required.');
|
||||
err.code = 'BAD_REQUEST';
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return runInTransaction(db, async (tx) => {
|
||||
await run(tx, 'DELETE FROM billing_accounts WHERE user_id = $1', [userId]);
|
||||
await run(
|
||||
tx,
|
||||
`DELETE FROM billing_idempotency
|
||||
WHERE id LIKE $1 OR id LIKE $2`,
|
||||
[`endpoint:%:${userId}:%`, `charge:%:${userId}:%`],
|
||||
);
|
||||
const result = await run(tx, 'DELETE FROM auth_users WHERE id = $1', [userId]);
|
||||
return { deleted: result.changes > 0 };
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
deleteAccount,
|
||||
ensureAuthSchema,
|
||||
signUp,
|
||||
login,
|
||||
signInWithApple,
|
||||
issueToken,
|
||||
verifyJwt,
|
||||
verifyAppleIdentityToken,
|
||||
};
|
||||
|
||||
@@ -142,9 +142,10 @@ const normalizeIdentifyResult = (raw, language) => {
|
||||
};
|
||||
|
||||
const normalizeHealthAnalysis = (raw, language) => {
|
||||
const scoreRaw = getNumber(raw.overallHealthScore);
|
||||
const statusRaw = getString(raw.status);
|
||||
const issuesRaw = raw.likelyIssues;
|
||||
const scoreRaw = getNumber(raw.overallHealthScore);
|
||||
const statusRaw = getString(raw.status);
|
||||
const analysisSummary = getString(raw.analysisSummary);
|
||||
const issuesRaw = raw.likelyIssues;
|
||||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
||||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
||||
|
||||
@@ -180,9 +181,10 @@ const normalizeHealthAnalysis = (raw, language) => {
|
||||
? 'La IA no pudo extraer senales de salud estables.'
|
||||
: 'AI could not extract stable health signals.';
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||
status,
|
||||
likelyIssues: [
|
||||
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||
status,
|
||||
analysisSummary: analysisSummary || fallbackIssue,
|
||||
likelyIssues: [
|
||||
{
|
||||
title: language === 'de'
|
||||
? 'Analyse unsicher'
|
||||
@@ -203,9 +205,10 @@ const normalizeHealthAnalysis = (raw, language) => {
|
||||
}
|
||||
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||
status,
|
||||
likelyIssues,
|
||||
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||
status,
|
||||
analysisSummary,
|
||||
likelyIssues,
|
||||
actionsNow: actionsNowRaw,
|
||||
plan7Days: plan7DaysRaw,
|
||||
};
|
||||
@@ -260,12 +263,13 @@ const buildHealthPrompt = (language, plantContext) => {
|
||||
'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.',
|
||||
'',
|
||||
'Return strict JSON only in this exact shape:',
|
||||
'{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
|
||||
'{"overallHealthScore":72,"status":"watch","analysisSummary":"...","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
|
||||
'',
|
||||
'Rules:',
|
||||
'- "overallHealthScore": integer 0–100. 100=perfect health, 80–99=minor cosmetic only, 60–79=noticeable issues needing attention, 40–59=significant stress, below 40=severe/critical.',
|
||||
'- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 50–79, needs monitoring), "critical" (score<50, urgent action needed).',
|
||||
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
|
||||
`- "analysisSummary": 6 to 9 precise sentences in ${getLanguageLabel(language)} describing visible condition, symptom pattern, likely root cause, urgency, confidence limits, and what the owner should monitor next.`,
|
||||
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
|
||||
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
|
||||
' - "confidence": float 0.05–0.99 reflecting visual certainty',
|
||||
' - "details": 2–4 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.',
|
||||
|
||||
Reference in New Issue
Block a user