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)
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
}
}
const fwdUsers = await getForwardUsers(models, forward)
if (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' } })
}
const [item] = await serialize(models,
models.$queryRawUnsafe(`${SELECT} FROM update_poll($1, $2::INTEGER, $3, $4, $5::INTEGER, $6, $7::INTEGER) AS "Item"`,
sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
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, JSON.stringify(fwdUsers)))
await createMentions(item, models)
item.comments = []
@ -688,8 +682,8 @@ export default {
} else {
const [query] = await serialize(models,
models.$queryRawUnsafe(
`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx)
`${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, JSON.stringify(fwdUsers)), ...trx)
const item = trx.length > 0 ? query[0] : query
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 = [
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 updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${
numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
sendUserNotification(updatedItem.userId, {
title,
body: updatedItem.title ? updatedItem.title : updatedItem.text,
item: updatedItem,
tag: `TIP-${updatedItem.id}`
}).catch(console.error)
const notify = async () => {
try {
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
const forwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } }))
const userResults = await Promise.allSettled(userPromises)
const mappedForwards = forwards.map((fwd, index) => ({ ...fwd, user: userResults[index].value ?? null }))
let forwardedSats = 0
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 {
vote,
@ -919,11 +950,15 @@ export default {
}
return await models.user.findUnique({ where: { id: item.userId } })
},
fwdUser: async (item, args, { models }) => {
if (!item.fwdUserId) {
return null
}
return await models.user.findUnique({ where: { id: item.fwdUserId } })
forwards: async (item, args, { models }) => {
return await models.itemForward.findMany({
where: {
itemId: item.id
},
include: {
user: true
}
})
},
comments: async (item, { sort }, { me, models }) => {
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' } })
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
}
}
const fwdUsers = await getForwardUsers(models, forward)
url = await proxyImages(url)
text = await proxyImages(text)
const [item] = await serialize(models,
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,
Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
Number(boost || 0), bounty ? Number(bounty) : null, JSON.stringify(fwdUsers)))
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' } })
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
}
}
const fwdUsers = await getForwardUsers(models, forward)
url = await proxyImages(url)
text = await proxyImages(text)
const [query] = await serialize(
models,
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',
title,
url,
@ -1172,7 +1193,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
bounty ? Number(bounty) : null,
Number(parentId),
Number(author.id),
Number(fwdUser?.id)),
JSON.stringify(fwdUsers)),
...trx)
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
}
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
export const SELECT =
`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"."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"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",

View File

@ -34,11 +34,15 @@ export default {
FROM "Donation"
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
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
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
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`
return result || { total: 0, time: 0, sources: [] }

View File

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

View File

