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)
|
await ssValidate(pollSchema, data, models, optionCount)
|
||||||
|
|
||||||
let fwdUser
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
if (forward) {
|
|
||||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
|
||||||
if (!fwdUser) {
|
|
||||||
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
@ -679,8 +673,8 @@ export default {
|
|||||||
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
const [item] = await serialize(models,
|
const [item] = await serialize(models,
|
||||||
models.$queryRawUnsafe(`${SELECT} FROM update_poll($1, $2::INTEGER, $3, $4, $5::INTEGER, $6, $7::INTEGER) AS "Item"`,
|
models.$queryRawUnsafe(`${SELECT} FROM update_poll($1, $2::INTEGER, $3, $4, $5::INTEGER, $6, $7::JSON) AS "Item"`,
|
||||||
sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, Number(fwdUser?.id)))
|
sub || 'bitcoin', Number(id), title, text, Number(boost || 0), options, JSON.stringify(fwdUsers)))
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
item.comments = []
|
item.comments = []
|
||||||
@ -688,8 +682,8 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
const [query] = await serialize(models,
|
const [query] = await serialize(models,
|
||||||
models.$queryRawUnsafe(
|
models.$queryRawUnsafe(
|
||||||
`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`,
|
`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::JSON, '${spamInterval}') AS "Item"`,
|
||||||
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx)
|
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, JSON.stringify(fwdUsers)), ...trx)
|
||||||
const item = trx.length > 0 ? query[0] : query
|
const item = trx.length > 0 ? query[0] : query
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
@ -794,6 +788,12 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disallow tips if me is one of the forward user recipients
|
||||||
|
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
|
||||||
|
if (existingForwards.some(fwd => Number(fwd.userId) === Number(user.id))) {
|
||||||
|
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
const calls = [
|
const calls = [
|
||||||
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
|
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
|
||||||
]
|
]
|
||||||
@ -803,15 +803,46 @@ export default {
|
|||||||
|
|
||||||
const [{ item_act: vote }] = await serialize(models, ...calls)
|
const [{ item_act: vote }] = await serialize(models, ...calls)
|
||||||
|
|
||||||
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
|
const notify = async () => {
|
||||||
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${
|
try {
|
||||||
numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
|
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
sendUserNotification(updatedItem.userId, {
|
const forwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
|
||||||
title,
|
const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } }))
|
||||||
body: updatedItem.title ? updatedItem.title : updatedItem.text,
|
const userResults = await Promise.allSettled(userPromises)
|
||||||
item: updatedItem,
|
const mappedForwards = forwards.map((fwd, index) => ({ ...fwd, user: userResults[index].value ?? null }))
|
||||||
tag: `TIP-${updatedItem.id}`
|
let forwardedSats = 0
|
||||||
}).catch(console.error)
|
let forwardedUsers = ''
|
||||||
|
if (mappedForwards.length) {
|
||||||
|
forwardedSats = Math.floor(msatsToSats(updatedItem.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
|
||||||
|
forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ')
|
||||||
|
}
|
||||||
|
let notificationTitle
|
||||||
|
if (updatedItem.title) {
|
||||||
|
if (forwards.length > 0) {
|
||||||
|
notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
|
||||||
|
} else {
|
||||||
|
notificationTitle = `your post stacked ${numWithUnits(msatsToSats(updatedItem.msats))}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (forwards.length > 0) {
|
||||||
|
// I don't think this case is possible
|
||||||
|
notificationTitle = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
|
||||||
|
} else {
|
||||||
|
notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(updatedItem.msats))}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sendUserNotification(updatedItem.userId, {
|
||||||
|
title: notificationTitle,
|
||||||
|
body: updatedItem.title ? updatedItem.title : updatedItem.text,
|
||||||
|
item: updatedItem,
|
||||||
|
tag: `TIP-${updatedItem.id}`
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vote,
|
vote,
|
||||||
@ -919,11 +950,15 @@ export default {
|
|||||||
}
|
}
|
||||||
return await models.user.findUnique({ where: { id: item.userId } })
|
return await models.user.findUnique({ where: { id: item.userId } })
|
||||||
},
|
},
|
||||||
fwdUser: async (item, args, { models }) => {
|
forwards: async (item, args, { models }) => {
|
||||||
if (!item.fwdUserId) {
|
return await models.itemForward.findMany({
|
||||||
return null
|
where: {
|
||||||
}
|
itemId: item.id
|
||||||
return await models.user.findUnique({ where: { id: item.fwdUserId } })
|
},
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
comments: async (item, { sort }, { me, models }) => {
|
comments: async (item, { sort }, { me, models }) => {
|
||||||
if (typeof item.comments !== 'undefined') return item.comments
|
if (typeof item.comments !== 'undefined') return item.comments
|
||||||
@ -1104,22 +1139,15 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
|
|||||||
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
let fwdUser
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
if (forward) {
|
|
||||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
|
||||||
if (!fwdUser) {
|
|
||||||
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url = await proxyImages(url)
|
url = await proxyImages(url)
|
||||||
text = await proxyImages(text)
|
text = await proxyImages(text)
|
||||||
|
|
||||||
const [item] = await serialize(models,
|
const [item] = await serialize(models,
|
||||||
models.$queryRawUnsafe(
|
models.$queryRawUnsafe(
|
||||||
`${SELECT} FROM update_item($1, $2::INTEGER, $3, $4, $5, $6::INTEGER, $7::INTEGER, $8::INTEGER) AS "Item"`,
|
`${SELECT} FROM update_item($1, $2::INTEGER, $3, $4, $5, $6::INTEGER, $7::INTEGER, $8::JSON) AS "Item"`,
|
||||||
old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text,
|
old.parentId ? null : sub || 'bitcoin', Number(id), title, url, text,
|
||||||
Number(boost || 0), bounty ? Number(bounty) : null, Number(fwdUser?.id)))
|
Number(boost || 0), bounty ? Number(bounty) : null, JSON.stringify(fwdUsers)))
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
|
|
||||||
@ -1149,21 +1177,14 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
|
|||||||
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
let fwdUser
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
if (forward) {
|
|
||||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
|
||||||
if (!fwdUser) {
|
|
||||||
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url = await proxyImages(url)
|
url = await proxyImages(url)
|
||||||
text = await proxyImages(text)
|
text = await proxyImages(text)
|
||||||
|
|
||||||
const [query] = await serialize(
|
const [query] = await serialize(
|
||||||
models,
|
models,
|
||||||
models.$queryRawUnsafe(
|
models.$queryRawUnsafe(
|
||||||
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${spamInterval}') AS "Item"`,
|
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::JSON, '${spamInterval}') AS "Item"`,
|
||||||
parentId ? null : sub || 'bitcoin',
|
parentId ? null : sub || 'bitcoin',
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
@ -1172,7 +1193,7 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
|
|||||||
bounty ? Number(bounty) : null,
|
bounty ? Number(bounty) : null,
|
||||||
Number(parentId),
|
Number(parentId),
|
||||||
Number(author.id),
|
Number(author.id),
|
||||||
Number(fwdUser?.id)),
|
JSON.stringify(fwdUsers)),
|
||||||
...trx)
|
...trx)
|
||||||
const item = trx.length > 0 ? query[0] : query
|
const item = trx.length > 0 ? query[0] : query
|
||||||
|
|
||||||
@ -1182,11 +1203,27 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getForwardUsers = async (models, forward) => {
|
||||||
|
const fwdUsers = []
|
||||||
|
if (forward) {
|
||||||
|
// find all users in one db query
|
||||||
|
const users = await models.user.findMany({ where: { OR: forward.map(fwd => ({ name: fwd.nym })) } })
|
||||||
|
// map users to fwdUser entries with id and pct
|
||||||
|
users.forEach(user => {
|
||||||
|
fwdUsers.push({
|
||||||
|
userId: user.id,
|
||||||
|
pct: forward.find(fwd => fwd.nym === user.name).pct
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fwdUsers
|
||||||
|
}
|
||||||
|
|
||||||
// we have to do our own query because ltree is unsupported
|
// we have to do our own query because ltree is unsupported
|
||||||
export const SELECT =
|
export const SELECT =
|
||||||
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at,
|
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at,
|
||||||
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty",
|
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty",
|
||||||
"Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
"Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
||||||
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
|
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
|
||||||
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
|
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
|
||||||
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
||||||
|
@ -34,11 +34,15 @@ export default {
|
|||||||
FROM "Donation"
|
FROM "Donation"
|
||||||
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
|
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
-- any earnings from anon's stack that are not forwarded to other users
|
||||||
(SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type
|
(SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
||||||
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL
|
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
|
||||||
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
|
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP'
|
||||||
|
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day
|
||||||
|
GROUP BY "ItemAct".id, "ItemAct".msats
|
||||||
|
HAVING COUNT("ItemForward".id) = 0)
|
||||||
) subquery`
|
) subquery`
|
||||||
|
|
||||||
return result || { total: 0, time: 0, sources: [] }
|
return result || { total: 0, time: 0, sources: [] }
|
||||||
|
@ -111,17 +111,33 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (include.has('stacked')) {
|
if (include.has('stacked')) {
|
||||||
|
// query1 - get all sats stacked as OP or as a forward
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('stacked' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
|
`(SELECT
|
||||||
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
|
('stacked' || "Item".id) AS id,
|
||||||
0 as "msatsFee", NULL as status, 'stacked' as type
|
"Item".id AS "factId",
|
||||||
|
NULL AS bolt11,
|
||||||
|
MAX("ItemAct".created_at) AS "createdAt",
|
||||||
|
FLOOR(
|
||||||
|
SUM("ItemAct".msats)
|
||||||
|
* (CASE WHEN "Item"."userId" = $1 THEN
|
||||||
|
COALESCE(1 - ((SELECT SUM(pct) FROM "ItemForward" WHERE "itemId" = "Item".id) / 100.0), 1)
|
||||||
|
ELSE
|
||||||
|
(SELECT pct FROM "ItemForward" WHERE "itemId" = "Item".id AND "userId" = $1) / 100.0
|
||||||
|
END)
|
||||||
|
) AS "msats",
|
||||||
|
0 AS "msatsFee",
|
||||||
|
NULL AS status,
|
||||||
|
'stacked' AS type
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
|
||||||
WHERE act = 'TIP'
|
-- only join to with item forward for items where we aren't the OP
|
||||||
AND (("Item"."userId" = $1 AND "Item"."fwdUserId" IS NULL)
|
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "Item"."userId" <> $1
|
||||||
OR ("Item"."fwdUserId" = $1 AND "ItemAct"."userId" <> "Item"."userId"))
|
WHERE "ItemAct".act = 'TIP'
|
||||||
|
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
|
||||||
AND "ItemAct".created_at <= $2
|
AND "ItemAct".created_at <= $2
|
||||||
GROUP BY "Item".id)`)
|
GROUP BY "Item".id)`
|
||||||
|
)
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
|
`(SELECT ('earn' || min("Earn".id)) as id, min("Earn".id) as "factId", NULL as bolt11,
|
||||||
created_at as "createdAt", sum(msats),
|
created_at as "createdAt", sum(msats),
|
||||||
|
@ -3,6 +3,7 @@ import { gql } from 'graphql-tag'
|
|||||||
import user from './user'
|
import user from './user'
|
||||||
import message from './message'
|
import message from './message'
|
||||||
import item from './item'
|
import item from './item'
|
||||||
|
import itemForward from './itemForward'
|
||||||
import wallet from './wallet'
|
import wallet from './wallet'
|
||||||
import lnurl from './lnurl'
|
import lnurl from './lnurl'
|
||||||
import notifications from './notifications'
|
import notifications from './notifications'
|
||||||
@ -32,5 +33,5 @@ const common = gql`
|
|||||||
scalar Date
|
scalar Date
|
||||||
`
|
`
|
||||||
|
|
||||||
export default [common, user, item, message, wallet, lnurl, notifications, invite,
|
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
||||||
sub, upload, growth, rewards, referrals, price, admin]
|
sub, upload, growth, rewards, referrals, price, admin]
|
||||||
|
@ -26,12 +26,12 @@ export default gql`
|
|||||||
bookmarkItem(id: ID): Item
|
bookmarkItem(id: ID): Item
|
||||||
subscribeItem(id: ID): Item
|
subscribeItem(id: ID): Item
|
||||||
deleteItem(id: ID): Item
|
deleteItem(id: ID): Item
|
||||||
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
|
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
|
||||||
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
|
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
|
||||||
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item!
|
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: [ItemForwardInput]): Item!
|
||||||
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
||||||
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
|
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
|
||||||
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
|
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
|
||||||
createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item!
|
createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item!
|
||||||
updateComment(id: ID!, text: String!): Item!
|
updateComment(id: ID!, text: String!): Item!
|
||||||
dontLikeThis(id: ID!): Boolean!
|
dontLikeThis(id: ID!): Boolean!
|
||||||
@ -78,8 +78,6 @@ export default gql`
|
|||||||
root: Item
|
root: Item
|
||||||
user: User!
|
user: User!
|
||||||
userId: Int!
|
userId: Int!
|
||||||
fwdUserId: Int
|
|
||||||
fwdUser: User
|
|
||||||
depth: Int!
|
depth: Int!
|
||||||
mine: Boolean!
|
mine: Boolean!
|
||||||
boost: Int!
|
boost: Int!
|
||||||
@ -115,5 +113,11 @@ export default gql`
|
|||||||
uploadId: Int
|
uploadId: Int
|
||||||
otsHash: String
|
otsHash: String
|
||||||
parentOtsHash: String
|
parentOtsHash: String
|
||||||
|
forwards: [ItemForward]
|
||||||
|
}
|
||||||
|
|
||||||
|
input ItemForwardInput {
|
||||||
|
nym: String!
|
||||||
|
pct: Int!
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
13
api/typeDefs/itemForward.js
Normal file
13
api/typeDefs/itemForward.js
Normal 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!
|
||||||
|
}
|
||||||
|
`
|
@ -1,14 +1,16 @@
|
|||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import { Input, InputUserSuggest } from './form'
|
import { Input, InputUserSuggest, VariableInput } from './form'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
import { BOOST_MIN } from '../lib/constants'
|
import { BOOST_MIN, MAX_FORWARDS } from '../lib/constants'
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
import { numWithUnits } from '../lib/format'
|
import { numWithUnits } from '../lib/format'
|
||||||
|
|
||||||
|
const EMPTY_FORWARD = { nym: '', pct: '' }
|
||||||
|
|
||||||
export function AdvPostInitial ({ forward }) {
|
export function AdvPostInitial ({ forward }) {
|
||||||
return {
|
return {
|
||||||
boost: '',
|
boost: '',
|
||||||
forward: forward || ''
|
forward: forward?.length ? forward : [EMPTY_FORWARD]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,13 +46,36 @@ export default function AdvPostForm ({ edit }) {
|
|||||||
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
|
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
/>
|
/>
|
||||||
<InputUserSuggest
|
<VariableInput
|
||||||
label={<>forward sats to</>}
|
label='forward sats to'
|
||||||
name='forward'
|
name='forward'
|
||||||
hint={<span className='text-muted'>100% of sats will be sent to this stacker</span>}
|
min={0}
|
||||||
prepend={<InputGroup.Text>@</InputGroup.Text>}
|
max={MAX_FORWARDS}
|
||||||
showValid
|
emptyItem={EMPTY_FORWARD}
|
||||||
/>
|
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
|
||||||
|
>
|
||||||
|
{({ index, placeholder }) => {
|
||||||
|
return (
|
||||||
|
<div key={index} className='d-flex flex-row'>
|
||||||
|
<InputUserSuggest
|
||||||
|
name={`forward[${index}].nym`}
|
||||||
|
prepend={<InputGroup.Text>@</InputGroup.Text>}
|
||||||
|
showValid
|
||||||
|
groupClassName='flex-grow-1 me-3 mb-0'
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name={`forward[${index}].pct`}
|
||||||
|
type='number'
|
||||||
|
step='1'
|
||||||
|
min='1'
|
||||||
|
max='100'
|
||||||
|
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
|
||||||
|
groupClassName='flex-shrink-1 mb-0'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</VariableInput>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -10,6 +10,7 @@ import { SubSelectInitial } from './sub-select-form'
|
|||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useInvoiceable } from './invoice'
|
import { useInvoiceable } from './invoice'
|
||||||
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function BountyForm ({
|
export function BountyForm ({
|
||||||
item,
|
item,
|
||||||
@ -34,7 +35,7 @@ export function BountyForm ({
|
|||||||
$bounty: Int!
|
$bounty: Int!
|
||||||
$text: String
|
$text: String
|
||||||
$boost: Int
|
$boost: Int
|
||||||
$forward: String
|
$forward: [ItemForwardInput]
|
||||||
) {
|
) {
|
||||||
upsertBounty(
|
upsertBounty(
|
||||||
sub: $sub
|
sub: $sub
|
||||||
@ -60,7 +61,8 @@ export function BountyForm ({
|
|||||||
id: item?.id,
|
id: item?.id,
|
||||||
boost: boost ? Number(boost) : undefined,
|
boost: boost ? Number(boost) : undefined,
|
||||||
bounty: bounty ? Number(bounty) : undefined,
|
bounty: bounty ? Number(bounty) : undefined,
|
||||||
...values
|
...values,
|
||||||
|
forward: normalizeForwards(values.forward)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -83,7 +85,7 @@ export function BountyForm ({
|
|||||||
title: item?.title || '',
|
title: item?.title || '',
|
||||||
text: item?.text || '',
|
text: item?.text || '',
|
||||||
bounty: item?.bounty || 1000,
|
bounty: item?.bounty || 1000,
|
||||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
@ -14,6 +14,7 @@ import { SubSelectInitial } from './sub-select-form'
|
|||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useInvoiceable } from './invoice'
|
import { useInvoiceable } from './invoice'
|
||||||
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function DiscussionForm ({
|
export function DiscussionForm ({
|
||||||
item, sub, editThreshold, titleLabel = 'title',
|
item, sub, editThreshold, titleLabel = 'title',
|
||||||
@ -29,7 +30,7 @@ export function DiscussionForm ({
|
|||||||
// const me = useMe()
|
// const me = useMe()
|
||||||
const [upsertDiscussion] = useMutation(
|
const [upsertDiscussion] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) {
|
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
|
||||||
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@ -39,7 +40,15 @@ export function DiscussionForm ({
|
|||||||
const submitUpsertDiscussion = useCallback(
|
const submitUpsertDiscussion = useCallback(
|
||||||
async (_, boost, values, invoiceHash, invoiceHmac) => {
|
async (_, boost, values, invoiceHash, invoiceHmac) => {
|
||||||
const { error } = await upsertDiscussion({
|
const { error } = await upsertDiscussion({
|
||||||
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash, invoiceHmac }
|
variables: {
|
||||||
|
sub: item?.subName || sub?.name,
|
||||||
|
id: item?.id,
|
||||||
|
boost: boost ? Number(boost) : undefined,
|
||||||
|
...values,
|
||||||
|
forward: normalizeForwards(values.forward),
|
||||||
|
invoiceHash,
|
||||||
|
invoiceHmac
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
@ -74,7 +83,7 @@ export function DiscussionForm ({
|
|||||||
initial={{
|
initial={{
|
||||||
title: item?.title || shareTitle || '',
|
title: item?.title || shareTitle || '',
|
||||||
text: item?.text || '',
|
text: item?.text || '',
|
||||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
@ -398,10 +398,10 @@ export function Input ({ label, groupClassName, ...props }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, ...props }) {
|
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, children, emptyItem = '', ...props }) {
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<FieldArray name={name}>
|
<FieldArray name={name} hasValidation>
|
||||||
{({ form, ...fieldArrayHelpers }) => {
|
{({ form, ...fieldArrayHelpers }) => {
|
||||||
const options = form.values[name]
|
const options = form.values[name]
|
||||||
return (
|
return (
|
||||||
@ -410,11 +410,22 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
|
|||||||
<div key={i}>
|
<div key={i}>
|
||||||
<Row className='mb-2'>
|
<Row className='mb-2'>
|
||||||
<Col>
|
<Col>
|
||||||
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />
|
{children
|
||||||
|
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
|
||||||
|
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
|
||||||
</Col>
|
</Col>
|
||||||
{options.length - 1 === i && options.length !== max
|
<Col className='d-flex ps-0' xs='auto'>
|
||||||
? <Col className='d-flex ps-0' xs='auto'><AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push('')} /></Col>
|
{options.length - 1 === i && options.length !== max
|
||||||
: null}
|
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
|
||||||
|
// filler div for col alignment across rows
|
||||||
|
: <div style={{ width: '24px', height: '24px' }} />}
|
||||||
|
</Col>
|
||||||
|
{options.length - 1 === i &&
|
||||||
|
<>
|
||||||
|
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
|
||||||
|
{form.touched[name] && typeof form.errors[name] === 'string' &&
|
||||||
|
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
|
||||||
|
</>}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -422,11 +433,6 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
{hint && (
|
|
||||||
<BootstrapForm.Text>
|
|
||||||
{hint}
|
|
||||||
</BootstrapForm.Text>
|
|
||||||
)}
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -482,7 +488,12 @@ export function Form ({
|
|||||||
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
|
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
|
||||||
if (Array.isArray(values[v])) {
|
if (Array.isArray(values[v])) {
|
||||||
values[v].forEach(
|
values[v].forEach(
|
||||||
(_, i) => window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`))
|
(iv, i) => {
|
||||||
|
Object.keys(iv).forEach(k => {
|
||||||
|
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
|
||||||
|
})
|
||||||
|
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -103,13 +103,18 @@ function ItemEmbed ({ item }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function FwdUser ({ user }) {
|
function FwdUsers ({ forwards }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.other}>
|
<div className={styles.other}>
|
||||||
100% of zaps are forwarded to{' '}
|
zaps forwarded to {' '}
|
||||||
<Link href={`/${user.name}`}>
|
{forwards.map((fwd, index, arr) => (
|
||||||
@{user.name}
|
<span key={fwd.user.name}>
|
||||||
</Link>
|
<Link href={`/${fwd.user.name}`}>
|
||||||
|
@{fwd.user.name}
|
||||||
|
</Link>
|
||||||
|
{` (${fwd.pct}%)`}{index !== arr.length - 1 && ', '}
|
||||||
|
</span>))}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -128,7 +133,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
|||||||
<Toc text={item.text} />
|
<Toc text={item.text} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
belowTitle={item.fwdUser && <FwdUser user={item.fwdUser} />}
|
belowTitle={item.forwards && item.forwards.length > 0 && <FwdUsers forwards={item.forwards} />}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className={styles.fullItemContainer}>
|
<div className={styles.fullItemContainer}>
|
||||||
|
@ -15,6 +15,7 @@ import Moon from '../svgs/moon-fill.svg'
|
|||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select-form'
|
||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useInvoiceable } from './invoice'
|
import { useInvoiceable } from './invoice'
|
||||||
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function LinkForm ({ item, sub, editThreshold, children }) {
|
export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -67,7 +68,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
|
|
||||||
const [upsertLink] = useMutation(
|
const [upsertLink] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) {
|
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
|
||||||
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@ -77,7 +78,16 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
const submitUpsertLink = useCallback(
|
const submitUpsertLink = useCallback(
|
||||||
async (_, boost, title, values, invoiceHash, invoiceHmac) => {
|
async (_, boost, title, values, invoiceHash, invoiceHmac) => {
|
||||||
const { error } = await upsertLink({
|
const { error } = await upsertLink({
|
||||||
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, invoiceHmac, ...values }
|
variables: {
|
||||||
|
sub: item?.subName || sub?.name,
|
||||||
|
id: item?.id,
|
||||||
|
boost: boost ? Number(boost) : undefined,
|
||||||
|
title: title.trim(),
|
||||||
|
invoiceHash,
|
||||||
|
invoiceHmac,
|
||||||
|
...values,
|
||||||
|
forward: normalizeForwards(values.forward)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
@ -114,7 +124,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
initial={{
|
initial={{
|
||||||
title: item?.title || shareTitle || '',
|
title: item?.title || shareTitle || '',
|
||||||
url: item?.url || shareUrl || '',
|
url: item?.url || shareUrl || '',
|
||||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
@ -243,10 +243,27 @@ function Referral ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Votification ({ n }) {
|
function Votification ({ n }) {
|
||||||
|
let forwardedSats = 0
|
||||||
|
let ForwardedUsers = null
|
||||||
|
if (n.item.forwards?.length) {
|
||||||
|
forwardedSats = Math.floor(n.earnedSats * n.item.forwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100)
|
||||||
|
ForwardedUsers = () => n.item.forwards.map((fwd, i) =>
|
||||||
|
<span key={fwd.user.name}>
|
||||||
|
<Link className='text-success' href={`/${fwd.user.name}`}>
|
||||||
|
@{fwd.user.name}
|
||||||
|
</Link>
|
||||||
|
{i !== n.item.forwards.length - 1 && ', '}
|
||||||
|
</span>)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<small className='fw-bold text-success ms-2'>
|
<small className='fw-bold text-success ms-2'>
|
||||||
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {numWithUnits(n.earnedSats, { abbreviate: false })}{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
your {n.item.title ? 'post' : 'reply'} stacked {numWithUnits(n.earnedSats, { abbreviate: false })}
|
||||||
|
{n.item.forwards?.length > 0 &&
|
||||||
|
<>
|
||||||
|
{' '}and forwarded {numWithUnits(forwardedSats, { abbreviate: false })} to{' '}
|
||||||
|
<ForwardedUsers />
|
||||||
|
</>}
|
||||||
</small>
|
</small>
|
||||||
<div>
|
<div>
|
||||||
{n.item.title
|
{n.item.title
|
||||||
|
@ -12,6 +12,7 @@ import { SubSelectInitial } from './sub-select-form'
|
|||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useInvoiceable } from './invoice'
|
import { useInvoiceable } from './invoice'
|
||||||
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function PollForm ({ item, sub, editThreshold, children }) {
|
export function PollForm ({ item, sub, editThreshold, children }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -21,7 +22,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
const [upsertPoll] = useMutation(
|
const [upsertPoll] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
|
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
|
||||||
$options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) {
|
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
|
||||||
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
|
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
|
||||||
options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
||||||
id
|
id
|
||||||
@ -40,6 +41,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
options: optionsFiltered,
|
options: optionsFiltered,
|
||||||
...values,
|
...values,
|
||||||
|
forward: normalizeForwards(values.forward),
|
||||||
invoiceHash,
|
invoiceHash,
|
||||||
invoiceHmac
|
invoiceHmac
|
||||||
}
|
}
|
||||||
@ -65,7 +67,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
title: item?.title || '',
|
title: item?.title || '',
|
||||||
text: item?.text || '',
|
text: item?.text || '',
|
||||||
options: initialOptions || ['', ''],
|
options: initialOptions || ['', ''],
|
||||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
...AdvPostInitial({ forward: normalizeForwards(item?.forwards) }),
|
||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
@ -205,8 +205,17 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||||||
}, [pendingSats, act, item, showModal, setPendingSats])
|
}, [pendingSats, act, item, showModal, setPendingSats])
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
const disabled = useMemo(() => {
|
||||||
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
|
if (item?.mine) {
|
||||||
}, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt])
|
return true
|
||||||
|
}
|
||||||
|
if (me && item?.forwards?.some?.(fwd => Number(fwd.userId) === Number(me?.id))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (item?.deletedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [me?.id, item?.forwards, item?.mine, item?.deletedAt])
|
||||||
|
|
||||||
const [meSats, sats, overlayText, color] = useMemo(() => {
|
const [meSats, sats, overlayText, color] = useMemo(() => {
|
||||||
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats
|
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats
|
||||||
|
@ -15,7 +15,6 @@ export const ITEM_FIELDS = gql`
|
|||||||
hideCowboyHat
|
hideCowboyHat
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
fwdUserId
|
|
||||||
otsHash
|
otsHash
|
||||||
position
|
position
|
||||||
sats
|
sats
|
||||||
@ -51,12 +50,6 @@ export const ITEM_FULL_FIELDS = gql`
|
|||||||
fragment ItemFullFields on Item {
|
fragment ItemFullFields on Item {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
text
|
text
|
||||||
fwdUser {
|
|
||||||
name
|
|
||||||
streak
|
|
||||||
hideCowboyHat
|
|
||||||
id
|
|
||||||
}
|
|
||||||
root {
|
root {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
@ -70,6 +63,13 @@ export const ITEM_FULL_FIELDS = gql`
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
forwards {
|
||||||
|
userId
|
||||||
|
pct
|
||||||
|
user {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const ITEM_OTS_FIELDS = gql`
|
export const ITEM_OTS_FIELDS = gql`
|
||||||
|
@ -50,5 +50,6 @@ module.exports = {
|
|||||||
AD_USER_ID: 9,
|
AD_USER_ID: 9,
|
||||||
ANON_POST_FEE: 1000,
|
ANON_POST_FEE: 1000,
|
||||||
ANON_COMMENT_FEE: 100,
|
ANON_COMMENT_FEE: 100,
|
||||||
SSR: typeof window === 'undefined'
|
SSR: typeof window === 'undefined',
|
||||||
|
MAX_FORWARDS: 5
|
||||||
}
|
}
|
||||||
|
12
lib/form.js
Normal file
12
lib/form.js
Normal 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) }))
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup'
|
import { string, ValidationError, number, object, array, addMethod, boolean } from 'yup'
|
||||||
import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, SUBS_NO_JOBS } from './constants'
|
import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, SUBS_NO_JOBS, MAX_FORWARDS } from './constants'
|
||||||
import { NAME_QUERY } from '../fragments/users'
|
import { NAME_QUERY } from '../fragments/users'
|
||||||
import { URL_REGEXP, WS_REGEXP } from './url'
|
import { URL_REGEXP, WS_REGEXP } from './url'
|
||||||
import { SUPPORTED_CURRENCIES } from './currency'
|
import { SUPPORTED_CURRENCIES } from './currency'
|
||||||
@ -58,7 +58,6 @@ async function usernameExists (client, name) {
|
|||||||
return !!user
|
return !!user
|
||||||
}
|
}
|
||||||
|
|
||||||
// not sure how to use this on server ...
|
|
||||||
export function advPostSchemaMembers (client) {
|
export function advPostSchemaMembers (client) {
|
||||||
return {
|
return {
|
||||||
boost: intValidator
|
boost: intValidator
|
||||||
@ -70,14 +69,30 @@ export function advPostSchemaMembers (client) {
|
|||||||
},
|
},
|
||||||
message: `must be divisble be ${BOOST_MIN}`
|
message: `must be divisble be ${BOOST_MIN}`
|
||||||
}),
|
}),
|
||||||
forward: string()
|
// XXX this lets you forward to youself (it's financially equivalent but it should be disallowed)
|
||||||
|
forward: array()
|
||||||
|
.max(MAX_FORWARDS, `you can only configure ${MAX_FORWARDS} forward recipients`)
|
||||||
|
.of(object().shape({
|
||||||
|
nym: string().required('must specify a stacker').test({
|
||||||
|
name: 'nym',
|
||||||
|
test: async name => {
|
||||||
|
if (!name || !name.length) return true
|
||||||
|
return await usernameExists(client, name)
|
||||||
|
},
|
||||||
|
message: 'stacker does not exist'
|
||||||
|
}),
|
||||||
|
pct: intValidator.required('must specify a percentage').min(1, 'percentage must be at least 1').max(100, 'percentage must not exceed 100')
|
||||||
|
}))
|
||||||
|
.compact((v) => !v.nym && !v.pct)
|
||||||
.test({
|
.test({
|
||||||
name: 'name',
|
name: 'sum',
|
||||||
test: async name => {
|
test: forwards => forwards.map(fwd => Number(fwd.pct)).reduce((sum, cur) => sum + cur, 0) <= 100,
|
||||||
if (!name || !name.length) return true
|
message: 'the total forward percentage exceeds 100%'
|
||||||
return await usernameExists(client, name)
|
})
|
||||||
},
|
.test({
|
||||||
message: 'stacker does not exist'
|
name: 'uniqueStackers',
|
||||||
|
test: forwards => new Set(forwards.map(fwd => fwd.nym)).size === forwards.length,
|
||||||
|
message: 'duplicate stackers cannot be specified to receive forwarded sats'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
402
prisma/migrations/20230814233157_multiforward/migration.sql
Normal file
402
prisma/migrations/20230814233157_multiforward/migration.sql
Normal 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();
|
@ -63,7 +63,6 @@ model User {
|
|||||||
Earn Earn[]
|
Earn Earn[]
|
||||||
invites Invite[] @relation("Invites")
|
invites Invite[] @relation("Invites")
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
fwdItems Item[] @relation("FwdItem")
|
|
||||||
items Item[] @relation("UserItems")
|
items Item[] @relation("UserItems")
|
||||||
actions ItemAct[]
|
actions ItemAct[]
|
||||||
mentions Mention[]
|
mentions Mention[]
|
||||||
@ -84,6 +83,7 @@ model User {
|
|||||||
referrees User[] @relation("referrals")
|
referrees User[] @relation("referrals")
|
||||||
Account Account[]
|
Account Account[]
|
||||||
Session Session[]
|
Session Session[]
|
||||||
|
itemForwards ItemForward[]
|
||||||
hideBookmarks Boolean @default(false)
|
hideBookmarks Boolean @default(false)
|
||||||
|
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -228,7 +228,6 @@ model Item {
|
|||||||
statusUpdatedAt DateTime?
|
statusUpdatedAt DateTime?
|
||||||
status Status @default(ACTIVE)
|
status Status @default(ACTIVE)
|
||||||
company String?
|
company String?
|
||||||
fwdUserId Int?
|
|
||||||
weightedVotes Float @default(0)
|
weightedVotes Float @default(0)
|
||||||
boost Int @default(0)
|
boost Int @default(0)
|
||||||
uploadId Int?
|
uploadId Int?
|
||||||
@ -250,7 +249,6 @@ model Item {
|
|||||||
upvotes Int @default(0)
|
upvotes Int @default(0)
|
||||||
weightedComments Float @default(0)
|
weightedComments Float @default(0)
|
||||||
Bookmark Bookmark[]
|
Bookmark Bookmark[]
|
||||||
fwdUser User? @relation("FwdItem", fields: [fwdUserId], references: [id])
|
|
||||||
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
|
parent Item? @relation("ParentChildren", fields: [parentId], references: [id])
|
||||||
children Item[] @relation("ParentChildren")
|
children Item[] @relation("ParentChildren")
|
||||||
pin Pin? @relation(fields: [pinId], references: [id])
|
pin Pin? @relation(fields: [pinId], references: [id])
|
||||||
@ -265,6 +263,7 @@ model Item {
|
|||||||
ThreadSubscription ThreadSubscription[]
|
ThreadSubscription ThreadSubscription[]
|
||||||
upload Upload?
|
upload Upload?
|
||||||
User User[]
|
User User[]
|
||||||
|
itemForwards ItemForward[]
|
||||||
|
|
||||||
@@index([bio], map: "Item.bio_index")
|
@@index([bio], map: "Item.bio_index")
|
||||||
@@index([createdAt], map: "Item.created_at_index")
|
@@index([createdAt], map: "Item.created_at_index")
|
||||||
@ -283,6 +282,23 @@ model Item {
|
|||||||
@@index([weightedVotes], map: "Item.weightedVotes_index")
|
@@index([weightedVotes], map: "Item.weightedVotes_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: make all Item's forward 100% of sats to the OP by default
|
||||||
|
// so that forwards aren't a special case everywhere
|
||||||
|
model ItemForward {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
itemId Int // The item from which sats are forwarded
|
||||||
|
userId Int // The recipient of the forwarded sats
|
||||||
|
pct Int // The percentage of sats from the item to forward to this user
|
||||||
|
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([itemId], map: "ItemForward.itemId_index")
|
||||||
|
@@index([userId], map: "ItemForward.userId_index")
|
||||||
|
@@index([createdAt], map: "ItemForward.createdAt_index")
|
||||||
|
}
|
||||||
|
|
||||||
model PollOption {
|
model PollOption {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@ -496,6 +512,8 @@ model Bookmark {
|
|||||||
@@index([createdAt], map: "Bookmark.created_at_index")
|
@@index([createdAt], map: "Bookmark.created_at_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: make thread subscriptions for OP by default so they can
|
||||||
|
// unsubscribe from their own threads and its not a special case
|
||||||
model ThreadSubscription {
|
model ThreadSubscription {
|
||||||
userId Int
|
userId Int
|
||||||
itemId Int
|
itemId Int
|
||||||
|
@ -24,11 +24,15 @@ function earn ({ models }) {
|
|||||||
FROM "Donation"
|
FROM "Donation"
|
||||||
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago'))
|
WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago'))
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
-- any earnings from anon's stack that are not forwarded to other users
|
||||||
(SELECT "ItemAct".msats
|
(SELECT "ItemAct".msats
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
||||||
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL
|
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id
|
||||||
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago'))
|
WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP'
|
||||||
|
AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', (now() - interval '1 day') AT TIME ZONE 'America/Chicago')
|
||||||
|
GROUP BY "ItemAct".id, "ItemAct".msats
|
||||||
|
HAVING COUNT("ItemForward".id) = 0)
|
||||||
) subquery`
|
) subquery`
|
||||||
|
|
||||||
// XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js)
|
// XXX primsa will return a Decimal (https://mikemcl.github.io/decimal.js)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user