Skip to content
Open
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
150 changes: 97 additions & 53 deletions lib/webPush.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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}`,
Expand All @@ -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, {
Expand All @@ -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, {
Expand Down Expand Up @@ -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`,
Expand All @@ -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 #<id> 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 #<id> 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)
}
Expand Down