Skip to content

feat: daily-stacked-notifications #2132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export default gql`
noteJobIndicator: Boolean!
noteMentions: Boolean!
noteItemMentions: Boolean!
noteDailyStacked: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandomMin: Int
Expand Down Expand Up @@ -194,6 +195,7 @@ export default gql`
noteJobIndicator: Boolean!
noteMentions: Boolean!
noteItemMentions: Boolean!
noteDailyStacked: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandom: Boolean!
Expand Down
39 changes: 39 additions & 0 deletions docs/dev/daily-stacked-notifications.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions docs/user/daily-stacked-notifications.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions lib/webPush.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
6 changes: 6 additions & 0 deletions pages/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -338,6 +339,11 @@ export default function Settings ({ ssrData }) {
<Checkbox
label='I find or lose cowboy essentials (e.g. cowboy hat)'
name='noteCowboyHat'
groupClassName='mb-0'
/>
<Checkbox
label='I receive daily summary of sats stacked and spent'
name='noteDailyStacked'
/>
<div className='form-label'>wallet</div>
<Input
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add notification preference for daily stacked notifications
ALTER TABLE users ADD COLUMN "noteDailyStacked" BOOLEAN NOT NULL DEFAULT true;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ model User {
noteForwardedSats Boolean @default(true)
lastCheckedJobs DateTime?
noteJobIndicator Boolean @default(true)
noteDailyStacked Boolean @default(true)
photoId Int?
upvoteTrust Float @default(0)
hideInvoiceDesc Boolean @default(false)
Expand Down
38 changes: 38 additions & 0 deletions scripts/add-daily-notification-schedule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Run this script after the migration to add the scheduled job to PgBoss
const PgBoss = require('pg-boss')

async function addScheduledJob() {
console.log('Adding daily stacked notification schedule to PgBoss...')

const boss = new PgBoss(process.env.DATABASE_URL)
await boss.start()

try {
// Remove any existing schedule with the same name
await boss.deleteSchedule('dailyStackedNotification')

// Add the new schedule
await boss.schedule('dailyStackedNotification', '15 1 * * *', null, {}, { tz: 'America/Chicago' })

console.log('Successfully added daily stacked notification schedule!')
} catch (error) {
console.error('Error adding schedule:', error)
} finally {
await boss.stop()
}
}

// Only run directly (not when imported)
if (require.main === module) {
// Load env variables first
require('../worker/loadenv')

addScheduledJob()
.then(() => process.exit(0))
.catch(err => {
console.error('Unhandled error:', err)
process.exit(1)
})
}

module.exports = { addScheduledJob }
156 changes: 156 additions & 0 deletions worker/dailyStackedNotification.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 2 additions & 0 deletions worker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
}
Expand Down