multiple forwards on a post (#403)

* multiple forwards on a post

first phase of the multi-forward support

* update the graphql mutation for discussion posts to accept and validate multiple forwards

* update the discussion form to allow multiple forwards in the UI

* start working on db schema changes

* uncomment db schema, add migration to create the new model, and update create_item, update_item
stored procedures

* Propagate updates from discussion to poll, link, and bounty forms

Update the create, update poll sql functions for multi forward support

* Update gql, typedefs, and resolver to return forwarded users in items responses

* UI changes to show multiple forward recipients, and conditional upvote logic changes

* Update notification text to reflect multiple forwards upon vote action

* Disallow duplicate stacker entries

* reduce duplication in populating adv-post-form initial values

* Update item_act sql function to implement multi-way forwarding

* Update referral functions to scale referral bonuses for forwarded users

* Update notification text to reflect non-100% forwarded sats cases

* Update wallet history sql queries to accommodate multi-forward use cases

* Block zaps for posts you are forwarded zaps at the API layer, in addition
to in the UI

* Delete fwdUserId column from Item table as part of migration

* Fix how we calculate stacked sats after partial forwards in wallet history

* Exclude entries from wallet history that are 0 stacked sats from posts with 100% forwarded to other users

* Fix wallet history query for forwarded stacked sats to be scaled by the fwd pct

* Reduce duplication in adv post form, and do some style tweaks for better layout

* Use MAX_FORWARDS constants

* Address various PR feedback

* first enhancement pass

* enhancement pass too

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
SatsAllDay 2023-08-23 18:44:17 -04:00 committed by GitHub
parent 07e065d4be
commit 3da395a792
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 745 additions and 128 deletions

View File

@ -665,13 +665,7 @@ export default {
await ssValidate(pollSchema, data, models, optionCount) await ssValidate(pollSchema, data, models, optionCount)
let fwdUser const fwdUsers = await getForwardUsers(models, forward)
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
}
}
if (id) { if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } }) const old = await models.item.findUnique({ where: { id: Number(id) } })
@ -679,8 +673,8 @@ export default {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
} }
const [item] = await serialize(models, const [item] = await serialize(models,
models.$queryRawUnsafe(`${SELECT} FROM update_poll($1, $2::INTEGER, $3, $4, $5::INTEGER, $6, $7::INTEGER) AS "Item"`, models.$queryRawUnsafe(`${SELECT} FROM update_poll($1, $2::INTEGER, $3, $4, $5::INTEGER, $6, $7::JSON) AS "Item"`,
sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id))) sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, JSON.stringify(fwdUsers)))
await createMentions(item, models) await createMentions(item, models)
item.comments = [] item.comments = []
@ -688,8 +682,8 @@ export default {
} else { } else {
const [query] = await serialize(models, const [query] = await serialize(models,
models.$queryRawUnsafe( models.$queryRawUnsafe(
`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`, `${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::JSON, '${spamInterval}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx) sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, JSON.stringify(fwdUsers)), ...trx)
const item = trx.length > 0 ? query[0] : query const item = trx.length > 0 ? query[0] : query
await createMentions(item, models) await createMentions(item, models)
@ -794,6 +788,12 @@ export default {
} }
} }
// Disallow tips if me is one of the forward user recipients
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
if (existingForwards.some(fwd => Number(fwd.userId) === Number(user.id))) {
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
}
const calls = [ const calls = [
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
] ]
@ -803,15 +803,46 @@ export default {
const [{ item_act: vote }] = await serialize(models, ...calls) const [{ item_act: vote }] = await serialize(models, ...calls)
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } }) const notify = async () => {
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${ try {
numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}` const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
sendUserNotification(updatedItem.userId, { const forwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
title, const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } }))
body: updatedItem.title ? updatedItem.title : updatedItem.text, const userResults = await Promise.allSettled(userPromises)
item: updatedItem, const mappedForwards = forwards.map((fwd, index) => ({ ...fwd, user: userResults[index].value ?? null }))
tag: `TIP-${updatedItem.id}` let forwardedSats = 0
}).catch(console.error) let forwardedUsers = ''
if (mappedForwards.length) {
forwardedSats = Math.floor(msatsToSats(updatedItem.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ')
}
let notificationTitle
if (updatedItem.title) {
if (forwards.length > 0) {
notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
} else {
notificationTitle = `your post stacked ${numWithUnits(msatsToSats(updatedItem.msats))}`
}
} else {
if (forwards.length > 0) {
// I don't think this case is possible
notificationTitle = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
} else {
notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(updatedItem.msats))}`
}
}
await sendUserNotification(updatedItem.userId, {
title: notificationTitle,
body: updatedItem.title ? updatedItem.title : updatedItem.text,
item: updatedItem,
tag: `TIP-${updatedItem.id}`
})
} catch (err) {
console.error(err)
}
}
notify()
return { return {
vote, vote,
@ -919,11 +950,15 @@ export default {
} }
return await models.user.findUnique({ where: { id: item.userId } }) return await models.user.findUnique({ where: { id: item.userId } })
}, },
fwdUser: async (item, args, { models }) => { forwards: async (item, args, { models }) => {
if (!item.fwdUserId) { return await models.itemForward.findMany({
return null where: {
} itemId: item.id
return await models.user.findUnique({ where: { id: item.fwdUserId } }) },
include: {
user: true
}
})
}, },
comments: async (item, { sort }, { me, models }) => { comments: async (item, { sort }, { me, models }) => {
if (typeof item.comments !== 'undefined') return item.comments if (typeof item.comments !== 'undefined') return item.comments
@ -1104,22 +1139,15 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
} }
let fwdUser const fwdUsers = await getForwardUsers(models, forward)
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
}
}
url = await proxyImages(url) url = await proxyImages(url)
text = await proxyImages(text) text = await proxyImages(text)
const [item] = await serialize(models, const [item] = await serialize(models,
models.$queryRawUnsafe( models.$queryRawUnsafe(
`${SELECT} FROM update_item($1, $2::INTEGER, $3, $4, $5, $6::INTEGER, $7::INTEGER, $8::INTEGER) AS "Item"`, `${SELECT} FROM update_item($1, $2::INTEGER, $3, $4, $5, $6::INTEGER, $7::INTEGER, $8::JSON) AS "Item"`,
old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text, old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text,
Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id))) Number(boost || 0), bounty ? Number(bounty) : null, JSON.stringify(fwdUsers)))
await createMentions(item, models) await createMentions(item, models)
@ -1149,21 +1177,14 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
} }
let fwdUser const fwdUsers = await getForwardUsers(models, forward)
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
}
}
url = await proxyImages(url) url = await proxyImages(url)
text = await proxyImages(text) text = await proxyImages(text)
const [query] = await serialize( const [query] = await serialize(
models, models,
models.$queryRawUnsafe( models.$queryRawUnsafe(
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${spamInterval}') AS "Item"`, `${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::JSON, '${spamInterval}') AS "Item"`,
parentId ? null : sub || 'bitcoin', parentId ? null : sub || 'bitcoin',
title, title,
url, url,
@ -1172,7 +1193,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
bounty ? Number(bounty) : null, bounty ? Number(bounty) : null,
Number(parentId), Number(parentId),
Number(author.id), Number(author.id),
Number(fwdUser?.id)), JSON.stringify(fwdUsers)),
...trx) ...trx)
const item = trx.length > 0 ? query[0] : query const item = trx.length > 0 ? query[0] : query
@ -1182,11 +1203,27 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
return item return item
} }
const getForwardUsers = async (models, forward) => {
const fwdUsers = []
if (forward) {
// find all users in one db query
const users = await models.user.findMany({ where: { OR: forward.map(fwd => ({ name: fwd.nym })) } })
// map users to fwdUser entries with id and pct
users.forEach(user => {
fwdUsers.push({
userId: user.id,
pct: forward.find(fwd => fwd.nym === user.name).pct
})
})
}
return fwdUsers
}
// we have to do our own query because ltree is unsupported // we have to do our own query because ltree is unsupported
export const SELECT = export const SELECT =
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at, `SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at,
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty", "Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty",
"Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid", "Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt", "Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats, "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",

View File

@ -34,11 +34,15 @@ export default {
FROM "Donation" FROM "Donation"
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day) WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
UNION ALL UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type (SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type
FROM "Item" FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day) WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP'
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) subquery` ) subquery`
return result || { total: 0, time: 0, sources: [] } return result || { total: 0, time: 0, sources: [] }

View File

@ -111,17 +111,33 @@ export default {
} }
if (include.has('stacked')) { if (include.has('stacked')) {
// query1 - get all sats stacked as OP or as a forward
queries.push( queries.push(
`(SELECT ('stacked' || "Item".id) as id, "Item".id as "factId", NULL as bolt11, `(SELECT
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats, ('stacked' || "Item".id) AS id,
0 as "msatsFee", NULL as status, 'stacked' as type "Item".id AS "factId",
NULL AS bolt11,
MAX("ItemAct".created_at) AS "createdAt",
FLOOR(
SUM("ItemAct".msats)
* (CASE WHEN "Item"."userId" = $1 THEN
COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1)
ELSE
(SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
END)
) AS "msats",
0 AS "msatsFee",
NULL AS status,
'stacked' AS type
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" ON "ItemAct"."itemId" = "Item".id
WHERE act = 'TIP' -- only join to with item forward for items where we aren't the OP
AND (("Item"."userId" = $1 AND "Item"."fwdUserId" IS NULL) LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1
OR ("Item"."fwdUserId" = $1 AND "ItemAct"."userId" <> "Item"."userId")) WHERE "ItemAct".act = 'TIP'
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
AND "ItemAct".created_at <= $2 AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`) GROUP BY "Item".id)`
)
queries.push( queries.push(
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11, `(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
created_at as "createdAt", sum(msats), created_at as "createdAt", sum(msats),

View File

@ -3,6 +3,7 @@ import { gql } from 'graphql-tag'
import user from './user' import user from './user'
import message from './message' import message from './message'
import item from './item' import item from './item'
import itemForward from './itemForward'
import wallet from './wallet' import wallet from './wallet'
import lnurl from './lnurl' import lnurl from './lnurl'
import notifications from './notifications' import notifications from './notifications'
@ -32,5 +33,5 @@ const common = gql`
scalar Date scalar Date
` `
export default [common, user, item, message, wallet, lnurl, notifications, invite, export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin] sub, upload, growth, rewards, referrals, price, admin]

View File

@ -26,12 +26,12 @@ export default gql`
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item! upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: [ItemForwardInput]): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item! text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item! upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item! createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item!
updateComment(id: ID!, text: String!): Item! updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean! dontLikeThis(id: ID!): Boolean!
@ -78,8 +78,6 @@ export default gql`
root: Item root: Item
user: User! user: User!
userId: Int! userId: Int!
fwdUserId: Int
fwdUser: User
depth: Int! depth: Int!
mine: Boolean! mine: Boolean!
boost: Int! boost: Int!
@ -115,5 +113,11 @@ export default gql`
uploadId: Int uploadId: Int
otsHash: String otsHash: String
parentOtsHash: String parentOtsHash: String
forwards: [ItemForward]
}
input ItemForwardInput {
nym: String!
pct: Int!
} }
` `

