From 370e3c1c484266d6d98b5a595c8ce8bc360fc442 Mon Sep 17 00:00:00 2001 From: keyan Date: Mon, 25 Sep 2023 19:54:35 -0500 Subject: [PATCH] raise boost minimum to 25k, enhance editing boost, JIT fund editing costs --- api/resolvers/item.js | 59 +++++++++++++++++++++++++---------- api/resolvers/user.js | 2 +- components/adv-post-form.js | 16 +++++----- components/bounty-form.js | 4 +-- components/discussion-form.js | 4 +-- components/fee-button.js | 4 +-- components/link-form.js | 4 +-- components/poll-form.js | 4 +-- components/user-header.js | 2 +- lib/constants.js | 3 +- lib/validate.js | 53 +++++++++++++++++-------------- 11 files changed, 95 insertions(+), 60 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index e9786096..df7dd752 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -12,7 +12,7 @@ import { import { msatsToSats, numWithUnits } from '../../lib/format' import { parse } from 'tldts' import uu from 'url-unshort' -import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' +import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' @@ -624,34 +624,34 @@ export default { return await models.item.update({ where: { id: Number(id) }, data }) }, upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { - await ssValidate(linkSchema, item, models, me) + await ssValidate(linkSchema, item, { models, me }) if (id) { - return await updateItem(parent, { id, ...item }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) } else { return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { - await ssValidate(discussionSchema, item, models, me) + await ssValidate(discussionSchema, item, { models, me }) if (id) { - return await updateItem(parent, { id, ...item }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) } else { return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { - await ssValidate(bountySchema, item, models, me) + await ssValidate(bountySchema, item, { models, me }) if (id) { - return await updateItem(parent, { id, ...item }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) } else { return await createItem(parent, item, { me, models, lnd, hash, hmac }) } }, upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => { - const optionCount = id + const numExistingChoices = id ? await models.pollOption.count({ where: { itemId: Number(id) @@ -659,10 +659,10 @@ export default { }) : 0 - await ssValidate(pollSchema, item, models, me, optionCount) + await ssValidate(pollSchema, item, { models, me, numExistingChoices }) if (id) { - return await updateItem(parent, { id, ...item }, { me, models }) + return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac }) } else { item.pollCost = item.pollCost || POLL_COST return await createItem(parent, item, { me, models, lnd, hash, hmac }) @@ -674,7 +674,7 @@ export default { } item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location - await ssValidate(jobSchema, item, models) + await ssValidate(jobSchema, item, { models }) if (item.logo !== undefined) { item.uploadId = item.logo delete item.logo @@ -1119,13 +1119,15 @@ export const createMentions = async (item, models) => { } } -export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, 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) } }) if (Number(old.userId) !== Number(me?.id)) { throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) } + await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) + // if it's not the FAQ, not their bio, and older than 10 minutes const user = await models.user.findUnique({ where: { id: me.id } }) if (![349, 76894, 78763, 81862].includes(old.id) && user.bioId !== old.id && @@ -1141,18 +1143,43 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it item.url = removeTracking(item.url) item.url = await proxyImages(item.url) } + // only update item with the boost delta ... this is a bit of hack given the way + // boost used to work + if (item.boost > 0 && old.boost > 0) { + // only update the boost if it is higher than the old boost + if (item.boost > old.boost) { + item.boost = item.boost - old.boost + } else { + delete item.boost + } + } item = { subName, userId: me.id, ...item } const fwdUsers = await getForwardUsers(models, forward) - const [rItem] = await serialize(models, + let invoice + if (hash) { + invoice = await checkInvoice(models, hash, hmac) + } + + const trx = [ models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options))) + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)) + ] + if (invoice) { + trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`) + trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })) + } + + const query = await serialize(models, ...trx) + const rItem = trx.length > 1 ? query[1][0] : query[0] + + if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd }) await createMentions(rItem, models) - item.comments = [] - return item + rItem.comments = [] + return rItem } export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 9e2a5bf1..92155c73 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -484,7 +484,7 @@ export default { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } - await ssValidate(userSchema, data, models) + await ssValidate(userSchema, data, { models }) try { await models.user.update({ where: { id: me.id }, data }) diff --git a/components/adv-post-form.js b/components/adv-post-form.js index f67a4fce..0520aa88 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -1,21 +1,21 @@ import AccordianItem from './accordian-item' import { Input, InputUserSuggest, VariableInput } from './form' import InputGroup from 'react-bootstrap/InputGroup' -import { BOOST_MIN, MAX_FORWARDS } from '../lib/constants' +import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS } from '../lib/constants' import Info from './info' import { numWithUnits } from '../lib/format' import styles from './adv-post-form.module.css' const EMPTY_FORWARD = { nym: '', pct: '' } -export function AdvPostInitial ({ forward }) { +export function AdvPostInitial ({ forward, boost }) { return { - boost: '', + boost: boost || '', forward: forward?.length ? forward : [EMPTY_FORWARD] } } -export default function AdvPostForm ({ edit }) { +export default function AdvPostForm () { return ( options} @@ -23,17 +23,17 @@ export default function AdvPostForm ({ edit }) { <> {edit ? 'add boost' : 'boost'} +
boost
  1. Boost ranks posts higher temporarily based on the amount
  2. The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}
  3. -
  4. Each {numWithUnits(BOOST_MIN, { abbreviate: false })} of boost is equivalent to one trusted upvote +
  5. Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote
      -
    • e.g. {numWithUnits(BOOST_MIN * 2, { abbreviate: false })} is like 2 votes
    • +
    • e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes
  6. -
  7. The decay of boost "votes" increases at 2x the rate of organic votes +
  8. The decay of boost "votes" increases at 1.25x the rate of organic votes
    • i.e. boost votes fall out of ranking faster
    diff --git a/components/bounty-form.js b/components/bounty-form.js index bff28921..f1b14bf4 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -27,7 +27,7 @@ export function BountyForm ({ const router = useRouter() const client = useApolloClient() const me = useMe() - const schema = bountySchema(client, me) + const schema = bountySchema({ client, me, existingBoost: item?.boost }) const [upsertBounty] = useMutation( gql` mutation upsertBounty( @@ -89,7 +89,7 @@ export function BountyForm ({ title: item?.title || '', text: item?.text || '', bounty: item?.bounty || 1000, - ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }), + ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} diff --git a/components/discussion-form.js b/components/discussion-form.js index 72f57e27..2e561427 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -25,7 +25,7 @@ export function DiscussionForm ({ const router = useRouter() const client = useApolloClient() const me = useMe() - const schema = discussionSchema(client, me) + const schema = discussionSchema({ client, me, existingBoost: item?.boost }) // if Web Share Target API was used const shareTitle = router.query.title @@ -81,7 +81,7 @@ export function DiscussionForm ({ initial={{ title: item?.title || shareTitle || '', text: item?.text || '', - ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }), + ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} diff --git a/components/fee-button.js b/components/fee-button.js index 96f28473..9bc7dc72 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -137,7 +137,7 @@ function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) { const formik = useFormikContext() - const boost = formik?.values?.boost || 0 + const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0) const addImgLink = hasImgLink && !hadImgLink const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) @@ -148,7 +148,7 @@ export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, const show = alwaysShow || !formik?.isSubmitting return (
    - + = 0 ? cost : 0, { abbreviate: false })}> {text}{cost > 0 && show && {numWithUnits(cost, { abbreviate: false })}} {cost > 0 && show && diff --git a/components/link-form.js b/components/link-form.js index 0dbc0794..8770bb50 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -22,7 +22,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { const router = useRouter() const client = useApolloClient() const me = useMe() - const schema = linkSchema(client, me) + const schema = linkSchema({ client, me, existingBoost: item?.boost }) // if Web Share Target API was used const shareUrl = router.query.url const shareTitle = router.query.title @@ -123,7 +123,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { initial={{ title: item?.title || shareTitle || '', url: item?.url || shareUrl || '', - ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }), + ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} diff --git a/components/poll-form.js b/components/poll-form.js index 500657cf..6e678878 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -18,7 +18,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { const router = useRouter() const client = useApolloClient() const me = useMe() - const schema = pollSchema(client, me) + const schema = pollSchema({ client, me, existingBoost: item?.boost }) const [upsertPoll] = useMutation( gql` @@ -65,7 +65,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { title: item?.title || '', text: item?.text || '', options: initialOptions || ['', ''], - ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }), + ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} diff --git a/components/user-header.js b/components/user-header.js index 68b909a3..70dcd117 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -105,7 +105,7 @@ function NymEdit ({ user, setEditting }) { } }) const client = useApolloClient() - const schema = userSchema(client) + const schema = userSchema({ client }) return (
    s !== 'jobs') export const NOFOLLOW_LIMIT = 100 -export const BOOST_MIN = 5000 +export const BOOST_MULT = 5000 +export const BOOST_MIN = BOOST_MULT * 5 export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 export const IMAGE_PIXELS_MAX = 35000000 export const UPLOAD_TYPES_ALLOW = [ diff --git a/lib/validate.js b/lib/validate.js index b5dc562d..aebda199 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -1,14 +1,14 @@ import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup' -import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, SUBS_NO_JOBS, MAX_FORWARDS } from './constants' +import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, SUBS_NO_JOBS, MAX_FORWARDS, BOOST_MULT } from './constants' import { NAME_QUERY } from '../fragments/users' import { URL_REGEXP, WS_REGEXP } from './url' import { SUPPORTED_CURRENCIES } from './currency' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' -export async function ssValidate (schema, data, ...args) { +export async function ssValidate (schema, data, args) { try { if (typeof schema === 'function') { - await schema(...args).validate(data) + await schema(args).validate(data) } else { await schema.validate(data) } @@ -43,28 +43,29 @@ const titleValidator = string().required('required').trim().max( const intValidator = number().typeError('must be a number').integer('must be whole') -async function usernameExists (client, name) { - if (!client) { +async function usernameExists (name, { client, models }) { + if (!client && !models) { throw new Error('cannot check for user') } // apollo client - if (client.query) { + if (client) { const { data } = await client.query({ query: NAME_QUERY, variables: { name } }) return !data.nameAvailable } // prisma client - const user = await client.user.findUnique({ where: { name } }) + const user = await models.user.findUnique({ where: { name } }) return !!user } -export function advPostSchemaMembers (client, me) { +export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) { + const boostMin = existingBoost || BOOST_MIN return { boost: intValidator - .min(BOOST_MIN, `must be blank or at least ${BOOST_MIN}`).test({ + .min(boostMin, `must be ${existingBoost ? '' : 'blank or '}at least ${boostMin}`).test({ name: 'boost', - test: async boost => !boost || boost % BOOST_MIN === 0, - message: `must be divisble be ${BOOST_MIN}` + test: async boost => (!existingBoost && !boost) || boost % BOOST_MULT === 0, + message: `must be divisble be ${BOOST_MULT}` }), forward: array() .max(MAX_FORWARDS, `you can only configure ${MAX_FORWARDS} forward recipients`) @@ -74,7 +75,7 @@ export function advPostSchemaMembers (client, me) { name: 'nym', test: async name => { if (!name || !name.length) return false - return await usernameExists(client, name) + return await usernameExists(name, args) }, message: 'stacker does not exist' }) @@ -101,41 +102,47 @@ export function advPostSchemaMembers (client, me) { } } -export function subSelectSchemaMembers (client) { +export function subSelectSchemaMembers () { return { sub: string().required('required').oneOf(SUBS_NO_JOBS, 'required') } } +// for testing advPostSchemaMembers in isolation +export function advSchema (args) { + return object({ + ...advPostSchemaMembers(args) + }) +} -export function bountySchema (client, me) { +export function bountySchema (args) { return object({ title: titleValidator, bounty: intValidator .min(1000, 'must be at least 1000') .max(1000000, 'must be at most 1m'), - ...advPostSchemaMembers(client, me), + ...advPostSchemaMembers(args), ...subSelectSchemaMembers() }) } -export function discussionSchema (client, me) { +export function discussionSchema (args) { return object({ title: titleValidator, - ...advPostSchemaMembers(client, me), + ...advPostSchemaMembers(args), ...subSelectSchemaMembers() }) } -export function linkSchema (client, me) { +export function linkSchema (args) { return object({ title: titleValidator, url: string().matches(URL_REGEXP, 'invalid url').required('required'), - ...advPostSchemaMembers(client, me), + ...advPostSchemaMembers(args), ...subSelectSchemaMembers() }) } -export function pollSchema (client, me, numExistingChoices = 0) { +export function pollSchema ({ numExistingChoices = 0, ...args }) { return object({ title: titleValidator, options: array().of( @@ -151,12 +158,12 @@ export function pollSchema (client, me, numExistingChoices = 0) { message: `at least ${MIN_POLL_NUM_CHOICES} choices required`, test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices }), - ...advPostSchemaMembers(client, me), + ...advPostSchemaMembers(args), ...subSelectSchemaMembers() }) } -export function userSchema (client) { +export function userSchema (args) { return object({ name: string() .required('required') @@ -166,7 +173,7 @@ export function userSchema (client) { name: 'name', test: async name => { if (!name || !name.length) return false - return !(await usernameExists(client, name)) + return !(await usernameExists(name, args)) }, message: 'taken' })