From e13e37744e608ca091a50674546efb135e14a7d8 Mon Sep 17 00:00:00 2001 From: Austin Kelsay <53542748+AustinKelsay@users.noreply.github.com> Date: Thu, 26 Jan 2023 10:11:55 -0600 Subject: [PATCH] stackernews bounties (#227) bounties --- .gitignore | 1 + api/resolvers/item.js | 111 +++++++++++-- api/typeDefs/item.js | 5 + components/bounty-form.js | 148 ++++++++++++++++++ components/comment.js | 18 ++- components/comment.module.css | 12 ++ components/item-full.js | 17 +- components/item.js | 8 + components/item.module.css | 6 + components/past-bounties.js | 46 ++++++ components/pay-bounty.js | 114 ++++++++++++++ components/pay-bounty.module.css | 4 + components/recent-header.js | 2 +- components/reply.js | 15 +- components/reply.module.css | 3 +- fragments/comments.js | 2 + fragments/items.js | 13 ++ pages/[name]/bounties.js | 23 +++ pages/items/[id]/edit.js | 7 +- pages/post.js | 21 ++- .../20221213203919_add_bounty/migration.sql | 93 +++++++++++ prisma/schema.prisma | 1 + svgs/bounty-bag.svg | 1 + 23 files changed, 641 insertions(+), 30 deletions(-) create mode 100644 components/bounty-form.js create mode 100644 components/past-bounties.js create mode 100644 components/pay-bounty.js create mode 100644 components/pay-bounty.module.css create mode 100644 pages/[name]/bounties.js create mode 100644 prisma/migrations/20221213203919_add_bounty/migration.sql create mode 100644 svgs/bounty-bag.svg diff --git a/.gitignore b/.gitignore index e4be0b78..af818d30 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +.cache # testing /coverage diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 4a78ab1f..d476b073 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -138,6 +138,8 @@ function recentClause (type) { return ' AND "pollCost" IS NOT NULL' case 'bios': return ' AND bio = true' + case 'bounties': + return ' AND bounty IS NOT NULL' default: return '' } @@ -399,6 +401,32 @@ export default { items } }, + getBountiesByUserName: async (parent, { name, cursor, limit }, { models }) => { + const decodedCursor = decodeCursor(cursor) + const user = await models.user.findUnique({ where: { name } }) + + if (!user) { + throw new UserInputError('user not found', { + argumentName: 'name' + }) + } + + const items = await models.$queryRaw( + `${SELECT} + FROM "Item" + WHERE "userId" = $1 + AND "bounty" IS NOT NULL + ORDER BY created_at DESC + OFFSET $2 + LIMIT $3`, + user.id, decodedCursor.offset, limit || LIMIT + ) + + return { + cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null, + items + } + }, moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) @@ -589,6 +617,20 @@ export default { return await createItem(parent, data, { me, models }) } }, + upsertBounty: async (parent, args, { me, models }) => { + const { id, ...data } = args + const { bounty } = data + + if (bounty < 1000 || bounty > 1000000) { + throw new UserInputError('invalid bounty amount', { argumentName: 'bounty' }) + } + + if (id) { + return await updateItem(parent, { id, data }, { me, models }) + } else { + return await createItem(parent, data, { me, models }) + } + }, upsertPoll: async (parent, { id, forward, boost, title, text, options }, { me, models }) => { if (!me) { throw new AuthenticationError('you must be logged in') @@ -878,6 +920,50 @@ export default { return (msats && msatsToSats(msats)) || 0 }, + bountyPaid: async (item, args, { models }) => { + if (!item.bounty) { + return null + } + + // if there's a child where the OP paid the amount, but it isn't the OP's own comment + const paid = await models.$queryRaw` + -- Sum up the sats and if they are greater than or equal to item.bounty than return true, else return false + SELECT "Item"."id" + FROM "ItemAct" + JOIN "Item" ON "ItemAct"."itemId" = "Item"."id" + WHERE "ItemAct"."userId" = ${item.userId} + AND "Item".path <@ text2ltree (${item.path}) + AND "Item"."userId" <> ${item.userId} + AND act IN ('TIP', 'FEE') + GROUP BY "Item"."id" + HAVING coalesce(sum("ItemAct"."msats"), 0) >= ${item.bounty * 1000} + ` + + return paid.length > 0 + }, + bountyPaidTo: async (item, args, { models }) => { + if (!item.bounty) { + return [] + } + + const paidTo = await models.$queryRaw` + SELECT "Item"."id" + FROM "ItemAct" + JOIN "Item" ON "ItemAct"."itemId" = "Item"."id" + WHERE "ItemAct"."userId" = ${item.userId} + AND "Item".path <@ text2ltree (${item.path}) + AND "Item"."userId" <> ${item.userId} + AND act IN ('TIP', 'FEE') + GROUP BY "Item"."id" + HAVING coalesce(sum("ItemAct"."msats"), 0) >= ${item.bounty * 1000} + ` + + if (paidTo.length === 0) { + return [] + } + + return paidTo.map(i => i.id) + }, meDontLike: async (item, args, { me, models }) => { if (!me) return false @@ -967,7 +1053,7 @@ export const createMentions = async (item, models) => { } } -export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, parentId } }, { me, models }) => { +export const updateItem = async (parent, { id, data: { title, url, text, boost, forward, bounty, parentId } }, { me, models }) => { // update iff this item belongs to me const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { @@ -998,15 +1084,15 @@ export const updateItem = async (parent, { id, data: { title, url, text, boost, const [item] = await serialize(models, models.$queryRaw( - `${SELECT} FROM update_item($1, $2, $3, $4, $5, $6) AS "Item"`, - Number(id), title, url, text, Number(boost || 0), Number(fwdUser?.id))) + `${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7) AS "Item"`, + Number(id), title, url, text, Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id))) await createMentions(item, models) return item } -const createItem = async (parent, { title, url, text, boost, forward, parentId }, { me, models }) => { +const createItem = async (parent, { title, url, text, boost, forward, bounty, parentId }, { me, models }) => { if (!me) { throw new AuthenticationError('you must be logged in') } @@ -1027,11 +1113,18 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } } } - const [item] = await serialize(models, + const [item] = await serialize( + models, models.$queryRaw( - `${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, '${ITEM_SPAM_INTERVAL}') AS "Item"`, - title, url, text, Number(boost || 0), Number(parentId), Number(me.id), - Number(fwdUser?.id))) + `${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + title, + url, + text, + Number(boost || 0), + bounty ? Number(bounty) : null, + Number(parentId), + Number(me.id), + Number(fwdUser?.id))) await createMentions(item, models) @@ -1067,7 +1160,7 @@ function nestComments (flat, parentId) { // we have to do our own query because ltree is unsupported export const SELECT = `SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, - "Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", + "Item".text, "Item".url, "Item"."bounty", "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", "Item".company, "Item".location, "Item".remote, "Item"."deletedAt", "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 5f21c15f..fb0d936c 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -10,6 +10,7 @@ export default gql` dupes(url: String!): [Item!] related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items allItems(cursor: String): Items + getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items auctionPosition(sub: String, id: ID, bid: Int!): Int! itemRepetition(parentId: ID): Int! @@ -34,6 +35,7 @@ export default gql` deleteItem(id: ID): Item upsertLink(id: ID, title: String!, url: String!, boost: Int, forward: String): Item! upsertDiscussion(id: ID, title: String!, text: String, boost: Int, forward: String): Item! + upsertBounty(id: ID, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item! upsertJob(id: ID, sub: ID!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! upsertPoll(id: ID, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item! @@ -87,6 +89,9 @@ export default gql` depth: Int! mine: Boolean! boost: Int! + bounty: Int + bountyPaid: Boolean + bountyPaidTo: [Int]! sats: Int! commentSats: Int! lastCommentAt: String diff --git a/components/bounty-form.js b/components/bounty-form.js new file mode 100644 index 00000000..63d4860c --- /dev/null +++ b/components/bounty-form.js @@ -0,0 +1,148 @@ +import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' +import { useRouter } from 'next/router' +import * as Yup from 'yup' +import { gql, useApolloClient, useMutation } from '@apollo/client' +import TextareaAutosize from 'react-textarea-autosize' +import Countdown from './countdown' +import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' +import { MAX_TITLE_LENGTH } from '../lib/constants' +import FeeButton, { EditFeeButton } from './fee-button' +import { InputGroup } from 'react-bootstrap' + +export function BountyForm ({ + item, + editThreshold, + titleLabel = 'title', + bountyLabel = 'bounty', + textLabel = 'text', + buttonText = 'post', + adv, + handleSubmit +}) { + const router = useRouter() + const client = useApolloClient() + const [upsertBounty] = useMutation( + gql` + mutation upsertBounty( + $id: ID + $title: String! + $bounty: Int! + $text: String + $boost: Int + $forward: String + ) { + upsertBounty( + id: $id + title: $title + bounty: $bounty + text: $text + boost: $boost + forward: $forward + ) { + id + } + } + ` + ) + + const BountySchema = Yup.object({ + title: Yup.string() + .required('required') + .trim() + .max( + MAX_TITLE_LENGTH, + ({ max, value }) => `${Math.abs(max - value.length)} too many` + ), + bounty: Yup.number() + .required('required') + .min(1000, 'must be at least 1000 sats') + .max(1000000, 'must be at most 1m sats') + .integer('must be whole'), + + ...AdvPostSchema(client) + }) + + return ( +
{ + const { error } = await upsertBounty({ + variables: { + id: item?.id, + boost: Number(boost), + bounty: Number(bounty), + ...values + } + }) + if (error) { + throw new Error({ message: error.toString() }) + } + + if (item) { + await router.push(`/items/${item.id}`) + } else { + await router.push('/recent') + } + }) + } + storageKeyPrefix={item ? undefined : 'discussion'} + > + + sats} + /> + + {textLabel} optional + + } + name='text' + as={TextareaAutosize} + minRows={6} + hint={ + editThreshold + ? ( +
+ +
+ ) + : null + } + /> + {adv && } +
+ {item + ? ( + + ) + : ( + + )} +
+ + ) +} diff --git a/components/comment.js b/components/comment.js index 158a9586..550c35a6 100644 --- a/components/comment.js +++ b/components/comment.js @@ -13,6 +13,9 @@ import CommentEdit from './comment-edit' import Countdown from './countdown' import { COMMENT_DEPTH_LIMIT, NOFOLLOW_LIMIT } from '../lib/constants' import { ignoreClick } from '../lib/clicks' +import PayBounty from './pay-bounty' +import BountyIcon from '../svgs/bounty-bag.svg' +import ActionTooltip from './action-tooltip' import { useMe } from './me' import DontLikeThis from './dont-link-this' import Flag from '../svgs/flag-fill.svg' @@ -107,13 +110,16 @@ export default function Comment ({ const bottomedOut = depth === COMMENT_DEPTH_LIMIT const op = item.root?.user.name === item.user.name + const bountyPaid = item.root?.bountyPaidTo?.includes(Number(item.id)) return (
- {item.meDontLike ? : } + {item.meDontLike + ? + : }
@@ -136,6 +142,10 @@ export default function Comment ({ {timeSince(new Date(item.createdAt))} {includeParent && } + {bountyPaid && + + + } {me && !item.meSats && !item.meDontLike && !item.mine && !item.deletedAt && } {(item.outlawed && {' '}OUTLAWED) || (item.freebie && !item.mine && (me?.greeterMode) && {' '}FREEBIE)} @@ -198,9 +208,9 @@ export default function Comment ({ : (
{!noReply && - } + + {item.root?.bounty && !bountyPaid && } + } {children}
{item.comments && !noComments diff --git a/components/comment.module.css b/components/comment.module.css index 047a00d4..23fe064a 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -78,6 +78,12 @@ padding-bottom: .5rem; } +.replyContainer { + display: flex; + justify-content: flex-start; + align-items: center; +} + .comment { border-radius: .4rem; padding-top: .5rem; @@ -85,6 +91,12 @@ background-color: var(--theme-commentBg); } +.bountyIcon { + margin-left: 5px; + margin-right: 5px; + margin-top: -4px; +} + .hunk { margin-bottom: 0; margin-top: 0.15rem; diff --git a/components/item-full.js b/components/item-full.js index 4ab0d60f..9ee71dbc 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -15,6 +15,8 @@ import { useEffect, useState } from 'react' import Poll from './poll' import { commentsViewed } from '../lib/new-comments' import Related from './related' +import PastBounties from './past-bounties' +import Check from '../svgs/check-double-line.svg' function BioItem ({ item, handleClick }) { const me = useMe() @@ -97,10 +99,23 @@ function TopLevelItem ({ item, noReply, ...props }) { {item.text && } {item.url && } {item.poll && } + {item.bounty && +
+ {item.bountyPaid + ? ( +
+ {item.bounty} sats paid +
) + : ( +
+ {item.bounty} sats bounty +
)} +
} {!noReply && <> - {!item.position && !item.isJob && !item.parentId && } + {!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && } + {item.bounty > 0 && } } ) diff --git a/components/item.js b/components/item.js index dc4d2b81..8ebf3c5a 100644 --- a/components/item.js +++ b/components/item.js @@ -9,6 +9,8 @@ import Pin from '../svgs/pushpin-fill.svg' import reactStringReplace from 'react-string-replace' import Toc from './table-of-contents' import PollIcon from '../svgs/bar-chart-horizontal-fill.svg' +import BountyIcon from '../svgs/bounty-bag.svg' +import ActionTooltip from './action-tooltip' import { Badge } from 'react-bootstrap' import { newComments } from '../lib/new-comments' import { useMe } from './me' @@ -74,6 +76,12 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { {item.searchTitle ? : item.title} {item.pollCost && } + {item.bounty > 0 && + + + + + } {item.url && diff --git a/components/item.module.css b/components/item.module.css index 470934c8..9a9bcf77 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -89,6 +89,12 @@ a.link:visited { line-height: 1.06rem; } +.bountyIcon { + margin-left: 5px; + margin-right: 5px; + margin-top: -2px; +} + /* .itemJob .hunk { align-self: center; } diff --git a/components/past-bounties.js b/components/past-bounties.js new file mode 100644 index 00000000..f687e3ce --- /dev/null +++ b/components/past-bounties.js @@ -0,0 +1,46 @@ +import React from 'react' +import { useQuery } from '@apollo/client' +import AccordianItem from './accordian-item' +import Item, { ItemSkeleton } from './item' +import { BOUNTY_ITEMS_BY_USER_NAME } from '../fragments/items' +import Link from 'next/link' +import styles from './items.module.css' + +export default function PastBounties ({ children, item }) { + const emptyItems = new Array(5).fill(null) + + const { data, loading } = useQuery(BOUNTY_ITEMS_BY_USER_NAME, { + variables: { + name: item.user.name, + limit: 5 + }, + fetchPolicy: 'cache-first' + }) + + let items, cursor + if (data) { + ({ getBountiesByUserName: { items, cursor } } = data) + items = items.filter(i => i.id !== item.id) + } + + return ( + {item.user.name}'s bounties
} + body={ + <> +
+ {loading + ? emptyItems.map((_, i) => ) + : (items?.length + ? items.map(bountyItem => { + return + }) + :
EMPTY
+ )} +
+ {cursor && view all past bounties} + + } + /> + ) +} diff --git a/components/pay-bounty.js b/components/pay-bounty.js new file mode 100644 index 00000000..efe61795 --- /dev/null +++ b/components/pay-bounty.js @@ -0,0 +1,114 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import styles from './pay-bounty.module.css' +import ActionTooltip from './action-tooltip' +import ModalButton from './modal-button' +import { useMutation, gql } from '@apollo/client' +import { useMe } from './me' +import { abbrNum } from '../lib/format' +import { useShowModal } from './modal' +import FundError from './fund-error' + +export default function PayBounty ({ children, item }) { + const me = useMe() + const showModal = useShowModal() + + const [act] = useMutation( + gql` + mutation act($id: ID!, $sats: Int!) { + act(id: $id, sats: $sats) { + sats + } + }`, { + update (cache, { data: { act: { sats } } }) { + cache.modify({ + id: `Item:${item.id}`, + fields: { + sats (existingSats = 0) { + return existingSats + sats + }, + meSats (existingSats = 0) { + return existingSats + sats + } + } + }) + + // update all ancestor comment sats + item.path.split('.').forEach(id => { + if (Number(id) === Number(item.id)) return + cache.modify({ + id: `Item:${id}`, + fields: { + commentSats (existingCommentSats = 0) { + return existingCommentSats + sats + } + } + }) + }) + + // update root bounty status + cache.modify({ + id: `Item:${item.root.id}`, + fields: { + bountyPaid () { + return true + }, + bountyPaidTo (existingPaidTo = []) { + return [...existingPaidTo, Number(item.id)] + } + } + }) + } + } + ) + + const handlePayBounty = async () => { + try { + await act({ + variables: { id: item.id, sats: item.root.bounty }, + optimisticResponse: { + act: { + id: `Item:${item.id}`, + sats: item.root.bounty + } + } + }) + } catch (error) { + if (error.toString().includes('insufficient funds')) { + showModal(onClose => { + return + }) + return + } + throw new Error({ message: error.toString() }) + } + } + + if (!me || item.root.user.name !== me.name || item.mine || item.root.bountyPaid) { + return null + } + + return ( + + + pay bounty +
+ } + > +
+ Pay this bounty to {item.user.name}? +
+
+ +
+ + + ) +} diff --git a/components/pay-bounty.module.css b/components/pay-bounty.module.css new file mode 100644 index 00000000..2ff15246 --- /dev/null +++ b/components/pay-bounty.module.css @@ -0,0 +1,4 @@ +.pay { + color: var(--success); + margin-left: 1rem; +} \ No newline at end of file diff --git a/components/recent-header.js b/components/recent-header.js index b18938c9..17fdce5e 100644 --- a/components/recent-header.js +++ b/components/recent-header.js @@ -16,7 +16,7 @@ export default function RecentHeader ({ type }) { className='w-auto' name='type' size='sm' - items={['posts', 'comments', 'links', 'discussions', 'polls', 'bios']} + items={['posts', 'bounties', 'comments', 'links', 'discussions', 'polls', 'bios']} onChange={(formik, e) => router.push(e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`)} />
diff --git a/components/reply.js b/components/reply.js index 29e86799..7dec45ab 100644 --- a/components/reply.js +++ b/components/reply.js @@ -22,7 +22,7 @@ export function ReplyOnAnotherPage ({ parentId }) { ) } -export default function Reply ({ item, onSuccess, replyOpen }) { +export default function Reply ({ item, onSuccess, replyOpen, children }) { const [reply, setReply] = useState(replyOpen) const me = useMe() const parentId = item.id @@ -84,11 +84,14 @@ export default function Reply ({ item, onSuccess, replyOpen }) { {replyOpen ?
: ( -
setReply(!reply)} - > - {reply ? 'cancel' : 'reply'} +
+
setReply(!reply)} + > + {reply ? 'cancel' : 'reply'} +
+ {/* HACK if we need more items, we should probably do a comment toolbar */} + {children}
)}
+
{router.query.name}'s bounties
+ data.getBountiesByUserName} + query={BOUNTY_ITEMS_BY_USER_NAME} + /> + + ) +} diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js index 40028457..45f9f9b5 100644 --- a/pages/items/[id]/edit.js +++ b/pages/items/[id]/edit.js @@ -5,6 +5,7 @@ import { LinkForm } from '../../../components/link-form' import LayoutCenter from '../../../components/layout-center' import JobForm from '../../../components/job-form' import { PollForm } from '../../../components/poll-form' +import { BountyForm } from '../../../components/bounty-form' export const getServerSideProps = getGetServerSideProps(ITEM, null, data => !data.item) @@ -19,8 +20,10 @@ export default function PostEdit ({ data: { item } }) { : (item.url ? : (item.pollCost - ? - : ))} + ? + : (item.bounty + ? + : )))} ) } diff --git a/pages/post.js b/pages/post.js index e671b31f..316aabad 100644 --- a/pages/post.js +++ b/pages/post.js @@ -8,6 +8,7 @@ import { LinkForm } from '../components/link-form' import { getGetServerSideProps } from '../api/ssrApollo' import AccordianItem from '../components/accordian-item' import { PollForm } from '../components/poll-form' +import { BountyForm } from '../components/bounty-form' export const getServerSideProps = getGetServerSideProps() @@ -28,15 +29,21 @@ export function PostForm () { -
+
more
} body={ - - - - } +
+ + + + or + + + +
+ } />
@@ -47,8 +54,10 @@ export function PostForm () { return } else if (router.query.type === 'link') { return - } else { + } else if (router.query.type === 'poll') { return + } else { + return } } diff --git a/prisma/migrations/20221213203919_add_bounty/migration.sql b/prisma/migrations/20221213203919_add_bounty/migration.sql new file mode 100644 index 00000000..d4f8b477 --- /dev/null +++ b/prisma/migrations/20221213203919_add_bounty/migration.sql @@ -0,0 +1,93 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "bounty" INTEGER; +ALTER TABLE "Item" ADD CONSTRAINT "bounty" CHECK ("bounty" IS NULL OR "bounty" > 0) NOT VALID; + +CREATE OR REPLACE FUNCTION create_item( + title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + free_posts INTEGER; + free_comments INTEGER; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats, "freePosts", "freeComments" + INTO user_msats, free_posts, free_comments + FROM users WHERE id = user_id; + + cost_msats := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)); + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, boost = 0, and they have freebies left + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (boost = 0) AND ((parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 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" = user_id; + + -- 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 + IF med_votes >= 0 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + INSERT INTO "Item" (title, url, text, bounty, "userId", "parentId", "fwdUserId", freebie, "weightedDownVotes", created_at, updated_at) + VALUES (title, url, text, bounty, user_id, parent_id, fwd_user_id, freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item; + + IF freebie THEN + IF parent_id IS NULL THEN + UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id; + ELSE + UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id; + END IF; + ELSE + UPDATE users SET msats = msats - cost_msats WHERE id = user_id; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost_msats, item.id, user_id, 'FEE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; + +CREATE OR REPLACE FUNCTION update_item(item_id INTEGER, + item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,item_bounty INTEGER, + fwd_user_id INTEGER) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + UPDATE "Item" set title = item_title, url = item_url, text = item_text, bounty = item_bounty, "fwdUserId" = fwd_user_id + WHERE id = item_id + RETURNING * INTO item; + + IF boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d21a80d5..09961559 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -228,6 +228,7 @@ model Item { pin Pin? @relation(fields: [pinId], references: [id]) pinId Int? boost Int @default(0) + bounty Int? uploadId Int? upload Upload? paidImgLink Boolean @default(false) diff --git a/svgs/bounty-bag.svg b/svgs/bounty-bag.svg new file mode 100644 index 00000000..022a80f7 --- /dev/null +++ b/svgs/bounty-bag.svg @@ -0,0 +1 @@ + \ No newline at end of file