View File

@ -0,0 +1,13 @@
import { gql } from 'graphql-tag'
export default gql`
type ItemForward {
id: ID!
created_at: Date!
updated_at: Date!
itemId: Int!
userId: Int!
user: User!
pct: Int!
}
`

View File

@ -1,14 +1,16 @@
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { Input, InputUserSuggest } from './form' import { Input, InputUserSuggest, VariableInput } from './form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { BOOST_MIN } from '../lib/constants' import { BOOST_MIN, MAX_FORWARDS } from '../lib/constants'
import Info from './info' import Info from './info'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
const EMPTY_FORWARD = { nym: '', pct: '' }
export function AdvPostInitial ({ forward }) { export function AdvPostInitial ({ forward }) {
return { return {
boost: '', boost: '',
forward: forward || '' forward: forward?.length ? forward : [EMPTY_FORWARD]
} }
} }
@ -44,13 +46,36 @@ export default function AdvPostForm ({ edit }) {
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>} hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/> />
<InputUserSuggest <VariableInput
label={<>forward sats to</>} label='forward sats to'
name='forward' name='forward'
hint={<span className='text-muted'>100% of sats will be sent to this stacker</span>} min={0}
prepend={<InputGroup.Text>@</InputGroup.Text>} max={MAX_FORWARDS}
showValid emptyItem={EMPTY_FORWARD}
/> hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
>
{({ index, placeholder }) => {
return (
<div key={index} className='d-flex flex-row'>
<InputUserSuggest
name={`forward[${index}].nym`}
prepend={<InputGroup.Text>@</InputGroup.Text>}
showValid
groupClassName='flex-grow-1 me-3 mb-0'
/>
<Input
name={`forward[${index}].pct`}
type='number'
step='1'
min='1'
max='100'
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
groupClassName='flex-shrink-1 mb-0'
/>
</div>
)
}}
</VariableInput>
</> </>
} }
/> />

