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>
This commit is contained in:
ekzyis 2024-06-03 11:26:19 -05:00 committed by GitHub
parent e9aa268996
commit 86b857b8d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 75 additions and 56 deletions

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'
export default {
@ -17,7 +17,7 @@ export function uploadIdsFromText (text, { models }) {
export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format:
// { 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 totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats)

View File

@ -8,8 +8,8 @@ import domino from 'domino'
import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL
USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
@ -876,7 +876,7 @@ export default {
models.$queryRaw`
SELECT
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 }
)
}
@ -1157,7 +1157,7 @@ export default {
return parent.otsHash
},
deleteScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null
@ -1166,8 +1166,8 @@ export default {
return deleteJobs[0]?.startafter ?? null
},
reminderScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
if (meId !== item.userId || meId === ANON_USER_ID) {
const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId || meId === USER_ID.anon) {
// don't show reminders on an item if it isn't yours
// don't support reminders for ANON
return null
@ -1227,10 +1227,21 @@ export const createMentions = async (item, models) => {
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
// update iff this item belongs to me
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' } })
}
if (subName && old.subName !== subName) {
const differentSub = subName && old.subName !== subName
if (differentSub) {
const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.freebie) {
if (!sub.allowFreebies) {
@ -1244,10 +1255,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
// in case they lied about their existing 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 } })
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' } })
}
@ -1266,7 +1280,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 uploadIds = uploadIdsFromText(item.text, { models })
@ -1303,7 +1317,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
item.subName = 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)
if (item.url && !isJob(item)) {
@ -1367,7 +1381,7 @@ const enqueueDeletionJob = async (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([
models.$queryRawUnsafe(`
DELETE FROM pgboss.job
@ -1389,7 +1403,7 @@ const deleteReminderAndJob = async ({ me, item, models }) => {
const createReminderAndJob = async ({ me, item, models }) => {
// disallow anon to use reminder
if (!me || me.id === ANON_USER_ID) {
if (!me || me.id === USER_ID.anon) {
return
}
const reminderCommand = getReminderCommand(item.text)

View File

@ -1,7 +1,7 @@
import { GraphQLError } from 'graphql'
import { amountSchema, ssValidate } from '@/lib/validate'
import serialize from './serial'
import { ANON_USER_ID } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
import { getItem } from './item'
import { topUsers } from './user'
@ -165,7 +165,7 @@ export default {
await ssValidate(amountSchema, { amount: sats })
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 }
)

View File

@ -1,5 +1,5 @@
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'
export default {
@ -26,7 +26,7 @@ export default {
size,
width,
height,
userId: me?.id || ANON_USER_ID,
userId: me?.id || USER_ID.anon,
paid: false
}

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
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 { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
@ -217,7 +217,7 @@ export default {
SELECT name
FROM users
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
ORDER BY SIMILARITY(name, ${q}) DESC
@ -518,7 +518,7 @@ export default {
return await models.$queryRaw`
SELECT *
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}`
},
userStatsActions: async (parent, { when, from, to }, { me, models }) => {

View File

@ -7,7 +7,7 @@ import { SELECT } from './item'
import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
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 assertGofacYourself from './ofac'
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' } })
}
if (inv.user.id === ANON_USER_ID) {
if (inv.user.id === USER_ID.anon) {
return inv
}
if (!me) {
@ -339,7 +339,7 @@ export default {
expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = ANON_USER_ID
id = USER_ID.anon
}
const user = await models.user.findUnique({ where: { id } })

View File

@ -2,7 +2,7 @@ import { useApolloClient } from '@apollo/client'
import { useMe } from './me'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
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 Item from './item'
import { RootProvider } from './root'
@ -25,7 +25,7 @@ export function ClientNotificationProvider ({ children }) {
const me = useMe()
// anons don't have access to /notifications
// 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(() => {
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 { useRouter } from 'next/router'
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 BountyIcon from '@/svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip'
@ -128,9 +128,9 @@ export default function Comment ({
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
// 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'
: 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'
: null
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 AnonIcon from '@/svgs/spy-fill.svg'
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 }) {
if (!user || Number(user.id) === AD_USER_ID) return null
if (Number(user.id) === ANON_USER_ID) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
<HatTooltip overlayText='anonymous'>
{badge

View File

@ -15,7 +15,7 @@ import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat'
import { AD_USER_ID } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote'
@ -58,7 +58,7 @@ export default function ItemInfo ({
return (
<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, {
abbreviate: false,

View File

@ -2,7 +2,7 @@ import Link from 'next/link'
import styles from './item.module.css'
import UpVote from './upvote'
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 reactStringReplace from 'react-string-replace'
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} />
: item.meDontLikeSats > item.meSats
? <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} />
: <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}>
@ -99,7 +99,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
full={full} item={item}
onQuoteReply={onQuoteReply}
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}
</div>
@ -130,7 +130,7 @@ export function ItemSummary ({ item }) {
item={item}
showUser={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 Price from '../price'
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 NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
@ -307,7 +307,7 @@ export function AnonDropdown ({ path }) {
<Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<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>
</Dropdown.Toggle>
<Dropdown.Menu className='p-3'>

View File

@ -36,8 +36,16 @@ export const ITEM_SPAM_INTERVAL = '10m'
export const ANON_ITEM_SPAM_INTERVAL = '0'
export const INV_PENDING_LIMIT = 100
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
export const SN_USER_IDS = [616, 6030, 4502, 27]
export const SN_NO_REWARDS_IDS = [27, 4502]
export const USER_ID = {
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_BALANCE_LIMIT_MSATS = 0 // disable
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_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
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 SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5
export const LNURLP_COMMENT_MAX_LENGTH = 1000
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 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
// From lawyers: north korea, cuba, iran, ukraine, syria

View File

@ -1,6 +1,6 @@
import webPush from 'web-push'
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 models from '@/api/models'
import { isMuted } from '@/lib/user'
@ -179,7 +179,7 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
export const notifyItemParents = async ({ models, item, me }) => {
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(
'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)',

View File

@ -1,5 +1,5 @@
import { deleteObjects } from '@/api/s3'
import { ANON_USER_ID } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
export async function deleteUnusedImages ({ models }) {
// 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 "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)
if (s3Keys.length === 0) {

View File

@ -1,5 +1,5 @@
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 }) {
try {
@ -127,7 +127,7 @@ async function getGraph (models) {
FROM "ItemAct"
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"
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
HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}