Skip to content

Commit 94bd0ee

Browse files
committed
fix: implement QA feedback for user deletion
1 parent 4ce7c56 commit 94bd0ee

File tree

12 files changed

+89
-211
lines changed

12 files changed

+89
-211
lines changed

api/paidAction/zap.js

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export async function getSybilFeePercent () {
5757
}
5858

5959
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) {
60+
const item = await tx.item.findUnique({ where: { id: parseInt(itemId) } })
61+
if (item.userId === USER_ID.delete) {
62+
throw new Error('cannot zap deleted content')
63+
}
64+
6065
const feeMsats = cost * sybilFeePercent / 100n
6166
const zapMsats = cost - feeMsats
6267
itemId = parseInt(itemId)
@@ -140,20 +145,11 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
140145
)
141146
UPDATE users
142147
SET
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
148+
mcredits = users.mcredits + recipients.mcredits,
149+
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
150+
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
155151
FROM recipients
156-
WHERE users.id = recipients."userId"`
152+
WHERE users.id = recipients."userId" AND users."deletedAt" IS NULL`
157153

158154
// Donate msats that would have gone to deleted users to the rewards pool
159155
const deletedUserMsats = await tx.$queryRaw`

api/resolvers/item.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ export default {
965965
FROM "Item"
966966
WHERE id = $1`, Number(id))
967967

