diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js
index 66b4d6a0c..96eb0b3be 100644
--- a/api/paidAction/zap.js
+++ b/api/paidAction/zap.js
@@ -57,6 +57,11 @@ export async function getSybilFeePercent () {
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) {
+ const item = await tx.item.findUnique({ where: { id: parseInt(itemId) } })
+ if (item.userId === USER_ID.delete) {
+ throw new Error('cannot zap deleted content')
+ }
+
const feeMsats = cost * sybilFeePercent / 100n
const zapMsats = cost - feeMsats
itemId = parseInt(itemId)
@@ -144,7 +149,40 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
FROM recipients
- WHERE users.id = recipients."userId"`
+ WHERE users.id = recipients."userId" AND users."deletedAt" IS NULL`
+
+ // Donate msats that would have gone to deleted users to the rewards pool
+ const deletedUserMsats = await tx.$queryRaw`
+ WITH forwardees AS (
+ SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
+ FROM "ItemForward"
+ WHERE "itemId" = ${itemAct.itemId}::INTEGER
+ ), total_forwarded AS (
+ SELECT COALESCE(SUM(mcredits), 0) as mcredits
+ FROM forwardees
+ ), recipients AS (
+ SELECT "userId", mcredits FROM forwardees
+ UNION
+ SELECT ${itemAct.item.userId}::INTEGER as "userId",
+ ${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
+ )
+ SELECT COALESCE(SUM(recipients.mcredits), 0)::BIGINT as msats
+ FROM recipients
+ JOIN users ON users.id = recipients."userId"
+ WHERE users."deletedAt" IS NOT NULL`
+
+ if (deletedUserMsats.length > 0 && deletedUserMsats[0].msats > 0) {
+ // Convert msats to sats and donate to rewards pool
+ const donationSats = Number(deletedUserMsats[0].msats / 1000n)
+ if (donationSats > 0) {
+ await tx.donation.create({
+ data: {
+ sats: donationSats,
+ userId: USER_ID.sn // System donation
+ }
+ })
+ }
+ }
}
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index a03a0cf9c..c84053297 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -971,7 +971,7 @@ export default {
FROM "Item"
WHERE id = $1`, Number(id))
- if (item.deletedAt) {
+ if (item.deletedAt || item.userId === USER_ID.delete) {
throw new GqlInputError('item is deleted')
}
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index f73f6bc34..2475a5514 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -1,3 +1,4 @@
+import { createHash } from 'crypto'
import { readFile } from 'fs/promises'
import { join, resolve } from 'path'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
@@ -12,6 +13,7 @@ import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { processCrop } from '@/worker/imgproxy'
+import { deleteItemByAuthor } from '@/lib/item'
const contributors = new Set()
@@ -656,6 +658,101 @@ export default {
},
Mutation: {
+ deleteAccount: async (parent, { confirmation, donateBalance }, { me, models }) => {
+ if (!me) {
+ throw new GqlAuthenticationError()
+ }
+
+ if (confirmation !== 'DELETE MY ACCOUNT') {
+ throw new GqlInputError('incorrect confirmation text')
+ }
+
+ return await models.$transaction(async (tx) => {
+ await tx.user.upsert({
+ where: { id: USER_ID.delete },
+ update: {},
+ create: {
+ id: USER_ID.delete,
+ name: 'delete'
+ }
+ })
+
+ const user = await tx.user.findUnique({
+ where: { id: me.id },
+ select: {
+ msats: true,
+ mcredits: true,
+ name: true
+ }
+ })
+
+ const totalBalance = user.msats + user.mcredits
+ if (totalBalance > 0 && !donateBalance) {
+ throw new GqlInputError('please withdraw your balance before deleting your account or confirm donation to rewards pool')
+ }
+
+ // If user has balance and confirmed donation, add to donations
+ if (totalBalance > 0 && donateBalance) {
+ await tx.donation.create({
+ data: {
+ sats: Number(totalBalance / 1000n), // Convert msats to sats
+ userId: me.id
+ }
+ })
+
+ // Zero out user balance
+ await tx.user.update({
+ where: { id: me.id },
+ data: {
+ msats: 0,
+ mcredits: 0
+ }
+ })
+ }
+
+ const items = await tx.item.findMany({ where: { userId: me.id } })
+ for (const item of items) {
+ await deleteItemByAuthor({ models: tx, id: item.id, item })
+ }
+
+ // Always move user's content to the @delete user
+ await tx.item.updateMany({
+ where: { userId: me.id },
+ data: { userId: USER_ID.delete }
+ })
+
+ // Remove all attached wallets
+ await tx.wallet.deleteMany({
+ where: { userId: me.id }
+ })
+
+ // Create deletion timestamp and hash the old username with it
+ const deletionTimestamp = new Date()
+ const usernameHashInput = `${user.name}${deletionTimestamp.toISOString()}`
+ const hashedUsername = createHash('sha256').update(usernameHashInput).digest('hex')
+
+ // Mark user as deleted and anonymize data
+ await tx.user.update({
+ where: { id: me.id },
+ data: {
+ deletedAt: deletionTimestamp,
+ name: hashedUsername,
+ email: null,
+ emailVerified: null,
+ emailHash: null,
+ image: null,
+ pubkey: null,
+ apiKeyHash: null,
+ nostrPubkey: null,
+ nostrAuthPubkey: null,
+ twitterId: null,
+ githubId: null
+ }
+ })
+
+ return true
+ })
+ },
disableFreebies: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
diff --git a/api/ssrApollo.js b/api/ssrApollo.js
index d5513b7d9..5fd9ccbf6 100644
--- a/api/ssrApollo.js
+++ b/api/ssrApollo.js
@@ -19,6 +19,12 @@ import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
+
+ let meUser = me
+ if (session?.user) {
+ meUser = session.user
+ }
+
const client = new ApolloClient({
ssrMode: true,
link: new SchemaLink({
@@ -28,9 +34,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
}),
context: {
models,
- me: session
- ? session.user
- : me,
+ me: meUser,
lnd,
search
}
diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js
index 1e7155914..298a13840 100644
--- a/api/typeDefs/user.js
+++ b/api/typeDefs/user.js
@@ -56,6 +56,7 @@ export default gql`
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
disableFreebies: Boolean
+ deleteAccount(confirmation: String!, donateBalance: Boolean, deleteContent: Boolean): Boolean
}
type User {
@@ -70,6 +71,7 @@ export default gql`
bioId: Int
photoId: Int
since: Int
+ deletedAt: Date
"""
this is only returned when we sort stackers by value
diff --git a/components/item.js b/components/item.js
index 0ae1d0afb..91b142d68 100644
--- a/components/item.js
+++ b/components/item.js
@@ -115,7 +115,9 @@ export default function Item ({
?
+ This will permanently delete your account. This action cannot be undone. +
+ + {!showConfirmation + ? ( + + ) + : ( + + )} +