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 (
+
+ )
+}
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}
)}
@@ -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