Download ping
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -29,7 +29,10 @@ server/data/*.sqlite-*
|
|||||||
# Expo
|
# Expo
|
||||||
.expo/
|
.expo/
|
||||||
|
|
||||||
|
# Generated social media assets
|
||||||
|
social_out/
|
||||||
|
|
||||||
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
|
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as SplashScreen from 'expo-splash-screen';
|
|||||||
import { AuthService } from '../services/authService';
|
import { AuthService } from '../services/authService';
|
||||||
import { Analytics } from '../services/analytics';
|
import { Analytics } from '../services/analytics';
|
||||||
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
||||||
|
import { reportInstallOnce } from '../services/installPing';
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync().catch(() => { });
|
SplashScreen.preventAutoHideAsync().catch(() => { });
|
||||||
@@ -113,6 +114,8 @@ function RootLayoutInner() {
|
|||||||
await signOut();
|
await signOut();
|
||||||
}
|
}
|
||||||
setInstallCheckDone(true);
|
setInstallCheckDone(true);
|
||||||
|
// Fire-and-forget: pings the downloads Discord channel once per install.
|
||||||
|
reportInstallOnce();
|
||||||
})();
|
})();
|
||||||
}, [signOut]);
|
}, [signOut]);
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const {
|
|||||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
||||||
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
||||||
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||||
const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord');
|
const { isPurchaseEventType, notifyPurchase, notifyNewUser, notifyDownload } = require('./lib/discord');
|
||||||
const {
|
const {
|
||||||
exchangeCodeForTokens: exchangeTiktokCode,
|
exchangeCodeForTokens: exchangeTiktokCode,
|
||||||
getTiktokTokens,
|
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 ────────────────────────────────────────────────────────
|
// ─── Auth endpoints ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/auth/signup', async (request, response) => {
|
app.post('/auth/signup', async (request, response) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const PLAN_NAMES_BY_PRODUCT = {
|
|||||||
|
|
||||||
const EMBED_COLOR_SALE = 0x2ecc71;
|
const EMBED_COLOR_SALE = 0x2ecc71;
|
||||||
const EMBED_COLOR_NEW_USER = 0x3498db;
|
const EMBED_COLOR_NEW_USER = 0x3498db;
|
||||||
|
const EMBED_COLOR_DOWNLOAD = 0x9b59b6;
|
||||||
|
|
||||||
const isPurchaseEventType = (eventType) =>
|
const isPurchaseEventType = (eventType) =>
|
||||||
PURCHASE_EVENT_TYPES.has(String(eventType || '').toUpperCase());
|
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 = {
|
module.exports = {
|
||||||
isPurchaseEventType,
|
isPurchaseEventType,
|
||||||
notifyPurchase,
|
notifyPurchase,
|
||||||
notifyNewUser,
|
notifyNewUser,
|
||||||
|
notifyDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
46
services/installPing.ts
Normal file
46
services/installPing.ts
Normal file
@@ -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<void> => {
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user