Calculate image fee info in postgres function
* we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable`
This commit is contained in:
parent
31fa3dcf26
commit
fe267cd3b9
@ -1,92 +1,23 @@
|
|||||||
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '../../lib/constants'
|
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '../../lib/constants'
|
||||||
import { datePivot } from '../../lib/time'
|
import { msatsToSats } from '../../lib/format'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
imageFees: async (parent, { s3Keys }, { models, me }) => {
|
imageFeesInfo: async (parent, { s3Keys }, { models, me }) => {
|
||||||
const imgFees = await imageFees(s3Keys, { models, me })
|
return imageFeesInfo(s3Keys, { models, me })
|
||||||
delete imgFees.queries
|
|
||||||
// database returns NULL for sizes if no rows are found
|
|
||||||
imgFees.size24h ??= 0
|
|
||||||
imgFees.sizeNow ??= 0
|
|
||||||
// add defaults so we can be sure these properties always exist in the frontend
|
|
||||||
return Object.assign({ fees: 0, unpaid: 0, feesPerImage: 0, size24h: 0, sizeNow: 0 }, imgFees)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function imageFeesFromText (text, { models, me }) {
|
export function uploadIdsFromText (text, { models }) {
|
||||||
// no text means no image fees
|
if (!text) return null
|
||||||
if (!text) return { queries: itemId => [], fees: 0 }
|
return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||||
|
|
||||||
// parse all s3 keys (= image ids) from text
|
|
||||||
const textS3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
|
||||||
if (!textS3Keys.length) return { queries: itemId => [], fees: 0 }
|
|
||||||
|
|
||||||
return imageFees(textS3Keys, { models, me })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function imageFees (s3Keys, { models, me }) {
|
export async function imageFeesInfo (s3Keys, { models, me }) {
|
||||||
// To apply image fees, we return queries which need to be run, preferably in the same transaction as creating or updating an item.
|
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys)
|
||||||
function queries (userId, imgIds, imgFees) {
|
const imageFee = msatsToSats(info.imageFeeMsats)
|
||||||
return itemId => {
|
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
|
||||||
return [
|
const totalFees = msatsToSats(totalFeesMsats)
|
||||||
// pay fees
|
return { ...info, imageFee, totalFees, totalFeesMsats }
|
||||||
models.$queryRawUnsafe('SELECT * FROM user_fee($1::INTEGER, $2::INTEGER, $3::BIGINT)', userId, itemId, imgFees * 1000),
|
|
||||||
// mark images as paid
|
|
||||||
models.upload.updateMany({ where: { id: { in: imgIds } }, data: { paid: true } })
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// we want to ignore image ids for which someone already paid during fee calculation
|
|
||||||
// to make sure that every image is only paid once
|
|
||||||
const unpaidS3Keys = (await models.upload.findMany({ select: { id: true }, where: { id: { in: s3Keys }, paid: false } })).map(({ id }) => id)
|
|
||||||
const unpaid = unpaidS3Keys.length
|
|
||||||
|
|
||||||
if (!unpaid) return { queries: itemId => [], fees: 0 }
|
|
||||||
|
|
||||||
if (!me) {
|
|
||||||
// anons pay for every new image 100 sats
|
|
||||||
const feesPerImage = 100
|
|
||||||
const fees = feesPerImage * unpaid
|
|
||||||
return { queries: queries(ANON_USER_ID, unpaidS3Keys, fees), fees, feesPerImage, unpaid }
|
|
||||||
}
|
|
||||||
|
|
||||||
// check how much stacker uploaded in last 24 hours
|
|
||||||
const { _sum: { size: size24h } } = await models.upload.aggregate({
|
|
||||||
_sum: { size: true },
|
|
||||||
where: {
|
|
||||||
userId: me.id,
|
|
||||||
createdAt: { gt: datePivot(new Date(), { days: -1 }) },
|
|
||||||
paid: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// check how much stacker uploaded now in size
|
|
||||||
const { _sum: { size: sizeNow } } = await models.upload.aggregate({
|
|
||||||
_count: { id: true },
|
|
||||||
_sum: { size: true },
|
|
||||||
where: { id: { in: unpaidS3Keys } }
|
|
||||||
})
|
|
||||||
|
|
||||||
// total size that we consider to calculate fees includes size of images within last 24 hours and size of incoming images
|
|
||||||
const size = size24h + sizeNow
|
|
||||||
const MB = 1024 * 1024 // factor for bytes -> megabytes
|
|
||||||
|
|
||||||
// 10 MB per 24 hours are free. fee is also 0 if there are no incoming images (obviously)
|
|
||||||
let feesPerImage
|
|
||||||
if (!sizeNow || size <= 10 * MB) {
|
|
||||||
feesPerImage = 0
|
|
||||||
} else if (size <= 25 * MB) {
|
|
||||||
feesPerImage = 10
|
|
||||||
} else if (size <= 50 * MB) {
|
|
||||||
feesPerImage = 100
|
|
||||||
} else if (size <= 100 * MB) {
|
|
||||||
feesPerImage = 1000
|
|
||||||
} else {
|
|
||||||
feesPerImage = 10000
|
|
||||||
}
|
|
||||||
const fees = feesPerImage * unpaid
|
|
||||||
return { queries: queries(me.id, unpaidS3Keys, fees * unpaid), fees, feesPerImage, unpaid, size24h, sizeNow }
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { ensureProtocol, removeTracking } from '../../lib/url'
|
import { ensureProtocol, removeTracking } from '../../lib/url'
|
||||||
import serialize, { serializeInvoicable } from './serial'
|
import { serializeInvoicable } from './serial'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
||||||
import { ruleSet as publicationDateRuleSet } from '../../lib/timedate-scraper'
|
import { ruleSet as publicationDateRuleSet } from '../../lib/timedate-scraper'
|
||||||
@ -19,7 +19,7 @@ import { sendUserNotification } from '../webPush'
|
|||||||
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
|
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
|
||||||
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
|
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
|
||||||
import { datePivot } from '../../lib/time'
|
import { datePivot } from '../../lib/time'
|
||||||
import { imageFeesFromText } from './image'
|
import { imageFeesInfo, uploadIdsFromText } from './image'
|
||||||
|
|
||||||
export async function commentFilterClause (me, models) {
|
export async function commentFilterClause (me, models) {
|
||||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||||
@ -1091,13 +1091,12 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
|||||||
item = { subName, userId: me.id, ...item }
|
item = { subName, userId: me.id, ...item }
|
||||||
const fwdUsers = await getForwardUsers(models, forward)
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
|
|
||||||
const { queries: imgQueriesFn, fees: imgFees } = await imageFeesFromText(item.text, { models, me })
|
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
|
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
|
||||||
|
|
||||||
item = await serializeInvoicable(
|
item = await serializeInvoicable(
|
||||||
[
|
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
|
||||||
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`,
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
|
||||||
...imgQueriesFn(Number(item.id))
|
|
||||||
],
|
|
||||||
{ models, lnd, hash, hmac, me, enforceFee: imgFees }
|
{ models, lnd, hash, hmac, me, enforceFee: imgFees }
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1128,19 +1127,17 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||||||
item.url = removeTracking(item.url)
|
item.url = removeTracking(item.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { queries: imgQueriesFn, fees: imgFees } = await imageFeesFromText(item.text, { models, me })
|
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
|
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
|
||||||
|
|
||||||
const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees
|
const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees
|
||||||
item = await serializeInvoicable(
|
item = await serializeInvoicable(
|
||||||
models.$queryRawUnsafe(
|
models.$queryRawUnsafe(
|
||||||
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
|
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||||
{ models, lnd, hash, hmac, me, enforceFee }
|
{ models, lnd, hash, hmac, me, enforceFee }
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO run image queries in same transaction as create_item
|
|
||||||
const imgQueries = imgQueriesFn(item.id)
|
|
||||||
if (imgQueries.length > 0) await serialize(models, ...imgQueries)
|
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
|
|
||||||
await enqueueDeletionJob(item, models)
|
await enqueueDeletionJob(item, models)
|
||||||
|
@ -59,29 +59,25 @@ export default async function serialize (models, ...calls) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serializeInvoicable (queries, { models, lnd, hash, hmac, me, enforceFee }) {
|
export async function serializeInvoicable (query, { models, lnd, hash, hmac, me, enforceFee }) {
|
||||||
if (!me && !hash) {
|
if (!me && !hash) {
|
||||||
throw new Error('you must be logged in or pay')
|
throw new Error('you must be logged in or pay')
|
||||||
}
|
}
|
||||||
|
|
||||||
let trx = Array.isArray(queries) ? queries : [queries]
|
let trx = [query]
|
||||||
// save offset to the first query in arguments relative to queries that we will run
|
|
||||||
let queryOffset = 0
|
|
||||||
|
|
||||||
let invoice
|
let invoice
|
||||||
if (hash) {
|
if (hash) {
|
||||||
invoice = await checkInvoice(models, hash, hmac, enforceFee)
|
invoice = await checkInvoice(models, hash, hmac, enforceFee)
|
||||||
trx = [
|
trx = [
|
||||||
models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`,
|
models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`,
|
||||||
...trx,
|
query,
|
||||||
models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })
|
models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } })
|
||||||
]
|
]
|
||||||
// first query in arguments is now at index 1
|
|
||||||
queryOffset = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await serialize(models, ...trx)
|
const results = await serialize(models, ...trx)
|
||||||
const result = trx.length > 1 ? results[queryOffset][0] : results[0]
|
const result = trx.length > 1 ? results[1][0] : results[0]
|
||||||
|
|
||||||
if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
||||||
|
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
type ImageFees {
|
type ImageFeesInfo {
|
||||||
fees: Int!
|
totalFees: Int!
|
||||||
unpaid: Int!
|
totalFeesMsats: Int!
|
||||||
feesPerImage: Int!
|
imageFee: Int!
|
||||||
sizeNow: Int!
|
imageFeeMsats: Int!
|
||||||
size24h: Int!
|
nUnpaid: Int!
|
||||||
|
bytesUnpaid: Int!
|
||||||
|
bytes24h: Int!
|
||||||
}
|
}
|
||||||
extend type Query {
|
extend type Query {
|
||||||
imageFees(s3Keys: [Int]!): ImageFees!
|
imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo!
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -12,7 +12,7 @@ import AnonIcon from '../svgs/spy-fill.svg'
|
|||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
function Receipt ({ cost, repetition, imageFees, baseFee, parentId, boost }) {
|
function Receipt ({ cost, repetition, imageFeesInfo, baseFee, parentId, boost }) {
|
||||||
return (
|
return (
|
||||||
<Table className={styles.receipt} borderless size='sm'>
|
<Table className={styles.receipt} borderless size='sm'>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -25,9 +25,9 @@ function Receipt ({ cost, repetition, imageFees, baseFee, parentId, boost }) {
|
|||||||
<td>x 10<sup>{repetition}</sup></td>
|
<td>x 10<sup>{repetition}</sup></td>
|
||||||
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
|
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
|
||||||
</tr>}
|
</tr>}
|
||||||
{imageFees.fees > 0 &&
|
{imageFeesInfo.totalFees > 0 &&
|
||||||
<tr>
|
<tr>
|
||||||
<td>+ {imageFees.unpaid} x {numWithUnits(imageFees.feesPerImage, { abbreviate: false })}</td>
|
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
|
||||||
<td align='right' className='font-weight-light'>image fee</td>
|
<td align='right' className='font-weight-light'>image fee</td>
|
||||||
</tr>}
|
</tr>}
|
||||||
{boost > 0 &&
|
{boost > 0 &&
|
||||||
@ -85,8 +85,8 @@ export default function FeeButton ({ parentId, baseFee, ChildButton, variant, te
|
|||||||
formik?.setFieldValue('cost', cost)
|
formik?.setFieldValue('cost', cost)
|
||||||
}, [formik?.getFieldProps('cost').value, cost])
|
}, [formik?.getFieldProps('cost').value, cost])
|
||||||
|
|
||||||
const imageFees = formik?.getFieldProps('imageFees').value || { fees: 0 }
|
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
|
||||||
const totalCost = cost + imageFees.fees
|
const totalCost = cost + imageFeesInfo.totalFees
|
||||||
|
|
||||||
const show = alwaysShow || !formik?.isSubmitting
|
const show = alwaysShow || !formik?.isSubmitting
|
||||||
return (
|
return (
|
||||||
@ -97,13 +97,13 @@ export default function FeeButton ({ parentId, baseFee, ChildButton, variant, te
|
|||||||
{!me && <AnonInfo />}
|
{!me && <AnonInfo />}
|
||||||
{totalCost > baseFee && show &&
|
{totalCost > baseFee && show &&
|
||||||
<Info>
|
<Info>
|
||||||
<Receipt baseFee={baseFee} imageFees={imageFees} repetition={repetition} cost={totalCost} parentId={parentId} boost={boost} />
|
<Receipt baseFee={baseFee} imageFeesInfo={imageFeesInfo} repetition={repetition} cost={totalCost} parentId={parentId} boost={boost} />
|
||||||
</Info>}
|
</Info>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditReceipt ({ cost, paidSats, imageFees, boost, parentId }) {
|
function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) {
|
||||||
return (
|
return (
|
||||||
<Table className={styles.receipt} borderless size='sm'>
|
<Table className={styles.receipt} borderless size='sm'>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -111,9 +111,9 @@ function EditReceipt ({ cost, paidSats, imageFees, boost, parentId }) {
|
|||||||
<td>{numWithUnits(0, { abbreviate: false })}</td>
|
<td>{numWithUnits(0, { abbreviate: false })}</td>
|
||||||
<td align='right' className='font-weight-light'>edit fee</td>
|
<td align='right' className='font-weight-light'>edit fee</td>
|
||||||
</tr>
|
</tr>
|
||||||
{imageFees.fees > 0 &&
|
{imageFeesInfo.totalFees > 0 &&
|
||||||
<tr>
|
<tr>
|
||||||
<td>+ {imageFees.unpaid} x {numWithUnits(imageFees.feesPerImage, { abbreviate: false })}</td>
|
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
|
||||||
<td align='right' className='font-weight-light'>image fee</td>
|
<td align='right' className='font-weight-light'>image fee</td>
|
||||||
</tr>}
|
</tr>}
|
||||||
{boost > 0 &&
|
{boost > 0 &&
|
||||||
@ -141,8 +141,8 @@ export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysSho
|
|||||||
formik?.setFieldValue('cost', cost)
|
formik?.setFieldValue('cost', cost)
|
||||||
}, [formik?.getFieldProps('cost').value, cost])
|
}, [formik?.getFieldProps('cost').value, cost])
|
||||||
|
|
||||||
const imageFees = formik?.getFieldProps('imageFees').value || { fees: 0 }
|
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
|
||||||
const totalCost = cost + imageFees.fees
|
const totalCost = cost + imageFeesInfo.totalFees
|
||||||
|
|
||||||
const show = alwaysShow || !formik?.isSubmitting
|
const show = alwaysShow || !formik?.isSubmitting
|
||||||
return (
|
return (
|
||||||
@ -152,7 +152,7 @@ export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysSho
|
|||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
{totalCost > 0 && show &&
|
{totalCost > 0 && show &&
|
||||||
<Info>
|
<Info>
|
||||||
<EditReceipt paidSats={paidSats} imageFees={imageFees} cost={totalCost} parentId={parentId} boost={boost} />
|
<EditReceipt paidSats={paidSats} imageFeesInfo={imageFeesInfo} cost={totalCost} parentId={parentId} boost={boost} />
|
||||||
</Info>}
|
</Info>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -104,14 +104,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
const previousTab = useRef(tab)
|
const previousTab = useRef(tab)
|
||||||
const formik = useFormikContext()
|
const formik = useFormikContext()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const [updateImageFees] = useLazyQuery(gql`
|
const [updateImageFeesInfo] = useLazyQuery(gql`
|
||||||
query imageFees($s3Keys: [Int]!) {
|
query imageFeesInfo($s3Keys: [Int]!) {
|
||||||
imageFees(s3Keys: $s3Keys) {
|
imageFeesInfo(s3Keys: $s3Keys) {
|
||||||
fees
|
totalFees
|
||||||
unpaid
|
nUnpaid
|
||||||
feesPerImage
|
imageFee
|
||||||
size24h
|
bytes24h
|
||||||
sizeNow
|
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
fetchPolicy: 'no-cache',
|
fetchPolicy: 'no-cache',
|
||||||
@ -120,8 +119,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
console.log(err)
|
console.log(err)
|
||||||
toaster.danger(err.message || err.toString?.())
|
toaster.danger(err.message || err.toString?.())
|
||||||
},
|
},
|
||||||
onCompleted: ({ imageFees }) => {
|
onCompleted: ({ imageFeesInfo }) => {
|
||||||
formik?.setFieldValue('imageFees', imageFees)
|
formik?.setFieldValue('imageFeesInfo', imageFeesInfo)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -273,7 +272,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
text = text.replace(`![Uploading ${name}…]()`, ``)
|
text = text.replace(`![Uploading ${name}…]()`, ``)
|
||||||
helpers.setValue(text)
|
helpers.setValue(text)
|
||||||
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||||
updateImageFees({ variables: { s3Keys } })
|
updateImageFeesInfo({ variables: { s3Keys } })
|
||||||
}}
|
}}
|
||||||
onError={({ name }) => {
|
onError={({ name }) => {
|
||||||
let text = innerRef.current.value
|
let text = innerRef.current.value
|
||||||
@ -305,7 +304,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const text = innerRef?.current.value
|
const text = innerRef?.current.value
|
||||||
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
|
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
|
||||||
updateImageFees({ variables: { s3Keys } })
|
updateImageFeesInfo({ variables: { s3Keys } })
|
||||||
setTimeout(resetSuggestions, 100)
|
setTimeout(resetSuggestions, 100)
|
||||||
}}
|
}}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
|
@ -232,9 +232,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||||||
// this function will be called before the Form's onSubmit handler is called
|
// this function will be called before the Form's onSubmit handler is called
|
||||||
// and the form must include `cost` or `amount` as a value
|
// and the form must include `cost` or `amount` as a value
|
||||||
const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => {
|
const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => {
|
||||||
let { cost, imageFees, amount } = formValues
|
let { cost, imageFeesInfo, amount } = formValues
|
||||||
cost ??= amount
|
cost ??= amount
|
||||||
if (imageFees?.fees) cost += imageFees.fees
|
if (imageFeesInfo?.fees) cost += imageFeesInfo.fees
|
||||||
|
|
||||||
// action only allowed if logged in
|
// action only allowed if logged in
|
||||||
if (!me && options.requireSession) {
|
if (!me && options.requireSession) {
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
-- function to manually deduct fees from user, for example for images fees
|
|
||||||
CREATE OR REPLACE FUNCTION user_fee(user_id INTEGER, item_id INTEGER, cost_msats BIGINT)
|
|
||||||
RETURNS users
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
user users;
|
|
||||||
user_msats BIGINT;
|
|
||||||
BEGIN
|
|
||||||
PERFORM ASSERT_SERIALIZED();
|
|
||||||
|
|
||||||
SELECT msats INTO user_msats FROM users WHERE id = user_id;
|
|
||||||
IF cost_msats > user_msats THEN
|
|
||||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
UPDATE users SET msats = msats - cost_msats WHERE id = user_id RETURNING * INTO user;
|
|
||||||
|
|
||||||
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
|
||||||
VALUES (cost_msats, item_id, user_id, 'FEE');
|
|
||||||
|
|
||||||
RETURN user;
|
|
||||||
END;
|
|
||||||
$$;
|
|
241
prisma/migrations/20231026154807_image_fees_info/migration.sql
Normal file
241
prisma/migrations/20231026154807_image_fees_info/migration.sql
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
-- function to calculate image fees info for given user and upload ids
|
||||||
|
CREATE OR REPLACE FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[])
|
||||||
|
RETURNS TABLE (
|
||||||
|
"bytes24h" INTEGER,
|
||||||
|
"bytesUnpaid" INTEGER,
|
||||||
|
"nUnpaid" INTEGER,
|
||||||
|
"imageFeeMsats" BIGINT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
uploadinfo.*,
|
||||||
|
CASE
|
||||||
|
-- anons always pay 100 sats per image
|
||||||
|
WHEN user_id = 27 THEN 100000::BIGINT
|
||||||
|
ELSE CASE
|
||||||
|
-- 10 MB are free per stacker and 24 hours
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 10 * 1024 * 1024 THEN 0::BIGINT
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 25 * 1024 * 1024 THEN 10000::BIGINT
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 50 * 1024 * 1024 THEN 100000::BIGINT
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 100 * 1024 * 1024 THEN 1000000::BIGINT
|
||||||
|
ELSE 10000000::BIGINT
|
||||||
|
END
|
||||||
|
END AS "imageFeeMsats"
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
-- how much bytes did stacker upload in last 24 hours?
|
||||||
|
COALESCE(SUM(size) FILTER(WHERE paid = 't' AND created_at >= NOW() - interval '24 hours'), 0)::INTEGER AS "bytes24h",
|
||||||
|
-- how much unpaid bytes do they want to upload now?
|
||||||
|
COALESCE(SUM(size) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "bytesUnpaid",
|
||||||
|
-- how many unpaid images do they want to upload now?
|
||||||
|
COALESCE(COUNT(id) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "nUnpaid"
|
||||||
|
FROM "Upload"
|
||||||
|
WHERE "Upload"."userId" = user_id
|
||||||
|
) uploadinfo;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
CREATE OR REPLACE FUNCTION create_item(
|
||||||
|
jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats BIGINT;
|
||||||
|
cost_msats BIGINT;
|
||||||
|
freebie BOOLEAN;
|
||||||
|
item "Item";
|
||||||
|
med_votes FLOAT;
|
||||||
|
select_clause TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
-- access fields with appropriate types
|
||||||
|
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||||
|
|
||||||
|
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||||
|
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
cost_msats := 1000000;
|
||||||
|
ELSE
|
||||||
|
cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
|
||||||
|
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 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 (item.boost IS NULL OR item.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" = item."userId";
|
||||||
|
|
||||||
|
-- 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 item."userId" = 27 THEN
|
||||||
|
med_votes := 0;
|
||||||
|
ELSE
|
||||||
|
med_votes := ABS(med_votes);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- there's no great way to set default column values when using json_populate_record
|
||||||
|
-- so we need to only select fields with non-null values that way when func input
|
||||||
|
-- does not include a value, the default value is used instead of null
|
||||||
|
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||||
|
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key);
|
||||||
|
-- insert the item
|
||||||
|
EXECUTE format($fmt$
|
||||||
|
INSERT INTO "Item" (%s, "weightedDownVotes", freebie)
|
||||||
|
SELECT %1$s, %L, %L
|
||||||
|
FROM jsonb_populate_record(NULL::"Item", %L) RETURNING *
|
||||||
|
$fmt$, select_clause, med_votes, freebie, jitem) INTO item;
|
||||||
|
|
||||||
|
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||||
|
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
-- Automatically subscribe to one's own posts
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
VALUES (item.id, item."userId");
|
||||||
|
|
||||||
|
-- Automatically subscribe forward recipients to the new post
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
INSERT INTO "PollOption" ("itemId", "option")
|
||||||
|
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
|
||||||
|
|
||||||
|
IF NOT freebie THEN
|
||||||
|
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
|
||||||
|
|
||||||
|
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
||||||
|
VALUES (cost_msats, item.id, item."userId", 'FEE');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this item has boost
|
||||||
|
IF item.boost > 0 THEN
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this is a job
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
PERFORM run_auction(item.id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- if this is a bio
|
||||||
|
IF item.bio THEN
|
||||||
|
UPDATE users SET "bioId" = item.id WHERE id = item."userId";
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- schedule imgproxy job
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
CREATE OR REPLACE FUNCTION update_item(
|
||||||
|
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_msats INTEGER;
|
||||||
|
cost_msats BIGINT;
|
||||||
|
item "Item";
|
||||||
|
select_clause TEXT;
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||||
|
|
||||||
|
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||||
|
cost_msats := 0;
|
||||||
|
|
||||||
|
-- add image fees
|
||||||
|
IF upload_ids IS NOT NULL THEN
|
||||||
|
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
|
||||||
|
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF cost_msats > 0 AND cost_msats > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
ELSE
|
||||||
|
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
|
||||||
|
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
|
||||||
|
VALUES (cost_msats, item.id, item."userId", 'FEE');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item.boost > 0 THEN
|
||||||
|
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
|
||||||
|
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item.status IS NOT NULL THEN
|
||||||
|
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
|
||||||
|
WHERE id = item.id AND status <> item.status;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||||
|
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
|
||||||
|
WHERE key <> 'boost';
|
||||||
|
|
||||||
|
EXECUTE format($fmt$
|
||||||
|
UPDATE "Item" SET (%s) = (
|
||||||
|
SELECT %1$s
|
||||||
|
FROM jsonb_populate_record(NULL::"Item", %L)
|
||||||
|
) WHERE id = %L RETURNING *
|
||||||
|
$fmt$, select_clause, jitem, item.id) INTO item;
|
||||||
|
|
||||||
|
-- Delete any old thread subs if the user is no longer a fwd recipient
|
||||||
|
DELETE FROM "ThreadSubscription"
|
||||||
|
WHERE "itemId" = item.id
|
||||||
|
-- they aren't in the new forward list
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
|
||||||
|
-- and they are in the old forward list
|
||||||
|
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
|
||||||
|
|
||||||
|
-- Automatically subscribe any new forward recipients to the post
|
||||||
|
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||||
|
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
|
||||||
|
EXCEPT
|
||||||
|
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
|
||||||
|
|
||||||
|
-- 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 jsonb_populate_recordset(NULL::"ItemForward", forward);
|
||||||
|
|
||||||
|
INSERT INTO "PollOption" ("itemId", "option")
|
||||||
|
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
|
||||||
|
|
||||||
|
-- if this is a job
|
||||||
|
IF item."maxBid" IS NOT NULL THEN
|
||||||
|
PERFORM run_auction(item.id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- schedule imgproxy job
|
||||||
|
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
|
||||||
|
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
Loading…
x
Reference in New Issue
Block a user