diff --git a/.gitignore b/.gitignore index 4adad4b6b..cfb07a158 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ .DS_Store *.pem /*.sql +/*.patch # debug npm-debug.log* diff --git a/api/resolvers/item.js b/api/resolvers/item.js index dce6d2f07..839936c32 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -9,14 +9,14 @@ import { ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL, POLL_COST, - ITEM_ALLOW_EDITS, GLOBAL_SEED + ITEM_ALLOW_EDITS, GLOBAL_SEED, UPPER_CHARS_TITLE_FEE_MULT } from '../../lib/constants' import { msatsToSats } from '../../lib/format' import { parse } from 'tldts' import uu from 'url-unshort' import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { sendUserNotification } from '../webPush' -import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' +import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, titleExceedsFreeUppercase } from '../../lib/item' import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications' import { datePivot, whenRange } from '../../lib/time' import { imageFeesInfo, uploadIdsFromText } from './image' @@ -1092,10 +1092,35 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it const uploadIds = uploadIdsFromText(item.text, { models }) const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me }) + const hasPaidUpperTitleFee = old.upperTitleFeePaid + const titleUpperMult = !hasPaidUpperTitleFee && titleExceedsFreeUppercase(item) ? UPPER_CHARS_TITLE_FEE_MULT : 1 + let additionalFeeMsats = 0 + if (titleUpperMult > 1) { + const { _sum: { msats: paidMsats } } = await models.itemAct.aggregate({ + _sum: { + msats: true + }, + where: { + itemId: Number(item.id), + userId: me.id, + act: 'FEE' + } + }) + const { baseCost } = await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } }) + // If the original post was a freebie, that doesn't mean this edit should be free + if (Number(paidMsats) === 0) { + additionalFeeMsats = titleUpperMult * baseCost * 1000 + item.freebie = false + } else { + additionalFeeMsats = (titleUpperMult - 1) * baseCost * 1000 + } + item.upperTitleFeePaid = true + } + item = await serializeInvoicable( - models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds), - { models, lnd, hash, hmac, me, enforceFee: imgFees } + models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[], $5::BIGINT) AS "Item"`, + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds, additionalFeeMsats), + { models, lnd, hash, hmac, me, enforceFee: imgFees + additionalFeeMsats } ) await createMentions(item, models) @@ -1128,11 +1153,14 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo const uploadIds = uploadIdsFromText(item.text, { models }) const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me }) - const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees + const titleUpperMult = titleExceedsFreeUppercase(item) ? UPPER_CHARS_TITLE_FEE_MULT : 1 + item.upperTitleFeePaid = titleUpperMult > 1 + + const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE * titleUpperMult + (item.boost || 0)))) + imgFees item = await serializeInvoicable( models.$queryRawUnsafe( - `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds), + `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[], $5::INTEGER) AS "Item"`, + JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds, titleUpperMult), { models, lnd, hash, hmac, me, enforceFee } ) @@ -1184,7 +1212,8 @@ export const SELECT = "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo", - ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls"` + ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", + "Item"."upperTitleFeePaid"` function topOrderByWeightedSats (me, models) { return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC` diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index e915c01e3..98aa4d9b9 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -117,6 +117,7 @@ export default gql` parentOtsHash: String forwards: [ItemForward] imgproxyUrls: JSONObject + upperTitleFeePaid: Boolean } input ItemForwardInput { diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 849dc1ac4..ec4523dcf 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -55,9 +55,10 @@ export default function AdvPostForm ({ children }) { name='boost' onChange={(_, e) => merge({ boost: { - term: `+ ${e.target.value}`, + term: `+ ${numWithUnits(e.target.value, { abbreviate: false, format: true })}`, label: 'boost', - modifier: cost => cost + Number(e.target.value) + modifier: cost => cost + Number(e.target.value), + omit: !Number(e.target.value) } })} hint={ranks posts higher temporarily based on the amount} diff --git a/components/bounty-form.js b/components/bounty-form.js index 362d2f608..01278857e 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { gql, useApolloClient, useMutation } from '@apollo/client' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' +import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button' import InputGroup from 'react-bootstrap/InputGroup' import { bountySchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' @@ -84,6 +85,7 @@ export function BountyForm ({ toastDeleteScheduled(toaster, data, !!item, values.text) }, [upsertBounty, router] ) + const feeButton = useFeeButton() return (
{ + if (e.target.value) { + uppercaseTitleFeeHandler(feeButton, e.target.value, item) + } + }} /> modifier(acc), 0), + total: Object.entries(lines) + .sort(([keyA], [keyB]) => { + // boost and image uploads comes last, so they don't get multiplied by any multipliers + if (['boost', 'imageFee'].includes(keyB)) { + return -1 + } + if (['boost', 'imageFee'].includes(keyA)) { + return 1 + } + return 0 + }) + .map(entry => entry[1]) + .reduce((acc, { modifier }) => modifier(acc), 0), disabled, setDisabled } @@ -134,6 +147,35 @@ export default function FeeButton ({ ChildButton = SubmitButton, variant, text, ) } +export const uppercaseTitleFeeHandler = (feeButtonHook, title, item) => { + const { freebie, sub } = item ?? {} + const tooManyUppercase = !item?.hasPaidUpperTitleFee && titleExceedsFreeUppercase({ title }) + feeButtonHook.merge({ + uppercaseTitle: { + term: (() => { + if (freebie) { + return `+ ${numWithUnits(UPPER_CHARS_TITLE_FEE_MULT * sub.baseCost)}` + } + if (item) { + return `+ ${numWithUnits((UPPER_CHARS_TITLE_FEE_MULT - 1) * sub.baseCost)}` + } + return `x ${UPPER_CHARS_TITLE_FEE_MULT}` + })(), + label: 'uppercase title mult', + modifier: cost => { + if (freebie) { + return cost + (tooManyUppercase ? UPPER_CHARS_TITLE_FEE_MULT * sub.baseCost : 0) + } + if (item) { + return cost + (tooManyUppercase ? (UPPER_CHARS_TITLE_FEE_MULT - 1) * sub.baseCost : 0) + } + return cost * (tooManyUppercase ? UPPER_CHARS_TITLE_FEE_MULT : 1) + }, + omit: !tooManyUppercase + } + }) +} + function Receipt ({ lines, total }) { return ( diff --git a/components/job-form.js b/components/job-form.js index 0b82308c4..50d211ede 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -16,6 +16,7 @@ import { usePrice } from './price' import Avatar from './avatar' import { jobSchema } from '../lib/validate' import { MAX_TITLE_LENGTH } from '../lib/constants' +import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button' import { useToast } from './toast' import { toastDeleteScheduled } from '../lib/form' import { ItemButtonBar } from './post' @@ -85,6 +86,7 @@ export default function JobForm ({ item, sub }) { toastDeleteScheduled(toaster, data, !!item, values.text) }, [upsertJob, router, logoId] ) + const feeButton = useFeeButton() return ( <> @@ -122,6 +124,11 @@ export default function JobForm ({ item, sub }) { autoFocus clear maxLength={MAX_TITLE_LENGTH} + onChange={async (formik, e) => { + if (e.target.value) { + uppercaseTitleFeeHandler(feeButton, e.target.value, item) + } + }} /> - txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase())) + uppercaseTitleFeeHandler(feeButton, e.target.value, item) } }} maxLength={MAX_TITLE_LENGTH} diff --git a/components/poll-form.js b/components/poll-form.js index 59e519509..1eb95d601 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -4,6 +4,7 @@ import { gql, useApolloClient, useMutation } from '@apollo/client' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial } from './adv-post-form' import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants' +import { useFeeButton, uppercaseTitleFeeHandler } from './fee-button' import { pollSchema } from '../lib/validate' import { SubSelectInitial } from './sub-select-form' import { useCallback } from 'react' @@ -59,6 +60,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { ) const initialOptions = item?.poll?.options.map(i => i.option) + const feeButton = useFeeButton() return ( { + if (e.target.value) { + uppercaseTitleFeeHandler(feeButton, e.target.value, item) + } + }} /> { @@ -52,3 +52,7 @@ export const deleteItemByAuthor = async ({ models, id, item }) => { return await models.item.update({ where: { id: Number(id) }, data: updateData }) } + +export const getUppercaseCharCountInTitle = item => (item?.title?.match(/[A-Z]/g) || []).length + +export const titleExceedsFreeUppercase = item => getUppercaseCharCountInTitle(item) > MAX_FREE_UPPER_CHARS_TITLE diff --git a/prisma/migrations/20231118000000_uppercase_title/migration.sql b/prisma/migrations/20231118000000_uppercase_title/migration.sql new file mode 100644 index 000000000..e7d1b114a --- /dev/null +++ b/prisma/migrations/20231118000000_uppercase_title/migration.sql @@ -0,0 +1,233 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "upperTitleFeePaid" BOOLEAN NOT NULL DEFAULT false; + +DROP FUNCTION IF EXISTS create_item(JSONB, JSONB, JSONB, INTERVAL, INTEGER[]); + +-- support uppercase chars in title fee multiplier +CREATE OR REPLACE FUNCTION create_item( + jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[], upper_title_fee_mult INTEGER) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT := 1000; + base_cost_msats BIGINT := 1000; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- access fields with appropriate types + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + + -- if this is a post, get the base cost of the sub + IF item."parentId" IS NULL AND item."subName" IS NOT NULL THEN + SELECT "baseCost" * 1000, "baseCost" * 1000 + INTO base_cost_msats, cost_msats + FROM "Sub" + WHERE name = item."subName"; + END IF; + + IF item."maxBid" IS NULL THEN + -- spam multiplier + cost_msats := cost_msats * POWER(10, item_spam(item."parentId", item."userId", spam_within)); + END IF; + + -- Uppercase title fee multiplier (before image fees or after?) + cost_msats := cost_msats * upper_title_fee_mult; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + END IF; + + -- it's only a freebie if it's no greater than the base cost, they have less than the cost, and boost = 0 + freebie := (cost_msats <= base_cost_msats) AND (user_msats < cost_msats) AND (item.boost IS NULL OR item.boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE( + percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = item."userId"; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR item."userId" = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + -- there's no great way to set default column values when using json_populate_record + -- so we need to only select fields with non-null values that way when func input + -- does not include a value, the default value is used instead of null + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key); + -- insert the item + EXECUTE format($fmt$ + INSERT INTO "Item" (%s, "weightedDownVotes", freebie) + SELECT %1$s, %L, %L + FROM jsonb_populate_record(NULL::"Item", %L) RETURNING * + $fmt$, select_clause, med_votes, freebie, jitem) INTO item; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + -- Automatically subscribe to one's own posts + INSERT INTO "ThreadSubscription" ("itemId", "userId") + VALUES (item.id, item."userId"); + + -- Automatically subscribe forward recipients to the new post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + -- if this item has boost + IF item.boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- if this is a bio + IF item.bio THEN + UPDATE users SET "bioId" = item.id WHERE id = item."userId"; + END IF; + + -- record attachments + IF upload_ids IS NOT NULL THEN + INSERT INTO "ItemUpload" ("itemId", "uploadId") + SELECT item.id, * FROM UNNEST(upload_ids); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; + +DROP FUNCTION IF EXISTS update_item(JSONB, JSONB, JSONB, INTEGER[]); + +-- support an additional fee parameter to incur on edits +CREATE OR REPLACE FUNCTION update_item( + jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[], additional_fee_msats BIGINT) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost_msats BIGINT; + item "Item"; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + cost_msats := 0; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + -- delete any old uploads that are no longer attached + DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids); + -- insert any new uploads that are not already attached + INSERT INTO "ItemUpload" ("itemId", "uploadId") + SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING; + END IF; + + -- add addl fees + cost_msats := cost_msats + additional_fee_msats; + + IF cost_msats > 0 AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + ELSE + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + IF item.boost > 0 THEN + UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id; + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + IF item.status IS NOT NULL THEN + UPDATE "Item" SET "statusUpdatedAt" = now_utc() + WHERE id = item.id AND status <> item.status; + END IF; + + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key) + WHERE key <> 'boost'; + + EXECUTE format($fmt$ + UPDATE "Item" SET (%s) = ( + SELECT %1$s + FROM jsonb_populate_record(NULL::"Item", %L) + ) WHERE id = %L RETURNING * + $fmt$, select_clause, jitem, item.id) INTO item; + + -- Delete any old thread subs if the user is no longer a fwd recipient + DELETE FROM "ThreadSubscription" + WHERE "itemId" = item.id + -- they aren't in the new forward list + AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId") + -- and they are in the old forward list + AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" ); + + -- Automatically subscribe any new forward recipients to the post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward) + EXCEPT + SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id; + + -- Delete all old forward entries, to recreate in next command + DELETE FROM "ItemForward" WHERE "itemId" = item.id; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab2babd73..c3760ba78 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -300,6 +300,7 @@ model Item { bountyPaidTo Int[] upvotes Int @default(0) weightedComments Float @default(0) + upperTitleFeePaid Boolean @default(false) Bookmark Bookmark[] parent Item? @relation("ParentChildren", fields: [parentId], references: [id]) children Item[] @relation("ParentChildren")