From c13eb331be850a2942db4c9625f58823fac659a8 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 3 Apr 2026 19:41:04 +0200 Subject: [PATCH] feat: add data privacy settings screen and initialize backend service infrastructure --- .env.example | 4 - README.md | 5 +- app.json | 4 +- app/profile/data.tsx | 2 +- docker-compose.yml | 3 - greenlns-landing/README.md | 3 - greenlns-landing/docker-compose.yml | 3 - server/index.js | 176 ++++++--------------------- server/lib/storage.js | 71 +++++++---- services/backend/backendApiClient.ts | 19 ++- services/backend/contracts.ts | 21 ++-- 11 files changed, 110 insertions(+), 201 deletions(-) diff --git a/.env.example b/.env.example index 7f11568..9aa1bc7 100644 --- a/.env.example +++ b/.env.example @@ -17,10 +17,6 @@ OPENAI_API_KEY= OPENAI_SCAN_MODEL=gpt-5-mini OPENAI_HEALTH_MODEL=gpt-5-mini -STRIPE_SECRET_KEY= -STRIPE_PUBLISHABLE_KEY= -STRIPE_WEBHOOK_SECRET= - REVENUECAT_WEBHOOK_SECRET= REVENUECAT_PRO_ENTITLEMENT_ID=pro diff --git a/README.md b/README.md index 66974cf..3a542b5 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,6 @@ Required backend environment: Optional integrations: - `OPENAI_API_KEY` -- `STRIPE_SECRET_KEY` -- `STRIPE_PUBLISHABLE_KEY` -- `STRIPE_WEBHOOK_SECRET` - `REVENUECAT_WEBHOOK_SECRET` - `PLANT_IMPORT_ADMIN_KEY` - `MINIO_ENDPOINT` @@ -71,7 +68,7 @@ Then fill at least: - `POSTGRES_PASSWORD` - `JWT_SECRET` - `MINIO_SECRET_KEY` -- optional: `OPENAI_API_KEY`, `STRIPE_*`, `REVENUECAT_*` +- optional: `OPENAI_API_KEY`, `REVENUECAT_*` ### 2. Start the full production stack diff --git a/app.json b/app.json index 518da96..6598584 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "GreenLens", "slug": "greenlens", - "version": "2.1.5", + "version": "2.1.6", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -68,4 +68,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/profile/data.tsx b/app/profile/data.tsx index 92eb6e8..0e30031 100644 --- a/app/profile/data.tsx +++ b/app/profile/data.tsx @@ -121,7 +121,7 @@ export default function DataScreen() { text: copy.deleteActionBtn, style: 'destructive', 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(); router.replace('/onboarding'); }, diff --git a/docker-compose.yml b/docker-compose.yml index acf60cc..a5c733b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,9 +56,6 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_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_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} diff --git a/greenlns-landing/README.md b/greenlns-landing/README.md index 8b21865..38ccded 100644 --- a/greenlns-landing/README.md +++ b/greenlns-landing/README.md @@ -34,8 +34,5 @@ Required environment variables: Optional service secrets: - `OPENAI_API_KEY` -- `STRIPE_SECRET_KEY` -- `STRIPE_PUBLISHABLE_KEY` -- `STRIPE_WEBHOOK_SECRET` - `REVENUECAT_WEBHOOK_SECRET` - `PLANT_IMPORT_ADMIN_KEY` diff --git a/greenlns-landing/docker-compose.yml b/greenlns-landing/docker-compose.yml index 536ca52..336b748 100644 --- a/greenlns-landing/docker-compose.yml +++ b/greenlns-landing/docker-compose.yml @@ -56,9 +56,6 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_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_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} diff --git a/server/index.js b/server/index.js index 1b7b2d6..f08b2e1 100644 --- a/server/index.js +++ b/server/index.js @@ -1,9 +1,8 @@ -const fs = require('fs'); -const path = require('path'); -const dotenv = require('dotenv'); -const express = require('express'); -const cors = require('cors'); -const Stripe = require('stripe'); +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); +const express = require('express'); +const cors = require('cors'); const loadEnvFiles = (filePaths) => { const mergedFileEnv = {}; @@ -50,41 +49,19 @@ const { syncRevenueCatWebhookEvent, storeEndpointResponse, } = require('./lib/billing'); -const { - analyzePlantHealth, - getHealthModel, - getScanModel, - identifyPlant, +const { + analyzePlantHealth, + getHealthModel, + getScanModel, + identifyPlant, isConfigured: isOpenAiConfigured, -} = require('./lib/openai'); -const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); -const { ensureStorageBucket, uploadImage, isStorageConfigured } = require('./lib/storage'); +} = require('./lib/openai'); +const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); +const { ensureStorageBucketWithRetry, 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.'); -} -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 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; @@ -323,35 +300,6 @@ const isAuthorizedRevenueCatWebhook = (request) => { 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) => { try { if (!isAuthorizedRevenueCatWebhook(request)) { @@ -371,13 +319,12 @@ app.use(express.json({ limit: '10mb' })); app.get('/', (_request, response) => { response.status(200).json({ service: 'greenlns-api', - status: 'ok', - endpoints: [ - 'GET /health', - 'POST /api/payment-sheet', - 'GET /api/plants', - 'POST /api/plants/rebuild', - 'POST /auth/signup', + status: 'ok', + endpoints: [ + 'GET /health', + 'GET /api/plants', + 'POST /api/plants/rebuild', + 'POST /auth/signup', 'POST /auth/login', 'GET /v1/billing/summary', 'POST /v1/billing/sync-revenuecat', @@ -406,7 +353,6 @@ const getDatabaseHealthTarget = () => { }; app.get('/health', (_request, response) => { - const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim(); response.status(200).json({ ok: true, uptimeSec: Math.round(process.uptime()), @@ -414,13 +360,10 @@ app.get('/health', (_request, response) => { openAiConfigured: isOpenAiConfigured(), dbReady: Boolean(db), dbPath: getDatabaseHealthTarget(), - stripeConfigured: Boolean(stripeSecret), - stripeMode: getStripeSecretMode(), - stripePublishableMode: getStripePublishableMode(), - scanModel: getScanModel(), - healthModel: getHealthModel(), - }); -}); + scanModel: getScanModel(), + healthModel: getHealthModel(), + }); +}); app.get('/api/plants', async (request, response) => { 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) => { try { const userId = ensureRequestAuth(request); @@ -900,29 +812,19 @@ app.post('/auth/login', async (request, response) => { // ─── Startup ─────────────────────────────────────────────────────────────── -const start = async () => { - db = await openDatabase(); - await ensurePlantSchema(db); - 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(); - 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 start = async () => { + db = await openDatabase(); + await ensurePlantSchema(db); + await ensureBillingSchema(db); + await ensureAuthSchema(db); + await seedBootstrapCatalogIfNeeded(); + if (isStorageConfigured()) { + await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); + } + + const server = app.listen(port, () => { + console.log(`GreenLens server listening at http://localhost:${port}`); + }); const gracefulShutdown = async () => { try { diff --git a/server/lib/storage.js b/server/lib/storage.js index 8e23db9..b4bccf2 100644 --- a/server/lib/storage.js +++ b/server/lib/storage.js @@ -15,10 +15,10 @@ const isStorageConfigured = () => Boolean(MINIO_ENDPOINT && MINIO_ACCESS_KEY && const getMinioPublicUrl = () => getTrimmedEnv('MINIO_PUBLIC_URL', `http://${MINIO_ENDPOINT}:${MINIO_PORT}`).replace(/\/$/, ''); -const getClient = () => { - if (!isStorageConfigured()) { - throw new Error('Image storage is not configured.'); - } +const getClient = () => { + if (!isStorageConfigured()) { + throw new Error('Image storage is not configured.'); + } return new Minio.Client({ endPoint: MINIO_ENDPOINT, @@ -26,13 +26,15 @@ const getClient = () => { useSSL: MINIO_USE_SSL, accessKey: MINIO_ACCESS_KEY, secretKey: MINIO_SECRET_KEY, - }); -}; - -const ensureStorageBucket = async () => { - const client = getClient(); - const exists = await client.bucketExists(MINIO_BUCKET); - if (!exists) { + }); +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const ensureStorageBucket = async () => { + const client = getClient(); + const exists = await client.bucketExists(MINIO_BUCKET); + if (!exists) { await client.makeBucket(MINIO_BUCKET); const policy = JSON.stringify({ Version: '2012-10-17', @@ -46,13 +48,35 @@ const ensureStorageBucket = async () => { ], }); await client.setBucketPolicy(MINIO_BUCKET, policy); - console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`); - } -}; - -const uploadImage = async (base64Data, contentType = 'image/jpeg') => { - const client = getClient(); - const rawExtension = contentType.split('/')[1] || 'jpg'; + console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`); + } +}; + +const ensureStorageBucketWithRetry = async (options = {}) => { + const attempts = Number(options.attempts || 5); + 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 filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`; const buffer = Buffer.from(base64Data, 'base64'); @@ -65,8 +89,9 @@ const uploadImage = async (base64Data, contentType = 'image/jpeg') => { return { url, filename }; }; -module.exports = { - ensureStorageBucket, - uploadImage, - isStorageConfigured, -}; +module.exports = { + ensureStorageBucket, + ensureStorageBucketWithRetry, + uploadImage, + isStorageConfigured, +}; diff --git a/services/backend/backendApiClient.ts b/services/backend/backendApiClient.ts index c39351c..e8e11cb 100644 --- a/services/backend/backendApiClient.ts +++ b/services/backend/backendApiClient.ts @@ -110,16 +110,15 @@ export const backendApiClient = { getServiceHealth: async (): Promise => { if (!getConfiguredBackendRootUrl()) { return { - ok: true, - uptimeSec: 0, - timestamp: new Date().toISOString(), - openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY), - dbReady: true, - 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(), - healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), - }; + ok: true, + uptimeSec: 0, + timestamp: new Date().toISOString(), + openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY), + dbReady: true, + dbPath: 'in-app-mock-backend', + 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(), + }; } const token = await getAuthToken(); diff --git a/services/backend/contracts.ts b/services/backend/contracts.ts index c5867f1..6ad27b1 100644 --- a/services/backend/contracts.ts +++ b/services/backend/contracts.ts @@ -112,17 +112,16 @@ export interface HealthCheckResponse { billing: BillingSummary; } -export interface ServiceHealthResponse { - ok: boolean; - uptimeSec: number; - timestamp: string; - openAiConfigured: boolean; - dbReady?: boolean; - dbPath?: string; - stripeConfigured?: boolean; - scanModel?: string; - healthModel?: string; -} +export interface ServiceHealthResponse { + ok: boolean; + uptimeSec: number; + timestamp: string; + openAiConfigured: boolean; + dbReady?: boolean; + dbPath?: string; + scanModel?: string; + healthModel?: string; +} export interface SimulatePurchaseRequest { userId: string;