feat: add data privacy settings screen and initialize backend service infrastructure
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -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 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
176
server/index.js
176
server/index.js
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user