ca11ac9fb8
* wip backend optimism * another inch * make action state transitions only happen once * another inch * almost ready for testing * use interactive txs * another inch * ready for basic testing * lint fix * inches * wip item update * get item update to work * donate and downzap * inchy inch * fix territory paid actions * wip usePaidMutation * usePaidMutation error handling * PENDING_HELD and HELD transitions, gql paidAction return types * mostly working pessimism * make sure invoice field is present in optimisticResponse * inches * show optimistic values to current me * first pass at notifications and payment status reporting * fix migration to have withdrawal hash * reverse optimism on payment failure * Revert "Optimistic updates via pending sats in item context (#1229)" This reverts commit 93713b33df9bc3701dc5a692b86a04ff64e8cfb1. * add onCompleted to usePaidMutation * onPaid and onPayError for new comments * use 'IS DISTINCT FROM' for NULL invoiceActionState columns * make usePaidMutation easier to read * enhance invoice qr * prevent actions on unpaid items * allow navigation to action's invoice * retry create item * start edit window after item is paid for * fix ux of retries from notifications * refine retries * fix optimistic downzaps * remember item updates can't be retried * store reference to action item in invoice * remove invoice modal layout shift * fix destructuring * fix zap undos * make sure ItemAct is paid in aggregate queries * dont toast on long press zap undo * fix delete and remindme bots * optimistic poll votes with retries * fix retry notifications and invoice item context * fix pessimisitic typo * item mentions and mention notifications * dont show payment retry on item popover * make bios work * refactor paidAction transitions * remove stray console.log * restore docker compose nwc settings * add new todos * persist qr modal on post submission + unify item form submission * fix post edit threshold * make bounty payments work * make job posting work * remove more store procedure usage ... document serialization concerns * dont use dynamic imports for paid action modules * inline comment denormalization * create item starts with median votes * fix potential of serialization anomalies in zaps * dont trigger notification indicator on successful paid action invoices * ignore invoiceId on territory actions and add optimistic concurrency control * begin docs for paid actions * better error toasts and fix apollo cache warnings * small documentation enhancements * improve paid action docs * optimistic concurrency control for territory updates * use satsToMsats and msatsToSats helpers * explictly type raw query template parameters * improve consistency of nested relation names * complete paid action docs * useEffect for canEdit on payment * make sure invoiceId is provided when required * don't return null when expecting array * remove buy credits * move verifyPayment to paidAction * fix comments invoicePaidAt time zone * close nwc connections once * grouped logs for paid actions * stop invoiceWaitUntilPaid if not attempting to pay * allow actionState to transition directly from HELD to PAID * make paid mutation wait until pessimistic are fully paid * change button text when form submits/pays * pulsing form submit button * ignore me in notification indicator for territory subscription * filter unpaid items from more queries * fix donation stike timing * fix pending poll vote * fix recent item notifcation padding * no default form submitting button text * don't show paying on submit button on free edits * fix territory autorenew with fee credits * reorg readme * allow jobs to be editted forever * fix image uploads * more filter fixes for aggregate views * finalize paid action invoice expirations * remove unnecessary async * keep clientside cache normal/consistent * add more detail to paid action doc * improve paid action table * remove actionType guard * fix top territories * typo api/paidAction/README.md Co-authored-by: ekzyis <ek@stacker.news> * typo components/use-paid-mutation.js Co-authored-by: ekzyis <ek@stacker.news> * Apply suggestions from code review Co-authored-by: ekzyis <ek@stacker.news> * encorporate ek feeback * more ek suggestions * fix 'cost to post' hover on items * Apply suggestions from code review Co-authored-by: ekzyis <ek@stacker.news> --------- Co-authored-by: ekzyis <ek@stacker.news>
152 lines
5.7 KiB
JavaScript
152 lines
5.7 KiB
JavaScript
import { USER_ID } from '@/lib/constants'
|
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
|
import { notifyZapped } from '@/lib/webPush'
|
|
|
|
export const anonable = true
|
|
export const supportsPessimism = true
|
|
export const supportsOptimism = true
|
|
|
|
export async function getCost ({ sats }) {
|
|
return satsToMsats(sats)
|
|
}
|
|
|
|
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
|
|
const feeMsats = cost / BigInt(100)
|
|
const zapMsats = cost - feeMsats
|
|
itemId = parseInt(itemId)
|
|
|
|
let invoiceData = {}
|
|
if (invoiceId) {
|
|
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
|
|
// store a reference to the item in the invoice
|
|
await tx.invoice.update({
|
|
where: { id: invoiceId },
|
|
data: { actionId: itemId }
|
|
})
|
|
}
|
|
|
|
const acts = await tx.itemAct.createManyAndReturn({
|
|
data: [
|
|
{ msats: feeMsats, itemId, userId: me?.id || USER_ID.anon, act: 'FEE', ...invoiceData },
|
|
{ msats: zapMsats, itemId, userId: me?.id || USER_ID.anon, act: 'TIP', ...invoiceData }
|
|
]
|
|
})
|
|
|
|
const [{ path }] = await tx.$queryRaw`
|
|
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
|
|
return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) }
|
|
}
|
|
|
|
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
|
|
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
|
|
const [{ id, path }] = await tx.$queryRaw`
|
|
SELECT "Item".id, ltree2text(path) as path
|
|
FROM "Item"
|
|
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
|
|
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
|
|
return { id, sats: msatsToSats(cost), act: 'TIP', path }
|
|
}
|
|
|
|
export async function onPaid ({ invoice, actIds }, { models, tx }) {
|
|
let acts
|
|
if (invoice) {
|
|
await tx.itemAct.updateMany({
|
|
where: { invoiceId: invoice.id },
|
|
data: {
|
|
invoiceActionState: 'PAID'
|
|
}
|
|
})
|
|
acts = await tx.itemAct.findMany({ where: { invoiceId: invoice.id }, include: { item: true } })
|
|
actIds = acts.map(act => act.id)
|
|
} else if (actIds) {
|
|
acts = await tx.itemAct.findMany({ where: { id: { in: actIds } }, include: { item: true } })
|
|
} else {
|
|
throw new Error('No invoice or actIds')
|
|
}
|
|
|
|
const msats = acts.reduce((a, b) => a + BigInt(b.msats), BigInt(0))
|
|
const sats = msatsToSats(msats)
|
|
const itemAct = acts.find(act => act.act === 'TIP')
|
|
|
|
// give user and all forwards the sats
|
|
await tx.$executeRaw`
|
|
WITH forwardees AS (
|
|
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS msats
|
|
FROM "ItemForward"
|
|
WHERE "itemId" = ${itemAct.itemId}::INTEGER
|
|
), total_forwarded AS (
|
|
SELECT COALESCE(SUM(msats), 0) as msats
|
|
FROM forwardees
|
|
), forward AS (
|
|
UPDATE users
|
|
SET msats = users.msats + forwardees.msats
|
|
FROM forwardees
|
|
WHERE users.id = forwardees."userId"
|
|
)
|
|
UPDATE users
|
|
SET msats = msats + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
|
|
WHERE id = ${itemAct.item.userId}::INTEGER`
|
|
|
|
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
|
|
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
|
await tx.$executeRaw`
|
|
WITH zapper AS (
|
|
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER
|
|
), zap AS (
|
|
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
|
|
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
|
|
ON CONFLICT ("itemId", "userId") DO UPDATE
|
|
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
|
|
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
|
|
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
|
|
)
|
|
UPDATE "Item"
|
|
SET
|
|
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats),
|
|
upvotes = upvotes + zap.first_vote,
|
|
msats = "Item".msats + ${msats}::BIGINT,
|
|
"lastZapAt" = now()
|
|
FROM zap, zapper
|
|
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
|
|
|
|
// record potential bounty payment
|
|
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
|
|
// we won't miss a zap that aggregates into a bounty payment, regardless of the order of updates
|
|
await tx.$executeRaw`
|
|
WITH bounty AS (
|
|
SELECT root.id, "ItemUserAgg"."zapSats" >= root.bounty AS paid, "ItemUserAgg"."itemId" AS target
|
|
FROM "ItemUserAgg"
|
|
JOIN "Item" ON "Item".id = "ItemUserAgg"."itemId"
|
|
LEFT JOIN "Item" root ON root.id = "Item"."rootId"
|
|
WHERE "ItemUserAgg"."userId" = ${itemAct.userId}::INTEGER
|
|
AND "ItemUserAgg"."itemId" = ${itemAct.itemId}::INTEGER
|
|
AND root."userId" = ${itemAct.userId}::INTEGER
|
|
AND root.bounty IS NOT NULL
|
|
)
|
|
UPDATE "Item"
|
|
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
|
|
FROM bounty
|
|
WHERE "Item".id = bounty.id AND bounty.paid`
|
|
|
|
// update commentMsats on ancestors
|
|
await tx.$executeRaw`
|
|
WITH zapped AS (
|
|
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
|
|
)
|
|
UPDATE "Item"
|
|
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT
|
|
FROM zapped
|
|
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
|
|
|
|
// TODO: referrals
|
|
notifyZapped({ models, id: itemAct.itemId }).catch(console.error)
|
|
}
|
|
|
|
export async function onFail ({ invoice }, { tx }) {
|
|
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
|
|
}
|
|
|
|
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
|
|
return `SN: zap ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
|
|
}
|