Skip to content

Commit b4eccc6

Browse files
committed
feat: option to donate sats for removed accounts
1 parent abf9561 commit b4eccc6

File tree

8 files changed

+183
-12
lines changed

8 files changed

+183
-12
lines changed

api/paidAction/zap.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,53 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
140140
)
141141
UPDATE users
142142
SET
143-
mcredits = users.mcredits + recipients.mcredits,
144-
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
145-
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
143+
mcredits = CASE
144+
WHEN users."deletedAt" IS NULL THEN users.mcredits + recipients.mcredits
145+
ELSE users.mcredits
146+
END,
147+
"stackedMsats" = CASE
148+
WHEN users."deletedAt" IS NULL THEN users."stackedMsats" + recipients.mcredits
149+
ELSE users."stackedMsats"
150+
END,
151+
"stackedMcredits" = CASE
152+
WHEN users."deletedAt" IS NULL THEN users."stackedMcredits" + recipients.mcredits
153+
ELSE users."stackedMcredits"
154+
END
146155
FROM recipients
147156
WHERE users.id = recipients."userId"`
157+
158+
// Donate msats that would have gone to deleted users to the rewards pool
159+
const deletedUserMsats = await tx.$queryRaw`
160+
WITH forwardees AS (
161+
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
162+
FROM "ItemForward"
163+
WHERE "itemId" = ${itemAct.itemId}::INTEGER
164+
), total_forwarded AS (
165+
SELECT COALESCE(SUM(mcredits), 0) as mcredits
166+
FROM forwardees
167+
), recipients AS (
168+
SELECT "userId", mcredits FROM forwardees
169+
UNION
170+
SELECT ${itemAct.item.userId}::INTEGER as "userId",
171+
${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
172+
)
173+
SELECT COALESCE(SUM(recipients.mcredits), 0)::BIGINT as msats
174+
FROM recipients
175+
JOIN users ON users.id = recipients."userId"
176+
WHERE users."deletedAt" IS NOT NULL`
177+
178+
if (deletedUserMsats.length > 0 && deletedUserMsats[0].msats > 0) {
179+
// Convert msats to sats and donate to rewards pool
180+
const donationSats = Number(deletedUserMsats[0].msats / 1000n)
181+
if (donationSats > 0) {
182+
await tx.donation.create({
183+
data: {
184+
sats: donationSats,
185+
userId: USER_ID.sn // System donation
186+
}
187+
})
188+
}
189+
}
148190
}
149191

150192
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt

api/resolvers/user.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ export default {
657657
},
658658

659659
Mutation: {
660-
deleteAccount: async (parent, { deleteContent, confirmation }, { me, models }) => {
660+
deleteAccount: async (parent, { deleteContent, confirmation, donateBalance }, { me, models }) => {
661661
if (!me) {
662662
throw new GqlAuthenticationError()
663663
}
@@ -676,8 +676,28 @@ export default {
676676
}
677677
})
678678

679-
if ((user.msats + user.mcredits) > 0) {
680-
throw new GqlInputError('please withdraw your balance before deleting your account')
679+
const totalBalance = user.msats + user.mcredits
680+
if (totalBalance > 0 && !donateBalance) {
681+
throw new GqlInputError('please withdraw your balance before deleting your account or confirm donation to rewards pool')
682+
}
683+
684+
// If user has balance and confirmed donation, add to donations
685+
if (totalBalance > 0 && donateBalance) {
686+
await tx.donation.create({
687+
data: {
688+
sats: Number(totalBalance / 1000n), // Convert msats to sats
689+
userId: me.id
690+
}
691+
})
692+
693+
// Zero out user balance
694+
await tx.user.update({
695+
where: { id: me.id },
696+
data: {
697+
msats: 0,
698+
mcredits: 0
699+
}
700+
})
681701
}
682702

683703
// If deleteContent is true, replace content with hash
@@ -710,6 +730,11 @@ export default {
710730
}
711731
}
712732

733+
// Remove all attached wallets
734+
await tx.wallet.deleteMany({
735+
where: { userId: me.id }
736+
})
737+
713738
// Create deletion timestamp and hash the old username with it
714739
const deletionTimestamp = new Date()
715740
const usernameHashInput = `${user.name}${deletionTimestamp.toISOString()}`

api/typeDefs/user.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export default gql`
5656
generateApiKey(id: ID!): String
5757
deleteApiKey(id: ID!): User
5858
disableFreebies: Boolean
59-
deleteAccount(deleteContent: Boolean!, confirmation: String!): Boolean
59+
deleteAccount(deleteContent: Boolean!, confirmation: String!, donateBalance: Boolean): Boolean
6060
}
6161
6262
type User {

pages/settings/index.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,15 @@ export function SettingsHeader () {
8484
}
8585

8686
const DELETE_ACCOUNT = gql`
87-
mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!) {
88-
deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation)
87+
mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!, $donateBalance: Boolean) {
88+
deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation, donateBalance: $donateBalance)
8989
}
9090
`
9191

9292
function DeleteAccount () {
9393
const [showConfirmation, setShowConfirmation] = useState(false)
9494
const [deleteContent, setDeleteContent] = useState(false)
95+
const [donateBalance, setDonateBalance] = useState(false)
9596
const [confirmation, setConfirmation] = useState('')
9697
const [deleteAccount] = useMutation(DELETE_ACCOUNT)
9798
const toaster = useToast()
@@ -101,7 +102,8 @@ function DeleteAccount () {
101102
await deleteAccount({
102103
variables: {
103104
deleteContent,
104-
confirmation
105+
confirmation,
106+
donateBalance
105107
}
106108
})
107109

@@ -141,7 +143,7 @@ function DeleteAccount () {
141143
<p><strong>Warning:</strong> Account deletion is permanent and cannot be reversed.</p>
142144
<p>Before proceeding, please ensure:</p>
143145
<ul>
144-
<li>You have withdrawn all your sats (you cannot delete an account with a balance)</li>
146+
<li>You have withdrawn all your sats or checked the box to donate your balance to the rewards pool</li>
145147
<li>You understand that you will lose access to your account name</li>
146148
<li>You have considered that this action affects your entire account history</li>
147149
</ul>
@@ -156,6 +158,15 @@ function DeleteAccount () {
156158
className='mb-3'
157159
/>
158160

161+
<BootstrapForm.Check
162+
type='checkbox'
163+
id='donate-balance'
164+
label='Donate my remaining balance to the rewards pool (required if you have a balance)'
165+
checked={donateBalance}
166+
onChange={(e) => setDonateBalance(e.target.checked)}
167+
className='mb-3'
168+
/>
169+
159170
<BootstrapForm.Group className='mb-3'>
160171
<BootstrapForm.Label>Type "DELETE MY ACCOUNT" to confirm:</BootstrapForm.Label>
161172
<BootstrapForm.Control
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE OR REPLACE FUNCTION schedule_deleted_user_earnings_job()
2+
RETURNS INTEGER
3+
LANGUAGE plpgsql
4+
AS $$
5+
DECLARE
6+
BEGIN
7+
-- Run at 12:20 AM daily to collect earnings from deleted users and donate to rewards pool
8+
INSERT INTO pgboss.schedule (name, cron, timezone)
9+
VALUES ('deletedUserEarnings', '20 0 * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
10+
return 0;
11+
EXCEPTION WHEN OTHERS THEN
12+
return 0;
13+
END;
14+
$$;
15+
16+
SELECT schedule_deleted_user_earnings_job();
17+
DROP FUNCTION IF EXISTS schedule_deleted_user_earnings_job;

worker/deletedUserEarnings.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import createPrisma from '@/lib/create-prisma'
2+
import { USER_ID } from '@/lib/constants'
3+
4+
export async function deletedUserEarnings ({ name }) {
5+
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
6+
7+
try {
8+
console.log(name, 'collecting earnings for deleted users')
9+
10+
// Find earnings that went to deleted users since yesterday
11+
const deletedEarnings = await models.$queryRaw`
12+
SELECT
13+
e."userId",
14+
SUM(e.msats)::BIGINT as total_msats,
15+
u."deletedAt"
16+
FROM "Earn" e
17+
JOIN "User" u ON e."userId" = u.id
18+
WHERE u."deletedAt" IS NOT NULL
19+
AND e."createdAt" >= date_trunc('day', now() AT TIME ZONE 'America/Chicago' - interval '1 day')
20+
AND e."createdAt" < date_trunc('day', now() AT TIME ZONE 'America/Chicago')
21+
GROUP BY e."userId", u."deletedAt"
22+
`
23+
24+
if (deletedEarnings.length === 0) {
25+
console.log(name, 'no earnings for deleted users found')
26+
return
27+
}
28+
29+
let totalDonatedSats = 0
30+
31+
await models.$transaction(async (tx) => {
32+
for (const earning of deletedEarnings) {
33+
const donationSats = Number(earning.total_msats / 1000n)
34+
35+
if (donationSats > 0) {
36+
// Create donation record
37+
await tx.donation.create({
38+
data: {
39+
sats: donationSats,
40+
userId: USER_ID.sn, // System donation
41+
createdAt: new Date()
42+
}
43+
})
44+
45+
totalDonatedSats += donationSats
46+
47+
// Remove the earnings from the deleted user (set msats to 0)
48+
await tx.user.update({
49+
where: { id: earning.userId },
50+
data: {
51+
msats: 0,
52+
mcredits: 0
53+
}
54+
})
55+
56+
console.log(
57+
name,
58+
`donated ${donationSats} sats from deleted user ${earning.userId} to rewards pool`
59+
)
60+
}
61+
}
62+
})
63+
64+
console.log(name, `total donated: ${totalDonatedSats} sats from ${deletedEarnings.length} deleted users`)
65+
} catch (error) {
66+
console.error(name, 'error collecting deleted user earnings:', error)
67+
throw error
68+
} finally {
69+
models.$disconnect().catch(console.error)
70+
}
71+
}

worker/earn.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ function earnStmts (data, { models }) {
182182
return [
183183
models.earn.create({ data }),
184184
models.user.update({
185-
where: { id: userId },
185+
where: {
186+
id: userId,
187+
deletedAt: null // Only update if user is not deleted
188+
},
186189
data: {
187190
msats: { increment: msats },
188191
stackedMsats: { increment: msats }

worker/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { expireBoost } from './expireBoost'
3838
import { payingActionConfirmed, payingActionFailed } from './payingAction'
3939
import { autoDropBolt11s } from './autoDropBolt11'
4040
import { postToSocial } from './socialPoster'
41+
import { deletedUserEarnings } from './deletedUserEarnings'
4142

4243
// WebSocket polyfill
4344
import ws from 'isomorphic-ws'
@@ -131,6 +132,7 @@ async function work () {
131132
await boss.work('timestampItem', jobWrapper(timestampItem))
132133
await boss.work('earn', jobWrapper(earn))
133134
await boss.work('earnRefill', jobWrapper(earnRefill))
135+
await boss.work('deletedUserEarnings', jobWrapper(deletedUserEarnings))
134136
await boss.work('streak', jobWrapper(computeStreaks))
135137
await boss.work('checkStreak', jobWrapper(checkStreak))
136138
await boss.work('nip57', jobWrapper(nip57))

0 commit comments

Comments
 (0)