968-
if (item.deletedAt) {
968+
if (item.deletedAt || item.userId === USER_ID.delete) {
969969
throw new GqlInputError('item is deleted')
970970
}
971971

api/resolvers/user.js

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { hashEmail } from '@/lib/crypto'
1313
import { isMuted } from '@/lib/user'
1414
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
1515
import { processCrop } from '@/worker/imgproxy'
16+
import { deleteItemByAuthor } from '@/lib/item'
1617

1718
const contributors = new Set()
1819

@@ -657,7 +658,7 @@ export default {
657658
},
658659

659660
Mutation: {
660-
deleteAccount: async (parent, { deleteContent, confirmation, donateBalance }, { me, models }) => {
661+
deleteAccount: async (parent, { confirmation, donateBalance }, { me, models }) => {
661662
if (!me) {
662663
throw new GqlAuthenticationError()
663664
}
@@ -667,6 +668,15 @@ export default {
667668
}
668669

669670
return await models.$transaction(async (tx) => {
671+
await tx.user.upsert({
672+
where: { id: USER_ID.delete },
673+
update: {},
674+
create: {
675+
id: USER_ID.delete,
676+
name: 'delete'
677+
}
678+
})
679+
670680
const user = await tx.user.findUnique({
671681
where: { id: me.id },
672682
select: {
@@ -700,36 +710,17 @@ export default {
700710
})
701711
}
702712

703-
// If deleteContent is true, replace content with hash
704-
if (deleteContent) {
705-
// Get all items for this user
706-
const items = await tx.item.findMany({
707-
where: { userId: me.id },
708-
select: { id: true, title: true, text: true, url: true }
709-
})
710-
711-
// Update each item with hashed content
712-
for (const item of items) {
713-
const originalContent = JSON.stringify({
714-
title: item.title,
715-
text: item.text,
716-
url: item.url
717-
})
718-
719-
const hash = createHash('sha256').update(originalContent).digest('hex')
720-
const deletedContent = `[deleted] ${hash}`
721-
722-
await tx.item.update({
723-
where: { id: item.id },
724-
data: {
725-
title: item.title ? deletedContent : null,
726-
text: item.text ? deletedContent : null,
727-
url: item.url ? deletedContent : null
728-
}
729-
})
730-
}
713+
const items = await tx.item.findMany({ where: { userId: me.id } })
714+
for (const item of items) {
715+
await deleteItemByAuthor({ models: tx, id: item.id, item })
731716
}
732717

718+
// Always move user's content to the @delete user
719+
await tx.item.updateMany({
720+
where: { userId: me.id },
721+
data: { userId: USER_ID.delete }
722+
})
723+
733724
// Remove all attached wallets
734725
await tx.wallet.deleteMany({
735726
where: { userId: me.id }

api/ssrApollo.js

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,9 @@ import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
2020
export default async function getSSRApolloClient ({ req, res, me = null }) {
2121
const session = req && await getServerSession(req, res, getAuthOptions(req))
2222

23-
// If there's a session, check if the user is deleted
2423
let meUser = me
2524
if (session?.user) {
26-
const user = await models.user.findUnique({
27-
where: { id: parseInt(session.user.id) }, // Convert string to int
28-
select: { deletedAt: true }
29-
})
30-
31-
// If the user is deleted, don't pass the session to the context
32-
if (user?.deletedAt) {
33-
meUser = null
34-
} else {
35-
meUser = session.user
36-
}
25+
meUser = session.user
3726
}
3827

3928
const client = new ApolloClient({
@@ -175,18 +164,6 @@ export function getGetServerSideProps (
175164
me = null
176165
}
177166

178-
// Check if the user account is deleted
179-
if (me) {
180-
const user = await models.user.findUnique({
181-
where: { id: parseInt(me.id) }, // Convert string to int
182-
select: { deletedAt: true }
183-
})
184-
185-
if (user?.deletedAt) {
186-
me = null
187-
}
188-
}
189-
190167
if (authRequired && !me) {
191168
let callback = process.env.NEXT_PUBLIC_URL + req.url
192169
// On client-side routing, the callback is a NextJS URL

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!, donateBalance: Boolean): Boolean
59+
deleteAccount(confirmation: String!, donateBalance: Boolean, deleteContent: Boolean): Boolean
6060
}
6161
6262
type User {

components/item.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ export default function Item ({
115115
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
116116
: Number(item.user?.id) === USER_ID.ad
117117
? <AdIcon width={24} height={24} className={styles.ad} />
118-
: <UpVote item={item} className={styles.upvote} />}
118+
: Number(item.user?.id) === USER_ID.delete
119+
? null
120+
: <UpVote item={item} className={styles.upvote} />}
119121
<div className={styles.hunk}>
120122
<div className={`${styles.main} flex-wrap`}>
121123
<Link

pages/api/auth/[...nextauth].js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,18 @@ function getCallbacks (req, res) {
152152
async session ({ session, token }) {
153153
// note: this function takes the current token (result of running jwt above)
154154
// and returns a new object session that's returned whenever get|use[Server]Session is called
155-
session.user.id = token.id
155+
if (token?.id) {
156+
const dbUser = await prisma.user.findUnique({
157+
where: { id: token.id },
158+
select: { deletedAt: true }
159+
})
156160

161+
if (dbUser?.deletedAt) {
162+
return null
163+
}
164+
}
165+
166+
session.user.id = token.id
157167
return session
158168
}
159169
}

pages/settings/index.js

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '@/components/form'
2-
import { Alert, Button, InputGroup, Nav, Form as BootstrapForm, OverlayTrigger, Tooltip } from 'react-bootstrap'
2+
import { Alert, Button, InputGroup, Nav, OverlayTrigger, Tooltip } from 'react-bootstrap'
33
import Layout from '@/components/layout'
44
import { useState, useMemo } from 'react'
55
import { gql, useMutation, useQuery } from '@apollo/client'
@@ -15,6 +15,7 @@ import AccordianItem from '@/components/accordian-item'
1515
import { bech32 } from 'bech32'
1616
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
1717
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '@/lib/validate'
18+
import * as Yup from 'yup'
1819
import { SUPPORTED_CURRENCIES } from '@/lib/currency'
1920
import PageLoading from '@/components/page-loading'
2021
import { useShowModal } from '@/components/modal'
@@ -84,40 +85,43 @@ export function SettingsHeader () {
8485
}
8586

8687
const DELETE_ACCOUNT = gql`
87-
mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!, $donateBalance: Boolean) {
88-
deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation, donateBalance: $donateBalance)
88+
mutation deleteAccount($confirmation: String!, $donateBalance: Boolean) {
89+
deleteAccount(confirmation: $confirmation, donateBalance: $donateBalance)
8990
}
9091
`
9192

93+
const GET_USER_BALANCE = gql`
94+
{ me { privates { sats } } }
95+
`
96+
9297
function DeleteAccount () {
9398
const [showConfirmation, setShowConfirmation] = useState(false)
94-
const [deleteContent, setDeleteContent] = useState(false)
95-
const [donateBalance, setDonateBalance] = useState(false)
96-
const [confirmation, setConfirmation] = useState('')
9799
const [deleteAccount] = useMutation(DELETE_ACCOUNT)
98100
const toaster = useToast()
101+
const { data } = useQuery(GET_USER_BALANCE)
102+
const userBalance = data?.me?.privates?.sats || 0
99103

100-
const handleDelete = async () => {
104+
const handleDelete = async (values) => {
101105
try {
102-
await deleteAccount({
103-
variables: {
104-
deleteContent,
105-
confirmation,
106-
donateBalance
107-
}
108-
})
109-
110-
// Sign out the user after successful deletion
106+
await deleteAccount({ variables: { ...values } })
111107
signOut({ callbackUrl: '/' })
112-
113-
// Show success message
114108
toaster.success('Your account has been deleted')
115109
} catch (error) {
116110
console.error(error)
117111
toaster.danger(error.message || 'Failed to delete account')
118112
}
119113
}
120114

115+
const deleteAccountSchema = Yup.object({
116+
confirmation: Yup.string().oneOf(['DELETE MY ACCOUNT'], 'incorrect confirmation text').required('incorrect confirmation text'),
117+
donateBalance: Yup.boolean()
118+
.when('userBalance', {
119+
is: (balance) => balance > 0,
120+
then: schema => schema.oneOf([true], 'please withdraw your balance before deleting your account or confirm donation to rewards pool'),
121+
otherwise: schema => schema
122+
})
123+
})
124+
121125
return (
122126
<>
123127
<div className='form-label mt-4 text-danger'>danger zone</div>
@@ -138,7 +142,16 @@ function DeleteAccount () {
138142
</Button>
139143
)
140144
: (
141-
<>
145+
<Form
146+
initial={{
147+
confirmation: '',
148+
donateBalance: false,
149+
userBalance
150+
}}
151+
schema={deleteAccountSchema}
152+
onSubmit={handleDelete}
153+
context={{ userBalance }}
154+
>
142155
<Alert variant='danger'>
143156
<p><strong>Warning:</strong> Account deletion is permanent and cannot be reversed.</p>
144157
<p>Before proceeding, please ensure:</p>
@@ -149,33 +162,17 @@ function DeleteAccount () {
149162
</ul>
150163
</Alert>
151164

152-
<BootstrapForm.Check
153-
type='checkbox'
154-
id='delete-content'
155-
label='Also anonymize all my posts and comments (they will show as "[deleted]")'
156-
checked={deleteContent}
157-
onChange={(e) => setDeleteContent(e.target.checked)}
158-
className='mb-3'
159-
/>
160-
161-
<BootstrapForm.Check
162-
type='checkbox'
163-
id='donate-balance'
165+
<Checkbox
166+
name='donateBalance'
164167
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+
groupClassName='mb-3'
168169
/>
169170

170-
<BootstrapForm.Group className='mb-3'>
171-
<BootstrapForm.Label>Type "DELETE MY ACCOUNT" to confirm:</BootstrapForm.Label>
172-
<BootstrapForm.Control
173-
type='text'
174-
value={confirmation}
175-
onChange={(e) => setConfirmation(e.target.value)}
176-
placeholder='DELETE MY ACCOUNT'
177-
/>
178-
</BootstrapForm.Group>
171+
<Input
172+
name='confirmation'
173+
label='Type "DELETE MY ACCOUNT" to confirm:'
174+
placeholder='DELETE MY ACCOUNT'
175+
/>
179176

180177
<div className='d-flex justify-content-between'>
181178
<Button
@@ -184,15 +181,9 @@ function DeleteAccount () {
184181
>
185182
Cancel
186183
</Button>
187-
<Button
188-
variant='danger'
189-
disabled={confirmation !== 'DELETE MY ACCOUNT'}
190-
onClick={handleDelete}
191-
>
192-
Permanently delete my account
193-
</Button>
184+
<SubmitButton variant='danger'>Permanently delete my account</SubmitButton>
194185
</div>
195-
</>
186+
</Form>
196187
)}
197188
</div>
198189
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3);
2+
INSERT INTO "users" (id, name) VALUES (106, 'delete') ON CONFLICT (id) DO NOTHING;

prisma/migrations/20250610152839_deleted_user_earnings_job/migration.sql

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)