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:
parent
07e065d4be
commit
3da395a792
|
@ -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",
|
||||
|
|
|
@ -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: [] }
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
`
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}]`)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) }))
|
||||
}
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue