From 3388f818cfbb0c6b6c5896c727990bc376f95c3f Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 25 Mar 2024 21:20:11 +0100 Subject: [PATCH 1/4] Add withdrawal notifications --- api/resolvers/notifications.js | 18 +++++++- api/resolvers/user.js | 16 +++++++ api/resolvers/wallet.js | 46 +++++++++++-------- api/typeDefs/notifications.js | 9 +++- api/typeDefs/user.js | 4 +- components/notifications.js | 11 +++++ fragments/notifications.js | 9 ++++ fragments/users.js | 2 + lib/webPush.js | 1 + pages/settings/index.js | 6 +++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 12 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20240325114003_withdrawal_notifications/migration.sql diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 5fe0832d..3d2e75c6 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,7 +1,7 @@ import { GraphQLError } from 'graphql' import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' import { getItem, filterClause, whereClause, muteClause } from './item' -import { getInvoice } from './wallet' +import { getInvoice, getWithdrawal } from './wallet' import { pushSubscriptionSchema, ssValidate } from '@/lib/validate' import { replyToSubscription } from '@/lib/webPush' import { getSub } from './sub' @@ -221,6 +221,19 @@ export default { ) } + if (meFull.noteWithdrawals) { + queries.push( + `(SELECT "Withdrawl".id::text, "Withdrawl".created_at AS "sortTime", FLOOR("msatsPaid" / 1000) as "earnedSats", + 'WithdrawlPaid' AS type + FROM "Withdrawl" + WHERE "Withdrawl"."userId" = $1 + AND status = 'CONFIRMED' + AND created_at < $2 + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } + if (meFull.noteInvites) { queries.push( `(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats", @@ -430,6 +443,9 @@ export default { InvoicePaid: { invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) }, + WithdrawlPaid: { + withdrawl: async (n, args, { me, models }) => getWithdrawal(n, { id: n.id }, { me, models }) + }, Invitification: { invite: async (n, args, { models }) => { return await models.invite.findUnique({ diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 8032b99a..efbfe642 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -353,6 +353,22 @@ export default { } } + if (user.noteWithdrawals) { + const wdrwl = await models.withdrawl.findFirst({ + where: { + userId: me.id, + status: 'CONFIRMED', + updatedAt: { + gt: lastChecked + } + } + }) + if (wdrwl) { + foundNotes() + return true + } + } + // check if new invites have been redeemed if (user.noteInvites) { const [newInvites] = await models.$queryRawUnsafe(` diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index bfca346b..e2876d50 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -53,6 +53,31 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) { return inv } +export async function getWithdrawal (parent, { id }, { me, models }) { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + const wdrwl = await models.withdrawl.findUnique({ + where: { + id: Number(id) + }, + include: { + user: true + } + }) + + if (!wdrwl) { + throw new GraphQLError('withdrawal not found', { extensions: { code: 'BAD_INPUT' } }) + } + + if (wdrwl.user.id !== me.id) { + throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) + } + + return wdrwl +} + export function createHmac (hash) { const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex') return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') @@ -97,26 +122,7 @@ export default { } }) }, - withdrawl: async (parent, { id }, { me, models, lnd }) => { - if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - - const wdrwl = await models.withdrawl.findUnique({ - where: { - id: Number(id) - }, - include: { - user: true - } - }) - - if (wdrwl.user.id !== me.id) { - throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) - } - - return wdrwl - }, + withdrawl: getWithdrawal, numBolt11s: async (parent, args, { me, models, lnd }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 07d94701..5f237767 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -91,6 +91,13 @@ export default gql` sortTime: Date! } + type WithdrawlPaid { + id: ID! + earnedSats: Int! + withdrawl: Withdrawl! + sortTime: Date! + } + type Referral { id: ID! sortTime: Date! @@ -115,7 +122,7 @@ export default gql` } union Notification = Reply | Votification | Mention - | Invitification | Earn | JobChanged | InvoicePaid | Referral + | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus | TerritoryPost | TerritoryTransfer diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 800eb648..2300fdc3 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -82,7 +82,8 @@ export default gql` nostrRelays: [String!] noteAllDescendants: Boolean! noteCowboyHat: Boolean! - noteDeposits: Boolean! + noteDeposits: Boolean!, + noteWithdrawals: Boolean!, noteEarning: Boolean! noteForwardedSats: Boolean! noteInvites: Boolean! @@ -148,6 +149,7 @@ export default gql` noteAllDescendants: Boolean! noteCowboyHat: Boolean! noteDeposits: Boolean! + noteWithdrawals: Boolean! noteEarning: Boolean! noteForwardedSats: Boolean! noteInvites: Boolean! diff --git a/components/notifications.js b/components/notifications.js index 9a9d3360..2a99661d 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -40,6 +40,7 @@ function Notification ({ n, fresh }) { (type === 'Revenue' && ) || (type === 'Invitification' && ) || (type === 'InvoicePaid' && (n.invoice.nostr ? : )) || + (type === 'WithdrawlPaid' && ) || (type === 'Referral' && ) || (type === 'Streak' && ) || (type === 'Votification' && ) || @@ -95,6 +96,7 @@ const defaultOnClick = n => { if (type === 'SubStatus') return { href: `/~${n.sub.name}` } if (type === 'Invitification') return { href: '/invites' } if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` } + if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.withdrawl.id}` } if (type === 'Referral') return { href: '/referrals/month' } if (type === 'Streak') return {} if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } @@ -277,6 +279,15 @@ function InvoicePaid ({ n }) { ) } +function WithdrawlPaid ({ n }) { + return ( +
+ {numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account + {timeSince(new Date(n.sortTime))} +
+ ) +} + function Referral ({ n }) { return ( diff --git a/fragments/notifications.js b/fragments/notifications.js index 247506da..bc91ac42 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -133,6 +133,15 @@ export const NOTIFICATIONS = gql` lud18Data } } + ... on WithdrawlPaid { + id + sortTime + earnedSats + withdrawl { + id + satsPaid + } + } } } } ` diff --git a/fragments/users.js b/fragments/users.js index f9225eca..8f94c10e 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -30,6 +30,7 @@ export const ME = gql` noteAllDescendants noteCowboyHat noteDeposits + noteWithdrawals noteEarning noteForwardedSats noteInvites @@ -72,6 +73,7 @@ export const SETTINGS_FIELDS = gql` noteAllDescendants noteMentions noteDeposits + noteWithdrawals noteInvites noteJobIndicator noteCowboyHat diff --git a/lib/webPush.js b/lib/webPush.js index c4daa44a..f6313e5c 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -43,6 +43,7 @@ const createUserFilter = (tag) => { INVITE: 'noteInvites', EARN: 'noteEarning', DEPOSIT: 'noteDeposits', + WITHDRAWAL: 'noteWithdrawals', STREAK: 'noteCowboyHat' } const key = tagMap[tag.split('-')[0]] diff --git a/pages/settings/index.js b/pages/settings/index.js index e67c6326..7de221e3 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -75,6 +75,7 @@ export default function Settings ({ ssrData }) { noteAllDescendants: settings?.noteAllDescendants, noteMentions: settings?.noteMentions, noteDeposits: settings?.noteDeposits, + noteWithdrawals: settings?.noteWithdrawals, noteInvites: settings?.noteInvites, noteJobIndicator: settings?.noteJobIndicator, noteCowboyHat: settings?.noteCowboyHat, @@ -223,6 +224,11 @@ export default function Settings ({ ssrData }) { name='noteDeposits' groupClassName='mb-0' /> + Date: Tue, 26 Mar 2024 00:47:23 +0100 Subject: [PATCH 2/4] Add withdrawal push notifications --- lib/webPush.js | 12 +++++++++ .../migration.sql | 27 +++++++++++++++++++ sw/eventListener.js | 9 ++++--- worker/wallet.js | 7 +++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/webPush.js b/lib/webPush.js index f6313e5c..3b7b41c7 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -318,6 +318,18 @@ export async function notifyDeposit (userId, invoice) { } } +export async function notifyWithdrawal (userId, wdrwl) { + try { + await sendUserNotification(userId, { + title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false })} were withdrawn from your account`, + tag: 'WITHDRAWAL', + data: { sats: msatsToSats(wdrwl.payment.mtokens) } + }) + } catch (err) { + console.error(err) + } +} + export async function notifyNewStreak (userId, streak) { const index = streak.id % FOUND_BLURBS.length const blurb = FOUND_BLURBS[index] diff --git a/prisma/migrations/20240325114003_withdrawal_notifications/migration.sql b/prisma/migrations/20240325114003_withdrawal_notifications/migration.sql index 838eb1a9..f1903eb8 100644 --- a/prisma/migrations/20240325114003_withdrawal_notifications/migration.sql +++ b/prisma/migrations/20240325114003_withdrawal_notifications/migration.sql @@ -1,2 +1,29 @@ -- AlterTable ALTER TABLE "users" ADD COLUMN "noteWithdrawals" BOOLEAN NOT NULL DEFAULT true; + +CREATE OR REPLACE FUNCTION confirm_withdrawl(wid INTEGER, msats_paid BIGINT, msats_fee_paid BIGINT) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + msats_fee_paying BIGINT; + user_id INTEGER; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE id = wid AND status IS NULL) THEN + SELECT "msatsFeePaying", "userId" INTO msats_fee_paying, user_id + FROM "Withdrawl" WHERE id = wid AND status IS NULL; + + UPDATE "Withdrawl" + SET status = 'CONFIRMED', "msatsPaid" = msats_paid, + "msatsFeePaid" = msats_fee_paid, updated_at = now_utc() + WHERE id = wid AND status IS NULL; + + UPDATE users SET msats = msats + (msats_fee_paying - msats_fee_paid) WHERE id = user_id; + RETURN 0; + END IF; + + RETURN 1; +END; +$$; \ No newline at end of file diff --git a/sw/eventListener.js b/sw/eventListener.js index 5cb8b6a7..576b9145 100644 --- a/sw/eventListener.js +++ b/sw/eventListener.js @@ -116,7 +116,7 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, // tags that need to know the amount of notifications with same tag for merging const AMOUNT_TAGS = ['REPLY', 'MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST'] // tags that need to know the sum of sats of notifications with same tag for merging - const SUM_SATS_TAGS = ['DEPOSIT'] + const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL'] // this should reflect the amount of notifications that were already merged before let initialAmount = currentNotifications[0]?.data?.amount || 1 if (iOS()) initialAmount = 1 @@ -153,8 +153,11 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, title = `you have ${amount} new posts in ~${subName}` } } else if (SUM_SATS_TAGS.includes(compareTag)) { - // there is only DEPOSIT in this array - title = `${numWithUnits(sats, { abbreviate: false })} were deposited in your account` + if (compareTag === 'DEPOSIT') { + title = `${numWithUnits(sats, { abbreviate: false })} were deposited in your account` + } else if (compareTag === 'WITHDRAWAL') { + title = `${numWithUnits(sats, { abbreviate: false })} were withdrawn from your account` + } } log(`[sw:push] ${nid} - calculated title: ${title}`) diff --git a/worker/wallet.js b/worker/wallet.js index 8242202b..13179552 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -3,7 +3,7 @@ import { getInvoice, getPayment, cancelHodlInvoice, deletePayment, subscribeToInvoices, subscribeToPayments, subscribeToInvoice } from 'ln-service' -import { notifyDeposit } from '@/lib/webPush' +import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush' import { INVOICE_RETENTION_DAYS } from '@/lib/constants' import { datePivot, sleep } from '@/lib/time.js' import retry from 'async-retry' @@ -228,8 +228,11 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) { if (wdrwl?.is_confirmed) { const fee = Number(wdrwl.payment.fee_mtokens) const paid = Number(wdrwl.payment.mtokens) - fee - await serialize(models, models.$executeRaw` + const [{ confirm_withdrawl: code }] = await serialize(models, models.$queryRaw` SELECT confirm_withdrawl(${dbWdrwl.id}::INTEGER, ${paid}, ${fee})`) + if (code === 0) { + notifyWithdrawal(dbWdrwl.userId, wdrwl) + } } else if (wdrwl?.is_failed || notFound) { let status = 'UNKNOWN_FAILURE' if (wdrwl?.failed.is_insufficient_balance) { From a1317b97e92914539c4129c81b1d24476ff5be39 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 26 Mar 2024 00:53:49 +0100 Subject: [PATCH 3/4] Add missing unitSingular, unitPlural --- lib/webPush.js | 4 ++-- sw/eventListener.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/webPush.js b/lib/webPush.js index 3b7b41c7..3140ac0c 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -308,7 +308,7 @@ export async function notifyEarner (userId, earnings) { export async function notifyDeposit (userId, invoice) { try { await sendUserNotification(userId, { - title: `${numWithUnits(msatsToSats(invoice.received_mtokens), { abbreviate: false })} were deposited in your account`, + title: `${numWithUnits(msatsToSats(invoice.received_mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`, body: invoice.comment || undefined, tag: 'DEPOSIT', data: { sats: msatsToSats(invoice.received_mtokens) } @@ -321,7 +321,7 @@ export async function notifyDeposit (userId, invoice) { export async function notifyWithdrawal (userId, wdrwl) { try { await sendUserNotification(userId, { - title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false })} were withdrawn from your account`, + title: `${numWithUnits(msatsToSats(wdrwl.payment.mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`, tag: 'WITHDRAWAL', data: { sats: msatsToSats(wdrwl.payment.mtokens) } }) diff --git a/sw/eventListener.js b/sw/eventListener.js index 576b9145..36961920 100644 --- a/sw/eventListener.js +++ b/sw/eventListener.js @@ -154,9 +154,9 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag, } } else if (SUM_SATS_TAGS.includes(compareTag)) { if (compareTag === 'DEPOSIT') { - title = `${numWithUnits(sats, { abbreviate: false })} were deposited in your account` + title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account` } else if (compareTag === 'WITHDRAWAL') { - title = `${numWithUnits(sats, { abbreviate: false })} were withdrawn from your account` + title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account` } } log(`[sw:push] ${nid} - calculated title: ${title}`) From 922d2394fd28ddb558ea0d706b242268bc109e3c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 26 Mar 2024 02:09:28 +0100 Subject: [PATCH 4/4] Remove unnecessary withdrawl field --- api/resolvers/notifications.js | 5 +--- api/resolvers/wallet.js | 50 ++++++++++++++++------------------ api/typeDefs/notifications.js | 1 - components/notifications.js | 2 +- fragments/notifications.js | 4 --- 5 files changed, 26 insertions(+), 36 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 3d2e75c6..52c5f84b 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,7 +1,7 @@ import { GraphQLError } from 'graphql' import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' import { getItem, filterClause, whereClause, muteClause } from './item' -import { getInvoice, getWithdrawal } from './wallet' +import { getInvoice } from './wallet' import { pushSubscriptionSchema, ssValidate } from '@/lib/validate' import { replyToSubscription } from '@/lib/webPush' import { getSub } from './sub' @@ -443,9 +443,6 @@ export default { InvoicePaid: { invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) }, - WithdrawlPaid: { - withdrawl: async (n, args, { me, models }) => getWithdrawal(n, { id: n.id }, { me, models }) - }, Invitification: { invite: async (n, args, { models }) => { return await models.invite.findUnique({ diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index e2876d50..e10fe422 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -53,31 +53,6 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) { return inv } -export async function getWithdrawal (parent, { id }, { me, models }) { - if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - - const wdrwl = await models.withdrawl.findUnique({ - where: { - id: Number(id) - }, - include: { - user: true - } - }) - - if (!wdrwl) { - throw new GraphQLError('withdrawal not found', { extensions: { code: 'BAD_INPUT' } }) - } - - if (wdrwl.user.id !== me.id) { - throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) - } - - return wdrwl -} - export function createHmac (hash) { const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex') return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') @@ -122,7 +97,30 @@ export default { } }) }, - withdrawl: getWithdrawal, + withdrawl: async (parent, { id }, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + const wdrwl = await models.withdrawl.findUnique({ + where: { + id: Number(id) + }, + include: { + user: true + } + }) + + if (!wdrwl) { + throw new GraphQLError('withdrawal not found', { extensions: { code: 'BAD_INPUT' } }) + } + + if (wdrwl.user.id !== me.id) { + throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } }) + } + + return wdrwl + }, numBolt11s: async (parent, args, { me, models, lnd }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 5f237767..12c58acc 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -94,7 +94,6 @@ export default gql` type WithdrawlPaid { id: ID! earnedSats: Int! - withdrawl: Withdrawl! sortTime: Date! } diff --git a/components/notifications.js b/components/notifications.js index 2a99661d..529d4bcf 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -96,7 +96,7 @@ const defaultOnClick = n => { if (type === 'SubStatus') return { href: `/~${n.sub.name}` } if (type === 'Invitification') return { href: '/invites' } if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` } - if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.withdrawl.id}` } + if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` } if (type === 'Referral') return { href: '/referrals/month' } if (type === 'Streak') return {} if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } diff --git a/fragments/notifications.js b/fragments/notifications.js index bc91ac42..38bb0bab 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -137,10 +137,6 @@ export const NOTIFICATIONS = gql` id sortTime earnedSats - withdrawl { - id - satsPaid - } } } }