Compare commits

...

9 Commits

Author SHA1 Message Date
keyan
061d3f220d fix local dev missing snl row 2024-06-03 16:55:03 -05:00
keyan
c90eb055c7 fix performance of sub nested resolvers 2024-06-03 16:50:38 -05:00
keyan
7b667821d2 fix notification indicator for item mentions 2024-06-03 16:34:38 -05:00
Keyan
b4c120ab39
Update awards.csv 2024-06-03 14:40:37 -05:00
SatsAllDay
e3571af1e1
Make Polls Anonymous (#1197)
* make polls anonymous

Introduce a `PollBlindVote` DB table that tracks when a user votes in a poll,
but does not track which choice they made.

Alter the `PollVote` DB table to remove the `userId` column, meaning `PollVote`
now tracks poll votes anonymously - it captures votes per poll option,
but does not track which user submitted the vote.

Update the `poll_vote` DB function to work with both tables now.

Update the `item.poll` resolver to calculate `meVoted` based on the `PollBlindVote`
table instead of `PollVote`.

* remove `meVoted` on `PollOption`s

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-06-03 13:56:43 -05:00
ekzyis
2597eb56f3
Item mention notifications (#1208)
* Parse internal refs to links

* Item mention notifications

* Also parse item mentions as URLs

* Fix subType determined by referrer item instead of referee item

* Ignore subType

Considering if the item that was referred to was a post or comment made the code more complex than initially necessary.

For example, notifications for /notifications are deduplicated based on item id and the same item could refer to posts and comments, so to include "one of your posts" or "one of your comments" in the title would require splitting notifications based on the type of referred item.

I didn't want to do this but also wanted to have consistent notification titles between push and /notifications, so I use "items" in both places now, even though I think using "items" isn't ideal from a user perspective. I think it might be confusing.

* Fix rootText

* Replace full links to #<id> syntax in push notifications

* Refactor mention code into separate functions
2024-06-03 12:12:42 -05:00
Tom
d454bbdb72
Fix issue with popover on full sn links (#1216)
* Fix issue with popover on full sn links

* allow custom text for internal links

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-03 12:05:31 -05:00
ekzyis
86b857b8d4
Allow SN users to edit special items forever (#1204)
* Allow SN users to edit special items

* Refactor item edit validation

* Create object for user IDs

* Remove anon from SN_USER_IDS

* Fix isMine and myBio checks

* Don't update author

* remove anon from trust graph

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-03 11:26:19 -05:00
SatsAllDay
e9aa268996
add fund_user sndev command (#1214)
* add `fund_user` sndev command

* update help and set msats rather than add

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-03 09:37:58 -05:00
37 changed files with 540 additions and 113 deletions

View File

@ -1,8 +1,8 @@
export default { export default {
Query: { Query: {
snl: async (parent, _, { models }) => { snl: async (parent, _, { models }) => {
const { live } = await models.snl.findFirst() const snl = await models.snl.findFirst()
return live return !!snl?.live
} }
}, },
Mutation: { Mutation: {

View File

@ -1,4 +1,4 @@
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants' import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
export default { export default {
@ -17,7 +17,7 @@ export function uploadIdsFromText (text, { models }) {
export async function imageFeesInfo (s3Keys, { models, me }) { export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format: // returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt } // { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys) const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const imageFee = msatsToSats(info.imageFeeMsats) const imageFee = msatsToSats(info.imageFeeMsats)
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats) const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats) const totalFees = msatsToSats(totalFeesMsats)

View File

@ -8,14 +8,14 @@ import domino from 'domino'
import { import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST, USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
} from '@/lib/constants' } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } from '@/lib/webPush' import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention, notifyItemMention } from '@/lib/webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time' import { datePivot, whenRange } from '@/lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { imageFeesInfo, uploadIdsFromText } from './image'
@ -876,7 +876,7 @@ export default {
models.$queryRaw` models.$queryRaw`
SELECT SELECT
item_act(${Number(id)}::INTEGER, item_act(${Number(id)}::INTEGER,
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`, ${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats } { models, lnd, me, hash, hmac, fee: sats }
) )
} }
@ -1004,8 +1004,7 @@ export default {
} }
const options = await models.$queryRaw` const options = await models.$queryRaw`
SELECT "PollOption".id, option, count("PollVote"."userId")::INTEGER as count, SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
FROM "PollOption" FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
WHERE "PollOption"."itemId" = ${item.id} WHERE "PollOption"."itemId" = ${item.id}
@ -1013,9 +1012,16 @@ export default {
ORDER BY "PollOption".id ASC ORDER BY "PollOption".id ASC
` `
const meVoted = await models.pollBlindVote.findFirst({
where: {
userId: me?.id,
itemId: item.id
}
})
const poll = {} const poll = {}
poll.options = options poll.options = options
poll.meVoted = options.some(o => o.meVoted) poll.meVoted = !!meVoted
poll.count = options.reduce((t, o) => t + o.count, 0) poll.count = options.reduce((t, o) => t + o.count, 0)
return poll return poll
@ -1157,7 +1163,7 @@ export default {
return parent.otsHash return parent.otsHash
}, },
deleteScheduledAt: async (item, args, { me, models }) => { deleteScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId) { if (meId !== item.userId) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized // Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null return null
@ -1166,8 +1172,8 @@ export default {
return deleteJobs[0]?.startafter ?? null return deleteJobs[0]?.startafter ?? null
}, },
reminderScheduledAt: async (item, args, { me, models }) => { reminderScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId || meId === ANON_USER_ID) { if (meId !== item.userId || meId === USER_ID.anon) {
// don't show reminders on an item if it isn't yours // don't show reminders on an item if it isn't yours
// don't support reminders for ANON // don't support reminders for ANON
return null return null
@ -1179,6 +1185,7 @@ export default {
} }
const namePattern = /\B@[\w_]+/gi const namePattern = /\B@[\w_]+/gi
const refPattern = new RegExp(`(?:#|${process.env.NEXT_PUBLIC_URL}/items/)(?<id>\\d+)`, 'gi')
export const createMentions = async (item, models) => { export const createMentions = async (item, models) => {
// if we miss a mention, in the rare circumstance there's some kind of // if we miss a mention, in the rare circumstance there's some kind of
@ -1188,9 +1195,25 @@ export const createMentions = async (item, models) => {
return return
} }
// user mentions
try { try {
await createUserMentions(item, models)
} catch (e) {
console.error('user mention failure', e)
}
// item mentions
try {
await createItemMentions(item, models)
} catch (e) {
console.error('item mention failure', e)
}
}
const createUserMentions = async (item, models) => {
const mentions = item.text.match(namePattern)?.map(m => m.slice(1)) const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) { if (!mentions || mentions.length === 0) return
const users = await models.user.findMany({ const users = await models.user.findMany({
where: { where: {
name: { in: mentions }, name: { in: mentions },
@ -1219,18 +1242,63 @@ export const createMentions = async (item, models) => {
} }
}) })
} }
} catch (e) {
console.error('mention failure', e) const createItemMentions = async (item, models) => {
const refs = item.text.match(refPattern)?.map(m => {
if (m.startsWith('#')) return Number(m.slice(1))
// is not #<id> syntax but full URL
return Number(m.split('/').slice(-1)[0])
})
if (!refs || refs.length === 0) return
const referee = await models.item.findMany({
where: {
id: { in: refs },
// Don't create mentions for your own items
userId: { not: item.userId }
} }
})
referee.forEach(async r => {
const data = {
referrerId: item.id,
refereeId: r.id
}
const mention = await models.itemMention.upsert({
where: {
referrerId_refereeId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyItemMention({ models, referrerItem: item, refereeItem: r })
}
})
} }
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => { export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
// update iff this item belongs to me // update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } }) const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
if (Number(old.userId) !== Number(me?.id)) {
// author can always edit their own item
const mid = Number(me?.id)
const isMine = Number(old.userId) === mid
// allow admins to edit special items
const allowEdit = ITEM_ALLOW_EDITS.includes(old.id)
const adminEdit = SN_USER_IDS.includes(mid) && allowEdit
if (!isMine && !adminEdit) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
} }
if (subName && old.subName !== subName) {
const differentSub = subName && old.subName !== subName
if (differentSub) {
const sub = await models.sub.findUnique({ where: { name: subName } }) const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.freebie) { if (old.freebie) {
if (!sub.allowFreebies) { if (!sub.allowFreebies) {
@ -1244,10 +1312,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
// in case they lied about their existing boost // in case they lied about their existing boost
await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
if (!ITEM_ALLOW_EDITS.includes(old.id) && user.bioId !== old.id &&
!isJob(item) && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { // prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const myBio = user.bioId === old.id
const timer = Date.now() < new Date(old.createdAt).getTime() + 10 * 60_000
if (!allowEdit && !myBio && !timer) {
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
} }
@ -1266,7 +1337,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
} }
} }
item = { subName, userId: me.id, ...item } item = { subName, userId: old.userId, ...item }
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
const uploadIds = uploadIdsFromText(item.text, { models }) const uploadIds = uploadIdsFromText(item.text, { models })
@ -1303,7 +1374,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
item.subName = item.sub item.subName = item.sub
delete item.sub delete item.sub
item.userId = me ? Number(me.id) : ANON_USER_ID item.userId = me ? Number(me.id) : USER_ID.anon
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
if (item.url && !isJob(item)) { if (item.url && !isJob(item)) {
@ -1367,7 +1438,7 @@ const enqueueDeletionJob = async (item, models) => {
} }
const deleteReminderAndJob = async ({ me, item, models }) => { const deleteReminderAndJob = async ({ me, item, models }) => {
if (me?.id && me.id !== ANON_USER_ID) { if (me?.id && me.id !== USER_ID.anon) {
await models.$transaction([ await models.$transaction([
models.$queryRawUnsafe(` models.$queryRawUnsafe(`
DELETE FROM pgboss.job DELETE FROM pgboss.job
@ -1389,7 +1460,7 @@ const deleteReminderAndJob = async ({ me, item, models }) => {
const createReminderAndJob = async ({ me, item, models }) => { const createReminderAndJob = async ({ me, item, models }) => {
// disallow anon to use reminder // disallow anon to use reminder
if (!me || me.id === ANON_USER_ID) { if (!me || me.id === USER_ID.anon) {
return return
} }
const reminderCommand = getReminderCommand(item.text) const reminderCommand = getReminderCommand(item.text)

View File

@ -140,6 +140,22 @@ export default {
LIMIT ${LIMIT}` LIMIT ${LIMIT}`
) )
} }
// item mentions
if (meFull.noteItemMentions) {
itemDrivenQueries.push(
`SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type
FROM "ItemMention"
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id
${whereClause(
'"ItemMention".created_at < $2',
'"Referrer"."userId" <> $1',
'"Referee"."userId" = $1'
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
}
// Inner union to de-dupe item-driven notifications // Inner union to de-dupe item-driven notifications
queries.push( queries.push(
// Only record per item ID // Only record per item ID
@ -157,6 +173,7 @@ export default {
WHEN type = 'Reply' THEN 2 WHEN type = 'Reply' THEN 2
WHEN type = 'FollowActivity' THEN 3 WHEN type = 'FollowActivity' THEN 3
WHEN type = 'TerritoryPost' THEN 4 WHEN type = 'TerritoryPost' THEN 4
WHEN type = 'ItemMention' THEN 5
END ASC END ASC
)` )`
) )
@ -456,6 +473,9 @@ export default {
mention: async (n, args, { models }) => true, mention: async (n, args, { models }) => true,
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
}, },
ItemMention: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
InvoicePaid: { InvoicePaid: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
}, },

View File

@ -1,7 +1,7 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { amountSchema, ssValidate } from '@/lib/validate' import { amountSchema, ssValidate } from '@/lib/validate'
import serialize from './serial' import serialize from './serial'
import { ANON_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import { getItem } from './item' import { getItem } from './item'
import { topUsers } from './user' import { topUsers } from './user'
@ -165,7 +165,7 @@ export default {
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })
await serialize( await serialize(
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`, models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || USER_ID.anon}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats } { models, lnd, me, hash, hmac, fee: sats }
) )

View File

@ -382,7 +382,10 @@ export default {
return await models.user.findUnique({ where: { id: sub.userId } }) return await models.user.findUnique({ where: { id: sub.userId } })
}, },
meMuteSub: async (sub, args, { models }) => { meMuteSub: async (sub, args, { models }) => {
return sub.meMuteSub || sub.MuteSub?.length > 0 if (sub.meMuteSub !== undefined) {
return sub.meMuteSub
}
return sub.MuteSub?.length > 0
}, },
nposts: async (sub, { when, from, to }, { models }) => { nposts: async (sub, { when, from, to }, { models }) => {
if (typeof sub.nposts !== 'undefined') { if (typeof sub.nposts !== 'undefined') {
@ -395,7 +398,11 @@ export default {
} }
}, },
meSubscription: async (sub, args, { me, models }) => { meSubscription: async (sub, args, { me, models }) => {
return sub.meSubscription || sub.SubSubscription?.length > 0 if (sub.meSubscription !== undefined) {
return sub.meSubscription
}
return sub.SubSubscription?.length > 0
}, },
createdAt: sub => sub.createdAt || sub.created_at createdAt: sub => sub.createdAt || sub.created_at
} }

View File

@ -1,5 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants' import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
import { createPresignedPost } from '@/api/s3' import { createPresignedPost } from '@/api/s3'
export default { export default {
@ -26,7 +26,7 @@ export default {
size, size,
width, width,
height, height,
userId: me?.id || ANON_USER_ID, userId: me?.id || USER_ID.anon,
paid: false paid: false
} }

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants' import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
import { viewGroup } from './growth' import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time' import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
@ -217,7 +217,7 @@ export default {
SELECT name SELECT name
FROM users FROM users
WHERE ( WHERE (
id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID}) id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete})
) )
AND SIMILARITY(name, ${q}) > 0.1 AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC ORDER BY SIMILARITY(name, ${q}) DESC
@ -347,6 +347,26 @@ export default {
} }
} }
if (user.noteItemMentions) {
const [newMentions] = await models.$queryRawUnsafe(`
SELECT EXISTS(
SELECT *
FROM "ItemMention"
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
JOIN "Item" ON "ItemMention"."referrerId" = "Item".id
${whereClause(
'"ItemMention".created_at > $2',
'"Item"."userId" <> $1',
'"Referee"."userId" = $1',
await filterClause(me, models),
muteClause(me)
)})`, me.id, lastChecked)
if (newMentions.exists) {
foundNotes()
return true
}
}
if (user.noteForwardedSats) { if (user.noteForwardedSats) {
const [newFwdSats] = await models.$queryRawUnsafe(` const [newFwdSats] = await models.$queryRawUnsafe(`
SELECT EXISTS( SELECT EXISTS(
@ -518,7 +538,7 @@ export default {
return await models.$queryRaw` return await models.$queryRaw`
SELECT * SELECT *
FROM users FROM users
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID})) WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}))
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}` AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
}, },
userStatsActions: async (parent, { when, from, to }, { me, models }) => { userStatsActions: async (parent, { when, from, to }, { me, models }) => {

View File

@ -7,7 +7,7 @@ import { SELECT } from './item'
import { lnAddrOptions } from '@/lib/lnurl' import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format' import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate' import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants' import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
@ -28,7 +28,7 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
} }
if (inv.user.id === ANON_USER_ID) { if (inv.user.id === USER_ID.anon) {
return inv return inv
} }
if (!me) { if (!me) {
@ -339,7 +339,7 @@ export default {
expirePivot = { seconds: Math.min(expireSecs, 180) } expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = ANON_USER_ID id = USER_ID.anon
} }
const user = await models.user.findUnique({ where: { id } }) const user = await models.user.findUnique({ where: { id } })

View File

@ -46,7 +46,6 @@ export default gql`
id: ID, id: ID,
option: String! option: String!
count: Int! count: Int!
meVoted: Boolean!
} }
type Poll { type Poll {

View File

@ -43,6 +43,12 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type ItemMention {
id: ID!
item: Item!
sortTime: Date!
}
type Invitification { type Invitification {
id: ID! id: ID!
invite: Invite! invite: Invite!
@ -130,7 +136,7 @@ export default gql`
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | TerritoryPost | TerritoryTransfer | Reminder | ItemMention
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -95,6 +95,7 @@ export default gql`
noteItemSats: Boolean! noteItemSats: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteMentions: Boolean! noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean! nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!
@ -161,6 +162,7 @@ export default gql`
noteItemSats: Boolean! noteItemSats: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteMentions: Boolean! noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean! nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!

View File

@ -105,3 +105,6 @@ cointastical,issue,#1191,#134,medium,,,,22k,cointastical@stacker.news,2024-05-28
kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,nichro@getalby.com,2024-05-28 kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,nichro@getalby.com,2024-05-28
OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28 OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28
tsmith123,pr,#1207,#837,easy,high,1,,180k,stickymarch60@walletofsatoshi.com,2024-05-31 tsmith123,pr,#1207,#837,easy,high,1,,180k,stickymarch60@walletofsatoshi.com,2024-05-31
SatsAllDay,pr,#1214,#1199,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-06-03
SatsAllDay,pr,#1197,#1192,medium,,,,250k,weareallsatoshi@getalby.com,2024-06-03
tsmith123,pr,#1216,#1213,easy,,1,,90k,stickymarch60@walletofsatoshi.com,2024-06-03

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
105 kravhen pr #1198 #1180 good-first-issue required linting 18k nichro@getalby.com 2024-05-28
106 OneOneSeven117 issue #1198 #1180 good-first-issue required linting 2k OneOneSeven@stacker.news 2024-05-28
107 tsmith123 pr #1207 #837 easy high 1 180k stickymarch60@walletofsatoshi.com 2024-05-31
108 SatsAllDay pr #1214 #1199 good-first-issue 20k weareallsatoshi@getalby.com 2024-06-03
109 SatsAllDay pr #1197 #1192 medium 250k weareallsatoshi@getalby.com 2024-06-03
110 tsmith123 pr #1216 #1213 easy 1 90k stickymarch60@walletofsatoshi.com 2024-06-03

View File

@ -2,7 +2,7 @@ import { useApolloClient } from '@apollo/client'
import { useMe } from './me' import { useMe } from './me'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { datePivot, timeSince } from '@/lib/time' import { datePivot, timeSince } from '@/lib/time'
import { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item from './item' import Item from './item'
import { RootProvider } from './root' import { RootProvider } from './root'
@ -25,7 +25,7 @@ export function ClientNotificationProvider ({ children }) {
const me = useMe() const me = useMe()
// anons don't have access to /notifications // anons don't have access to /notifications
// but we'll store client notifications anyway for simplicity's sake // but we'll store client notifications anyway for simplicity's sake
const storageKey = `client-notifications:${me?.id || ANON_USER_ID}` const storageKey = `client-notifications:${me?.id || USER_ID.anon}`
useEffect(() => { useEffect(() => {
const loaded = loadNotifications(storageKey, client) const loaded = loadNotifications(storageKey, client)

View File

@ -9,7 +9,7 @@ import Eye from '@/svgs/eye-fill.svg'
import EyeClose from '@/svgs/eye-close-line.svg' import EyeClose from '@/svgs/eye-close-line.svg'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import CommentEdit from './comment-edit' import CommentEdit from './comment-edit'
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants'
import PayBounty from './pay-bounty' import PayBounty from './pay-bounty'
import BountyIcon from '@/svgs/bounty-bag.svg' import BountyIcon from '@/svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
@ -128,9 +128,9 @@ export default function Comment ({
const bottomedOut = depth === COMMENT_DEPTH_LIMIT const bottomedOut = depth === COMMENT_DEPTH_LIMIT
// Don't show OP badge when anon user comments on anon user posts // Don't show OP badge when anon user comments on anon user posts
const op = root.user.name === item.user.name && Number(item.user.id) !== ANON_USER_ID const op = root.user.name === item.user.name && Number(item.user.id) !== USER_ID.anon
? 'OP' ? 'OP'
: root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== ANON_USER_ID : root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== USER_ID.anon
? 'fwd' ? 'fwd'
: null : null
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))

View File

@ -4,11 +4,11 @@ import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg' import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { AD_USER_ID, ANON_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) { export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === AD_USER_ID) return null if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === ANON_USER_ID) { if (Number(user.id) === USER_ID.anon) {
return ( return (
<HatTooltip overlayText='anonymous'> <HatTooltip overlayText='anonymous'>
{badge {badge

View File

@ -15,7 +15,7 @@ import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe' import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share' import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat' import Hat from './hat'
import { AD_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute' import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote' import { DropdownItemUpVote } from './upvote'
@ -58,7 +58,7 @@ export default function ItemInfo ({
return ( return (
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) && {!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
<> <>
<span title={`from ${numWithUnits(item.upvotes, { <span title={`from ${numWithUnits(item.upvotes, {
abbreviate: false, abbreviate: false,

View File

@ -2,7 +2,7 @@ import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import UpVote from './upvote' import UpVote from './upvote'
import { useRef } from 'react' import { useRef } from 'react'
import { AD_USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg' import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg'
@ -64,7 +64,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
? <Pin width={24} height={24} className={styles.pin} /> ? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats : item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} /> ? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === AD_USER_ID : Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} /> ? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />} : <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}> <div className={styles.hunk}>
@ -99,7 +99,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
full={full} item={item} full={full} item={item}
onQuoteReply={onQuoteReply} onQuoteReply={onQuoteReply}
pinnable={pinnable} pinnable={pinnable}
extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
{belowTitle} {belowTitle}
</div> </div>
@ -130,7 +130,7 @@ export function ItemSummary ({ item }) {
item={item} item={item}
showUser={false} showUser={false}
showActionDropdown={false} showActionDropdown={false}
extraBadges={item.title && Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={item.title && Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
) )

View File

@ -6,7 +6,7 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import Price from '../price' import Price from '../price'
import SubSelect from '../sub-select' import SubSelect from '../sub-select'
import { ANON_USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants' import { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants'
import Head from 'next/head' import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg' import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me' import { useMe } from '../me'
@ -307,7 +307,7 @@ export function AnonDropdown ({ path }) {
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'> <Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'> <Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@anon<Hat user={{ id: ANON_USER_ID }} /> @anon<Hat user={{ id: USER_ID.anon }} />
</Nav.Link> </Nav.Link>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className='p-3'> <Dropdown.Menu className='p-3'>

View File

@ -54,6 +54,7 @@ function Notification ({ n, fresh }) {
(type === 'Votification' && <Votification n={n} />) || (type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) || (type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) || (type === 'Mention' && <Mention n={n} />) ||
(type === 'ItemMention' && <ItemMention n={n} />) ||
(type === 'JobChanged' && <JobChanged n={n} />) || (type === 'JobChanged' && <JobChanged n={n} />) ||
(type === 'Reply' && <Reply n={n} />) || (type === 'Reply' && <Reply n={n} />) ||
(type === 'SubStatus' && <SubStatus n={n} />) || (type === 'SubStatus' && <SubStatus n={n} />) ||
@ -391,6 +392,26 @@ function Mention ({ n }) {
) )
} }
function ItemMention ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>
your item was mentioned in
</small>
<div>
{n.item?.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
</RootProvider>
</div>)}
</div>
</>
)
}
function JobChanged ({ n }) { function JobChanged ({ n }) {
return ( return (
<> <>

View File

@ -4,7 +4,6 @@ import { fixedDecimal, numWithUnits } from '@/lib/format'
import { timeLeft } from '@/lib/time' import { timeLeft } from '@/lib/time'
import { useMe } from './me' import { useMe } from './me'
import styles from './poll.module.css' import styles from './poll.module.css'
import Check from '@/svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants' import { POLL_COST } from '@/lib/constants'
@ -40,9 +39,6 @@ export default function Poll ({ item }) {
fields: { fields: {
count (existingCount) { count (existingCount) {
return existingCount + 1 return existingCount + 1
},
meVoted () {
return true
} }
} }
}) })
@ -121,7 +117,7 @@ export default function Poll ({ item }) {
function PollResult ({ v, progress }) { function PollResult ({ v, progress }) {
return ( return (
<div className={styles.pollResult}> <div className={styles.pollResult}>
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ms-1 align-self-center flex-shrink-0' width={16} height={16} />}</span> <span className={styles.pollOption}>{v.option}</span>
<span className='ms-auto me-2 align-self-center'>{progress}%</span> <span className='ms-auto me-2 align-self-center'>{progress}%</span>
<div className={styles.pollProgress} style={{ width: `${progress}%` }} /> <div className={styles.pollProgress} style={{ width: `${progress}%` }} />
</div> </div>

View File

@ -23,6 +23,7 @@ import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual' import isEqual from 'lodash/isEqual'
import UserPopover from './user-popover' import UserPopover from './user-popover'
import ItemPopover from './item-popover' import ItemPopover from './item-popover'
import ref from '@/lib/remark-ref2link'
export function SearchText ({ text }) { export function SearchText ({ text }) {
return ( return (
@ -186,6 +187,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
} catch { } catch {
// ignore invalid URLs // ignore invalid URLs
} }
const internalURL = process.env.NEXT_PUBLIC_URL const internalURL = process.env.NEXT_PUBLIC_URL
if (!!text && !/^https?:\/\//.test(text)) { if (!!text && !/^https?:\/\//.test(text)) {
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') { if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
@ -210,6 +212,19 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</UserPopover> </UserPopover>
) )
} else if (href.startsWith('/') || url?.origin === internalURL) { } else if (href.startsWith('/') || url?.origin === internalURL) {
try {
const linkText = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{text}</Link>
</ItemPopover>
)
}
} catch {
// ignore errors like invalid URLs
}
return ( return (
<Link <Link
id={props.id} id={props.id}
@ -284,7 +299,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
}, },
img: Img img: Img
}} }}
remarkPlugins={[gfm, mention, sub]} remarkPlugins={[gfm, mention, sub, ref]}
rehypePlugins={[rehypeInlineCodeProperty]} rehypePlugins={[rehypeInlineCodeProperty]}
> >
{children} {children}

View File

@ -126,7 +126,6 @@ export const POLL_FIELDS = gql`
id id
option option
count count
meVoted
} }
} }
}` }`

View File

@ -25,6 +25,14 @@ export const NOTIFICATIONS = gql`
text text
} }
} }
... on ItemMention {
id
sortTime
item {
...ItemFullFields
text
}
}
... on Votification { ... on Votification {
id id
sortTime sortTime

View File

@ -38,6 +38,7 @@ export const ME = gql`
noteItemSats noteItemSats
noteJobIndicator noteJobIndicator
noteMentions noteMentions
noteItemMentions
sats sats
tipDefault tipDefault
tipPopover tipPopover
@ -73,6 +74,7 @@ export const SETTINGS_FIELDS = gql`
noteEarning noteEarning
noteAllDescendants noteAllDescendants
noteMentions noteMentions
noteItemMentions
noteDeposits noteDeposits
noteWithdrawals noteWithdrawals
noteInvites noteInvites

View File

@ -36,8 +36,16 @@ export const ITEM_SPAM_INTERVAL = '10m'
export const ANON_ITEM_SPAM_INTERVAL = '0' export const ANON_ITEM_SPAM_INTERVAL = '0'
export const INV_PENDING_LIMIT = 100 export const INV_PENDING_LIMIT = 100
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
export const SN_USER_IDS = [616, 6030, 4502, 27] export const USER_ID = {
export const SN_NO_REWARDS_IDS = [27, 4502] k00b: 616,
ek: 6030,
sn: 4502,
anon: 27,
ad: 9,
delete: 106
}
export const SN_USER_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn]
export const ANON_INV_PENDING_LIMIT = 1000 export const ANON_INV_PENDING_LIMIT = 1000
export const ANON_BALANCE_LIMIT_MSATS = 0 // disable export const ANON_BALANCE_LIMIT_MSATS = 0 // disable
export const MAX_POLL_NUM_CHOICES = 10 export const MAX_POLL_NUM_CHOICES = 10
@ -54,17 +62,14 @@ export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links',
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs'] export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']
export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies'] export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
export const OLD_ITEM_DAYS = 3 export const OLD_ITEM_DAYS = 3
export const ANON_USER_ID = 27
export const DELETE_USER_ID = 106
export const AD_USER_ID = 9
export const ANON_FEE_MULTIPLIER = 100 export const ANON_FEE_MULTIPLIER = 100
export const SSR = typeof window === 'undefined' export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5 export const MAX_FORWARDS = 5
export const LNURLP_COMMENT_MAX_LENGTH = 1000 export const LNURLP_COMMENT_MAX_LENGTH = 1000
export const RESERVED_MAX_USER_ID = 615 export const RESERVED_MAX_USER_ID = 615
export const GLOBAL_SEED = 616 export const GLOBAL_SEED = USER_ID.k00b
export const FREEBIE_BASE_COST_THRESHOLD = 10 export const FREEBIE_BASE_COST_THRESHOLD = 10
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, AD_USER_ID] export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, USER_ID.anon, USER_ID.ad]
// WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information // WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information
// From lawyers: north korea, cuba, iran, ukraine, syria // From lawyers: north korea, cuba, iran, ukraine, syria

26
lib/remark-ref2link.js Normal file
View File

@ -0,0 +1,26 @@
import { findAndReplace } from 'mdast-util-find-and-replace'
const refRegex = /#(\d+(\/(edit|related|ots))?)/gi
export default function ref (options) {
return function transformer (tree) {
findAndReplace(
tree,
[
[refRegex, replaceRef]
],
{ ignore: ['link', 'linkReference'] }
)
}
function replaceRef (value, itemId, match) {
const node = { type: 'text', value }
return {
type: 'link',
title: null,
url: `/items/${itemId}`,
children: [node]
}
}
}

View File

@ -26,14 +26,22 @@ export function removeTracking (value) {
/** /**
* parse links like https://stacker.news/items/123456 as #123456 * parse links like https://stacker.news/items/123456 as #123456
*/ */
export function isItemPath (pathname) {
if (!pathname) return false
const [page, id] = pathname.split('/').filter(part => !!part)
return page === 'items' && /^[0-9]+$/.test(id)
}
export function parseInternalLinks (href) { export function parseInternalLinks (href) {
const url = new URL(href) const url = new URL(href)
const internalURL = process.env.NEXT_PUBLIC_URL const internalURL = process.env.NEXT_PUBLIC_URL
const { pathname, searchParams } = url const { pathname, searchParams } = url
// ignore empty parts which exist due to pathname starting with '/' // ignore empty parts which exist due to pathname starting with '/'
const emptyPart = part => !!part if (isItemPath(pathname) && url.origin === internalURL) {
const parts = pathname.split('/').filter(emptyPart) const parts = pathname.split('/').filter(part => !!part)
if (parts[0] === 'items' && /^[0-9]+$/.test(parts[1]) && url.origin === internalURL) {
const itemId = parts[1] const itemId = parts[1]
// check for valid item page due to referral links like /items/123456/r/ekzyis // check for valid item page due to referral links like /items/123456/r/ekzyis
const itemPages = ['edit', 'ots', 'related'] const itemPages = ['edit', 'ots', 'related']

View File

@ -1,6 +1,6 @@
import webPush from 'web-push' import webPush from 'web-push'
import removeMd from 'remove-markdown' import removeMd from 'remove-markdown'
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants' import { USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
import { msatsToSats, numWithUnits } from './format' import { msatsToSats, numWithUnits } from './format'
import models from '@/api/models' import models from '@/api/models'
import { isMuted } from '@/lib/user' import { isMuted } from '@/lib/user'
@ -38,6 +38,7 @@ const createUserFilter = (tag) => {
const tagMap = { const tagMap = {
REPLY: 'noteAllDescendants', REPLY: 'noteAllDescendants',
MENTION: 'noteMentions', MENTION: 'noteMentions',
ITEM_MENTION: 'noteItemMentions',
TIP: 'noteItemSats', TIP: 'noteItemSats',
FORWARDEDTIP: 'noteForwardedSats', FORWARDEDTIP: 'noteForwardedSats',
REFERRAL: 'noteInvites', REFERRAL: 'noteInvites',
@ -179,7 +180,7 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
export const notifyItemParents = async ({ models, item, me }) => { export const notifyItemParents = async ({ models, item, me }) => {
try { try {
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) const user = await models.user.findUnique({ where: { id: me?.id || USER_ID.anon } })
const parents = await models.$queryRawUnsafe( const parents = await models.$queryRawUnsafe(
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' + 'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)', 'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)',
@ -262,6 +263,27 @@ export const notifyMention = async ({ models, userId, item }) => {
} }
} }
export const notifyItemMention = async ({ 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,
item: referrerItem,
tag: 'ITEM_MENTION'
})
}
} catch (err) {
console.error(err)
}
}
export const notifyReferral = async (userId) => { export const notifyReferral = async (userId) => {
try { try {
await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }) await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' })

View File

@ -120,6 +120,7 @@ export default function Settings ({ ssrData }) {
noteEarning: settings?.noteEarning, noteEarning: settings?.noteEarning,
noteAllDescendants: settings?.noteAllDescendants, noteAllDescendants: settings?.noteAllDescendants,
noteMentions: settings?.noteMentions, noteMentions: settings?.noteMentions,
noteItemMentions: settings?.noteItemMentions,
noteDeposits: settings?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteWithdrawals: settings?.noteWithdrawals, noteWithdrawals: settings?.noteWithdrawals,
noteInvites: settings?.noteInvites, noteInvites: settings?.noteInvites,
@ -280,6 +281,11 @@ export default function Settings ({ ssrData }) {
name='noteMentions' name='noteMentions'
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox
label='someone mentions one of my items'
name='noteItemMentions'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label='there is a new job' label='there is a new job'
name='noteJobIndicator' name='noteJobIndicator'

View File

@ -0,0 +1,92 @@
-- CreateTable
CREATE TABLE "PollBlindVote" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"itemId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "PollBlindVote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PollBlindVote.userId_index" ON "PollBlindVote"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PollBlindVote.itemId_userId_unique" ON "PollBlindVote"("itemId", "userId");
-- AddForeignKey
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- migrate existing poll votes
INSERT INTO "PollBlindVote" ("itemId", "userId")
SELECT "itemId", "userId" FROM "PollVote";
/*
Warnings:
- You are about to drop the column `userId` on the `PollVote` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "PollVote" DROP CONSTRAINT "PollVote_userId_fkey";
-- DropIndex
DROP INDEX "PollVote.itemId_userId_unique";
-- DropIndex
DROP INDEX "PollVote.userId_index";
-- AlterTable
ALTER TABLE "PollVote" DROP COLUMN "userId";
-- update `poll_vote` function to update both "PollVote" and "PollBlindVote" tables
-- create poll vote
-- if user hasn't already voted
-- charges user item.pollCost
-- adds POLL to ItemAct
-- adds PollVote
-- adds PollBlindVote
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option "PollOption";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO option FROM "PollOption" where id = option_id;
IF option IS NULL THEN
RAISE EXCEPTION 'INVALID_POLL_OPTION';
END IF;
SELECT * INTO item FROM "Item" where id = option."itemId";
IF item IS NULL THEN
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
END IF;
IF item."userId" = user_id THEN
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
END IF;
-- no longer check `PollVote` to see if a user has voted. Instead, check `PollBlindVote`
IF EXISTS (SELECT 1 FROM "PollBlindVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
END IF;
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId")
VALUES (now_utc(), now_utc(), item.id, option_id);
INSERT INTO "PollBlindVote" (created_at, updated_at, "itemId", "userId")
VALUES (now_utc(), now_utc(), item.id, user_id);
RETURN item;
END;
$$;

View File

@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ItemMention" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"referrerId" INTEGER NOT NULL,
"refereeId" INTEGER NOT NULL,
CONSTRAINT "ItemMention_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ItemMention.created_at_index" ON "ItemMention"("created_at");
-- CreateIndex
CREATE INDEX "ItemMention.referrerId_index" ON "ItemMention"("referrerId");
-- CreateIndex
CREATE INDEX "ItemMention.refereeId_index" ON "ItemMention"("refereeId");
-- CreateIndex
CREATE UNIQUE INDEX "ItemMention.referrerId_refereeId_unique" ON "ItemMention"("referrerId", "refereeId");
-- AddForeignKey
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "noteItemMentions" BOOLEAN NOT NULL DEFAULT true;

View File

@ -44,6 +44,7 @@ model User {
noteInvites Boolean @default(true) noteInvites Boolean @default(true)
noteItemSats Boolean @default(true) noteItemSats Boolean @default(true)
noteMentions Boolean @default(true) noteMentions Boolean @default(true)
noteItemMentions Boolean @default(true)
noteForwardedSats Boolean @default(true) noteForwardedSats Boolean @default(true)
lastCheckedJobs DateTime? lastCheckedJobs DateTime?
noteJobIndicator Boolean @default(true) noteJobIndicator Boolean @default(true)
@ -79,7 +80,6 @@ model User {
actions ItemAct[] actions ItemAct[]
mentions Mention[] mentions Mention[]
messages Message[] messages Message[]
PollVote PollVote[]
PushSubscriptions PushSubscription[] PushSubscriptions PushSubscription[]
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
Streak Streak[] Streak Streak[]
@ -125,6 +125,7 @@ model User {
Replies Reply[] Replies Reply[]
walletLogs WalletLog[] walletLogs WalletLog[]
Reminder Reminder[] Reminder Reminder[]
PollBlindVote PollBlindVote[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -411,6 +412,8 @@ model Item {
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade) user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
actions ItemAct[] actions ItemAct[]
mentions Mention[] mentions Mention[]
referrer ItemMention[] @relation("referrer")
referee ItemMention[] @relation("referee")
PollOption PollOption[] PollOption PollOption[]
PollVote PollVote[] PollVote PollVote[]
ThreadSubscription ThreadSubscription[] ThreadSubscription ThreadSubscription[]
@ -424,6 +427,7 @@ model Item {
Ancestors Reply[] @relation("AncestorReplyItem") Ancestors Reply[] @relation("AncestorReplyItem")
Replies Reply[] Replies Reply[]
Reminder Reminder[] Reminder Reminder[]
PollBlindVote PollBlindVote[]
@@index([uploadId]) @@index([uploadId])
@@index([lastZapAt]) @@index([lastZapAt])
@ -501,16 +505,25 @@ model PollVote {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
itemId Int itemId Int
pollOptionId Int pollOptionId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade) pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade)
@@index([pollOptionId], map: "PollVote.pollOptionId_index")
}
model PollBlindVote {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int
userId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([itemId, userId], map: "PollVote.itemId_userId_unique") @@unique([itemId, userId], map: "PollBlindVote.itemId_userId_unique")
@@index([pollOptionId], map: "PollVote.pollOptionId_index") @@index([userId], map: "PollBlindVote.userId_index")
@@index([userId], map: "PollVote.userId_index")
} }
enum BillingType { enum BillingType {
@ -661,6 +674,21 @@ model Mention {
@@index([userId], map: "Mention.userId_index") @@index([userId], map: "Mention.userId_index")
} }
model ItemMention {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
referrerId Int
refereeId Int
referrerItem Item @relation("referrer", fields: [referrerId], references: [id], onDelete: Cascade)
refereeItem Item @relation("referee", fields: [refereeId], references: [id], onDelete: Cascade)
@@unique([referrerId, refereeId], map: "ItemMention.referrerId_refereeId_unique")
@@index([createdAt], map: "ItemMention.created_at_index")
@@index([referrerId], map: "ItemMention.referrerId_index")
@@index([refereeId], map: "ItemMention.refereeId_index")
}
model Invoice { model Invoice {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

38
sndev
View File

@ -219,6 +219,43 @@ USAGE
echo "$help" echo "$help"
} }
sndev__fund_user() {
shift
if [ -z "$1" ]; then
echo "<nym> argument required"
sndev__help_fund_user
exit 1
fi
if [ -z "$2" ]; then
echo "<msats> argument required"
sndev__help_fund_user
exit 2
fi
re='^[0-9]+$'
if ! [[ $2 =~ $re ]]; then
echo "<msats> is not a positive integer"
sndev__help_fund_user
exit 3
fi
docker__exec db psql -U sn -d stackernews -q <<EOF
UPDATE users set msats = $2 where name = '$1';
EOF
}
sndev__help_fund_user() {
help="
fund a nym without using an LN invoice (local only)
USAGE
$ sndev fund_user <nym> <msats>
<nym> - the name of the user you want to fund
<msats> - the amount of millisatoshis to set the account to. Must be >= 0
"
echo "$help"
}
sndev__fund() { sndev__fund() {
shift shift
docker__stacker_lnd -t payinvoice "$@" docker__stacker_lnd -t payinvoice "$@"
@ -516,6 +553,7 @@ COMMANDS
sn: sn:
login login as a nym login login as a nym
fund_user fund a nym without using an LN invoice
lnd: lnd:
fund pay a bolt11 for funding fund pay a bolt11 for funding

View File

@ -114,7 +114,7 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
// merge notifications into single notification payload // merge notifications into single notification payload
// --- // ---
// tags that need to know the amount of notifications with same tag for merging // tags that need to know the amount of notifications with same tag for merging
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST'] const AMOUNT_TAGS = ['REPLY', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
// tags that need to know the sum of sats of notifications with same tag for merging // tags that need to know the sum of sats of notifications with same tag for merging
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL'] const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
// this should reflect the amount of notifications that were already merged before // this should reflect the amount of notifications that were already merged before
@ -143,6 +143,8 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
title = `you have ${amount} new replies` title = `you have ${amount} new replies`
} else if (compareTag === 'MENTION') { } else if (compareTag === 'MENTION') {
title = `you were mentioned ${amount} times` title = `you were mentioned ${amount} times`
} else if (compareTag === 'ITEM_MENTION') {
title = `your items were mentioned ${amount} times`
} else if (compareTag === 'REFERRAL') { } else if (compareTag === 'REFERRAL') {
title = `${amount} stackers joined via your referral links` title = `${amount} stackers joined via your referral links`
} else if (compareTag === 'INVITE') { } else if (compareTag === 'INVITE') {

View File

@ -1,5 +1,5 @@
import { deleteObjects } from '@/api/s3' import { deleteObjects } from '@/api/s3'
import { ANON_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
export async function deleteUnusedImages ({ models }) { export async function deleteUnusedImages ({ models }) {
// delete all images in database and S3 which weren't paid in the last 24 hours // delete all images in database and S3 which weren't paid in the last 24 hours
@ -14,7 +14,7 @@ export async function deleteUnusedImages ({ models }) {
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id) AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id) AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
)) ))
AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${ANON_USER_ID} THEN interval '1 hour' ELSE interval '24 hours' END)` AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${USER_ID.anon} THEN interval '1 hour' ELSE interval '24 hours' END)`
const s3Keys = unpaidImages.map(({ id }) => id) const s3Keys = unpaidImages.map(({ id }) => id)
if (s3Keys.length === 0) { if (s3Keys.length === 0) {

View File

@ -1,5 +1,5 @@
import * as math from 'mathjs' import * as math from 'mathjs'
import { ANON_USER_ID, SN_USER_IDS } from '@/lib/constants.js' import { USER_ID, SN_USER_IDS } from '@/lib/constants.js'
export async function trust ({ boss, models }) { export async function trust ({ boss, models }) {
try { try {
@ -127,7 +127,7 @@ async function getGraph (models) {
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS') JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId" AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${ANON_USER_ID} JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
GROUP BY user_id, name, item_id, user_at, against GROUP BY user_id, name, item_id, user_at, against
HAVING CASE WHEN HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN} "ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}