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')
}