Webhook Discord
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
assets/icon.png
BIN
assets/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 914 KiB |
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
102
server/lib/discord.js
Normal file
102
server/lib/discord.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <T,>(
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${options.token}`,
|
||||
...getAppInfoHeaders(),
|
||||
};
|
||||
if (options.idempotencyKey) {
|
||||
headers['Idempotency-Key'] = options.idempotencyKey;
|
||||
|
||||
7
utils/appInfoHeaders.ts
Normal file
7
utils/appInfoHeaders.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Platform } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
export const getAppInfoHeaders = (): Record<string, string> => ({
|
||||
'X-App-Platform': Platform.OS,
|
||||
'X-App-Version': Constants.expoConfig?.version ?? 'unknown',
|
||||
});
|
||||
Reference in New Issue
Block a user