diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 26e8c4872..930621f60 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -5,6 +5,7 @@ import { pushSubscriptionSchema, validateSchema } from '@/lib/validate' import { replyToSubscription } from '@/lib/webPush' import { getSub } from './sub' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { msatsToSats } from '@/lib/format' export default { Query: { @@ -361,6 +362,21 @@ export default { LIMIT ${LIMIT})` ) + if (meFull.noteSatSummary) { + queries.push( + `(SELECT 'stats_' || date_trunc('day', t)::text as id, + t AS "sortTime", NULL as "earnedSats", 'SatSummary' AS type + FROM user_stats_days + WHERE id = $1 + AND t >= date_trunc('day', CURRENT_DATE - INTERVAL '2 day') + AND t <= $2 + GROUP BY t + HAVING sum(msats_stacked) != 0 OR sum(msats_spent) != 0 + ORDER BY "sortTime" DESC + LIMIT 1)` + ) + } + const notifications = await models.$queryRawUnsafe( `SELECT id, "sortTime", "earnedSats", type, "sortTime" AS "minSortTime" @@ -486,6 +502,29 @@ export default { return res.length ? res[0].type : null } }, + SatSummary: { + date: async (n, args, { models }) => { + return new Date(n.sortTime) + }, + stacked: async (n, args, { me, models }) => { + const [{ stacked }] = await models.$queryRaw` + SELECT sum(msats_stacked) as stacked + FROM user_stats_days + WHERE id = ${Number(me.id)} + AND t = ${n.sortTime}::timestamp + ` + return (stacked && msatsToSats(stacked)) || 0 + }, + spent: async (n, args, { me, models }) => { + const [{ spent }] = await models.$queryRaw` + SELECT sum(msats_spent) as spent + FROM user_stats_days + WHERE id = ${Number(me.id)} + AND t = ${n.sortTime}::timestamp + ` + return (spent && msatsToSats(spent)) || 0 + } + }, Earn: { sources: async (n, args, { me, models }) => { const [sources] = await models.$queryRawUnsafe(` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index f131fa929..77225f91e 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -503,6 +503,20 @@ export default { } } + if (user.noteSatSummary) { + const [satSummary] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * + FROM user_stats_days + WHERE "user_stats_days"."id" = $1 + AND t > $2 + LIMIT 1)`, me.id, lastChecked) + if (satSummary.exists) { + foundNotes() + return true + } + } + const subStatus = await models.sub.findFirst({ where: { userId: me.id, diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 4eabb3564..c70e207fa 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -82,6 +82,14 @@ export default gql` type: String! } + type SatSummary { + id: ID! + sortTime: Date! + date: Date! + stacked: Int + spent: Int + } + type Earn { id: ID! earnedSats: Int! @@ -155,7 +163,7 @@ export default gql` union Notification = Reply | Votification | Mention | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral - | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus + | Streak | SatSummary | FollowActivity | ForwardedVotification | Revenue | SubStatus | TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification | ReferralReward diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index bfefe7e3d..204a7ed0c 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -94,6 +94,7 @@ export default gql` nostrPubkey: String nostrRelays: [String!] noteAllDescendants: Boolean! + noteSatSummary: Boolean! noteCowboyHat: Boolean! noteDeposits: Boolean!, noteWithdrawals: Boolean!, @@ -171,6 +172,7 @@ export default gql` nostrPubkey: String nostrRelays: [String!] noteAllDescendants: Boolean! + noteSatSummary: Boolean! noteCowboyHat: Boolean! noteDeposits: Boolean! noteWithdrawals: Boolean! diff --git a/components/notifications.js b/components/notifications.js index d159c74d1..7c1146723 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -13,6 +13,7 @@ import HandCoin from '@/svgs/hand-coin-fill.svg' import UserAdd from '@/svgs/user-add-fill.svg' import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants' import CowboyHatIcon from '@/svgs/cowboy.svg' +import ScalesIcon from '@/svgs/scales-of-justice.svg' import BaldIcon from '@/svgs/bald.svg' import GunIcon from '@/svgs/revolver.svg' import HorseIcon from '@/svgs/horse.svg' @@ -57,6 +58,7 @@ function Notification ({ n, fresh }) { (type === 'WithdrawlPaid' && ) || (type === 'Referral' && ) || (type === 'Streak' && ) || + (type === 'SatSummary' && ) || (type === 'Votification' && ) || (type === 'ForwardedVotification' && ) || (type === 'Mention' && ) || @@ -162,6 +164,7 @@ const defaultOnClick = n => { if (type === 'Referral') return { href: '/referrals/month' } if (type === 'ReferralReward') return { href: '/referrals/month' } if (type === 'Streak') return {} + if (type === 'SatSummary') return { href: '/satistics?inc=stacked,spent' } if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } if (!n.item) return {} @@ -200,6 +203,18 @@ function Streak ({ n }) { ) } +function SatSummary ({ n }) { + return ( +
+
+
+ you stacked {numWithUnits(n.stacked, { abbreviate: false })} and spent {numWithUnits(n.spent, { abbreviate: false })} +
on {dayMonthYear(new Date(n.date))}
+
+
+ ) +} + function EarnNotification ({ n }) { const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}` diff --git a/fragments/notifications.js b/fragments/notifications.js index 0915ccdf1..5cd15577f 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -88,6 +88,13 @@ export const NOTIFICATIONS = gql` days type } + ... on SatSummary { + id + sortTime + date + stacked + spent + } ... on Earn { id sortTime diff --git a/fragments/users.js b/fragments/users.js index c65cc2a06..9b3424613 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -83,6 +83,7 @@ export const SETTINGS_FIELDS = gql` noteWithdrawals noteInvites noteJobIndicator + noteSatSummary noteCowboyHat noteForwardedSats hideInvoiceDesc diff --git a/lib/apollo.js b/lib/apollo.js index 6e2eeab1a..f412fbfae 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -63,6 +63,7 @@ function getClient (uri) { 'WithdrawlPaid', 'Referral', 'Streak', + 'SatSummary', 'FollowActivity', 'ForwardedVotification', 'Revenue', diff --git a/lib/webPush.js b/lib/webPush.js index 88b20047d..537925b70 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -46,6 +46,7 @@ const createUserFilter = (tag) => { EARN: 'noteEarning', DEPOSIT: 'noteDeposits', WITHDRAWAL: 'noteWithdrawals', + DAILY_STATS: 'noteSatSummary', STREAK: 'noteCowboyHat' } const key = tagMap[tag.split('-')[0]] @@ -392,6 +393,18 @@ export async function notifyStreakLost (userId, streak) { } } +export async function notifySatSummary (userId, stacked, spent) { + try { + await sendUserNotification(userId, { + title: 'your daily sat summary is ready', + body: `you stacked ${numWithUnits(stacked, { abbreviate: false })} and spent ${numWithUnits(spent, { abbreviate: false })}`, + tag: 'DAILY_STATS' + }) + } catch (err) { + console.error(err) + } +} + export async function notifyReminder ({ userId, item, itemId }) { try { await sendUserNotification(userId, { diff --git a/pages/settings/index.js b/pages/settings/index.js index 4f1e912d9..1aa09ed24 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -138,6 +138,7 @@ export default function Settings ({ ssrData }) { noteInvites: settings?.noteInvites, noteJobIndicator: settings?.noteJobIndicator, noteCowboyHat: settings?.noteCowboyHat, + noteSatSummary: settings?.noteSatSummary, noteForwardedSats: settings?.noteForwardedSats, hideInvoiceDesc: settings?.hideInvoiceDesc, autoDropBolt11s: settings?.autoDropBolt11s, @@ -335,6 +336,11 @@ export default function Settings ({ ssrData }) { name='noteJobIndicator' groupClassName='mb-0' /> + \ No newline at end of file diff --git a/sw/eventListener.js b/sw/eventListener.js index fab3e910a..af807b518 100644 --- a/sw/eventListener.js +++ b/sw/eventListener.js @@ -67,7 +67,7 @@ export function onPush (sw) { // if there is no tag or the tag is one of the following // we show the notification immediately const immediatelyShowNotification = (tag) => - !tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER'].includes(tag.split('-')[0]) + !tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER', 'DAILY_STATS'].includes(tag.split('-')[0]) // merge notifications with the same tag const mergeNotification = (event, sw, payload, currentNotifications, tag, nid) => { diff --git a/worker/satSummary.js b/worker/satSummary.js new file mode 100644 index 000000000..2a036fecb --- /dev/null +++ b/worker/satSummary.js @@ -0,0 +1,33 @@ +import { notifySatSummary } from '@/lib/webPush' +import { msatsToSats } from '@/lib/format' +export async function dailySatSummary ({ models }) { + console.log('running dailySatSummary') + try { + const stats = await models.$queryRaw` + SELECT + id as userId, sum(msats_stacked) as stacked, sum(msats_spent) as spent + FROM user_stats_days + WHERE t >= date_trunc('day', CURRENT_DATE - INTERVAL '1 day') + AND t <= date_trunc('day', CURRENT_DATE) + GROUP BY id + HAVING sum(msats_stacked) != 0 OR sum(msats_spent) != 0 + ` + + if (stats.length) { + for (const stat of stats) { + const user = await models.user.findUnique({ where: { id: stat.userid } }) + if (user && user.noteSatSummary) { + await notifySatSummary( + stat.userid, + (stats.stacked && msatsToSats(stats.stacked)) || 0, + (stats.spent && msatsToSats(stats.spent)) || 0 + ) + } + } + } + } catch (err) { + console.error('failed to process daily sat summary', err) + } finally { + console.log('finished dailySatSummary') + } +} diff --git a/worker/views.js b/worker/views.js index e358b5f62..92fbc59c8 100644 --- a/worker/views.js +++ b/worker/views.js @@ -1,4 +1,5 @@ import createPrisma from '@/lib/create-prisma' +import { dailySatSummary } from './satSummary' const viewPrefixes = ['reg_growth', 'spender_growth', 'item_growth', 'spending_growth', 'stackers_growth', 'stacking_growth', 'user_stats', 'sub_stats'] @@ -22,6 +23,7 @@ export async function views ({ data: { period } = { period: 'days' } }) { await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}_${period}`) } } finally { + if (period === 'days') await dailySatSummary({ models }).catch(console.error) // run dailySatSummary after views-days refresh await models.$disconnect() } }