diff --git a/lib/webPush.js b/lib/webPush.js index 97c54b484..aa7d67708 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -5,7 +5,6 @@ import { msatsToSats, numWithUnits } from './format' import { nextBillingWithGrace } from '@/lib/territory' import models from '@/api/models' import { isMuted } from '@/lib/user' -import { Prisma } from '@prisma/client' const webPushEnabled = process.env.NODE_ENV === 'production' || (process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY) @@ -33,6 +32,14 @@ function createPayload (notification) { } }) } +function satsFilterClause (userTable, itemTable) { + return `( + CASE WHEN ${itemTable}."parentId" IS NULL + THEN (${itemTable}.cost + ${itemTable}.boost + (${itemTable}.msats / 1000)) >= ${userTable}."satsFilter" + ELSE (${itemTable}.cost + ${itemTable}.boost + (${itemTable}.msats / 1000)) >= LEAST(${userTable}."satsFilter", 1) + END + )` +} function userFilterFragment (setting) { return setting ? { user: { [setting]: true } } : undefined @@ -139,35 +146,40 @@ export async function notifyUserSubscribers ({ models, item }) { try { const isPost = !!item.title - const userSubsExcludingMutes = await models.$queryRaw` + const userSubsExcludingMutes = await models.$queryRawUnsafe(` SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName" FROM "UserSubscription" INNER JOIN users ON users.id = "UserSubscription"."followeeId" - WHERE "followeeId" = ${Number(item.userId)}::INTEGER - AND ${isPost ? Prisma.sql`"postsSubscribedAt"` : Prisma.sql`"commentsSubscribedAt"`} IS NOT NULL + INNER JOIN users follower ON follower.id = "UserSubscription"."followerId" + INNER JOIN "Item" i ON i.id = $1::INTEGER + WHERE "followeeId" = $2::INTEGER + AND ${isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"'} IS NOT NULL + -- satsFilter check + AND ${satsFilterClause('follower', 'i')} -- ignore muted users AND NOT EXISTS ( SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = "UserSubscription"."followerId" - AND "Mute"."mutedId" = ${Number(item.userId)}::INTEGER) + AND "Mute"."mutedId" = $2::INTEGER) -- ignore subscription if user was already notified of item as a reply AND NOT EXISTS ( SELECT 1 FROM "Reply" - INNER JOIN users follower ON follower.id = "UserSubscription"."followerId" - WHERE "Reply"."itemId" = ${Number(item.id)}::INTEGER - AND "Reply"."ancestorUserId" = follower.id - AND follower."noteAllDescendants" + INNER JOIN users f ON f.id = "UserSubscription"."followerId" + WHERE "Reply"."itemId" = $1::INTEGER + AND "Reply"."ancestorUserId" = f.id + AND f."noteAllDescendants" ) - -- ignore subscription if user has posted to a territory the recipient is subscribed to ${isPost - ? Prisma.sql`AND NOT EXISTS ( - SELECT 1 - FROM "SubSubscription" - WHERE "SubSubscription"."userId" = "UserSubscription"."followerId" - AND "SubSubscription"."subName" = ${item.subName} - )` - : Prisma.empty}` + ? `-- ignore subscription if user has posted to a territory the recipient is subscribed to + AND NOT EXISTS ( + SELECT 1 + FROM "SubSubscription" + WHERE "SubSubscription"."userId" = "UserSubscription"."followerId" + AND "SubSubscription"."subName" = $3 + )` + : ''} + `, Number(item.id), Number(item.userId), item.subName) await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, { title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`, body: isPost ? item.title : item.text, @@ -187,17 +199,25 @@ export async function notifyTerritorySubscribers ({ models, item }) { if (!isPost || !subName) return const territorySubsExcludingMuted = await models.$queryRawUnsafe(` - SELECT "userId" FROM "SubSubscription" - WHERE "subName" = $1 - AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = "SubSubscription"."userId" AND m."mutedId" = $2) - `, subName, Number(item.userId)) - + SELECT "SubSubscription"."userId" + FROM "SubSubscription" + INNER JOIN users u ON u.id = "SubSubscription"."userId" + INNER JOIN "Item" i ON i.id = $1::INTEGER + WHERE "subName" = $2 + AND "SubSubscription"."userId" <> $3::INTEGER + -- satsFilter check + AND ${satsFilterClause('u', 'i')} + -- ignore muted users + AND NOT EXISTS ( + SELECT 1 FROM "Mute" m + WHERE m."muterId" = "SubSubscription"."userId" + AND m."mutedId" = $3::INTEGER + ) + `, Number(item.id), subName, Number(item.userId)) const author = await models.user.findUnique({ where: { id: item.userId } }) await Promise.allSettled( territorySubsExcludingMuted - // don't send push notification to author itself - .filter(({ userId }) => userId !== author.id) .map(({ userId }) => sendUserNotification(userId, { title: `@${author.name} created a post in ~${subName}`, @@ -213,23 +233,29 @@ export async function notifyThreadSubscribers ({ models, item }) { try { const author = await models.user.findUnique({ where: { id: item.userId } }) - const subscribers = await models.$queryRaw` + const subscribers = await models.$queryRawUnsafe(` SELECT DISTINCT "ThreadSubscription"."userId" FROM "ThreadSubscription" JOIN users ON users.id = "ThreadSubscription"."userId" JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId" - WHERE r."itemId" = ${item.id} + JOIN "Item" i ON i.id = $1::INTEGER + WHERE r."itemId" = $1::INTEGER -- don't send notifications for own items AND r."userId" <> "ThreadSubscription"."userId" -- send notifications for all levels? AND CASE WHEN users."noteAllDescendants" THEN TRUE ELSE r.level = 1 END + -- satsFilter check + AND ${satsFilterClause('users', 'i')} -- muted? AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = users.id AND m."mutedId" = r."userId") -- already received notification as reply to self? AND NOT EXISTS ( - SELECT 1 FROM "Item" i - JOIN "Item" p ON p.path @> i.path - WHERE i.id = ${item.parentId} AND p."userId" = "ThreadSubscription"."userId" AND users."noteAllDescendants" - )` + SELECT 1 FROM "Item" ancestor + JOIN "Item" p ON p.path @> ancestor.path + WHERE ancestor.id = $2::INTEGER + AND p."userId" = "ThreadSubscription"."userId" + AND users."noteAllDescendants" + ) + `, Number(item.id), Number(item.parentId)) await Promise.allSettled(subscribers.map(({ userId }) => sendUserNotification(userId, { @@ -248,19 +274,26 @@ export async function notifyThreadSubscribers ({ models, item }) { export async function notifyItemParents ({ models, item }) { try { const user = await models.user.findUnique({ where: { id: item.userId } }) - const parents = await models.$queryRaw` + const parents = await models.$queryRawUnsafe(` SELECT DISTINCT p."userId", i."userId" = p."userId" as "isDirect" FROM "Item" i JOIN "Item" p ON p.path @> i.path - WHERE i.id = ${Number(item.parentId)} and p."userId" <> ${Number(user.id)} - AND NOT EXISTS ( - SELECT 1 FROM "Mute" m - WHERE m."muterId" = p."userId" AND m."mutedId" = ${Number(user.id)} - ) - AND EXISTS ( - -- check that there is at least one parent subscribed to this thread - SELECT 1 FROM "ThreadSubscription" ts WHERE p.id = ts."itemId" AND p."userId" = ts."userId" - )` + JOIN users u ON u.id = p."userId" + JOIN "Item" notified ON notified.id = $1::INTEGER + WHERE i.id = $2::INTEGER + AND p."userId" <> $3::INTEGER + -- satsFilter check + AND ${satsFilterClause('u', 'notified')} + -- ignore muted users + AND NOT EXISTS ( + SELECT 1 FROM "Mute" m + WHERE m."muterId" = p."userId" AND m."mutedId" = $3::INTEGER + ) + AND EXISTS ( + -- check that there is at least one parent subscribed to this thread + SELECT 1 FROM "ThreadSubscription" ts WHERE p.id = ts."itemId" AND p."userId" = ts."userId" + ) + `, Number(item.id), Number(item.parentId), Number(user.id)) await Promise.allSettled( parents.map(({ userId, isDirect }) => { return sendUserNotification(userId, { @@ -331,6 +364,12 @@ export async function notifyMention ({ models, userId, item }) { try { const muted = await isMuted({ models, muterId: userId, mutedId: item.userId }) if (muted) return + const [{ passesFilter }] = await models.$queryRawUnsafe(` + SELECT ${satsFilterClause('u', 'i')} as "passesFilter" + FROM users u, "Item" i + WHERE u.id = $1 AND i.id = $2 + `, userId, item.id) + if (!passesFilter) return await sendUserNotification(userId, { title: `@${item.user.name} mentioned you`, @@ -346,19 +385,24 @@ export async function notifyMention ({ models, userId, item }) { export async function notifyItemMention ({ models, referrerItem, refereeItem }) { try { const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId }) - if (!muted) { - const referrer = await models.user.findUnique({ where: { id: referrerItem.userId } }) - - // replace full links to # syntax as rendered on site - const body = referrerItem.text.replace(new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/(\\d+)`, 'gi'), '#$1') - - await sendUserNotification(refereeItem.userId, { - title: `@${referrer.name} mentioned one of your items`, - body, - itemId: referrerItem.id, - setting: 'noteItemMentions' - }) - } + if (muted) return + const [{ passesFilter }] = await models.$queryRawUnsafe(` + SELECT ${satsFilterClause('u', 'i')} as "passesFilter" + FROM users u, "Item" i + WHERE u.id = $1 AND i.id = $2 + `, refereeItem.userId, referrerItem.id) + if (!passesFilter) return + const referrer = await models.user.findUnique({ where: { id: referrerItem.userId } }) + + // replace full links to # syntax as rendered on site + const body = referrerItem.text.replace(new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/(\\d+)`, 'gi'), '#$1') + + await sendUserNotification(refereeItem.userId, { + title: `@${referrer.name} mentioned one of your items`, + body, + itemId: referrerItem.id, + setting: 'noteItemMentions' + }) } catch (err) { log('error sending item mention notification:', err) }