@ -3,6 +3,7 @@ import { gql } from 'graphql-tag'
import user from './user'
import message from './message'
import item from './item'
import itemForward from './itemForward'
import wallet from './wallet'
import lnurl from './lnurl'
import notifications from './notifications'
@ -32,5 +33,5 @@ const common = gql`
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]

View File

@ -26,12 +26,12 @@ export default gql`
bookmarkItem(id: ID): Item
subscribeItem(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!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: 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: [ItemForwardInput], invoiceHash: String, invoiceHmac: 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,
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!
updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean!
@ -78,8 +78,6 @@ export default gql`
root: Item
user: User!
userId: Int!
fwdUserId: Int
fwdUser: User
depth: Int!
mine: Boolean!
boost: Int!
@ -115,5 +113,11 @@ export default gql`
uploadId: Int
otsHash: 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 { Input, InputUserSuggest } from './form'
import { Input, InputUserSuggest, VariableInput } from './form'
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 { numWithUnits } from '../lib/format'
const EMPTY_FORWARD = { nym: '', pct: '' }
export function AdvPostInitial ({ forward }) {
return {
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>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<InputUserSuggest
label={<>forward sats to</>}
<VariableInput
label='forward sats to'
name='forward'
hint={<span className='text-muted'>100% of sats will be sent to this stacker</span>}
prepend={<InputGroup.Text>@</InputGroup.Text>}
showValid
/>
min={0}
max={MAX_FORWARDS}
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 { useCallback } from 'react'
import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form'
export function BountyForm ({
item,
@ -34,7 +35,7 @@ export function BountyForm ({
$bounty: Int!
$text: String
$boost: Int
$forward: String
$forward: [ItemForwardInput]
) {
upsertBounty(
sub: $sub
@ -60,7 +61,8 @@ export function BountyForm ({
id: item?.id,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
...values
...values,
forward: normalizeForwards(values.forward)
}
})
if (error) {
@ -83,7 +85,7 @@ export function BountyForm ({
title: item?.title || '',
text: item?.text || '',
bounty: item?.bounty || 1000,
...AdvPostInitial({ forward: item?.fwdUser?.name }),
...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}

View File

@ -14,6 +14,7 @@ import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
import { normalizeForwards } from '../lib/form'
export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title',
@ -29,7 +30,7 @@ export function DiscussionForm ({
// const me = useMe()
const [upsertDiscussion] = useMutation(
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) {
id
}
@ -39,7 +40,15 @@ export function DiscussionForm ({
const submitUpsertDiscussion = useCallback(
async (_, boost, values, invoiceHash, invoiceHmac) => {
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) {
throw new Error({ message: error.toString() })
@ -74,7 +83,7 @@ export function DiscussionForm ({
initial={{
title: item?.title || shareTitle || '',
text: item?.text || '',
...AdvPostInitial({ forward: item?.fwdUser?.name }),
...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
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 (
<FormGroup label={label} className={groupClassName}>
<FieldArray name={name}>
<FieldArray name={name} hasValidation>
{({ form, ...fieldArrayHelpers }) => {
const options = form.values[name]
return (
@ -410,11 +410,22 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
<div key={i}>
<Row className='mb-2'>
<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>
{options.length - 1 === i && options.length !== max
? <Col className='d-flex ps-0' xs='auto'><AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push('')} /></Col>
: null}
<Col className='d-flex ps-0' xs='auto'>
{options.length - 1 === i && options.length !== max
? <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>
</div>
))}
@ -422,11 +433,6 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
)
}}
</FieldArray>
{hint && (
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>
)}
</FormGroup>
)
}
@ -482,7 +488,12 @@ export function Form ({
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) {
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
}
function FwdUser ({ user }) {
function FwdUsers ({ forwards }) {
return (
<div className={styles.other}>
100% of zaps are forwarded to{' '}
<Link href={`/${user.name}`}>
@{user.name}
</Link>
zaps forwarded to {' '}
{forwards.map((fwd, index, arr) => (
<span key={fwd.user.name}>
<Link href={`/${fwd.user.name}`}>
@{fwd.user.name}
</Link>
{` (${fwd.pct}%)`}{index !== arr.length - 1 && ', '}
</span>))}
</div>
)
}
@ -128,7 +133,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
<Toc text={item.text} />
</>
}
belowTitle={item.fwdUser && <FwdUser user={item.fwdUser} />}
belowTitle={item.forwards && item.forwards.length > 0 && <FwdUsers forwards={item.forwards} />}
{...props}
>
<div className={styles.fullItemContainer}>

View File

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

View File

@ -243,10 +243,27 @@ function Referral ({ 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 (
<>
<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>
<div>
{n.item.title

View File

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

View File

@ -205,8 +205,17 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}, [pendingSats, act, item, showModal, setPendingSats])
const disabled = useMemo(() => {
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
}, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt])
if (item?.mine) {
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 = (item?.meSats || item?.meAnonSats || 0) + pendingSats

View File

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

View File

@ -50,5 +50,6 @@ module.exports = {
AD_USER_ID: 9,
ANON_POST_FEE: 1000,
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 { 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 { URL_REGEXP, WS_REGEXP } from './url'
import { SUPPORTED_CURRENCIES } from './currency'
@ -58,7 +58,6 @@ async function usernameExists (client, name) {
return !!user
}
// not sure how to use this on server ...
export function advPostSchemaMembers (client) {
return {
boost: intValidator
@ -70,14 +69,30 @@ export function advPostSchemaMembers (client) {
},
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({
name: 'name',
test: async name => {
if (!name || !name.length) return true
return await usernameExists(client, name)
},
message: 'stacker does not exist'
name: 'sum',
test: forwards => forwards.map(fwd => Number(fwd.pct)).reduce((sum, cur) => sum + cur, 0) <= 100,
message: 'the total forward percentage exceeds 100%'
})
.test({
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[]
invites Invite[] @relation("Invites")
invoices Invoice[]
fwdItems Item[] @relation("FwdItem")
items Item[] @relation("UserItems")
actions ItemAct[]
mentions Mention[]
@ -84,6 +83,7 @@ model User {
referrees User[] @relation("referrals")
Account Account[]
Session Session[]
itemForwards ItemForward[]
hideBookmarks Boolean @default(false)
@@index([createdAt], map: "users.created_at_index")
@ -228,7 +228,6 @@ model Item {
statusUpdatedAt DateTime?
status Status @default(ACTIVE)
company String?
fwdUserId Int?
weightedVotes Float @default(0)
boost Int @default(0)
uploadId Int?
@ -250,7 +249,6 @@ model Item {
upvotes Int @default(0)
weightedComments Float @default(0)
Bookmark Bookmark[]
fwdUser User? @relation("FwdItem", fields: [fwdUserId], references: [id])
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
children Item[] @relation("ParentChildren")
pin Pin? @relation(fields: [pinId], references: [id])
@ -265,6 +263,7 @@ model Item {
ThreadSubscription ThreadSubscription[]
upload Upload?
User User[]
itemForwards ItemForward[]
@@index([bio], map: "Item.bio_index")
@@index([createdAt], map: "Item.created_at_index")
@ -283,6 +282,23 @@ model Item {
@@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 {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
@ -496,6 +512,8 @@ model Bookmark {
@@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 {
userId Int
itemId Int

View File

@ -24,11 +24,15 @@ function earn ({ models }) {
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'))
UNION ALL
-- any earnings from anon's stack that are not forwarded to other users
(SELECT "ItemAct".msats
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL
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'))
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
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`
// XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js)