diff --git a/.env.example b/.env.example index c266ea8..4ef10a0 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ REVENUECAT_PRO_ENTITLEMENT_ID=pro PLANT_IMPORT_ADMIN_KEY= +DISCORD_WEBHOOK_SALES_URL= +DISCORD_WEBHOOK_DOWNLOADS_URL= + TIKTOK_CLIENT_KEY= TIKTOK_CLIENT_SECRET= TIKTOK_REDIRECT_URI=https://greenlenspro.com/api/tiktok/callback diff --git a/CLAUDE.md b/CLAUDE.md index 1cb7d55..aa9ddc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,8 @@ MINIO_ACCESS_KEY MINIO_SECRET_KEY MINIO_BUCKET MINIO_PUBLIC_URL +DISCORD_WEBHOOK_SALES_URL +DISCORD_WEBHOOK_DOWNLOADS_URL ``` ### Landing and deployment diff --git a/assets/icon.png b/assets/icon.png index b25ca78..9f581ae 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/greenlns-landing/docker-compose.yml b/greenlns-landing/docker-compose.yml index b1ee536..51951bc 100644 --- a/greenlns-landing/docker-compose.yml +++ b/greenlns-landing/docker-compose.yml @@ -45,6 +45,8 @@ services: OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini} REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} + DISCORD_WEBHOOK_SALES_URL: ${DISCORD_WEBHOOK_SALES_URL:-} + DISCORD_WEBHOOK_DOWNLOADS_URL: ${DISCORD_WEBHOOK_DOWNLOADS_URL:-} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} PLANT_IMPORT_ADMIN_KEY: ${PLANT_IMPORT_ADMIN_KEY:-} depends_on: diff --git a/server/index.js b/server/index.js index aaab2d6..78d5a1a 100644 --- a/server/index.js +++ b/server/index.js @@ -52,6 +52,7 @@ const { getBillingSummary, getEndpointResponse, isInsufficientCreditsError, + claimNotificationOnce, simulatePurchase, simulateWebhook, syncRevenueCatCustomerInfo, @@ -67,6 +68,7 @@ const { } = require('./lib/openai'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); +const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord'); const { exchangeCodeForTokens: exchangeTiktokCode, getTiktokTokens, @@ -521,6 +523,28 @@ app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (reque } const eventPayload = request.body?.event || request.body; const result = await syncRevenueCatWebhookEvent(db, eventPayload); + if (isPurchaseEventType(eventPayload?.type)) { + // RevenueCat delivers webhooks at-least-once; dedupe notifications by + // event id so redeliveries don't ping the sales channel twice. + const eventId = String(eventPayload?.id || eventPayload?.transaction_id || '').trim(); + const isFirstDelivery = eventId + ? await claimNotificationOnce(db, `discord-purchase-event:${eventId}`) + : true; + if (isFirstDelivery) { + // RevenueCat's `price` is always USD; only `price_in_purchased_currency` + // matches the `currency` field. + const hasLocalPrice = typeof eventPayload?.price_in_purchased_currency === 'number'; + notifyPurchase({ + productId: eventPayload?.product_id, + price: hasLocalPrice ? eventPayload.price_in_purchased_currency : eventPayload?.price, + currency: hasLocalPrice ? eventPayload?.currency : 'USD', + store: eventPayload?.store, + isTrial: String(eventPayload?.period_type || '').toUpperCase() === 'TRIAL', + isRenewal: String(eventPayload?.type || '').toUpperCase() === 'RENEWAL', + isSandbox: String(eventPayload?.environment || '').toUpperCase() === 'SANDBOX', + }); + } + } response.status(200).json({ received: true, syncedAt: result.syncedAt }); } catch (error) { const payload = toApiErrorPayload(error); @@ -1059,6 +1083,11 @@ app.post('/auth/signup', async (request, response) => { } const user = await authSignUp(db, email, name, password); const token = issueToken(user.id, user.email, user.name); + notifyNewUser({ + provider: 'email', + platform: request.header('x-app-platform'), + appVersion: request.header('x-app-version'), + }); response.status(201).json({ userId: user.id, email: user.email, name: user.name, token }); } catch (error) { const status = error.status || 500; @@ -1089,6 +1118,13 @@ app.post('/auth/apple', async (request, response) => { } const user = await authSignInWithApple(db, identityToken, { appleUser, email, name }); const token = issueToken(user.id, user.email, user.name); + if (user.isNewUser) { + notifyNewUser({ + provider: 'apple', + platform: request.header('x-app-platform'), + appVersion: request.header('x-app-version'), + }); + } response.status(200).json({ userId: user.id, email: user.email, diff --git a/server/lib/billing.js b/server/lib/billing.js index 4450fd7..cef59e8 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -400,6 +400,20 @@ const writeIdempotentValue = async (db, key, value) => { ); }; +// Atomically claims a one-time slot for the given key (e.g. per webhook event +// notification). Returns true only for the first caller; concurrent retries of +// the same event lose the INSERT race and get false. +const claimNotificationOnce = async (db, key) => { + const result = await run( + db, + `INSERT INTO billing_idempotency (id, response_json, created_at) + VALUES ($1, CAST($2 AS jsonb), $3) + ON CONFLICT (id) DO NOTHING`, + [key, JSON.stringify({ claimedAt: nowIso() }), nowIso()], + ); + return result.changes > 0; +}; + const grantRevenueCatTopupIfNeeded = async (db, account, transactionId, productId) => { if (!transactionId || !isSupportedTopupProduct(productId)) { return false; @@ -790,6 +804,7 @@ const isInsufficientCreditsError = (error) => { module.exports = { AVAILABLE_PRODUCTS, chargeKey, + claimNotificationOnce, consumeCreditsWithIdempotency, endpointKey, ensureBillingSchema, diff --git a/server/lib/discord.js b/server/lib/discord.js new file mode 100644 index 0000000..d411bbd --- /dev/null +++ b/server/lib/discord.js @@ -0,0 +1,102 @@ +const SALES_WEBHOOK_URL = (process.env.DISCORD_WEBHOOK_SALES_URL || '').trim(); +const DOWNLOADS_WEBHOOK_URL = (process.env.DISCORD_WEBHOOK_DOWNLOADS_URL || '').trim(); + +const PURCHASE_EVENT_TYPES = new Set([ + 'INITIAL_PURCHASE', + 'RENEWAL', + 'NON_RENEWING_PURCHASE', + 'PRODUCT_CHANGE', +]); + +const PLAN_NAMES_BY_PRODUCT = { + monthly_pro: 'Pro (monatlich)', + yearly_pro: 'Pro (jährlich)', + topup_small: 'Top-up Small (30 Credits)', + topup_medium: 'Top-up Medium (100 Credits)', + topup_large: 'Top-up Large (250 Credits)', +}; + +const EMBED_COLOR_SALE = 0x2ecc71; +const EMBED_COLOR_NEW_USER = 0x3498db; + +const isPurchaseEventType = (eventType) => + PURCHASE_EVENT_TYPES.has(String(eventType || '').toUpperCase()); + +// Header/payload values end up in Discord embeds; strip markdown-relevant +// characters and cap length so user-controlled input cannot break or spoof +// the embed (Discord rejects fields over 1024 chars). +const sanitizeEmbedText = (value, maxLength = 64) => + String(value ?? '').replace(/[\r\n`]/g, ' ').trim().slice(0, maxLength); + +const formatStore = (store) => { + const normalized = String(store || '').toUpperCase(); + if (normalized === 'APP_STORE' || normalized === 'MAC_APP_STORE') return 'iOS'; + if (normalized === 'PLAY_STORE') return 'Android'; + return normalized || 'Unbekannt'; +}; + +const formatPlatform = (platform) => { + const normalized = String(platform || '').toLowerCase(); + if (normalized === 'ios') return 'iOS'; + if (normalized === 'android') return 'Android'; + return 'Unbekannt'; +}; + +const formatPrice = (price, currency) => { + if (typeof price !== 'number' || !Number.isFinite(price)) return '–'; + return `${price.toFixed(2)} ${String(currency || 'USD').toUpperCase()}`; +}; + +const sendDiscordEmbed = (webhookUrl, embed) => { + if (!webhookUrl) return; + fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ embeds: [embed] }), + signal: AbortSignal.timeout(5000), + }) + // Drain the body so undici can release the connection back to the pool. + .then((response) => response.arrayBuffer()) + .catch(() => {}); +}; + +const notifyPurchase = ({ productId, price, currency, store, isTrial, isRenewal, isSandbox } = {}) => { + const safeProductId = sanitizeEmbedText(productId); + const planName = Object.hasOwn(PLAN_NAMES_BY_PRODUCT, safeProductId) + ? PLAN_NAMES_BY_PRODUCT[safeProductId] + : safeProductId || 'Unbekannt'; + const title = isTrial + ? '🛍️ Trial gestartet' + : isRenewal + ? '🔁 Abo verlängert' + : '🛍️ Neuer Kauf'; + sendDiscordEmbed(SALES_WEBHOOK_URL, { + title: isSandbox ? `${title} (Sandbox)` : title, + color: EMBED_COLOR_SALE, + fields: [ + { name: 'Produkt', value: `${planName} (\`${safeProductId || '–'}\`)`, inline: true }, + { name: 'Preis', value: isTrial ? 'Trial' : formatPrice(price, currency), inline: true }, + { name: 'Gerät', value: formatStore(store), inline: true }, + ], + timestamp: new Date().toISOString(), + }); +}; + +const notifyNewUser = ({ platform, appVersion, provider } = {}) => { + sendDiscordEmbed(DOWNLOADS_WEBHOOK_URL, { + title: '📲 Neuer User', + color: EMBED_COLOR_NEW_USER, + fields: [ + { name: 'Gerät', value: formatPlatform(platform), inline: true }, + { name: 'App-Version', value: sanitizeEmbedText(appVersion) || 'Unbekannt', inline: true }, + { name: 'Anmeldung', value: String(provider || 'Unbekannt'), inline: true }, + ], + timestamp: new Date().toISOString(), + }); +}; + +module.exports = { + isPurchaseEventType, + notifyPurchase, + notifyNewUser, +}; diff --git a/services/authService.ts b/services/authService.ts index 82e15b6..0faffc7 100644 --- a/services/authService.ts +++ b/services/authService.ts @@ -1,6 +1,7 @@ import * as SecureStore from 'expo-secure-store'; import { AuthDb } from './database'; import { getConfiguredBackendRootUrl } from '../utils/backendUrl'; +import { getAppInfoHeaders } from '../utils/appInfoHeaders'; const SESSION_KEY = 'greenlens_session_v3'; @@ -28,7 +29,7 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e try { response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...getAppInfoHeaders() }, body: JSON.stringify(body), }); } catch (e) { diff --git a/services/backend/backendApiClient.ts b/services/backend/backendApiClient.ts index e1c3026..4cfce18 100644 --- a/services/backend/backendApiClient.ts +++ b/services/backend/backendApiClient.ts @@ -18,6 +18,7 @@ import { getAuthToken } from './userIdentityService'; import { mockBackendService } from './mockBackendService'; import { CareInfo, Language } from '../../types'; import { getConfiguredBackendRootUrl } from '../../utils/backendUrl'; +import { getAppInfoHeaders } from '../../utils/appInfoHeaders'; const REQUEST_TIMEOUT_MS = 60000; @@ -61,6 +62,7 @@ const makeRequest = async ( const headers: Record = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${options.token}`, + ...getAppInfoHeaders(), }; if (options.idempotencyKey) { headers['Idempotency-Key'] = options.idempotencyKey; diff --git a/utils/appInfoHeaders.ts b/utils/appInfoHeaders.ts new file mode 100644 index 0000000..e49b18a --- /dev/null +++ b/utils/appInfoHeaders.ts @@ -0,0 +1,7 @@ +import { Platform } from 'react-native'; +import Constants from 'expo-constants'; + +export const getAppInfoHeaders = (): Record => ({ + 'X-App-Platform': Platform.OS, + 'X-App-Version': Constants.expoConfig?.version ?? 'unknown', +});