fix: add auth endpoints to server, fix auth bypass and registration
- server/: commit server code for the first time (was untracked)
- POST /auth/signup and /auth/login endpoints now deployed
- GET /v1/billing/summary now verifies user exists in auth_users
(prevents stale JWTs from bypassing auth → fixes empty dashboard)
- app/_layout.tsx: dual-marker install check (SQLite + SecureStore)
to detect fresh installs reliably on Android
- app/auth/login.tsx, signup.tsx: replace Ionicons leaf logo with
actual app icon image (assets/icon.png)
- services/authService.ts: log HTTP status + server message on auth errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
107
server/lib/auth.js
Normal file
107
server/lib/auth.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const crypto = require('crypto');
|
||||
const { get, run } = require('./sqlite');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
||||
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
||||
|
||||
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
|
||||
|
||||
const b64url = (input) => {
|
||||
const str = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
return Buffer.from(str).toString('base64url');
|
||||
};
|
||||
|
||||
const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString();
|
||||
|
||||
const signJwt = (payload) => {
|
||||
const header = b64url({ alg: 'HS256', typ: 'JWT' });
|
||||
const body = b64url(payload);
|
||||
const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
||||
return `${header}.${body}.${sig}`;
|
||||
};
|
||||
|
||||
const verifyJwt = (token) => {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, sig] = parts;
|
||||
const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
||||
if (sig !== expected) return null;
|
||||
try {
|
||||
const payload = JSON.parse(b64urlDecode(body));
|
||||
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const issueToken = (userId, email, name) =>
|
||||
signJwt({
|
||||
sub: userId,
|
||||
email,
|
||||
name,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
// ─── Password hashing ──────────────────────────────────────────────────────
|
||||
|
||||
const hashPassword = (password) =>
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
||||
|
||||
// ─── Schema ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ensureAuthSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Operations ───────────────────────────────────────────────────────────
|
||||
|
||||
const signUp = async (db, email, name, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]);
|
||||
if (existing) {
|
||||
const err = new Error('Email already in use.');
|
||||
err.code = 'EMAIL_TAKEN';
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [
|
||||
id,
|
||||
normalizedEmail,
|
||||
name.trim(),
|
||||
hashPassword(password),
|
||||
]);
|
||||
return { id, email: normalizedEmail, name: name.trim() };
|
||||
};
|
||||
|
||||
const login = async (db, email, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]);
|
||||
if (!user) {
|
||||
const err = new Error('No account found for this email.');
|
||||
err.code = 'USER_NOT_FOUND';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (user.password_hash !== hashPassword(password)) {
|
||||
const err = new Error('Wrong password.');
|
||||
err.code = 'WRONG_PASSWORD';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
};
|
||||
|
||||
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
|
||||
490
server/lib/billing.js
Normal file
490
server/lib/billing.js
Normal file
@@ -0,0 +1,490 @@
|
||||
const { get, run } = require('./sqlite');
|
||||
|
||||
const FREE_MONTHLY_CREDITS = 15;
|
||||
const PRO_MONTHLY_CREDITS = 50;
|
||||
const TOPUP_DEFAULT_CREDITS = 60;
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT = {
|
||||
pro_monthly: 0,
|
||||
topup_small: 50,
|
||||
topup_medium: 120,
|
||||
topup_large: 300,
|
||||
};
|
||||
|
||||
const AVAILABLE_PRODUCTS = ['pro_monthly', 'topup_small', 'topup_medium', 'topup_large'];
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const startOfUtcMonth = (date) => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
};
|
||||
|
||||
const addUtcMonths = (date, months) => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
|
||||
};
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const result = new Date(date.getTime());
|
||||
result.setUTCDate(result.getUTCDate() + days);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getCycleBounds = (now) => {
|
||||
const cycleStartedAt = startOfUtcMonth(now);
|
||||
const cycleEndsAt = addUtcMonths(cycleStartedAt, 1);
|
||||
return { cycleStartedAt, cycleEndsAt };
|
||||
};
|
||||
|
||||
const getMonthlyAllowanceForPlan = (plan) => {
|
||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const createInsufficientCreditsError = (required, available) => {
|
||||
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
|
||||
error.code = 'INSUFFICIENT_CREDITS';
|
||||
error.status = 402;
|
||||
error.metadata = { required, available };
|
||||
return error;
|
||||
};
|
||||
|
||||
const runInTransaction = async (db, worker) => {
|
||||
await run(db, 'BEGIN IMMEDIATE TRANSACTION');
|
||||
try {
|
||||
const result = await worker();
|
||||
await run(db, 'COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
await run(db, 'ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
console.error('Failed to rollback SQLite transaction.', rollbackError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAccountRow = (row) => {
|
||||
if (!row) return null;
|
||||
return {
|
||||
userId: String(row.userId),
|
||||
plan: row.plan === 'pro' ? 'pro' : 'free',
|
||||
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'stripe',
|
||||
cycleStartedAt: String(row.cycleStartedAt),
|
||||
cycleEndsAt: String(row.cycleEndsAt),
|
||||
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
|
||||
usedThisCycle: Number(row.usedThisCycle) || 0,
|
||||
topupBalance: Number(row.topupBalance) || 0,
|
||||
renewsAt: row.renewsAt ? String(row.renewsAt) : null,
|
||||
updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(),
|
||||
};
|
||||
};
|
||||
|
||||
const buildDefaultAccount = (userId, now) => {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
return {
|
||||
userId,
|
||||
plan: 'free',
|
||||
provider: 'stripe',
|
||||
cycleStartedAt: cycleStartedAt.toISOString(),
|
||||
cycleEndsAt: cycleEndsAt.toISOString(),
|
||||
monthlyAllowance: FREE_MONTHLY_CREDITS,
|
||||
usedThisCycle: 0,
|
||||
topupBalance: 0,
|
||||
renewsAt: null,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
};
|
||||
|
||||
const alignAccountToCurrentCycle = (account, now) => {
|
||||
const next = { ...account };
|
||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
|
||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
if (!next.renewsAt && next.plan === 'pro') {
|
||||
next.renewsAt = addDays(now, 30).toISOString();
|
||||
}
|
||||
|
||||
const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime();
|
||||
if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
next.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
next.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
next.usedThisCycle = 0;
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const accountChanged = (a, b) => {
|
||||
return a.userId !== b.userId
|
||||
|| a.plan !== b.plan
|
||||
|| a.provider !== b.provider
|
||||
|| a.cycleStartedAt !== b.cycleStartedAt
|
||||
|| a.cycleEndsAt !== b.cycleEndsAt
|
||||
|| a.monthlyAllowance !== b.monthlyAllowance
|
||||
|| a.usedThisCycle !== b.usedThisCycle
|
||||
|| a.topupBalance !== b.topupBalance
|
||||
|| a.renewsAt !== b.renewsAt;
|
||||
};
|
||||
|
||||
const upsertAccount = async (db, account) => {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO billing_accounts (
|
||||
userId,
|
||||
plan,
|
||||
provider,
|
||||
cycleStartedAt,
|
||||
cycleEndsAt,
|
||||
monthlyAllowance,
|
||||
usedThisCycle,
|
||||
topupBalance,
|
||||
renewsAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(userId) DO UPDATE SET
|
||||
plan = excluded.plan,
|
||||
provider = excluded.provider,
|
||||
cycleStartedAt = excluded.cycleStartedAt,
|
||||
cycleEndsAt = excluded.cycleEndsAt,
|
||||
monthlyAllowance = excluded.monthlyAllowance,
|
||||
usedThisCycle = excluded.usedThisCycle,
|
||||
topupBalance = excluded.topupBalance,
|
||||
renewsAt = excluded.renewsAt,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
[
|
||||
account.userId,
|
||||
account.plan,
|
||||
account.provider,
|
||||
account.cycleStartedAt,
|
||||
account.cycleEndsAt,
|
||||
account.monthlyAllowance,
|
||||
account.usedThisCycle,
|
||||
account.topupBalance,
|
||||
account.renewsAt,
|
||||
account.updatedAt,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const getOrCreateAccount = async (db, userId) => {
|
||||
const row = await get(
|
||||
db,
|
||||
`SELECT
|
||||
userId,
|
||||
plan,
|
||||
provider,
|
||||
cycleStartedAt,
|
||||
cycleEndsAt,
|
||||
monthlyAllowance,
|
||||
usedThisCycle,
|
||||
topupBalance,
|
||||
renewsAt,
|
||||
updatedAt
|
||||
FROM billing_accounts
|
||||
WHERE userId = ?`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
if (!row) {
|
||||
const created = buildDefaultAccount(userId, now);
|
||||
await upsertAccount(db, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
const existing = normalizeAccountRow(row);
|
||||
const aligned = alignAccountToCurrentCycle(existing, now);
|
||||
if (accountChanged(existing, aligned)) {
|
||||
aligned.updatedAt = nowIso();
|
||||
await upsertAccount(db, aligned);
|
||||
}
|
||||
return aligned;
|
||||
};
|
||||
|
||||
const getAvailableCredits = (account) => {
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||
};
|
||||
|
||||
const buildBillingSummary = (account) => {
|
||||
return {
|
||||
entitlement: {
|
||||
plan: account.plan,
|
||||
provider: account.provider,
|
||||
status: 'active',
|
||||
renewsAt: account.renewsAt,
|
||||
},
|
||||
credits: {
|
||||
monthlyAllowance: account.monthlyAllowance,
|
||||
usedThisCycle: account.usedThisCycle,
|
||||
topupBalance: account.topupBalance,
|
||||
available: getAvailableCredits(account),
|
||||
cycleStartedAt: account.cycleStartedAt,
|
||||
cycleEndsAt: account.cycleEndsAt,
|
||||
},
|
||||
availableProducts: AVAILABLE_PRODUCTS,
|
||||
};
|
||||
};
|
||||
|
||||
const consumeCredits = (account, cost) => {
|
||||
if (cost <= 0) return 0;
|
||||
|
||||
const available = getAvailableCredits(account);
|
||||
if (available < cost) {
|
||||
throw createInsufficientCreditsError(cost, available);
|
||||
}
|
||||
|
||||
let remaining = cost;
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
if (monthlyRemaining > 0) {
|
||||
const monthlyUsage = Math.min(monthlyRemaining, remaining);
|
||||
account.usedThisCycle += monthlyUsage;
|
||||
remaining -= monthlyUsage;
|
||||
}
|
||||
|
||||
if (remaining > 0 && account.topupBalance > 0) {
|
||||
const topupUsage = Math.min(account.topupBalance, remaining);
|
||||
account.topupBalance -= topupUsage;
|
||||
remaining -= topupUsage;
|
||||
}
|
||||
|
||||
return cost;
|
||||
};
|
||||
|
||||
const parseStoredJson = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readIdempotentValue = async (db, key) => {
|
||||
const row = await get(db, 'SELECT responseJson FROM billing_idempotency WHERE id = ?', [key]);
|
||||
if (!row || typeof row.responseJson !== 'string') return null;
|
||||
return parseStoredJson(row.responseJson);
|
||||
};
|
||||
|
||||
const writeIdempotentValue = async (db, key, value) => {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO billing_idempotency (id, responseJson, createdAt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
responseJson = excluded.responseJson,
|
||||
createdAt = excluded.createdAt`,
|
||||
[key, JSON.stringify(value), nowIso()],
|
||||
);
|
||||
};
|
||||
|
||||
const endpointKey = (scope, userId, idempotencyKey) => {
|
||||
return `endpoint:${scope}:${userId}:${idempotencyKey}`;
|
||||
};
|
||||
|
||||
const chargeKey = (scope, userId, idempotencyKey) => {
|
||||
return `charge:${scope}:${userId}:${idempotencyKey}`;
|
||||
};
|
||||
|
||||
const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
|
||||
return runInTransaction(db, async () => {
|
||||
const existing = await readIdempotentValue(db, key);
|
||||
if (existing && typeof existing.charged === 'number') return existing.charged;
|
||||
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
const charged = consumeCredits(account, cost);
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
await writeIdempotentValue(db, key, { charged });
|
||||
return charged;
|
||||
});
|
||||
};
|
||||
|
||||
const getBillingSummary = async (db, userId) => {
|
||||
return runInTransaction(db, async () => {
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
return buildBillingSummary(account);
|
||||
});
|
||||
};
|
||||
|
||||
const getAccountSnapshot = async (db, userId) => {
|
||||
return runInTransaction(db, async () => {
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
return account;
|
||||
});
|
||||
};
|
||||
|
||||
const getEndpointResponse = async (db, key) => {
|
||||
const cached = await readIdempotentValue(db, key);
|
||||
return cached || null;
|
||||
};
|
||||
|
||||
const storeEndpointResponse = async (db, key, response) => {
|
||||
await writeIdempotentValue(db, key, response);
|
||||
};
|
||||
|
||||
const simulatePurchase = async (db, userId, idempotencyKey, productId) => {
|
||||
const endpointId = endpointKey('simulate-purchase', userId, idempotencyKey);
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await runInTransaction(db, async () => {
|
||||
const existingInsideTx = await readIdempotentValue(db, endpointId);
|
||||
if (existingInsideTx) return existingInsideTx;
|
||||
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
|
||||
if (productId === 'pro_monthly') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'pro';
|
||||
account.provider = 'stripe';
|
||||
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
|
||||
account.usedThisCycle = 0;
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.renewsAt = addDays(now, 30).toISOString();
|
||||
} else {
|
||||
const credits = TOPUP_CREDITS_BY_PRODUCT[productId];
|
||||
if (typeof credits !== 'number') {
|
||||
const error = new Error(`Unsupported product: ${productId}`);
|
||||
error.code = 'BAD_REQUEST';
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
account.topupBalance += credits;
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
|
||||
const payload = {
|
||||
appliedProduct: productId,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
return payload;
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const simulateWebhook = async (db, userId, idempotencyKey, event, payload = {}) => {
|
||||
const endpointId = endpointKey('simulate-webhook', userId, idempotencyKey);
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await runInTransaction(db, async () => {
|
||||
const existingInsideTx = await readIdempotentValue(db, endpointId);
|
||||
if (existingInsideTx) return existingInsideTx;
|
||||
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
|
||||
if (event === 'entitlement_granted') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'pro';
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
|
||||
account.usedThisCycle = 0;
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.renewsAt = addDays(now, 30).toISOString();
|
||||
} else if (event === 'entitlement_revoked') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'free';
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = FREE_MONTHLY_CREDITS;
|
||||
account.usedThisCycle = 0;
|
||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||
account.renewsAt = null;
|
||||
} else if (event === 'topup_granted') {
|
||||
const credits = Math.max(1, Number(payload.credits) || TOPUP_DEFAULT_CREDITS);
|
||||
account.topupBalance += credits;
|
||||
} else if (event === 'credits_depleted') {
|
||||
account.usedThisCycle = account.monthlyAllowance;
|
||||
account.topupBalance = 0;
|
||||
} else {
|
||||
const error = new Error(`Unsupported webhook event: ${event}`);
|
||||
error.code = 'BAD_REQUEST';
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
|
||||
const payloadResponse = {
|
||||
event,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
await storeEndpointResponse(db, endpointId, payloadResponse);
|
||||
return payloadResponse;
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const ensureBillingSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS billing_accounts (
|
||||
userId TEXT PRIMARY KEY,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
provider TEXT NOT NULL DEFAULT 'stripe',
|
||||
cycleStartedAt TEXT NOT NULL,
|
||||
cycleEndsAt TEXT NOT NULL,
|
||||
monthlyAllowance INTEGER NOT NULL DEFAULT 15,
|
||||
usedThisCycle INTEGER NOT NULL DEFAULT 0,
|
||||
topupBalance INTEGER NOT NULL DEFAULT 0,
|
||||
renewsAt TEXT,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS billing_idempotency (
|
||||
id TEXT PRIMARY KEY,
|
||||
responseJson TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_idempotency_created_at
|
||||
ON billing_idempotency(createdAt DESC)`,
|
||||
);
|
||||
};
|
||||
|
||||
const isInsufficientCreditsError = (error) => {
|
||||
return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_CREDITS');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
AVAILABLE_PRODUCTS,
|
||||
chargeKey,
|
||||
consumeCreditsWithIdempotency,
|
||||
endpointKey,
|
||||
ensureBillingSchema,
|
||||
getAccountSnapshot,
|
||||
getBillingSummary,
|
||||
getEndpointResponse,
|
||||
getMonthlyAllowanceForPlan,
|
||||
isInsufficientCreditsError,
|
||||
runInTransaction,
|
||||
simulatePurchase,
|
||||
simulateWebhook,
|
||||
storeEndpointResponse,
|
||||
};
|
||||
446
server/lib/openai.js
Normal file
446
server/lib/openai.js
Normal file
@@ -0,0 +1,446 @@
|
||||
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
|
||||
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim();
|
||||
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
|
||||
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4o-mini').trim();
|
||||
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
|
||||
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
|
||||
const OPENAI_TIMEOUT_MS = (() => {
|
||||
const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
|
||||
return 45000;
|
||||
})();
|
||||
|
||||
const parseModelChain = (primaryModel, fallbackModels) => {
|
||||
const models = [primaryModel];
|
||||
for (const model of String(fallbackModels || '').split(',')) {
|
||||
const normalized = model.trim();
|
||||
if (!normalized) continue;
|
||||
models.push(normalized);
|
||||
}
|
||||
return [...new Set(models.filter(Boolean))];
|
||||
};
|
||||
|
||||
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
|
||||
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
|
||||
|
||||
const clamp = (value, min, max) => {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
const toErrorMessage = (error) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
const summarizeImageUri = (imageUri) => {
|
||||
const trimmed = typeof imageUri === 'string' ? imageUri.trim() : '';
|
||||
if (!trimmed) return 'empty';
|
||||
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
|
||||
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
|
||||
};
|
||||
|
||||
const toJsonString = (content) => {
|
||||
const trimmed = typeof content === 'string' ? content.trim() : '';
|
||||
if (!trimmed) return '';
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (fenced && fenced[1]) return fenced[1].trim();
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const parseContentToJson = (content) => {
|
||||
try {
|
||||
const parsed = JSON.parse(toJsonString(content));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getString = (value) => {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const getNumber = (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStringArray = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getLanguageLabel = (language) => {
|
||||
if (language === 'de') return 'German';
|
||||
if (language === 'es') return 'Spanish';
|
||||
return 'English';
|
||||
};
|
||||
|
||||
const normalizeIdentifyResult = (raw, language) => {
|
||||
const name = getString(raw.name);
|
||||
const botanicalName = getString(raw.botanicalName);
|
||||
const description = getString(raw.description);
|
||||
const confidenceRaw = getNumber(raw.confidence);
|
||||
const careInfoRaw = raw.careInfo;
|
||||
|
||||
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
||||
const light = getString(careInfoRaw.light);
|
||||
const temp = getString(careInfoRaw.temp);
|
||||
if (waterIntervalRaw == null || !light || !temp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackDescription = language === 'de'
|
||||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||||
: language === 'es'
|
||||
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
|
||||
: `${name} was identified with AI. Care guidance is shown below.`;
|
||||
|
||||
return {
|
||||
name,
|
||||
botanicalName,
|
||||
confidence: clamp(confidenceRaw == null ? 0.72 : confidenceRaw, 0.05, 0.99),
|
||||
description: description || fallbackDescription,
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||||
light,
|
||||
temp,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeHealthAnalysis = (raw, language) => {
|
||||
const scoreRaw = getNumber(raw.overallHealthScore);
|
||||
const statusRaw = getString(raw.status);
|
||||
const issuesRaw = raw.likelyIssues;
|
||||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
||||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
||||
|
||||
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
|
||||
? statusRaw
|
||||
: 'watch';
|
||||
|
||||
const likelyIssues = issuesRaw
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||||
const title = getString(entry.title);
|
||||
const details = getString(entry.details);
|
||||
const confidenceRaw = getNumber(entry.confidence);
|
||||
if (!title || !details || confidenceRaw == null) return null;
|
||||
return {
|
||||
title,
|
||||
details,
|
||||
confidence: clamp(confidenceRaw, 0.05, 0.99),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
|
||||
if (likelyIssues.length === 0 || actionsNowRaw.length < 2 || plan7DaysRaw.length < 2) {
|
||||
const fallbackIssue = language === 'de'
|
||||
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
|
||||
: language === 'es'
|
||||
? 'La IA no pudo extraer senales de salud estables.'
|
||||
: 'AI could not extract stable health signals.';
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
status,
|
||||
likelyIssues: [
|
||||
{
|
||||
title: language === 'de'
|
||||
? 'Analyse unsicher'
|
||||
: language === 'es'
|
||||
? 'Analisis incierto'
|
||||
: 'Uncertain analysis',
|
||||
confidence: 0.35,
|
||||
details: fallbackIssue,
|
||||
},
|
||||
],
|
||||
actionsNow: actionsNowRaw.length > 0
|
||||
? actionsNowRaw
|
||||
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
|
||||
plan7Days: plan7DaysRaw.length > 0
|
||||
? plan7DaysRaw
|
||||
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow: actionsNowRaw,
|
||||
plan7Days: plan7DaysRaw,
|
||||
};
|
||||
};
|
||||
|
||||
const buildIdentifyPrompt = (language, mode) => {
|
||||
const reviewInstruction = mode === 'review'
|
||||
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
|
||||
: 'Identify the most likely houseplant species from this image with conservative confidence.';
|
||||
|
||||
return [
|
||||
`${reviewInstruction}`,
|
||||
'Return strict JSON only in this shape:',
|
||||
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
|
||||
'Rules:',
|
||||
`- "name", "description", and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||||
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
||||
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
||||
'- "confidence" must be between 0 and 1.',
|
||||
'- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.',
|
||||
'- "waterIntervalDays" must be an integer between 1 and 45.',
|
||||
'- Do not include markdown, explanations, or extra keys.',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const buildHealthPrompt = (language, plantContext) => {
|
||||
const contextLines = plantContext
|
||||
? [
|
||||
'Plant context:',
|
||||
`- name: ${plantContext.name || 'n/a'}`,
|
||||
`- botanicalName: ${plantContext.botanicalName || 'n/a'}`,
|
||||
`- care.light: ${plantContext.careInfo?.light || 'n/a'}`,
|
||||
`- care.temp: ${plantContext.careInfo?.temp || 'n/a'}`,
|
||||
`- care.waterIntervalDays: ${plantContext.careInfo?.waterIntervalDays || 'n/a'}`,
|
||||
`- description: ${plantContext.description || 'n/a'}`,
|
||||
]
|
||||
: ['Plant context: not provided'];
|
||||
|
||||
return [
|
||||
`You are an expert botanist and plant health diagnostician. Carefully examine every visible detail of this plant photo and produce a thorough, professional health assessment written in ${getLanguageLabel(language)}.`,
|
||||
'',
|
||||
'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":["..."]}',
|
||||
'',
|
||||
'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:',
|
||||
' - "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.',
|
||||
`- "actionsNow": 5 to 8 specific, actionable steps for the next 24–48 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`,
|
||||
`- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`,
|
||||
'- All text fields must be written in the specified language. No markdown, no extra keys.',
|
||||
...contextLines,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const extractMessageContent = (payload) => {
|
||||
const content = payload?.choices?.[0]?.message?.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((chunk) => (chunk && chunk.type === 'text' ? chunk.text || '' : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => {
|
||||
if (!OPENAI_API_KEY) return null;
|
||||
if (typeof fetch !== 'function') {
|
||||
throw new Error('Global fetch is not available in this Node runtime.');
|
||||
}
|
||||
|
||||
const attemptedModels = [];
|
||||
|
||||
for (const model of modelChain) {
|
||||
attemptedModels.push(model);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages,
|
||||
};
|
||||
if (typeof temperature === 'number') body.temperature = temperature;
|
||||
|
||||
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.warn('OpenAI request HTTP error.', {
|
||||
status: response.status,
|
||||
model,
|
||||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||||
image: summarizeImageUri(imageUri),
|
||||
bodyPreview: body.slice(0, 300),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return { payload, modelUsed: model, attemptedModels };
|
||||
} catch (error) {
|
||||
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
|
||||
console.warn('OpenAI request failed.', {
|
||||
model,
|
||||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||||
timeoutMs: OPENAI_TIMEOUT_MS,
|
||||
aborted: isTimeoutAbort,
|
||||
error: toErrorMessage(error),
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
continue;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return { payload: null, modelUsed: null, attemptedModels };
|
||||
};
|
||||
|
||||
const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
|
||||
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
|
||||
const completion = await postChatCompletion({
|
||||
modelChain: OPENAI_SCAN_MODEL_CHAIN,
|
||||
imageUri,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a plant identification assistant. Return strict JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!completion?.payload) {
|
||||
return {
|
||||
result: null,
|
||||
modelUsed: completion?.modelUsed || null,
|
||||
attemptedModels: completion?.attemptedModels || [],
|
||||
};
|
||||
}
|
||||
|
||||
const content = extractMessageContent(completion.payload);
|
||||
if (!content) {
|
||||
console.warn('OpenAI identify returned empty content.', {
|
||||
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
mode,
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
const parsed = parseContentToJson(content);
|
||||
if (!parsed) {
|
||||
console.warn('OpenAI identify returned non-JSON content.', {
|
||||
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
mode,
|
||||
preview: content.slice(0, 220),
|
||||
});
|
||||
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
const normalized = normalizeIdentifyResult(parsed, language);
|
||||
if (!normalized) {
|
||||
console.warn('OpenAI identify JSON did not match schema.', {
|
||||
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
mode,
|
||||
keys: Object.keys(parsed),
|
||||
});
|
||||
}
|
||||
return { result: normalized, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
};
|
||||
|
||||
const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
|
||||
if (!OPENAI_API_KEY) return { analysis: null, modelUsed: null, attemptedModels: [] };
|
||||
const completion = await postChatCompletion({
|
||||
modelChain: OPENAI_HEALTH_MODEL_CHAIN,
|
||||
imageUri,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!completion?.payload) {
|
||||
return {
|
||||
analysis: null,
|
||||
modelUsed: completion?.modelUsed || null,
|
||||
attemptedModels: completion?.attemptedModels || [],
|
||||
};
|
||||
}
|
||||
|
||||
const content = extractMessageContent(completion.payload);
|
||||
if (!content) {
|
||||
console.warn('OpenAI health returned empty content.', {
|
||||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
const parsed = parseContentToJson(content);
|
||||
if (!parsed) {
|
||||
console.warn('OpenAI health returned non-JSON content.', {
|
||||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
preview: content.slice(0, 220),
|
||||
});
|
||||
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
return {
|
||||
analysis: normalizeHealthAnalysis(parsed, language),
|
||||
modelUsed: completion.modelUsed,
|
||||
attemptedModels: completion.attemptedModels,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
analyzePlantHealth,
|
||||
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
getScanModel: () => OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
identifyPlant,
|
||||
isConfigured: () => Boolean(OPENAI_API_KEY),
|
||||
};
|
||||
652
server/lib/plants.js
Normal file
652
server/lib/plants.js
Normal file
@@ -0,0 +1,652 @@
|
||||
const crypto = require('crypto');
|
||||
const { all, get, run } = require('./sqlite');
|
||||
|
||||
const DEFAULT_LIMIT = 60;
|
||||
const MAX_LIMIT = 500;
|
||||
const MAX_AUDIT_DETAILS = 80;
|
||||
const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/';
|
||||
const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/';
|
||||
|
||||
class PlantImportValidationError extends Error {
|
||||
constructor(message, details) {
|
||||
super(message);
|
||||
this.name = 'PlantImportValidationError';
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeWhitespace = (value) => {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
};
|
||||
|
||||
const normalizeKey = (value) => {
|
||||
return normalizeWhitespace(value)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
};
|
||||
|
||||
const unwrapMarkdownLink = (value) => {
|
||||
const markdownMatch = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i);
|
||||
if (!markdownMatch) return value;
|
||||
const [, url, suffix] = markdownMatch;
|
||||
return `${url}${suffix || ''}`;
|
||||
};
|
||||
|
||||
const tryDecode = (value) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const convertWikimediaFilePathUrl = (value) => {
|
||||
const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT);
|
||||
if (segmentIndex < 0) return null;
|
||||
|
||||
const fileNameStart = segmentIndex + WIKIMEDIA_FILEPATH_SEGMENT.length;
|
||||
const rawFileName = value.slice(fileNameStart).split(/[?#]/)[0].trim();
|
||||
if (!rawFileName) return null;
|
||||
|
||||
const decodedFileName = tryDecode(rawFileName).replace(/\s+/g, ' ').trim();
|
||||
if (!decodedFileName) return null;
|
||||
const encodedFileName = encodeURIComponent(decodedFileName).replace(/%2F/g, '/');
|
||||
return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`;
|
||||
};
|
||||
|
||||
const normalizeImageUri = (rawUri) => {
|
||||
if (typeof rawUri !== 'string') return null;
|
||||
|
||||
const trimmed = rawUri.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const normalized = unwrapMarkdownLink(trimmed);
|
||||
const converted = convertWikimediaFilePathUrl(normalized);
|
||||
const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://');
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const protocol = parsedUrl.protocol.toLowerCase();
|
||||
if (protocol !== 'https:' && protocol !== 'http:') return null;
|
||||
if (!parsedUrl.hostname) return null;
|
||||
|
||||
parsedUrl.protocol = 'https:';
|
||||
return parsedUrl.toString();
|
||||
};
|
||||
|
||||
const toArrayOfStrings = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalized = value
|
||||
.map((item) => (typeof item === 'string' ? normalizeWhitespace(item) : ''))
|
||||
.filter(Boolean);
|
||||
return [...new Set(normalized)];
|
||||
};
|
||||
|
||||
const parseNumber = (value, fallback) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const buildStablePlantId = (botanicalName) => {
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(normalizeKey(botanicalName))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return `plant_${hash}`;
|
||||
};
|
||||
|
||||
const parseExistingIdMap = (rows) => {
|
||||
const botanicalToId = new Map();
|
||||
rows.forEach((row) => {
|
||||
if (!row || typeof row.botanicalName !== 'string' || typeof row.id !== 'string') return;
|
||||
botanicalToId.set(normalizeKey(row.botanicalName), row.id);
|
||||
});
|
||||
return botanicalToId;
|
||||
};
|
||||
|
||||
const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => {
|
||||
const errors = [];
|
||||
|
||||
const name = typeof rawEntry?.name === 'string' ? normalizeWhitespace(rawEntry.name) : '';
|
||||
const botanicalName = typeof rawEntry?.botanicalName === 'string'
|
||||
? normalizeWhitespace(rawEntry.botanicalName)
|
||||
: '';
|
||||
|
||||
if (!name) {
|
||||
errors.push({ index, field: 'name', message: 'name is required.' });
|
||||
}
|
||||
if (!botanicalName) {
|
||||
errors.push({ index, field: 'botanicalName', message: 'botanicalName is required.' });
|
||||
}
|
||||
|
||||
const normalizedBotanicalKey = botanicalName ? normalizeKey(botanicalName) : '';
|
||||
const existingId = preserveExistingIds ? existingIdMap.get(normalizedBotanicalKey) : null;
|
||||
|
||||
const incomingId = typeof rawEntry?.id === 'string' ? normalizeWhitespace(rawEntry.id) : '';
|
||||
const id = incomingId || existingId || (botanicalName ? buildStablePlantId(botanicalName) : '');
|
||||
|
||||
if (!id) {
|
||||
errors.push({ index, field: 'id', message: 'Could not derive stable plant id.' });
|
||||
}
|
||||
|
||||
const imageUri = normalizeImageUri(rawEntry?.imageUri);
|
||||
if (!imageUri) {
|
||||
errors.push({
|
||||
index,
|
||||
field: 'imageUri',
|
||||
message: 'imageUri is missing or invalid. A valid http(s) URL is required.',
|
||||
value: rawEntry?.imageUri ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const categories = toArrayOfStrings(rawEntry?.categories);
|
||||
const confidence = parseNumber(rawEntry?.confidence, 1);
|
||||
const clampedConfidence = Math.max(0, Math.min(1, Number(confidence.toFixed(4))));
|
||||
const description = typeof rawEntry?.description === 'string' ? rawEntry.description.trim() : '';
|
||||
const careInfoRaw = rawEntry?.careInfo || {};
|
||||
const careInfo = {
|
||||
waterIntervalDays: Math.max(1, Math.round(parseNumber(careInfoRaw.waterIntervalDays, 7))),
|
||||
light: typeof careInfoRaw.light === 'string' && careInfoRaw.light.trim()
|
||||
? normalizeWhitespace(careInfoRaw.light)
|
||||
: 'Unknown',
|
||||
temp: typeof careInfoRaw.temp === 'string' && careInfoRaw.temp.trim()
|
||||
? normalizeWhitespace(careInfoRaw.temp)
|
||||
: 'Unknown',
|
||||
};
|
||||
|
||||
return {
|
||||
entry: {
|
||||
id,
|
||||
name,
|
||||
botanicalName,
|
||||
imageUri,
|
||||
imageStatus: 'ok',
|
||||
description,
|
||||
categories,
|
||||
careInfo,
|
||||
confidence: clampedConfidence,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const collectDuplicateErrors = (entries, getKey, fieldName, message) => {
|
||||
const counts = new Map();
|
||||
entries.forEach((entry, index) => {
|
||||
const key = getKey(entry);
|
||||
if (!key) return;
|
||||
const existing = counts.get(key) || [];
|
||||
existing.push(index);
|
||||
counts.set(key, existing);
|
||||
});
|
||||
|
||||
const duplicateErrors = [];
|
||||
counts.forEach((indices, key) => {
|
||||
if (indices.length <= 1) return;
|
||||
indices.forEach((index) => {
|
||||
duplicateErrors.push({
|
||||
index,
|
||||
field: fieldName,
|
||||
message,
|
||||
value: key,
|
||||
});
|
||||
});
|
||||
});
|
||||
return duplicateErrors;
|
||||
};
|
||||
|
||||
const assertValidPreparedEntries = (entries, enforceUniqueImages) => {
|
||||
const duplicateErrors = [];
|
||||
duplicateErrors.push(
|
||||
...collectDuplicateErrors(
|
||||
entries,
|
||||
(entry) => entry.id,
|
||||
'id',
|
||||
'Duplicate plant id detected in import payload.',
|
||||
),
|
||||
);
|
||||
duplicateErrors.push(
|
||||
...collectDuplicateErrors(
|
||||
entries,
|
||||
(entry) => normalizeKey(entry.botanicalName),
|
||||
'botanicalName',
|
||||
'Duplicate botanicalName detected in import payload.',
|
||||
),
|
||||
);
|
||||
|
||||
if (enforceUniqueImages) {
|
||||
duplicateErrors.push(
|
||||
...collectDuplicateErrors(
|
||||
entries,
|
||||
(entry) => entry.imageUri,
|
||||
'imageUri',
|
||||
'Duplicate imageUri detected across multiple plants.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (duplicateErrors.length > 0) {
|
||||
throw new PlantImportValidationError(
|
||||
'Import payload contains duplicate keys.',
|
||||
duplicateErrors.slice(0, MAX_AUDIT_DETAILS),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureColumn = async (db, tableName, columnName, definitionSql) => {
|
||||
const columns = await all(db, `PRAGMA table_info(${tableName})`);
|
||||
const hasColumn = columns.some((column) => column.name === columnName);
|
||||
if (hasColumn) return;
|
||||
await run(db, `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definitionSql}`);
|
||||
};
|
||||
|
||||
const ensurePlantSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS plants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
botanicalName TEXT NOT NULL,
|
||||
imageUri TEXT NOT NULL,
|
||||
description TEXT,
|
||||
categories TEXT NOT NULL,
|
||||
careInfo TEXT NOT NULL,
|
||||
confidence REAL NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await ensureColumn(db, 'plants', 'imageStatus', `TEXT NOT NULL DEFAULT 'ok'`);
|
||||
await ensureColumn(db, 'plants', 'createdAt', `TEXT`);
|
||||
await ensureColumn(db, 'plants', 'updatedAt', `TEXT`);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS plant_import_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
importedCount INTEGER NOT NULL DEFAULT 0,
|
||||
preservedIds INTEGER NOT NULL DEFAULT 0,
|
||||
duplicateImageCount INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
details TEXT,
|
||||
backupTable TEXT,
|
||||
startedAt TEXT NOT NULL,
|
||||
completedAt TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_plants_name ON plants(name COLLATE NOCASE)`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_plants_botanical_name ON plants(botanicalName COLLATE NOCASE)`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_plant_import_audit_started_at ON plant_import_audit(startedAt DESC)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`UPDATE plants SET imageStatus = COALESCE(NULLIF(imageStatus, ''), 'ok')`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`UPDATE plants SET createdAt = COALESCE(createdAt, datetime('now'))`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`UPDATE plants SET updatedAt = COALESCE(updatedAt, datetime('now'))`,
|
||||
);
|
||||
};
|
||||
|
||||
const parseJsonArray = (value) => {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonObject = (value) => {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const toApiPlant = (row) => {
|
||||
const categories = parseJsonArray(row.categories);
|
||||
const careInfo = parseJsonObject(row.careInfo);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
botanicalName: row.botanicalName,
|
||||
imageUri: row.imageUri,
|
||||
imageStatus: row.imageStatus || 'ok',
|
||||
description: row.description || '',
|
||||
categories,
|
||||
careInfo,
|
||||
confidence: Number(row.confidence) || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getPlants = async (db, options = {}) => {
|
||||
const query = typeof options.query === 'string' ? options.query.trim().toLowerCase() : '';
|
||||
const category = typeof options.category === 'string' ? options.category.trim() : '';
|
||||
const limitRaw = Number(options.limit);
|
||||
const limit = Number.isFinite(limitRaw)
|
||||
? Math.max(1, Math.min(MAX_LIMIT, Math.round(limitRaw)))
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
let sql = `SELECT
|
||||
id,
|
||||
name,
|
||||
botanicalName,
|
||||
imageUri,
|
||||
imageStatus,
|
||||
description,
|
||||
categories,
|
||||
careInfo,
|
||||
confidence
|
||||
FROM plants`;
|
||||
const params = [];
|
||||
if (query) {
|
||||
sql += ` WHERE (
|
||||
LOWER(name) LIKE ?
|
||||
OR LOWER(botanicalName) LIKE ?
|
||||
OR LOWER(COALESCE(description, '')) LIKE ?
|
||||
)`;
|
||||
const likePattern = `%${query}%`;
|
||||
params.push(likePattern, likePattern, likePattern);
|
||||
}
|
||||
sql += ' ORDER BY name COLLATE NOCASE ASC';
|
||||
|
||||
const rows = await all(db, sql, params);
|
||||
let results = rows.map(toApiPlant);
|
||||
|
||||
if (category) {
|
||||
results = results.filter((plant) => plant.categories.includes(category));
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
};
|
||||
|
||||
const getPlantDiagnostics = async (db) => {
|
||||
const totals = await get(
|
||||
db,
|
||||
`SELECT
|
||||
COUNT(*) AS totalCount,
|
||||
SUM(CASE WHEN imageUri IS NULL OR TRIM(imageUri) = '' THEN 1 ELSE 0 END) AS missingImageCount,
|
||||
SUM(CASE WHEN COALESCE(imageStatus, 'ok') <> 'ok' THEN 1 ELSE 0 END) AS nonOkImageStatusCount
|
||||
FROM plants`,
|
||||
);
|
||||
|
||||
const duplicateImages = await all(
|
||||
db,
|
||||
`SELECT imageUri, COUNT(*) AS count
|
||||
FROM plants
|
||||
WHERE imageUri IS NOT NULL AND TRIM(imageUri) <> ''
|
||||
GROUP BY imageUri
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY count DESC, imageUri ASC
|
||||
LIMIT 200`,
|
||||
);
|
||||
|
||||
const duplicateBotanicalNames = await all(
|
||||
db,
|
||||
`SELECT botanicalName, COUNT(*) AS count
|
||||
FROM plants
|
||||
WHERE botanicalName IS NOT NULL AND TRIM(botanicalName) <> ''
|
||||
GROUP BY LOWER(botanicalName)
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY count DESC, botanicalName ASC
|
||||
LIMIT 200`,
|
||||
);
|
||||
|
||||
const recentAudits = await all(
|
||||
db,
|
||||
`SELECT
|
||||
id,
|
||||
source,
|
||||
importedCount,
|
||||
preservedIds,
|
||||
duplicateImageCount,
|
||||
status,
|
||||
details,
|
||||
backupTable,
|
||||
startedAt,
|
||||
completedAt
|
||||
FROM plant_import_audit
|
||||
ORDER BY startedAt DESC
|
||||
LIMIT 20`,
|
||||
);
|
||||
|
||||
return {
|
||||
totalCount: Number(totals?.totalCount || 0),
|
||||
missingImageCount: Number(totals?.missingImageCount || 0),
|
||||
nonOkImageStatusCount: Number(totals?.nonOkImageStatusCount || 0),
|
||||
duplicateImageCount: duplicateImages.length,
|
||||
duplicateImages,
|
||||
duplicateBotanicalNameCount: duplicateBotanicalNames.length,
|
||||
duplicateBotanicalNames,
|
||||
recentAudits: recentAudits.map((audit) => ({
|
||||
...audit,
|
||||
details: audit.details ? parseJsonObject(audit.details) : null,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const writeAuditRow = async (db, audit) => {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO plant_import_audit (
|
||||
source,
|
||||
importedCount,
|
||||
preservedIds,
|
||||
duplicateImageCount,
|
||||
status,
|
||||
details,
|
||||
backupTable,
|
||||
startedAt,
|
||||
completedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
audit.source,
|
||||
audit.importedCount,
|
||||
audit.preservedIds,
|
||||
audit.duplicateImageCount,
|
||||
audit.status,
|
||||
JSON.stringify(audit.details || {}),
|
||||
audit.backupTable || null,
|
||||
audit.startedAt,
|
||||
audit.completedAt,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeIdentifier = (value) => {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`Invalid SQL identifier: ${value}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const rebuildPlantsCatalog = async (db, rawEntries, options = {}) => {
|
||||
if (!Array.isArray(rawEntries)) {
|
||||
throw new PlantImportValidationError('Import payload must be an array of entries.', [
|
||||
{ field: 'entries', message: 'Expected an array of plant objects.' },
|
||||
]);
|
||||
}
|
||||
|
||||
const source = typeof options.source === 'string' && options.source.trim()
|
||||
? options.source.trim()
|
||||
: 'manual';
|
||||
const preserveExistingIds = options.preserveExistingIds !== false;
|
||||
const enforceUniqueImages = options.enforceUniqueImages !== false;
|
||||
const startedAtIso = new Date().toISOString();
|
||||
|
||||
const existingRows = await all(db, 'SELECT id, botanicalName FROM plants');
|
||||
const existingIdMap = parseExistingIdMap(existingRows);
|
||||
|
||||
const validationErrors = [];
|
||||
const preparedEntries = rawEntries.map((rawEntry, index) => {
|
||||
const prepared = prepareEntry(rawEntry, index, existingIdMap, preserveExistingIds);
|
||||
if (prepared.errors.length > 0) {
|
||||
validationErrors.push(...prepared.errors);
|
||||
}
|
||||
return prepared.entry;
|
||||
});
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
throw new PlantImportValidationError(
|
||||
'Import payload failed validation checks.',
|
||||
validationErrors.slice(0, MAX_AUDIT_DETAILS),
|
||||
);
|
||||
}
|
||||
|
||||
assertValidPreparedEntries(preparedEntries, enforceUniqueImages);
|
||||
|
||||
const preservedIds = preparedEntries.reduce((count, entry) => {
|
||||
if (existingIdMap.get(normalizeKey(entry.botanicalName)) === entry.id) return count + 1;
|
||||
return count;
|
||||
}, 0);
|
||||
|
||||
const timestamp = startedAtIso.replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const backupTable = sanitizeIdentifier(`plants_backup_${timestamp}`);
|
||||
const details = {
|
||||
enforceUniqueImages,
|
||||
preserveExistingIds,
|
||||
inputCount: rawEntries.length,
|
||||
preparedCount: preparedEntries.length,
|
||||
};
|
||||
|
||||
try {
|
||||
await run(db, 'BEGIN IMMEDIATE TRANSACTION');
|
||||
await run(db, `DROP TABLE IF EXISTS ${backupTable}`);
|
||||
await run(db, `CREATE TABLE ${backupTable} AS SELECT * FROM plants`);
|
||||
await run(db, 'DELETE FROM plants');
|
||||
|
||||
for (const entry of preparedEntries) {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO plants (
|
||||
id,
|
||||
name,
|
||||
botanicalName,
|
||||
imageUri,
|
||||
imageStatus,
|
||||
description,
|
||||
categories,
|
||||
careInfo,
|
||||
confidence,
|
||||
createdAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.id,
|
||||
entry.name,
|
||||
entry.botanicalName,
|
||||
entry.imageUri,
|
||||
'ok',
|
||||
entry.description,
|
||||
JSON.stringify(entry.categories),
|
||||
JSON.stringify(entry.careInfo),
|
||||
entry.confidence,
|
||||
startedAtIso,
|
||||
startedAtIso,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await run(
|
||||
db,
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_plants_botanical_name_unique ON plants(botanicalName)',
|
||||
);
|
||||
if (enforceUniqueImages) {
|
||||
await run(
|
||||
db,
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_plants_image_uri_unique ON plants(imageUri)',
|
||||
);
|
||||
} else {
|
||||
await run(db, 'DROP INDEX IF EXISTS idx_plants_image_uri_unique');
|
||||
}
|
||||
|
||||
await run(db, 'COMMIT');
|
||||
} catch (error) {
|
||||
await run(db, 'ROLLBACK');
|
||||
const completedAtIso = new Date().toISOString();
|
||||
await writeAuditRow(db, {
|
||||
source,
|
||||
importedCount: 0,
|
||||
preservedIds: 0,
|
||||
duplicateImageCount: 0,
|
||||
status: 'failed',
|
||||
details: {
|
||||
...details,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
backupTable: null,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: completedAtIso,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const duplicateImages = await all(
|
||||
db,
|
||||
`SELECT imageUri, COUNT(*) AS count
|
||||
FROM plants
|
||||
GROUP BY imageUri
|
||||
HAVING COUNT(*) > 1`,
|
||||
);
|
||||
|
||||
const completedAtIso = new Date().toISOString();
|
||||
await writeAuditRow(db, {
|
||||
source,
|
||||
importedCount: preparedEntries.length,
|
||||
preservedIds,
|
||||
duplicateImageCount: duplicateImages.length,
|
||||
status: 'success',
|
||||
details,
|
||||
backupTable,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: completedAtIso,
|
||||
});
|
||||
|
||||
return {
|
||||
source,
|
||||
importedCount: preparedEntries.length,
|
||||
preservedIds,
|
||||
duplicateImageCount: duplicateImages.length,
|
||||
backupTable,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: completedAtIso,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
PlantImportValidationError,
|
||||
ensurePlantSchema,
|
||||
getPlantDiagnostics,
|
||||
getPlants,
|
||||
normalizeImageUri,
|
||||
rebuildPlantsCatalog,
|
||||
};
|
||||
86
server/lib/sqlite.js
Normal file
86
server/lib/sqlite.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const getDefaultDbPath = () => {
|
||||
return process.env.PLANT_DB_PATH || path.join(__dirname, '..', 'data', 'greenlns.sqlite');
|
||||
};
|
||||
|
||||
const ensureDbDirectory = (dbPath) => {
|
||||
const directory = path.dirname(dbPath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
};
|
||||
|
||||
const openDatabase = (dbPath = getDefaultDbPath()) => {
|
||||
ensureDbDirectory(dbPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(dbPath, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(db);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const closeDatabase = (db) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const run = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function onRun(error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
lastId: this.lastID,
|
||||
changes: this.changes,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const get = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row || null);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const all = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (error, rows) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(rows || []);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
all,
|
||||
closeDatabase,
|
||||
get,
|
||||
getDefaultDbPath,
|
||||
openDatabase,
|
||||
run,
|
||||
};
|
||||
Reference in New Issue
Block a user