feat: add data privacy settings screen and initialize backend service infrastructure

This commit is contained in:
2026-04-03 19:41:04 +02:00
parent 08483c7075
commit c13eb331be
11 changed files with 110 additions and 201 deletions

View File

@@ -17,10 +17,6 @@ OPENAI_API_KEY=
OPENAI_SCAN_MODEL=gpt-5-mini OPENAI_SCAN_MODEL=gpt-5-mini
OPENAI_HEALTH_MODEL=gpt-5-mini OPENAI_HEALTH_MODEL=gpt-5-mini
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
REVENUECAT_WEBHOOK_SECRET= REVENUECAT_WEBHOOK_SECRET=
REVENUECAT_PRO_ENTITLEMENT_ID=pro REVENUECAT_PRO_ENTITLEMENT_ID=pro

View File

@@ -27,9 +27,6 @@ Required backend environment:
Optional integrations: Optional integrations:
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
- `STRIPE_SECRET_KEY`
- `STRIPE_PUBLISHABLE_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `REVENUECAT_WEBHOOK_SECRET` - `REVENUECAT_WEBHOOK_SECRET`
- `PLANT_IMPORT_ADMIN_KEY` - `PLANT_IMPORT_ADMIN_KEY`
- `MINIO_ENDPOINT` - `MINIO_ENDPOINT`
@@ -71,7 +68,7 @@ Then fill at least:
- `POSTGRES_PASSWORD` - `POSTGRES_PASSWORD`
- `JWT_SECRET` - `JWT_SECRET`
- `MINIO_SECRET_KEY` - `MINIO_SECRET_KEY`
- optional: `OPENAI_API_KEY`, `STRIPE_*`, `REVENUECAT_*` - optional: `OPENAI_API_KEY`, `REVENUECAT_*`
### 2. Start the full production stack ### 2. Start the full production stack

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "GreenLens", "name": "GreenLens",
"slug": "greenlens", "slug": "greenlens",
"version": "2.1.5", "version": "2.1.6",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -68,4 +68,4 @@
} }
} }
} }
} }

View File

@@ -121,7 +121,7 @@ export default function DataScreen() {
text: copy.deleteActionBtn, text: copy.deleteActionBtn,
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
// Future implementation: call backend to wipe user data, cancel active Stripe subscriptions // Future implementation: call backend to wipe user data and cancel active app subscriptions
await signOut(); await signOut();
router.replace('/onboarding'); router.replace('/onboarding');
}, },

View File

@@ -56,9 +56,6 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini} OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-} REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}

View File

@@ -34,8 +34,5 @@ Required environment variables:
Optional service secrets: Optional service secrets:
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
- `STRIPE_SECRET_KEY`
- `STRIPE_PUBLISHABLE_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `REVENUECAT_WEBHOOK_SECRET` - `REVENUECAT_WEBHOOK_SECRET`
- `PLANT_IMPORT_ADMIN_KEY` - `PLANT_IMPORT_ADMIN_KEY`

View File

@@ -56,9 +56,6 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini} OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-} REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}

View File

