diff --git a/.gitignore b/.gitignore index c6031ab..3ca1270 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,10 @@ server/data/*.sqlite-* # Expo .expo/ +# Generated social media assets +social_out/ + # Claude / Agents (symlinks incompatible with EAS Build on Windows) .agents/ .claude/ -.env +.env diff --git a/app/_layout.tsx b/app/_layout.tsx index e9f6267..9bb2d93 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,6 +13,7 @@ import * as SplashScreen from 'expo-splash-screen'; import { AuthService } from '../services/authService'; import { Analytics } from '../services/analytics'; import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen'; +import { reportInstallOnce } from '../services/installPing'; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync().catch(() => { }); @@ -113,6 +114,8 @@ function RootLayoutInner() { await signOut(); } setInstallCheckDone(true); + // Fire-and-forget: pings the downloads Discord channel once per install. + reportInstallOnce(); })(); }, [signOut]); diff --git a/server/index.js b/server/index.js index 8453ad9..763b7f8 100644 --- a/server/index.js +++ b/server/index.js @@ -70,7 +70,7 @@ const { const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); -const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord'); +const { isPurchaseEventType, notifyPurchase, notifyNewUser, notifyDownload } = require('./lib/discord'); const { exchangeCodeForTokens: exchangeTiktokCode, getTiktokTokens, @@ -1118,6 +1118,31 @@ app.post('/v1/upload/image', async (request, response) => { } }); +// ─── Install ping ────────────────────────────────────────────────────────── + +// Fired once by the app on first launch (unauthenticated — there is no account +// yet). Deduped per install id so retries and reinstalls with a persisted id +// don't ping the downloads channel twice. +app.post('/v1/app-install', async (request, response) => { + try { + const installId = String(request.body?.installId || '').trim(); + if (!installId || installId.length > 128) { + return response.status(400).json({ code: 'BAD_REQUEST', message: 'installId is required.' }); + } + const isFirstReport = await claimNotificationOnce(db, `app-install:${installId}`); + if (isFirstReport) { + notifyDownload({ + platform: request.header('x-app-platform'), + appVersion: request.header('x-app-version'), + }); + } + response.status(200).json({ ok: true }); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + // ─── Auth endpoints ──────────────────────────────────────────────────────── app.post('/auth/signup', async (request, response) => { diff --git a/server/lib/discord.js b/server/lib/discord.js index d411bbd..d5731c1 100644 --- a/server/lib/discord.js +++ b/server/lib/discord.js @@ -18,6 +18,7 @@ const PLAN_NAMES_BY_PRODUCT = { const EMBED_COLOR_SALE = 0x2ecc71; const EMBED_COLOR_NEW_USER = 0x3498db; +const EMBED_COLOR_DOWNLOAD = 0x9b59b6; const isPurchaseEventType = (eventType) => PURCHASE_EVENT_TYPES.has(String(eventType || '').toUpperCase()); @@ -95,8 +96,21 @@ const notifyNewUser = ({ platform, appVersion, provider } = {}) => { }); }; +const notifyDownload = ({ platform, appVersion } = {}) => { + sendDiscordEmbed(DOWNLOADS_WEBHOOK_URL, { + title: '⬇️ Neuer Download', + color: EMBED_COLOR_DOWNLOAD, + fields: [ + { name: 'Gerät', value: formatPlatform(platform), inline: true }, + { name: 'App-Version', value: sanitizeEmbedText(appVersion) || 'Unbekannt', inline: true }, + ], + timestamp: new Date().toISOString(), + }); +}; + module.exports = { isPurchaseEventType, notifyPurchase, notifyNewUser, + notifyDownload, }; diff --git a/services/installPing.ts b/services/installPing.ts new file mode 100644 index 0000000..3ce4537 --- /dev/null +++ b/services/installPing.ts @@ -0,0 +1,46 @@ +import { AppMetaDb } from './database'; +import { getConfiguredBackendRootUrl } from '../utils/backendUrl'; +import { getAppInfoHeaders } from '../utils/appInfoHeaders'; + +const INSTALL_ID_KEY = 'install_ping_id_v1'; +const INSTALL_REPORTED_KEY = 'install_ping_reported_v1'; +const REQUEST_TIMEOUT_MS = 10000; + +const getOrCreateInstallId = (): string => { + const existing = AppMetaDb.get(INSTALL_ID_KEY); + if (existing) return existing; + const installId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}-${Math.random().toString(36).slice(2, 12)}`; + AppMetaDb.set(INSTALL_ID_KEY, installId); + return installId; +}; + +// Reports this install to the backend exactly once (fire-and-forget). The +// backend dedupes by install id, so retrying after a failed attempt is safe. +export const reportInstallOnce = async (): Promise => { + try { + if (AppMetaDb.get(INSTALL_REPORTED_KEY) === '1') return; + const backendBaseUrl = getConfiguredBackendRootUrl(); + if (!backendBaseUrl) return; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(`${backendBaseUrl.replace(/\/$/, '')}/v1/app-install`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAppInfoHeaders(), + }, + body: JSON.stringify({ installId: getOrCreateInstallId() }), + signal: controller.signal, + }); + if (response.ok) { + AppMetaDb.set(INSTALL_REPORTED_KEY, '1'); + } + } finally { + clearTimeout(timeout); + } + } catch { + // Offline or backend unreachable — retried on next app start. + } +};