View File

@ -10,6 +10,7 @@ import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useInvoiceable } from './invoice' import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form'
export function BountyForm ({ export function BountyForm ({
item, item,
@ -34,7 +35,7 @@ export function BountyForm ({
$bounty: Int! $bounty: Int!
$text: String $text: String
$boost: Int $boost: Int
$forward: String $forward: [ItemForwardInput]
) { ) {
upsertBounty( upsertBounty(
sub: $sub sub: $sub
@ -60,7 +61,8 @@ export function BountyForm ({
id: item?.id, id: item?.id,
boost: boost ? Number(boost) : undefined, boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined, bounty: bounty ? Number(bounty) : undefined,
...values ...values,
forward: normalizeForwards(values.forward)
} }
}) })
if (error) { if (error) {
@ -83,7 +85,7 @@ export function BountyForm ({
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
bounty: item?.bounty || 1000, bounty: item?.bounty || 1000,
...AdvPostInitial({ forward: item?.fwdUser?.name }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}

View File

@ -14,6 +14,7 @@ import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useInvoiceable } from './invoice' import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form'
export function DiscussionForm ({ export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title', item, sub, editThreshold, titleLabel = 'title',
@ -29,7 +30,7 @@ export function DiscussionForm ({
// const me = useMe() // const me = useMe()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
id id
} }
@ -39,7 +40,15 @@ export function DiscussionForm ({
const submitUpsertDiscussion = useCallback( const submitUpsertDiscussion = useCallback(
async (_, boost, values, invoiceHash, invoiceHmac) => { async (_, boost, values, invoiceHash, invoiceHmac) => {
const { error } = await upsertDiscussion({ const { error } = await upsertDiscussion({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash, invoiceHmac } variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
...values,
forward: normalizeForwards(values.forward),
invoiceHash,
invoiceHmac
}
}) })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
@ -74,7 +83,7 @@ export function DiscussionForm ({
initial={{ initial={{
title: item?.title || shareTitle || '', title: item?.title || shareTitle || '',
text: item?.text || '', text: item?.text || '',
...AdvPostInitial({ forward: item?.fwdUser?.name }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}

View File

@ -398,10 +398,10 @@ export function Input ({ label, groupClassName, ...props }) {
) )
} }
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, ...props }) { export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, children, emptyItem = '', ...props }) {
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<FieldArray name={name}> <FieldArray name={name} hasValidation>
{({ form, ...fieldArrayHelpers }) => { {({ form, ...fieldArrayHelpers }) => {
const options = form.values[name] const options = form.values[name]
return ( return (
@ -410,11 +410,22 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
<div key={i}> <div key={i}>
<Row className='mb-2'> <Row className='mb-2'>
<Col> <Col>
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} /> {children
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
</Col> </Col>
{options.length - 1 === i && options.length !== max <Col className='d-flex ps-0' xs='auto'>
? <Col className='d-flex ps-0' xs='auto'><AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push('')} /></Col> {options.length - 1 === i && options.length !== max
: null} ? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
// filler div for col alignment across rows
: <div style={{ width: '24px', height: '24px' }} />}
</Col>
{options.length - 1 === i &&
<>
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
{form.touched[name] && typeof form.errors[name] === 'string' &&
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
</>}
</Row> </Row>
</div> </div>
))} ))}
@ -422,11 +433,6 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
) )
}} }}
</FieldArray> </FieldArray>
{hint && (
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>
)}
</FormGroup> </FormGroup>
) )
} }
@ -482,7 +488,12 @@ export function Form ({
window.localStorage.removeItem(storageKeyPrefix + '-' + v) window.localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) { if (Array.isArray(values[v])) {
values[v].forEach( values[v].forEach(
(_, i) => window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)) (iv, i) => {
Object.keys(iv).forEach(k => {
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
})
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)
})
} }
} }
) )

