From 8234836991cc67205ccfb0662aa60d6f08a979a4 Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 24 Apr 2025 06:24:36 +0200 Subject: [PATCH] feat: daily-stacked-notifications --- api/typeDefs/user.js | 2 + docs/dev/daily-stacked-notifications.md | 39 +++++ docs/user/daily-stacked-notifications.md | 29 ++++ lib/webPush.js | 5 +- pages/settings/index.js | 6 + .../migration.sql | 2 + prisma/schema.prisma | 1 + scripts/add-daily-notification-schedule.js | 38 +++++ worker/dailyStackedNotification.js | 156 ++++++++++++++++++ worker/index.js | 2 + 10 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 docs/dev/daily-stacked-notifications.md create mode 100644 docs/user/daily-stacked-notifications.md create mode 100644 prisma/migrations/20250423204303_daily_stacked_notifications/migration.sql create mode 100644 scripts/add-daily-notification-schedule.js create mode 100644 worker/dailyStackedNotification.js diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 07adebf53..5a7677a24 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -116,6 +116,7 @@ export default gql` noteJobIndicator: Boolean! noteMentions: Boolean! noteItemMentions: Boolean! + noteDailyStacked: Boolean! nsfwMode: Boolean! tipDefault: Int! tipRandomMin: Int @@ -194,6 +195,7 @@ export default gql` noteJobIndicator: Boolean! noteMentions: Boolean! noteItemMentions: Boolean! + noteDailyStacked: Boolean! nsfwMode: Boolean! tipDefault: Int! tipRandom: Boolean! diff --git a/docs/dev/daily-stacked-notifications.md b/docs/dev/daily-stacked-notifications.md new file mode 100644 index 000000000..9e4dd6ffe --- /dev/null +++ b/docs/dev/daily-stacked-notifications.md @@ -0,0 +1,39 @@ +# Daily Stacked Notifications + +This feature sends daily summary notifications to users with details about how much they stacked and spent on the previous day. + +## Implementation + +The daily stacked notification system consists of several components: + +1. **Database schema** - A `noteDailyStacked` preference column on the `users` table that users can toggle on/off. +1. **Worker job** - A scheduled job (`dailyStackedNotification`) that runs daily at 1:15 AM Central Time to send notifications. +1. **Notification content** - Shows how much the user stacked and spent the previous day, plus their net gain/loss. +1. **User preferences** - A toggle in the settings UI allows users to enable or disable these notifications. + +## Setup + +After deploying the code changes: + +1. Run the migration: +1. Set up the PgBoss schedule: + ``` + node scripts/add-daily-notification-schedule.js + ``` + +## Testing + +To manually test the notification: + +```sql +INSERT INTO pgboss.job (name, data) VALUES ('dailyStackedNotification', '{}'); +``` + +## Related Files + +- `worker/dailyStackedNotification.js` - Worker implementation +- `worker/index.js` - Worker registration +- `lib/webPush.js` - Notification handling +- `pages/settings/index.js` - UI settings +- `api/typeDefs/user.js` - GraphQL schema +- `prisma/schema.prisma` - Database schema diff --git a/docs/user/daily-stacked-notifications.md b/docs/user/daily-stacked-notifications.md new file mode 100644 index 000000000..a2f8b452f --- /dev/null +++ b/docs/user/daily-stacked-notifications.md @@ -0,0 +1,29 @@ +# Daily Stacking Summary + +Stacker News now provides daily summary notifications of your stacking and spending activity! + +## What's included? + +At the start of each day, you'll receive a notification showing: + +- How many sats you stacked the previous day +- How many sats you spent the previous day +- Your net gain or loss for the day + +This helps you keep track of your activity on the platform and understand your daily stacking patterns. + +## How to enable or disable + +1. Go to your **Settings** page +2. Under the "notify me when..." section +3. Check or uncheck "I receive daily summary of sats stacked and spent" + +The notifications are enabled by default for all users. + +## When are notifications sent? + +Notifications are sent automatically at approximately 1:15 AM Central Time each day, summarizing the previous day's activity. + +## Privacy + +Like all notifications, daily stacked summaries are private and only visible to you. \ No newline at end of file diff --git a/lib/webPush.js b/lib/webPush.js index c697afd52..e58f00629 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -47,7 +47,8 @@ const createUserFilter = (tag) => { EARN: 'noteEarning', DEPOSIT: 'noteDeposits', WITHDRAWAL: 'noteWithdrawals', - STREAK: 'noteCowboyHat' + STREAK: 'noteCowboyHat', + DAILY_SUMMARY: 'noteDailyStacked' } const key = tagMap[tag.split('-')[0]] return key ? { user: { [key]: true } } : undefined @@ -87,7 +88,7 @@ const sendNotification = (subscription, payload) => { }) } -async function sendUserNotification (userId, notification) { +export async function sendUserNotification (userId, notification) { try { if (!userId) { throw new Error('user id is required') diff --git a/pages/settings/index.js b/pages/settings/index.js index 5ad2f813c..b97d82162 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -139,6 +139,7 @@ export default function Settings ({ ssrData }) { noteJobIndicator: settings?.noteJobIndicator, noteCowboyHat: settings?.noteCowboyHat, noteForwardedSats: settings?.noteForwardedSats, + noteDailyStacked: settings?.noteDailyStacked, hideInvoiceDesc: settings?.hideInvoiceDesc, autoDropBolt11s: settings?.autoDropBolt11s, hideFromTopUsers: settings?.hideFromTopUsers, @@ -338,6 +339,11 @@ export default function Settings ({ ssrData }) { +
wallet
process.exit(0)) + .catch(err => { + console.error('Unhandled error:', err) + process.exit(1) + }) +} + +module.exports = { addScheduledJob } \ No newline at end of file diff --git a/worker/dailyStackedNotification.js b/worker/dailyStackedNotification.js new file mode 100644 index 000000000..336a097d2 --- /dev/null +++ b/worker/dailyStackedNotification.js @@ -0,0 +1,156 @@ +import createPrisma from '@/lib/create-prisma' +import { numWithUnits } from '@/lib/format' +// Import directly from web-push package +import webPush from 'web-push' + +// Setup webPush config +const webPushEnabled = process.env.NODE_ENV === 'production' || + (process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY) + +if (webPushEnabled) { + webPush.setVapidDetails( + process.env.VAPID_MAILTO, + process.env.NEXT_PUBLIC_VAPID_PUBKEY, + process.env.VAPID_PRIVKEY + ) +} else { + console.warn('VAPID_* env vars not set, skipping webPush setup') +} + +// This job runs daily to send notifications to active users about their daily stacked and spent sats +export async function dailyStackedNotification () { + // grab a greedy connection + const models = createPrisma({ connectionParams: { connection_limit: 1 } }) + + try { + // Get yesterday's date (UTC) + const yesterday = new Date() + yesterday.setUTCDate(yesterday.getUTCDate() - 1) + const dateStr = yesterday.toISOString().split('T')[0] + + // First check that the data exists for yesterday + const dateCheck = await models.$queryRaw` + SELECT COUNT(*) as count FROM user_stats_days WHERE t::date = ${dateStr}::date + ` + console.log(`Found ${dateCheck[0].count} total user_stats_days records for ${dateStr}`) + + // Get users who had activity yesterday and have notifications enabled + const activeUsers = await models.$queryRaw` + SELECT + usd."id" as "userId", + usd."msats_stacked" / 1000 as "sats_stacked", + usd."msats_spent" / 1000 as "sats_spent" + FROM + user_stats_days usd + JOIN + users u ON usd."id" = u.id + WHERE + usd.t::date = ${dateStr}::date + AND (usd."msats_stacked" > 0 OR usd."msats_spent" > 0) + AND usd."id" IS NOT NULL + AND u."noteDailyStacked" = true + ` + + console.log(`Found ${activeUsers.length} active users with statistics for ${dateStr}`) + + // If no active users, exit early + if (activeUsers.length === 0) { + console.log('No active users found, exiting') + return + } + + // Send notifications to each active user + await Promise.all(activeUsers.map(async user => { + try { + // Use integer values for sats + const satsStacked = Math.floor(Number(user.sats_stacked)) + const satsSpent = Math.floor(Number(user.sats_spent)) + + // Format the stacked and spent amounts + const stackedFormatted = numWithUnits(satsStacked, { abbreviate: false }) + const spentFormatted = numWithUnits(satsSpent, { abbreviate: false }) + + // Create title with summary + let title = '' + + if (satsStacked > 0 && satsSpent > 0) { + title = `Yesterday you stacked ${stackedFormatted} and spent ${spentFormatted}` + } else if (satsStacked > 0) { + title = `Yesterday you stacked ${stackedFormatted}` + } else if (satsSpent > 0) { + title = `Yesterday you spent ${spentFormatted}` + } else { + // This shouldn't happen based on our query, but just to be safe + return + } + + // Calculate net change + const netChange = satsStacked - satsSpent + let body = '' + + if (netChange > 0) { + body = `Net gain: ${numWithUnits(netChange, { abbreviate: false })}` + } else if (netChange < 0) { + body = `Net loss: ${numWithUnits(Math.abs(netChange), { abbreviate: false })}` + } else { + body = 'Net change: 0 sats' + } + + // Get user's push subscriptions directly + const subscriptions = await models.pushSubscription.findMany({ + where: { + userId: user.userId, + user: { noteDailyStacked: true } + } + }) + + // Create notification payload + const payload = JSON.stringify({ + title, + options: { + body, + timestamp: Date.now(), + icon: '/icons/icon_x96.png', + tag: 'DAILY_SUMMARY', + data: { + stacked: satsStacked, + spent: satsSpent, + net: netChange + } + } + }) + + // Send notifications directly to each subscription + if (subscriptions.length > 0) { + console.log(`Sending ${subscriptions.length} notifications to user ${user.userId}`) + + // Check for required VAPID settings + if (!webPushEnabled) { + console.warn(`Skipping notifications for user ${user.userId} - webPush not configured`) + return + } + + await Promise.allSettled( + subscriptions.map(subscription => { + const { endpoint, p256dh, auth } = subscription + console.log(`Sending notification to endpoint: ${endpoint.substring(0, 30)}...`) + return webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, payload) + .then(() => console.log(`Successfully sent notification to user ${user.userId}`)) + .catch(err => console.error(`Push error for user ${user.userId}:`, err)) + }) + ) + } else { + console.log(`No push subscriptions found for user ${user.userId}`) + } + } catch (err) { + console.error(`Error sending notification to user ${user.userId}:`, err) + } + })) + + console.log(`Sent daily stacked notifications to ${activeUsers.length} users`) + } catch (err) { + console.error('Error in dailyStackedNotification:', err) + } finally { + await models.$disconnect() + } +} \ No newline at end of file diff --git a/worker/index.js b/worker/index.js index 03b1a3f4c..ddc4fa81d 100644 --- a/worker/index.js +++ b/worker/index.js @@ -38,6 +38,7 @@ import { expireBoost } from './expireBoost' import { payingActionConfirmed, payingActionFailed } from './payingAction' import { autoDropBolt11s } from './autoDropBolt11' import { postToSocial } from './socialPoster' +import { dailyStackedNotification } from './dailyStackedNotification' // WebSocket polyfill import ws from 'isomorphic-ws' @@ -145,6 +146,7 @@ async function work () { await boss.work('thisDay', jobWrapper(thisDay)) await boss.work('socialPoster', jobWrapper(postToSocial)) await boss.work('checkWallet', jobWrapper(checkWallet)) + await boss.work('dailyStackedNotification', jobWrapper(dailyStackedNotification)) console.log('working jobs') }