@@ -1,9 +1,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const Stripe = require('stripe');
const loadEnvFiles = (filePaths) => { const loadEnvFiles = (filePaths) => {
const mergedFileEnv = {}; const mergedFileEnv = {};
@@ -50,41 +49,19 @@ const {
syncRevenueCatWebhookEvent, syncRevenueCatWebhookEvent,
storeEndpointResponse, storeEndpointResponse,
} = require('./lib/billing'); } = require('./lib/billing');
const { const {
analyzePlantHealth, analyzePlantHealth,
getHealthModel, getHealthModel,
getScanModel, getScanModel,
identifyPlant, identifyPlant,
isConfigured: isOpenAiConfigured, isConfigured: isOpenAiConfigured,
} = require('./lib/openai'); } = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { ensureStorageBucket, uploadImage, isStorageConfigured } = require('./lib/storage'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants'); 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.');
}
const stripe = new Stripe(stripeSecretKey || 'sk_test_placeholder_key_not_configured');
const resolveStripeModeFromKey = (key, livePrefix, testPrefix) => {
const normalized = String(key || '').trim();
if (normalized.startsWith(livePrefix)) return 'LIVE';
if (normalized.startsWith(testPrefix)) return 'TEST';
return 'MOCK';
};
const getStripeSecretMode = () =>
resolveStripeModeFromKey(process.env.STRIPE_SECRET_KEY, 'sk_live_', 'sk_test_');
const getStripePublishableMode = () =>
resolveStripeModeFromKey(
process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY,
'pk_live_',
'pk_test_',
);
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1; const SCAN_REVIEW_COST = 1;
@@ -323,35 +300,6 @@ const isAuthorizedRevenueCatWebhook = (request) => {
return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`;
}; };
// Webhooks must be BEFORE express.json() to preserve raw body where required.
app.post('/api/webhook', express.raw({ type: 'application/json' }), (request, response) => {
const signature = request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
request.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
console.error(`Webhook Error: ${error.message}`);
response.status(400).send(`Webhook Error: ${error.message}`);
return;
}
switch (event.type) {
case 'payment_intent.succeeded':
console.log('PaymentIntent succeeded.');
break;
default:
console.log(`Unhandled event type: ${event.type}`);
break;
}
response.json({ received: true });
});
app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => {
try { try {
if (!isAuthorizedRevenueCatWebhook(request)) { if (!isAuthorizedRevenueCatWebhook(request)) {
@@ -371,13 +319,12 @@ app.use(express.json({ limit: '10mb' }));
app.get('/', (_request, response) => { app.get('/', (_request, response) => {
response.status(200).json({ response.status(200).json({
service: 'greenlns-api', service: 'greenlns-api',
status: 'ok', status: 'ok',
endpoints: [ endpoints: [
'GET /health', 'GET /health',
'POST /api/payment-sheet', 'GET /api/plants',
'GET /api/plants', 'POST /api/plants/rebuild',
'POST /api/plants/rebuild', 'POST /auth/signup',
'POST /auth/signup',
'POST /auth/login', 'POST /auth/login',
'GET /v1/billing/summary', 'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat', 'POST /v1/billing/sync-revenuecat',
@@ -406,7 +353,6 @@ const getDatabaseHealthTarget = () => {
}; };
app.get('/health', (_request, response) => { app.get('/health', (_request, response) => {
const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim();
response.status(200).json({ response.status(200).json({
ok: true, ok: true,
uptimeSec: Math.round(process.uptime()), uptimeSec: Math.round(process.uptime()),
@@ -414,13 +360,10 @@ app.get('/health', (_request, response) => {
openAiConfigured: isOpenAiConfigured(), openAiConfigured: isOpenAiConfigured(),
dbReady: Boolean(db), dbReady: Boolean(db),
dbPath: getDatabaseHealthTarget(), dbPath: getDatabaseHealthTarget(),
stripeConfigured: Boolean(stripeSecret), scanModel: getScanModel(),
stripeMode: getStripeSecretMode(), healthModel: getHealthModel(),
stripePublishableMode: getStripePublishableMode(), });
scanModel: getScanModel(), });
healthModel: getHealthModel(),
});
});
app.get('/api/plants', async (request, response) => { app.get('/api/plants', async (request, response) => {
try { try {
@@ -480,37 +423,6 @@ app.post('/api/plants/rebuild', async (request, response) => {
} }
}); });
app.post('/api/payment-sheet', async (request, response) => {
try {
const amount = Number(request.body?.amount || 500);
const currency = request.body?.currency || 'usd';
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: { enabled: true },
});
const customer = await stripe.customers.create();
const ephemeralKey = await stripe.ephemeralKeys.create(
{ customer: customer.id },
{ apiVersion: '2023-10-16' },
);
response.json({
paymentIntent: paymentIntent.client_secret,
ephemeralKey: ephemeralKey.secret,
customer: customer.id,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key',
});
} catch (error) {
response.status(400).json({
code: 'PAYMENT_SHEET_ERROR',
message: error instanceof Error ? error.message : String(error),
});
}
});
app.get('/v1/billing/summary', async (request, response) => { app.get('/v1/billing/summary', async (request, response) => {
try { try {
const userId = ensureRequestAuth(request); const userId = ensureRequestAuth(request);
@@ -900,29 +812,19 @@ app.post('/auth/login', async (request, response) => {
// ─── Startup ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────
const start = async () => { const start = async () => {
db = await openDatabase(); db = await openDatabase();
await ensurePlantSchema(db); await ensurePlantSchema(db);
await ensureBillingSchema(db); await ensureBillingSchema(db);
await ensureAuthSchema(db); await ensureAuthSchema(db);
await seedBootstrapCatalogIfNeeded(); await seedBootstrapCatalogIfNeeded();
if (isStorageConfigured()) { if (isStorageConfigured()) {
await ensureStorageBucket().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
} }
const stripeMode = getStripeSecretMode(); const server = app.listen(port, () => {
const stripePublishableMode = getStripePublishableMode(); console.log(`GreenLens server listening at http://localhost:${port}`);
const maskKey = (key) => { });
const k = String(key || '').trim();
if (k.length < 12) return k ? '(too short to mask)' : '(not set)';
return `${k.slice(0, 7)}...${k.slice(-4)}`;
};
console.log(`Stripe Mode: ${stripeMode} | Secret: ${maskKey(process.env.STRIPE_SECRET_KEY)}`);
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(`GreenLens server listening at http://localhost:${port}`);
});
const gracefulShutdown = async () => { const gracefulShutdown = async () => {
try { try {

View File

@@ -15,10 +15,10 @@ const isStorageConfigured = () => Boolean(MINIO_ENDPOINT && MINIO_ACCESS_KEY &&
const getMinioPublicUrl = () => const getMinioPublicUrl = () =>
getTrimmedEnv('MINIO_PUBLIC_URL', `http://${MINIO_ENDPOINT}:${MINIO_PORT}`).replace(/\/$/, ''); getTrimmedEnv('MINIO_PUBLIC_URL', `http://${MINIO_ENDPOINT}:${MINIO_PORT}`).replace(/\/$/, '');
const getClient = () => { const getClient = () => {
if (!isStorageConfigured()) { if (!isStorageConfigured()) {
throw new Error('Image storage is not configured.'); throw new Error('Image storage is not configured.');
} }
return new Minio.Client({ return new Minio.Client({
endPoint: MINIO_ENDPOINT, endPoint: MINIO_ENDPOINT,
@@ -26,13 +26,15 @@ const getClient = () => {
useSSL: MINIO_USE_SSL, useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY, accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY, secretKey: MINIO_SECRET_KEY,
}); });
}; };
const ensureStorageBucket = async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const client = getClient();
const exists = await client.bucketExists(MINIO_BUCKET); const ensureStorageBucket = async () => {
if (!exists) { const client = getClient();
const exists = await client.bucketExists(MINIO_BUCKET);
if (!exists) {
await client.makeBucket(MINIO_BUCKET); await client.makeBucket(MINIO_BUCKET);
const policy = JSON.stringify({ const policy = JSON.stringify({
Version: '2012-10-17', Version: '2012-10-17',
@@ -46,13 +48,35 @@ const ensureStorageBucket = async () => {
], ],
}); });
await client.setBucketPolicy(MINIO_BUCKET, policy); await client.setBucketPolicy(MINIO_BUCKET, policy);
console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`); console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`);
} }
}; };
const uploadImage = async (base64Data, contentType = 'image/jpeg') => { const ensureStorageBucketWithRetry = async (options = {}) => {
const client = getClient(); const attempts = Number(options.attempts || 5);
const rawExtension = contentType.split('/')[1] || 'jpg'; const delayMs = Number(options.delayMs || 2000);
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await ensureStorageBucket();
return;
} catch (error) {
lastError = error;
if (attempt === attempts) break;
console.warn(
`MinIO bucket setup attempt ${attempt}/${attempts} failed: ${error.message}. Retrying in ${delayMs}ms...`,
);
await sleep(delayMs);
}
}
throw lastError;
};
const uploadImage = async (base64Data, contentType = 'image/jpeg') => {
const client = getClient();
const rawExtension = contentType.split('/')[1] || 'jpg';
const extension = rawExtension === 'jpeg' ? 'jpg' : rawExtension; const extension = rawExtension === 'jpeg' ? 'jpg' : rawExtension;
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`; const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`;
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, 'base64');
@@ -65,8 +89,9 @@ const uploadImage = async (base64Data, contentType = 'image/jpeg') => {
return { url, filename }; return { url, filename };
}; };
module.exports = { module.exports = {
ensureStorageBucket, ensureStorageBucket,
uploadImage, ensureStorageBucketWithRetry,
isStorageConfigured, uploadImage,
}; isStorageConfigured,
};

View File

@@ -110,16 +110,15 @@ export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => { getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return { return {
ok: true, ok: true,
uptimeSec: 0, uptimeSec: 0,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY), openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
dbReady: true, dbReady: true,
dbPath: 'in-app-mock-backend', dbPath: 'in-app-mock-backend',
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY), scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), };
};
} }
const token = await getAuthToken(); const token = await getAuthToken();

View File

@@ -112,17 +112,16 @@ export interface HealthCheckResponse {
billing: BillingSummary; billing: BillingSummary;
} }
export interface ServiceHealthResponse { export interface ServiceHealthResponse {
ok: boolean; ok: boolean;
uptimeSec: number; uptimeSec: number;
timestamp: string; timestamp: string;
openAiConfigured: boolean; openAiConfigured: boolean;
dbReady?: boolean; dbReady?: boolean;
dbPath?: string; dbPath?: string;
stripeConfigured?: boolean; scanModel?: string;
scanModel?: string; healthModel?: string;
healthModel?: string; }
}
export interface SimulatePurchaseRequest { export interface SimulatePurchaseRequest {
userId: string; userId: string;