View File

@ -103,13 +103,18 @@ function ItemEmbed ({ item }) {
return null return null
} }
function FwdUser ({ user }) { function FwdUsers ({ forwards }) {
return ( return (
<div className={styles.other}> <div className={styles.other}>
100% of zaps are forwarded to{' '} zaps forwarded to {' '}
<Link href={`/${user.name}`}> {forwards.map((fwd, index, arr) => (
@{user.name} <span key={fwd.user.name}>
</Link> <Link href={`/${fwd.user.name}`}>
@{fwd.user.name}
</Link>
{` (${fwd.pct}%)`}{index !== arr.length - 1 && ', '}
</span>))}
</div> </div>
) )
} }
@ -128,7 +133,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
<Toc text={item.text} /> <Toc text={item.text} />
</> </>
} }
belowTitle={item.fwdUser && <FwdUser user={item.fwdUser} />} belowTitle={item.forwards && item.forwards.length > 0 && <FwdUsers forwards={item.forwards} />}
{...props} {...props}
> >
<div className={styles.fullItemContainer}> <div className={styles.fullItemContainer}>

View File

@ -15,6 +15,7 @@ import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useInvoiceable } from './invoice' import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form'
export function LinkForm ({ item, sub, editThreshold, children }) { export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
@ -67,7 +68,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const [upsertLink] = useMutation( const [upsertLink] = useMutation(
gql` gql`
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
id id
} }
@ -77,7 +78,16 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const submitUpsertLink = useCallback( const submitUpsertLink = useCallback(
async (_, boost, title, values, invoiceHash, invoiceHmac) => { async (_, boost, title, values, invoiceHash, invoiceHmac) => {
const { error } = await upsertLink({ const { error } = await upsertLink({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, invoiceHmac, ...values } variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
invoiceHash,
invoiceHmac,
...values,
forward: normalizeForwards(values.forward)
}
}) })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
@ -114,7 +124,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
initial={{ initial={{
title: item?.title || shareTitle || '', title: item?.title || shareTitle || '',
url: item?.url || shareUrl || '', url: item?.url || shareUrl || '',
...AdvPostInitial({ forward: item?.fwdUser?.name }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}

View File

@ -243,10 +243,27 @@ function Referral ({ n }) {
} }
function Votification ({ n }) { function Votification ({ n }) {
let forwardedSats = 0
let ForwardedUsers = null
if (n.item.forwards?.length) {
forwardedSats = Math.floor(n.earnedSats * n.item.forwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
ForwardedUsers = () => n.item.forwards.map((fwd, i) =>
<span key={fwd.user.name}>
<Link className='text-success' href={`/${fwd.user.name}`}>
@{fwd.user.name}
</Link>
{i !== n.item.forwards.length - 1 && ', '}
</span>)
}
return ( return (
<> <>
<small className='fw-bold text-success ms-2'> <small className='fw-bold text-success ms-2'>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {numWithUnits(n.earnedSats, { abbreviate: false })}{n.item.fwdUser && ` to @${n.item.fwdUser.name}`} your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
{n.item.forwards?.length > 0 &&
<>
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
<ForwardedUsers />
</>}
</small> </small>
<div> <div>
{n.item.title {n.item.title

View File

@ -12,6 +12,7 @@ import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button' import CancelButton from './cancel-button'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useInvoiceable } from './invoice' import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form'
export function PollForm ({ item, sub, editThreshold, children }) { export function PollForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
@ -21,7 +22,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) { $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text, upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) { options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
id id
@ -40,6 +41,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
title: title.trim(), title: title.trim(),
options: optionsFiltered, options: optionsFiltered,
...values, ...values,
forward: normalizeForwards(values.forward),
invoiceHash, invoiceHash,
invoiceHmac invoiceHmac
} }
@ -65,7 +67,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
options: initialOptions || ['', ''], options: initialOptions || ['', ''],
...AdvPostInitial({ forward: item?.fwdUser?.name }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}
schema={schema} schema={schema}

View File

@ -205,8 +205,17 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}, [pendingSats, act, item, showModal, setPendingSats]) }, [pendingSats, act, item, showModal, setPendingSats])
const disabled = useMemo(() => { const disabled = useMemo(() => {
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt if (item?.mine) {
}, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt]) return true
}
if (me && item?.forwards?.some?.(fwd => Number(fwd.userId) === Number(me?.id))) {
return true
}
if (item?.deletedAt) {
return true
}
return false
}, [me?.id, item?.forwards, item?.mine, item?.deletedAt])
const [meSats, sats, overlayText, color] = useMemo(() => { const [meSats, sats, overlayText, color] = useMemo(() => {
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats

View File

@ -15,7 +15,6 @@ export const ITEM_FIELDS = gql`
hideCowboyHat hideCowboyHat
id id
} }
fwdUserId
otsHash otsHash
position position
sats sats
@ -51,12 +50,6 @@ export const ITEM_FULL_FIELDS = gql`
fragment ItemFullFields on Item { fragment ItemFullFields on Item {
...ItemFields ...ItemFields
text text
fwdUser {
name
streak
hideCowboyHat
id
}
root { root {
id id
title title
@ -70,6 +63,13 @@ export const ITEM_FULL_FIELDS = gql`
id id
} }
} }
forwards {
userId
pct
user {
name
}
}
}` }`
export const ITEM_OTS_FIELDS = gql` export const ITEM_OTS_FIELDS = gql`

View File

@ -50,5 +50,6 @@ module.exports = {
AD_USER_ID: 9, AD_USER_ID: 9,
ANON_POST_FEE: 1000, ANON_POST_FEE: 1000,
ANON_COMMENT_FEE: 100, ANON_COMMENT_FEE: 100,
SSR: typeof window === 'undefined' SSR: typeof window === 'undefined',
MAX_FORWARDS: 5
} }

12
lib/form.js Normal file
View File

@ -0,0 +1,12 @@
/**
* Normalize an array of forwards by converting the pct from a string to a number
* Also extracts nym from nested user object, if necessary
* @param {*} forward Array of forward objects ({nym?: string, pct: string, user?: { name: string } })
* @returns normalized array, or undefined if not provided
*/
export const normalizeForwards = (forward) => {
if (!Array.isArray(forward)) {
return undefined
}
return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) }))
}

View File

@ -1,5 +1,5 @@
import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup' 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 } 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 } from './constants'
import { NAME_QUERY } from '../fragments/users' import { NAME_QUERY } from '../fragments/users'
import { URL_REGEXP, WS_REGEXP } from './url' import { URL_REGEXP, WS_REGEXP } from './url'
import { SUPPORTED_CURRENCIES } from './currency' import { SUPPORTED_CURRENCIES } from './currency'
@ -58,7 +58,6 @@ async function usernameExists (client, name) {
return !!user return !!user
} }
// not sure how to use this on server ...
export function advPostSchemaMembers (client) { export function advPostSchemaMembers (client) {
return { return {
boost: intValidator boost: intValidator
@ -70,14 +69,30 @@ export function advPostSchemaMembers (client) {
}, },
message: `must be divisble be ${BOOST_MIN}` message: `must be divisble be ${BOOST_MIN}`
}), }),
forward: string() // XXX this lets you forward to youself (it's financially equivalent but it should be disallowed)
forward: array()
.max(MAX_FORWARDS, `you can only configure ${MAX_FORWARDS} forward recipients`)
.of(object().shape({
nym: string().required('must specify a stacker').test({
name: 'nym',
test: async name => {
if (!name || !name.length) return true
return await usernameExists(client, name)
},
message: 'stacker does not exist'
}),
pct: intValidator.required('must specify a percentage').min(1, 'percentage must be at least 1').max(100, 'percentage must not exceed 100')
}))
.compact((v) => !v.nym && !v.pct)
.test({ .test({
name: 'name', name: 'sum',
test: async name => { test: forwards => forwards.map(fwd => Number(fwd.pct)).reduce((sum, cur) => sum + cur, 0) <= 100,
if (!name || !name.length) return true message: 'the total forward percentage exceeds 100%'
return await usernameExists(client, name) })
}, .test({
message: 'stacker does not exist' name: 'uniqueStackers',
test: forwards => new Set(forwards.map(fwd => fwd.nym)).size === forwards.length,
message: 'duplicate stackers cannot be specified to receive forwarded sats'
}) })
} }
} }

View File

@ -0,0 +1,402 @@
-- CreateTable
CREATE TABLE "ItemForward" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"itemId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"pct" INTEGER NOT NULL,
CONSTRAINT "ItemForward_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ItemForward" ADD CONSTRAINT "ItemForward_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemForward" ADD CONSTRAINT "ItemForward_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "ItemForward.itemId_index" ON "ItemForward"("itemId");
-- CreateIndex
CREATE INDEX "ItemForward.userId_index" ON "ItemForward"("userId");
-- CreateIndex
CREATE INDEX "ItemForward.createdAt_index" ON "ItemForward"("created_at");
-- Type used in create_item below for JSON processing
CREATE TYPE ItemForwardType as ("userId" INTEGER, "pct" INTEGER);
-- Migrate existing forward entries to the ItemForward table
-- All migrated entries will get 100% sats by default
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT "id" AS "itemId", "fwdUserId", 100 FROM "Item" WHERE "fwdUserId" IS NOT NULL;
-- Remove the existing fwdUserId column now that existing forwards have been migrated
ALTER TABLE "Item" DROP COLUMN "fwdUserId";
-- Delete old create_item function
DROP FUNCTION IF EXISTS create_item(
sub TEXT, title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER,
parent_id INTEGER, user_id INTEGER, forward INTEGER,
spam_within INTERVAL);
-- Update to create ItemForward entries accordingly
CREATE OR REPLACE FUNCTION create_item(
sub TEXT, title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER,
parent_id INTEGER, user_id INTEGER, forward JSON,
spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats INTO user_msats 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, and boost = 0
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (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" = 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
-- addendum: if they're an anon poster, always start at 0
IF med_votes >= 0 OR user_id = 27 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
INSERT INTO "Item"
("subName", title, url, text, bounty, "userId", "parentId",
freebie, "weightedDownVotes", created_at, updated_at)
VALUES
(sub, title, url, text, bounty, user_id, parent_id,
freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" from json_populate_recordset(null::ItemForwardType, forward);
IF NOT freebie THEN
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;
$$;
DROP FUNCTION IF EXISTS update_item(
sub TEXT, item_id INTEGER, item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
item_bounty INTEGER, fwd_user_id INTEGER);
CREATE OR REPLACE FUNCTION update_item(
sub TEXT, item_id INTEGER, item_title TEXT, item_url TEXT, item_text TEXT, boost INTEGER,
item_bounty INTEGER, forward JSON)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
item "Item";
BEGIN
PERFORM ASSERT_SERIALIZED();
UPDATE "Item"
SET "subName" = sub, title = item_title, url = item_url,
text = item_text, bounty = item_bounty
WHERE id = item_id
RETURNING * INTO item;
-- 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 json_populate_recordset(null::ItemForwardType, forward);
IF boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', boost);
END IF;
RETURN item;
END;
$$;
DROP FUNCTION IF EXISTS create_poll(
sub TEXT, title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], fwd_user_id INTEGER, spam_within INTERVAL);
CREATE OR REPLACE FUNCTION create_poll(
sub TEXT, title TEXT, text TEXT, poll_cost INTEGER, boost INTEGER, user_id INTEGER,
options TEXT[], forward JSON, spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := create_item(sub, title, null, text, boost, null, null, user_id, forward, spam_within);
UPDATE "Item" set "pollCost" = poll_cost where id = item.id;
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
DROP FUNCTION IF EXISTS update_poll(
sub TEXT, id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], fwd_user_id INTEGER);
CREATE OR REPLACE FUNCTION update_poll(
sub TEXT, id INTEGER, title TEXT, text TEXT, boost INTEGER,
options TEXT[], forward JSON)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := update_item(sub, id, title, null, text, boost, null, forward);
FOREACH option IN ARRAY options LOOP
INSERT INTO "PollOption" (created_at, updated_at, "itemId", "option") values (now_utc(), now_utc(), item.id, option);
END LOOP;
RETURN item;
END;
$$;
-- Update item_act to support multi-way forward splits
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
act_msats BIGINT;
fee_msats BIGINT;
item_act_id INTEGER;
fwd_entry record; -- for loop iterator variable to iterate across forward recipients
fwd_msats BIGINT; -- for loop variable calculating how many msats to give each forward recipient
total_fwd_msats BIGINT := 0; -- accumulator to see how many msats have been forwarded for the act
BEGIN
PERFORM ASSERT_SERIALIZED();
act_msats := act_sats * 1000;
SELECT msats INTO user_msats FROM users WHERE id = user_id;
IF act_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- deduct msats from actor
UPDATE users SET msats = msats - act_msats WHERE id = user_id;
IF act = 'TIP' THEN
-- call to influence weightedVotes ... we need to do this before we record the acts because
-- the priors acts are taken into account
PERFORM weighted_votes_after_tip(item_id, user_id, act_sats);
-- call to denormalize sats and commentSats
PERFORM sats_after_tip(item_id, user_id, act_msats);
-- take 10% and insert as FEE
fee_msats := CEIL(act_msats * 0.1);
act_msats := act_msats - fee_msats;
-- save the fee act into item_act_id so we can record referral acts
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (fee_msats, item_id, user_id, 'FEE', now_utc(), now_utc())
RETURNING id INTO item_act_id;
-- leave the rest as a tip
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, 'TIP', now_utc(), now_utc());
-- denormalize bounty paid (if applicable)
PERFORM bounty_paid_after_act(item_id, user_id);
-- add sats to actees' balance and stacked count
FOR fwd_entry IN SELECT "userId", "pct" FROM "ItemForward" WHERE "itemId" = item_id
LOOP
-- fwd_msats represents the sats for this forward recipient from this particular tip action
fwd_msats := act_msats * fwd_entry.pct / 100;
-- keep track of how many msats have been forwarded, so we can give any remaining to OP
total_fwd_msats := fwd_msats + total_fwd_msats;
UPDATE users
SET msats = msats + fwd_msats, "stackedMsats" = "stackedMsats" + fwd_msats
WHERE id = fwd_entry."userId";
END LOOP;
-- Give OP any remaining msats after forwards have been applied
IF act_msats - total_fwd_msats > 0 THEN
UPDATE users
SET msats = msats + act_msats - total_fwd_msats, "stackedMsats" = "stackedMsats" + act_msats - total_fwd_msats
WHERE id = (SELECT "userId" FROM "Item" WHERE id = item_id);
END IF;
ELSE -- BOOST, POLL, DONT_LIKE_THIS, STREAM
-- call to influence if DONT_LIKE_THIS weightedDownVotes
IF act = 'DONT_LIKE_THIS' THEN
-- make sure they haven't done this before
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'DONT_LIKE_THIS') THEN
RAISE EXCEPTION 'SN_DUPLICATE';
END IF;
PERFORM weighted_downvotes_after_act(item_id, user_id, act_sats);
END IF;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, act, now_utc(), now_utc())
RETURNING id INTO item_act_id;
END IF;
-- store referral effects
PERFORM referral_act(item_act_id);
RETURN 0;
END;
$$;
DROP FUNCTION referral_act(referrer_id INTEGER, item_act_id INTEGER);
DROP FUNCTION referral_act(referrer_id INTEGER, item_act_id INTEGER, act_msats BIGINT);
-- A new implementation of referral_act that accounts for forwards
CREATE OR REPLACE FUNCTION referral_act(item_act_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
act_act "ItemActType";
act_msats BIGINT;
act_item_id INTEGER;
act_user_id INTEGER;
referrer_id INTEGER;
referral_msats BIGINT;
fwd_ref_msats BIGINT;
total_fwd_ref_msats BIGINT := 0;
fwd_entry record;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- get the sats for the action that haven't already been forwarded
SELECT msats, act, "userId", "itemId"
INTO act_msats, act_act, act_user_id, act_item_id
FROM "ItemAct"
WHERE id = item_act_id;
referral_msats := CEIL(act_msats * .21);
-- take 21% of the act where the referrer is the actor's referrer
IF act_act IN ('BOOST', 'STREAM') THEN
SELECT "referrerId" INTO referrer_id FROM users WHERE id = act_user_id;
IF referrer_id IS NULL THEN
RETURN 0;
END IF;
INSERT INTO "ReferralAct" ("referrerId", "itemActId", msats, created_at, updated_at)
VALUES(referrer_id, item_act_id, referral_msats, now_utc(), now_utc());
UPDATE users
SET msats = msats + referral_msats, "stackedMsats" = "stackedMsats" + referral_msats
WHERE id = referrer_id;
-- take 21% of the fee where the referrer is the item's creator (and/or the item's forward users)
ELSIF act_act = 'FEE' THEN
FOR fwd_entry IN
SELECT users."referrerId" AS referrer_id, "ItemForward"."pct" AS pct
FROM "ItemForward"
JOIN users ON users.id = "ItemForward"."userId"
WHERE "ItemForward"."itemId" = act_item_id
LOOP
-- fwd_msats represents the sats for this forward recipient from this particular tip action
fwd_ref_msats := referral_msats * fwd_entry.pct / 100;
-- keep track of how many msats have been forwarded, so we can give any remaining to OP
total_fwd_ref_msats := fwd_ref_msats + total_fwd_ref_msats;
-- no referrer or tipping their own referee, no referral act
CONTINUE WHEN fwd_entry.referrer_id IS NULL OR fwd_entry.referrer_id = act_user_id;
INSERT INTO "ReferralAct" ("referrerId", "itemActId", msats, created_at, updated_at)
VALUES (fwd_entry.referrer_id, item_act_id, fwd_ref_msats, now_utc(), now_utc());
UPDATE users
SET msats = msats + fwd_ref_msats, "stackedMsats" = "stackedMsats" + fwd_ref_msats
WHERE id = fwd_entry.referrer_id;
END LOOP;
-- Give OP any remaining msats after forwards have been applied
IF referral_msats - total_fwd_ref_msats > 0 THEN
SELECT users."referrerId" INTO referrer_id
FROM "Item"
JOIN users ON users.id = "Item"."userId"
WHERE "Item".id = act_item_id;
IF referrer_id IS NULL OR referrer_id = act_user_id THEN
RETURN 0;
END IF;
INSERT INTO "ReferralAct" ("referrerId", "itemActId", msats, created_at, updated_at)
VALUES (referrer_id, item_act_id, referral_msats - total_fwd_ref_msats, now_utc(), now_utc());
UPDATE users
SET msats = msats + referral_msats - total_fwd_ref_msats,
"stackedMsats" = "stackedMsats" + referral_msats - total_fwd_ref_msats
WHERE id = referrer_id;
END IF;
END IF;
RETURN 0;
END;
$$;
-- constraints on ItemForward
ALTER TABLE "ItemForward" ADD CONSTRAINT "ItemForward_pct_range_check" CHECK ("pct" >= 0 AND "pct" <= 100) NOT VALID;
CREATE OR REPLACE FUNCTION item_forward_pct_total_trigger_func() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
BEGIN
IF (SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = NEW."itemId") > 100 THEN
raise exception 'Total forward pct exceeds 100';
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER item_forward_pct_total_trigger
AFTER INSERT OR UPDATE ON "ItemForward"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE PROCEDURE item_forward_pct_total_trigger_func();

View File

@ -63,7 +63,6 @@ model User {
Earn Earn[] Earn Earn[]
invites Invite[] @relation("Invites") invites Invite[] @relation("Invites")
invoices Invoice[] invoices Invoice[]
fwdItems Item[] @relation("FwdItem")
items Item[] @relation("UserItems") items Item[] @relation("UserItems")
actions ItemAct[] actions ItemAct[]
mentions Mention[] mentions Mention[]
@ -84,6 +83,7 @@ model User {
referrees User[] @relation("referrals") referrees User[] @relation("referrals")
Account Account[] Account Account[]
Session Session[] Session Session[]
itemForwards ItemForward[]
hideBookmarks Boolean @default(false) hideBookmarks Boolean @default(false)
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -228,7 +228,6 @@ model Item {
statusUpdatedAt DateTime? statusUpdatedAt DateTime?
status Status @default(ACTIVE) status Status @default(ACTIVE)
company String? company String?
fwdUserId Int?
weightedVotes Float @default(0) weightedVotes Float @default(0)
boost Int @default(0) boost Int @default(0)
uploadId Int? uploadId Int?
@ -250,7 +249,6 @@ model Item {
upvotes Int @default(0) upvotes Int @default(0)
weightedComments Float @default(0) weightedComments Float @default(0)
Bookmark Bookmark[] Bookmark Bookmark[]
fwdUser User? @relation("FwdItem", fields: [fwdUserId], references: [id])
parent Item? @relation("ParentChildren", fields: [parentId], references: [id]) parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
children Item[] @relation("ParentChildren") children Item[] @relation("ParentChildren")
pin Pin? @relation(fields: [pinId], references: [id]) pin Pin? @relation(fields: [pinId], references: [id])
@ -265,6 +263,7 @@ model Item {
ThreadSubscription ThreadSubscription[] ThreadSubscription ThreadSubscription[]
upload Upload? upload Upload?
User User[] User User[]
itemForwards ItemForward[]
@@index([bio], map: "Item.bio_index") @@index([bio], map: "Item.bio_index")
@@index([createdAt], map: "Item.created_at_index") @@index([createdAt], map: "Item.created_at_index")
@ -283,6 +282,23 @@ model Item {
@@index([weightedVotes], map: "Item.weightedVotes_index") @@index([weightedVotes], map: "Item.weightedVotes_index")
} }
// TODO: make all Item's forward 100% of sats to the OP by default
// so that forwards aren't a special case everywhere
model ItemForward {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int // The item from which sats are forwarded
userId Int // The recipient of the forwarded sats
pct Int // The percentage of sats from the item to forward to this user
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([itemId], map: "ItemForward.itemId_index")
@@index([userId], map: "ItemForward.userId_index")
@@index([createdAt], map: "ItemForward.createdAt_index")
}
model PollOption { model PollOption {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -496,6 +512,8 @@ model Bookmark {
@@index([createdAt], map: "Bookmark.created_at_index") @@index([createdAt], map: "Bookmark.created_at_index")
} }
// TODO: make thread subscriptions for OP by default so they can
// unsubscribe from their own threads and its not a special case
model ThreadSubscription { model ThreadSubscription {
userId Int userId Int
itemId Int itemId Int

View File

@ -24,11 +24,15 @@ function earn ({ models }) {
FROM "Donation" FROM "Donation"
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')) WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago'))
UNION ALL UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats (SELECT "ItemAct".msats
FROM "Item" FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')) WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP'
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
GROUP BY "ItemAct".id, "ItemAct".msats
HAVING COUNT("ItemForward".id) = 0)
) subquery` ) subquery`
// XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js) // XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js)