Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+79
View File
@@ -0,0 +1,79 @@
/**
* Discord webhook push.
*
* One outgoing webhook per channel. No bot library, no gateway connection;
* just a POST per message. Embeds render the share card via the `image`
* field — Discord fetches the URL server-side, so the URL must be publicly
* reachable (n8n can hand the share-card buffer to a CDN if needed).
*/
const axios = require('axios');
const FormData = require('form-data');
const HTTP_TIMEOUT_MS = 10_000;
const VYNDR_GREEN = 0x00D4A0;
const WEBHOOKS = Object.freeze({
daily: process.env.DISCORD_WEBHOOK_DAILY || '',
results: process.env.DISCORD_WEBHOOK_RESULTS || '',
alerts: process.env.DISCORD_WEBHOOK_ALERTS || '',
rare: process.env.DISCORD_WEBHOOK_RARE || '',
});
function webhookFor(channel) {
const url = WEBHOOKS[channel];
if (!url) return null;
// Don't accept arbitrary user input — only the small set above.
if (!url.startsWith('https://discord.com/api/webhooks/') &&
!url.startsWith('https://discordapp.com/api/webhooks/')) {
return null;
}
return url;
}
async function postToDiscord(channel, { text, imageUrl, imageBuffer, color } = {}) {
const url = webhookFor(channel);
if (!url) return { ok: false, error: `no webhook for ${channel}` };
const embed = {
description: text || '',
color: color || VYNDR_GREEN,
image: imageUrl ? { url: imageUrl } : undefined,
footer: { text: 'VYNDR · vyndr.app' },
timestamp: new Date().toISOString(),
};
const payload = { username: 'VYNDR', embeds: [embed] };
try {
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
// Multipart with attached PNG. Discord renders attachments inline.
const form = new FormData();
form.append('payload_json', JSON.stringify({
username: 'VYNDR',
embeds: [{
description: text || '',
color: color || VYNDR_GREEN,
image: { url: 'attachment://vyndr.png' },
footer: { text: 'VYNDR · vyndr.app' },
timestamp: new Date().toISOString(),
}],
}));
form.append('file1', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
await axios.post(url, form, {
timeout: HTTP_TIMEOUT_MS,
headers: form.getHeaders(),
maxContentLength: 8 * 1024 * 1024,
maxBodyLength: 8 * 1024 * 1024,
});
return { ok: true, channel, mode: 'attachment' };
}
await axios.post(url, payload, { timeout: HTTP_TIMEOUT_MS });
return { ok: true, channel, mode: 'embed' };
} catch (err) {
const detail = err?.response?.data || err?.message || 'unknown';
console.error(`[discord:${channel}] push failed:`, detail);
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
}
}
module.exports = { postToDiscord, webhookFor };
+72
View File
@@ -0,0 +1,72 @@
/**
* Telegram channel push.
*
* Webhook-style: one-direction POST to Telegram's Bot API. No polling,
* no command handling. The bot token + channel ID come from env.
*
* sendPhoto accepts either a Buffer (multipart upload) or a URL (the
* Telegram fetcher will pull it). We prefer the URL path when the share
* card lives behind a stable public endpoint.
*/
const axios = require('axios');
const FormData = require('form-data');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const CHANNEL_ID = process.env.TELEGRAM_CHANNEL_ID;
const HTTP_TIMEOUT_MS = 12_000;
function configured() {
return !!(BOT_TOKEN && CHANNEL_ID);
}
function endpoint(method) {
return `https://api.telegram.org/bot${BOT_TOKEN}/${method}`;
}
async function postToTelegram({ text, imageBuffer, imageUrl, parseMode = 'HTML' } = {}) {
if (!configured()) {
return { ok: false, error: 'TELEGRAM_BOT_TOKEN or TELEGRAM_CHANNEL_ID not set' };
}
try {
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
const form = new FormData();
form.append('chat_id', CHANNEL_ID);
if (text) form.append('caption', text);
form.append('parse_mode', parseMode);
form.append('photo', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
await axios.post(endpoint('sendPhoto'), form, {
timeout: HTTP_TIMEOUT_MS,
headers: form.getHeaders(),
maxContentLength: 8 * 1024 * 1024,
maxBodyLength: 8 * 1024 * 1024,
});
return { ok: true, mode: 'photo-buffer' };
}
if (imageUrl) {
await axios.post(endpoint('sendPhoto'), {
chat_id: CHANNEL_ID,
photo: imageUrl,
caption: text || '',
parse_mode: parseMode,
}, { timeout: HTTP_TIMEOUT_MS });
return { ok: true, mode: 'photo-url' };
}
if (text) {
await axios.post(endpoint('sendMessage'), {
chat_id: CHANNEL_ID,
text,
parse_mode: parseMode,
disable_web_page_preview: false,
}, { timeout: HTTP_TIMEOUT_MS });
return { ok: true, mode: 'text' };
}
return { ok: false, error: 'nothing to send' };
} catch (err) {
const detail = err?.response?.data || err?.message || 'unknown';
console.error('[telegram] push failed:', detail);
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
}
}
module.exports = { postToTelegram, configured };
+126
View File
@@ -0,0 +1,126 @@
/**
* Web Push delivery.
*
* One-direction POST to the user's browser push service (FCM, Mozilla, Apple).
* Subscriptions are stored in push_subscriptions (migration 015) and the
* service worker in web/src/sw.ts handles the `push` event.
*
* A 410 (Gone) or 404 response means the subscription is dead — we delete
* the row so we stop trying. Anything else is logged and treated as transient.
*/
const webpush = require('web-push');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const VAPID_PUBLIC = process.env.VAPID_PUBLIC_KEY;
const VAPID_PRIVATE = process.env.VAPID_PRIVATE_KEY;
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:contact@vyndr.app';
let _initialized = false;
function ensureInit() {
if (_initialized) return true;
if (!VAPID_PUBLIC || !VAPID_PRIVATE) return false;
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC, VAPID_PRIVATE);
_initialized = true;
return true;
}
function configured() {
return !!(VAPID_PUBLIC && VAPID_PRIVATE);
}
function rowToSubscription(row) {
return {
endpoint: row.endpoint,
keys: { p256dh: row.keys_p256dh, auth: row.keys_auth },
};
}
async function deleteSubscription(supabase, subscriptionId) {
await supabase.from('push_subscriptions').delete().eq('id', subscriptionId);
}
async function sendOne(supabase, row, payload) {
try {
await webpush.sendNotification(rowToSubscription(row), JSON.stringify(payload));
return { ok: true, id: row.id };
} catch (err) {
const status = err?.statusCode;
if (status === 404 || status === 410) {
await deleteSubscription(supabase, row.id);
return { ok: false, id: row.id, pruned: true };
}
console.warn('[webPush] send failed:', { id: row.id, status, message: err?.message });
return { ok: false, id: row.id, error: err?.message };
}
}
async function sendPushToUser(userId, notification) {
if (!configured() || !ensureInit()) {
return { ok: false, error: 'VAPID keys not configured' };
}
const supabase = getSupabaseServiceClient();
const { data: rows, error } = await supabase
.from('push_subscriptions')
.select('id, endpoint, keys_p256dh, keys_auth')
.eq('user_id', userId);
if (error) return { ok: false, error: error.message };
if (!rows || rows.length === 0) return { ok: true, sent: 0 };
const results = await Promise.allSettled(rows.map((row) => sendOne(supabase, row, notification)));
const summary = results.reduce(
(acc, r) => {
if (r.status === 'fulfilled' && r.value.ok) acc.sent += 1;
else if (r.status === 'fulfilled' && r.value.pruned) acc.pruned += 1;
else acc.failed += 1;
return acc;
},
{ sent: 0, pruned: 0, failed: 0 }
);
return { ok: true, ...summary };
}
async function sendPushToSport(sport, notification, opts = {}) {
if (!configured() || !ensureInit()) {
return { ok: false, error: 'VAPID keys not configured' };
}
const { kind } = opts;
const supabase = getSupabaseServiceClient();
let query = supabase
.from('push_subscriptions')
.select('id, endpoint, keys_p256dh, keys_auth')
.contains('sport_preferences', [sport]);
if (kind === 'resolution') query = query.eq('notify_on_resolution', true);
if (kind === 'cascade') query = query.eq('notify_on_cascade', true);
if (kind === 'cheatsheet') query = query.eq('notify_on_cheatsheet', true);
const { data: rows, error } = await query;
if (error) return { ok: false, error: error.message };
if (!rows || rows.length === 0) return { ok: true, sent: 0 };
const results = await Promise.allSettled(rows.map((row) => sendOne(supabase, row, notification)));
const summary = results.reduce(
(acc, r) => {
if (r.status === 'fulfilled' && r.value.ok) acc.sent += 1;
else if (r.status === 'fulfilled' && r.value.pruned) acc.pruned += 1;
else acc.failed += 1;
return acc;
},
{ sent: 0, pruned: 0, failed: 0 }
);
return { ok: true, ...summary };
}
async function cleanupExpired() {
// Called from a scheduled task. Walks every subscription and pings the
// push service with an empty payload — anything that 410s gets pruned.
// For now a no-op stub; cleanup happens lazily on send failures above.
return { ok: true, note: 'lazy cleanup runs on send failures' };
}
module.exports = {
configured,
sendPushToUser,
sendPushToSport,
cleanupExpired,
};