Provide option to clear withdrawal invoices (#591)
* add settings option * add auto-drop worker * add manual delete option * add warning and note * cleanup * incorporate most review feedback * add warning to settings option * remove debugging tweaks and simplify * refine auto delete bolt11s * refine UI --------- Co-authored-by: rleed <rleed1@pm.me> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
parent
5b14606b39
commit
d86d8b3bac
|
@ -80,6 +80,18 @@ export default {
|
|||
|
||||
return wdrwl
|
||||
},
|
||||
numBolt11s: async (parent, args, { me, models, lnd }) => {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
return await models.withdrawl.count({
|
||||
where: {
|
||||
userId: me.id,
|
||||
hash: { not: null }
|
||||
}
|
||||
})
|
||||
},
|
||||
connectAddress: async (parent, args, { lnd }) => {
|
||||
return process.env.LND_CONNECT_ADDRESS
|
||||
},
|
||||
|
@ -359,6 +371,23 @@ export default {
|
|||
}
|
||||
}))
|
||||
return inv
|
||||
},
|
||||
dropBolt11: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await models.withdrawl.update({
|
||||
where: {
|
||||
userId: me.id,
|
||||
id: Number(id),
|
||||
createdAt: {
|
||||
lte: datePivot(new Date(), { days: -7 })
|
||||
}
|
||||
},
|
||||
data: { bolt11: null, hash: null }
|
||||
})
|
||||
return { id }
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ export default gql`
|
|||
setName(name: String!): String
|
||||
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, withdrawMaxFeeDefault: Int!, noteItemSats: Boolean!,
|
||||
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, autoDropBolt11s: Boolean!,
|
||||
hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, imgproxyOnly: Boolean!,
|
||||
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrCrossposting: Boolean, nostrRelays: [String!], hideBookmarks: Boolean!,
|
||||
noteForwardedSats: Boolean!, hideWalletBalance: Boolean!, hideIsContributor: Boolean!, diagnostics: Boolean!): User
|
||||
|
@ -97,6 +97,7 @@ export default gql`
|
|||
noteCowboyHat: Boolean!
|
||||
noteForwardedSats: Boolean!
|
||||
hideInvoiceDesc: Boolean!
|
||||
autoDropBolt11s: Boolean!
|
||||
hideFromTopUsers: Boolean!
|
||||
hideCowboyHat: Boolean!
|
||||
hideBookmarks: Boolean!
|
||||
|
|
|
@ -4,6 +4,7 @@ export default gql`
|
|||
extend type Query {
|
||||
invoice(id: ID!): Invoice!
|
||||
withdrawl(id: ID!): Withdrawl!
|
||||
numBolt11s: Int!
|
||||
connectAddress: String!
|
||||
walletHistory(cursor: String, inc: String): History
|
||||
}
|
||||
|
@ -13,6 +14,7 @@ export default gql`
|
|||
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
||||
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
||||
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
||||
dropBolt11(id: ID): Withdrawl
|
||||
}
|
||||
|
||||
type Invoice {
|
||||
|
@ -36,8 +38,8 @@ export default gql`
|
|||
type Withdrawl {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
hash: String!
|
||||
bolt11: String!
|
||||
hash: String
|
||||
bolt11: String
|
||||
satsPaying: Int!
|
||||
satsPaid: Int
|
||||
satsFeePaying: Int!
|
||||
|
|
|
@ -2,8 +2,11 @@ import { decode } from 'bolt11'
|
|||
import AccordianItem from './accordian-item'
|
||||
import { CopyInput } from './form'
|
||||
|
||||
export default ({ bolt11, preimage }) => {
|
||||
const { tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11)
|
||||
export default ({ bolt11, preimage, children }) => {
|
||||
let description, paymentHash
|
||||
if (bolt11) {
|
||||
({ tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11))
|
||||
}
|
||||
if (!description && !paymentHash && !preimage) {
|
||||
return null
|
||||
}
|
||||
|
@ -41,6 +44,7 @@ export default ({ bolt11, preimage }) => {
|
|||
noForm
|
||||
placeholder={preimage}
|
||||
/>}
|
||||
{children}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -61,7 +61,7 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
|
|||
)
|
||||
}
|
||||
|
||||
function DeleteConfirm ({ onConfirm, type }) {
|
||||
export function DeleteConfirm ({ onConfirm, type }) {
|
||||
const [error, setError] = useState()
|
||||
const toaster = useToast()
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ export const ME = gql`
|
|||
noteCowboyHat
|
||||
noteForwardedSats
|
||||
hideInvoiceDesc
|
||||
autoDropBolt11s
|
||||
hideFromTopUsers
|
||||
hideCowboyHat
|
||||
imgproxyOnly
|
||||
|
@ -60,6 +61,7 @@ export const SETTINGS_FIELDS = gql`
|
|||
noteCowboyHat
|
||||
noteForwardedSats
|
||||
hideInvoiceDesc
|
||||
autoDropBolt11s
|
||||
hideFromTopUsers
|
||||
hideCowboyHat
|
||||
hideBookmarks
|
||||
|
@ -94,14 +96,14 @@ gql`
|
|||
${SETTINGS_FIELDS}
|
||||
mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $withdrawMaxFeeDefault: Int!, $noteItemSats: Boolean!,
|
||||
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
|
||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!,
|
||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, $autoDropBolt11s: Boolean!,
|
||||
$hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $imgproxyOnly: Boolean!,
|
||||
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrCrossposting: Boolean!, $nostrRelays: [String!], $hideBookmarks: Boolean!,
|
||||
$noteForwardedSats: Boolean!, $hideWalletBalance: Boolean!, $hideIsContributor: Boolean!, $diagnostics: Boolean!) {
|
||||
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency, withdrawMaxFeeDefault: $withdrawMaxFeeDefault,
|
||||
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
|
||||
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
|
||||
noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc,
|
||||
noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, autoDropBolt11s: $autoDropBolt11s,
|
||||
hideFromTopUsers: $hideFromTopUsers, hideCowboyHat: $hideCowboyHat, imgproxyOnly: $imgproxyOnly,
|
||||
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrCrossposting: $nostrCrossposting, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks,
|
||||
noteForwardedSats: $noteForwardedSats, hideWalletBalance: $hideWalletBalance, hideIsContributor: $hideIsContributor, diagnostics: $diagnostics) {
|
||||
|
|
|
@ -24,6 +24,7 @@ export const WITHDRAWL = gql`
|
|||
query Withdrawl($id: ID!) {
|
||||
withdrawl(id: $id) {
|
||||
id
|
||||
createdAt
|
||||
bolt11
|
||||
satsPaid
|
||||
satsFeePaying
|
||||
|
@ -40,6 +41,7 @@ export const WALLET_HISTORY = gql`
|
|||
facts {
|
||||
id
|
||||
factId
|
||||
bolt11
|
||||
type
|
||||
createdAt
|
||||
sats
|
||||
|
|
|
@ -127,6 +127,12 @@ function getClient (uri) {
|
|||
}
|
||||
}
|
||||
},
|
||||
numBolt11s: {
|
||||
keyArgs: [],
|
||||
merge (existing, incoming) {
|
||||
return incoming
|
||||
}
|
||||
},
|
||||
walletHistory: {
|
||||
keyArgs: ['inc'],
|
||||
merge (existing, incoming) {
|
||||
|
|
|
@ -79,3 +79,5 @@ export const ITEM_ALLOW_EDITS = [
|
|||
// FAQ, privacy policy, changelog, content guidelines
|
||||
349, 76894, 78763, 81862
|
||||
]
|
||||
|
||||
export const INVOICE_RETENTION_DAYS = 7
|
||||
|
|
|
@ -115,11 +115,12 @@ function Detail ({ fact }) {
|
|||
return (
|
||||
<div className='px-3'>
|
||||
<Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}>
|
||||
{(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</span>) ||
|
||||
{(!fact.bolt11 && <span className='d-block text-muted fw-bold fst-italic'>invoice deleted</span>) ||
|
||||
(zap && <span className='d-block'>nostr zap{zap.content && `: ${zap.content}`}</span>) ||
|
||||
(fact.description && <span className='d-block'>{fact.description}</span>)}
|
||||
<PayerData data={fact.invoicePayerData} className='text-muted' header />
|
||||
{fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>}
|
||||
{!fact.invoiceComment && !fact.description && <span className='d-block'>no description</span>}
|
||||
{!fact.invoiceComment && !fact.description && fact.bolt11 && <span className='d-block'>no description</span>}
|
||||
<Satus status={fact.status} />
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
@ -25,6 +25,7 @@ import { NostrAuth } from '../components/nostr-auth'
|
|||
import { useToast } from '../components/toast'
|
||||
import { useLogger } from '../components/logger'
|
||||
import { useMe } from '../components/me'
|
||||
import { INVOICE_RETENTION_DAYS } from '../lib/constants'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||
|
||||
|
@ -74,6 +75,7 @@ export default function Settings ({ ssrData }) {
|
|||
noteCowboyHat: settings?.noteCowboyHat,
|
||||
noteForwardedSats: settings?.noteForwardedSats,
|
||||
hideInvoiceDesc: settings?.hideInvoiceDesc,
|
||||
autoDropBolt11s: settings?.autoDropBolt11s,
|
||||
hideFromTopUsers: settings?.hideFromTopUsers,
|
||||
hideCowboyHat: settings?.hideCowboyHat,
|
||||
imgproxyOnly: settings?.imgproxyOnly,
|
||||
|
@ -236,6 +238,23 @@ export default function Settings ({ ssrData }) {
|
|||
name='hideInvoiceDesc'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
<DropBolt11sCheckbox
|
||||
ssrData={ssrData}
|
||||
label={
|
||||
<div className='d-flex align-items-center'>autodelete withdrawal invoices
|
||||
<Info>
|
||||
<ul className='fw-bold'>
|
||||
<li>use this to protect receiver privacy</li>
|
||||
<li>applies retroactively, cannot be reversed</li>
|
||||
<li>withdrawal invoices are kept at least {INVOICE_RETENTION_DAYS} days for security and debugging purposes</li>
|
||||
<li>autodeletions are run a daily basis at night</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='autoDropBolt11s'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
<Checkbox
|
||||
label={<>hide me from <Link href='/top/stackers/day'>top stackers</Link></>}
|
||||
name='hideFromTopUsers'
|
||||
|
@ -377,6 +396,38 @@ export default function Settings ({ ssrData }) {
|
|||
)
|
||||
}
|
||||
|
||||
const DropBolt11sCheckbox = ({ ssrData, ...props }) => {
|
||||
const showModal = useShowModal()
|
||||
const { data } = useQuery(gql`{ numBolt11s }`)
|
||||
const { numBolt11s } = data || ssrData
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
onClick={e => {
|
||||
if (e.target.checked) {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<>
|
||||
<p className='fw-bolder'>{numBolt11s} withdrawal invoices will be deleted with this setting.</p>
|
||||
<p className='fw-bolder'>You sure? This is a gone forever kind of delete.</p>
|
||||
<div className='d-flex justify-content-end'>
|
||||
<Button
|
||||
variant='danger' onClick={async () => {
|
||||
await onClose()
|
||||
}}
|
||||
>I am sure
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function QRLinkButton ({ provider, unlink, status }) {
|
||||
const showModal = useShowModal()
|
||||
const text = status ? 'Unlink' : 'Link'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery } from '@apollo/client'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { CenterLayout } from '../../components/layout'
|
||||
import { CopyInput, Input, InputSkeleton } from '../../components/form'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
|
@ -6,9 +6,15 @@ import InvoiceStatus from '../../components/invoice-status'
|
|||
import { useRouter } from 'next/router'
|
||||
import { WITHDRAWL } from '../../fragments/wallet'
|
||||
import Link from 'next/link'
|
||||
import { SSR } from '../../lib/constants'
|
||||
import { SSR, INVOICE_RETENTION_DAYS } from '../../lib/constants'
|
||||
import { numWithUnits } from '../../lib/format'
|
||||
import Bolt11Info from '../../components/bolt11-info'
|
||||
import { datePivot, timeLeft } from '../../lib/time'
|
||||
import { useMe } from '../../components/me'
|
||||
import { useToast } from '../../components/toast'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { useShowModal } from '../../components/modal'
|
||||
import { DeleteConfirm } from '../../components/delete'
|
||||
|
||||
export default function Withdrawl () {
|
||||
return (
|
||||
|
@ -87,7 +93,7 @@ function LoadWithdrawl () {
|
|||
<div className='w-100'>
|
||||
<CopyInput
|
||||
label='invoice' type='text'
|
||||
placeholder={data.withdrawl.bolt11} readOnly noForm
|
||||
placeholder={data.withdrawl.bolt11 || 'deleted'} readOnly noForm
|
||||
/>
|
||||
</div>
|
||||
<div className='w-100'>
|
||||
|
@ -98,7 +104,70 @@ function LoadWithdrawl () {
|
|||
/>
|
||||
</div>
|
||||
<InvoiceStatus variant={variant} status={status} />
|
||||
<Bolt11Info bolt11={data.withdrawl.bolt11} />
|
||||
<Bolt11Info bolt11={data.withdrawl.bolt11}>
|
||||
<PrivacyOption wd={data.withdrawl} />
|
||||
</Bolt11Info>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacyOption ({ wd }) {
|
||||
if (!wd.bolt11) return
|
||||
|
||||
const me = useMe()
|
||||
const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS })
|
||||
const oldEnough = new Date() >= keepUntil
|
||||
if (!oldEnough) {
|
||||
return (
|
||||
<span className='text-muted fst-italic'>
|
||||
{`this invoice ${me.autoDropBolt11s ? 'will be auto-deleted' : 'can be deleted'} in ${timeLeft(keepUntil)}`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
const [dropBolt11] = useMutation(
|
||||
gql`
|
||||
mutation dropBolt11($id: ID!) {
|
||||
dropBolt11(id: $id) {
|
||||
id
|
||||
}
|
||||
}`, {
|
||||
update (cache) {
|
||||
cache.modify({
|
||||
id: `Withdrawl:${wd.id}`,
|
||||
fields: {
|
||||
bolt11: () => null,
|
||||
hash: () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
className='btn btn-md btn-danger' onClick={() => {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<DeleteConfirm
|
||||
type='invoice'
|
||||
onConfirm={async () => {
|
||||
if (me) {
|
||||
try {
|
||||
await dropBolt11({ variables: { id: wd.id } })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('unable to delete invoice')
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}}
|
||||
>delete invoice
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "autoDropBolt11s" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Withdrawl" ALTER COLUMN "hash" DROP NOT NULL;
|
||||
ALTER TABLE "Withdrawl" ALTER COLUMN "bolt11" DROP NOT NULL;
|
||||
|
||||
-- hack ... prisma doesn't know about our other schemas (e.g. pgboss)
|
||||
-- and this is only really a problem on their "shadow database"
|
||||
-- so we catch the exception it throws and ignore it
|
||||
CREATE OR REPLACE FUNCTION create_autodrop_bolt11s_job()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
INSERT INTO pgboss.schedule (name, cron, timezone) VALUES ('autoDropBolt11s', '1 1 * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT create_autodrop_bolt11s_job();
|
||||
DROP FUNCTION create_autodrop_bolt11s_job();
|
|
@ -50,6 +50,7 @@ model User {
|
|||
greeterMode Boolean @default(false)
|
||||
fiatCurrency String @default("USD")
|
||||
withdrawMaxFeeDefault Int @default(10)
|
||||
autoDropBolt11s Boolean @default(false)
|
||||
hideFromTopUsers Boolean @default(false)
|
||||
turboTipping Boolean @default(false)
|
||||
imgproxyOnly Boolean @default(false)
|
||||
|
@ -493,8 +494,8 @@ model Withdrawl {
|
|||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId Int
|
||||
hash String
|
||||
bolt11 String
|
||||
hash String?
|
||||
bolt11 String?
|
||||
msatsPaying BigInt
|
||||
msatsPaid BigInt?
|
||||
msatsFeePaying BigInt
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import PgBoss from 'pg-boss'
|
||||
import nextEnv from '@next/env'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { checkInvoice, checkWithdrawal } from './wallet.js'
|
||||
import { checkInvoice, checkWithdrawal, autoDropBolt11s } from './wallet.js'
|
||||
import { repin } from './repin.js'
|
||||
import { trust } from './trust.js'
|
||||
import { auction } from './auction.js'
|
||||
|
@ -57,6 +57,7 @@ async function work () {
|
|||
await boss.start()
|
||||
await boss.work('checkInvoice', checkInvoice(args))
|
||||
await boss.work('checkWithdrawal', checkWithdrawal(args))
|
||||
await boss.work('autoDropBolt11s', autoDropBolt11s(args))
|
||||
await boss.work('repin-*', repin(args))
|
||||
await boss.work('trust', trust(args))
|
||||
await boss.work('timestampItem', timestampItem(args))
|
||||
|
|
|
@ -3,6 +3,7 @@ import { getInvoice, getPayment, cancelHodlInvoice } from 'ln-service'
|
|||
import { datePivot } from '../lib/time.js'
|
||||
import { sendUserNotification } from '../api/webPush/index.js'
|
||||
import { msatsToSats, numWithUnits } from '../lib/format'
|
||||
import { INVOICE_RETENTION_DAYS } from '../lib/constants'
|
||||
|
||||
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
||||
|
||||
|
@ -118,3 +119,20 @@ export function checkWithdrawal ({ boss, models, lnd }) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function autoDropBolt11s ({ models }) {
|
||||
return async function () {
|
||||
console.log('deleting invoices')
|
||||
try {
|
||||
await serialize(models, models.$executeRaw`
|
||||
UPDATE "Withdrawl"
|
||||
SET hash = NULL, bolt11 = NULL
|
||||
WHERE "userId" IN (SELECT id FROM users WHERE "autoDropBolt11s")
|
||||
AND now() > created_at + interval '${INVOICE_RETENTION_DAYS} days'
|
||||
AND hash IS NOT NULL;`
|
||||
)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue