Skip to content

Commit d7a4380

Browse files
committed
feat(manager): add helpful commands
1 parent 1ef3999 commit d7a4380

File tree

4 files changed

+210
-1
lines changed

4 files changed

+210
-1
lines changed

apps/manager/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"ioredis": "npm:@deepnotes/ioredis@^5.3.1",
1313
"knex": "2.3.0",
1414
"lodash": "^4.17.21",
15-
"objection": "3.0.1"
15+
"objection": "3.0.1",
16+
"stripe": "^14.3.0"
1617
},
1718
"devDependencies": {
1819
"@types/lodash": "^4.14.200",

apps/manager/src/index.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
import './env';
2+
import './stripe';
23

4+
import { decryptUserEmail, hashUserEmail } from '@deeplib/data';
5+
import {
6+
GroupJoinInvitationModel,
7+
GroupJoinRequestModel,
8+
GroupMemberModel,
9+
PageModel,
10+
SessionModel,
11+
UserModel,
12+
UserPageModel,
13+
} from '@deeplib/db';
314
import {
415
sendBrevoMail,
516
sendMailjetMail,
617
sendSendGridMail,
718
} from '@deeplib/mail';
819
import { mainLogger } from '@stdlib/misc';
20+
import { raw } from 'objection';
921
import readline from 'readline';
1022

1123
import { dataAbstraction } from './data/data-abstraction';
1224
import { initKnex } from './data/knex';
25+
import { stripe } from './stripe';
1326

1427
initKnex();
1528

@@ -22,6 +35,8 @@ function showHelp() {
2235
console.log('Available commands:');
2336
console.log('- hget <prefix> <suffix> <field>');
2437
console.log('- hset <prefix> <suffix> <field> <value>');
38+
console.log('- delete-user <userId>');
39+
console.log('- hash-email <email>');
2540
console.log('- send-sendgrid-test-mail <from> <to>');
2641
console.log('- send-brevo-test-mail <from> <to>');
2742
console.log('- send-mailjet-test-mail <from> <to>');
@@ -51,6 +66,16 @@ async function handleCommand(command: string) {
5166
});
5267
break;
5368

69+
case 'delete-user':
70+
await deleteUser(args[0]);
71+
break;
72+
73+
case 'hash-email':
74+
mainLogger.info(
75+
`Result: '\\x${Buffer.from(hashUserEmail(args[0])).toString('hex')}'`,
76+
);
77+
break;
78+
5479
case 'send-sendgrid-test-mail':
5580
await sendSendGridMail({
5681
from: { name: 'DeepNotes', email: args[0] },
@@ -89,6 +114,180 @@ async function handleCommand(command: string) {
89114
}
90115
}
91116

117+
async function deleteUser(userId: string) {
118+
await dataAbstraction().transaction(async (dtrx) => {
119+
// Check if any group has more than one member
120+
121+
const memberships = await GroupMemberModel.query(dtrx.trx)
122+
.where('group_members.user_id', userId)
123+
.leftJoin(
124+
GroupMemberModel.query(dtrx.trx)
125+
.groupBy('group_id')
126+
.select('group_id')
127+
.count('* as member_count')
128+
.as('member_counts'),
129+
'group_members.group_id',
130+
'member_counts.group_id',
131+
)
132+
.leftJoin(
133+
GroupMemberModel.query(dtrx.trx)
134+
.where('role', 'owner')
135+
.groupBy('group_id')
136+
.select('group_id')
137+
.count('* as owner_count')
138+
.as('owner_counts'),
139+
'group_members.group_id',
140+
'owner_counts.group_id',
141+
)
142+
.select(
143+
'group_members.group_id',
144+
raw('COALESCE(member_counts.member_count, 0) as member_count'),
145+
raw('COALESCE(owner_counts.owner_count, 0) as owner_count'),
146+
);
147+
148+
if (
149+
memberships.some(
150+
(count) =>
151+
(count as any).member_count > 1 && (count as any).owner_count <= 1,
152+
)
153+
) {
154+
throw {
155+
message:
156+
'Some groups would be left without an owner. Transfer ownership before deleting your account.',
157+
code: 'BAD_REQUEST',
158+
};
159+
}
160+
161+
const idsOfGroupsToDelete = memberships
162+
.filter((membership) => (membership as any).member_count <= 1)
163+
.map((membership) => membership.group_id);
164+
165+
// Get all user data
166+
167+
const [
168+
groupPageIds,
169+
invitations,
170+
requests,
171+
visitedPageIds,
172+
sessions,
173+
user,
174+
] = await Promise.all([
175+
PageModel.query(dtrx.trx)
176+
.whereIn('group_id', idsOfGroupsToDelete)
177+
.select('pages.id'),
178+
179+
GroupJoinInvitationModel.query(dtrx.trx)
180+
.where('user_id', userId)
181+
.select('group_id'),
182+
GroupJoinRequestModel.query(dtrx.trx)
183+
.where('user_id', userId)
184+
.select('group_id'),
185+
186+
UserPageModel.query(dtrx.trx).where('user_id', userId).select('page_id'),
187+
188+
SessionModel.query(dtrx.trx)
189+
.where('user_id', userId)
190+
.whereNot('invalidated', true)
191+
.select('id'),
192+
193+
UserModel.query(dtrx.trx)
194+
.findById(userId)
195+
.select('encrypted_email', 'personal_group_id', 'customer_id'),
196+
]);
197+
198+
if (user == null) {
199+
throw {
200+
message: 'User not found',
201+
code: 'NOT_FOUND',
202+
};
203+
}
204+
205+
// Delete all user data
206+
207+
await Promise.all([
208+
...groupPageIds.map((page) =>
209+
dataAbstraction().delete('page', page.id, {
210+
dtrx,
211+
cacheOnly: true,
212+
}),
213+
),
214+
215+
...invitations.map((invitation) =>
216+
dataAbstraction().delete(
217+
'group-join-invitation',
218+
`${invitation.group_id}:${userId}`,
219+
{ dtrx, cacheOnly: true },
220+
),
221+
),
222+
...requests.map((request) =>
223+
dataAbstraction().delete(
224+
'group-join-request',
225+
`${request.group_id}:${userId}`,
226+
{ dtrx, cacheOnly: true },
227+
),
228+
),
229+
...memberships.map((member) =>
230+
dataAbstraction().delete(
231+
'group-member',
232+
`${member.group_id}:${userId}`,
233+
{
234+
dtrx,
235+
cacheOnly: true,
236+
},
237+
),
238+
),
239+
240+
...idsOfGroupsToDelete.map((groupId) =>
241+
dataAbstraction().delete('group', groupId, {
242+
dtrx,
243+
}),
244+
),
245+
246+
...visitedPageIds.map((page) =>
247+
dataAbstraction().delete('user-page', `${userId}:${page.page_id}`, {
248+
dtrx,
249+
cacheOnly: true,
250+
}),
251+
),
252+
253+
...sessions.map((session) =>
254+
dataAbstraction().patch(
255+
'session',
256+
session.id,
257+
{ invalidated: true },
258+
{ dtrx, cacheOnly: true },
259+
),
260+
),
261+
262+
...(user.customer_id != null
263+
? [
264+
dataAbstraction().delete('customer', user.customer_id, {
265+
dtrx,
266+
cacheOnly: true,
267+
}),
268+
]
269+
: []),
270+
271+
dataAbstraction().delete(
272+
'email',
273+
decryptUserEmail(user.encrypted_email),
274+
{
275+
dtrx,
276+
cacheOnly: true,
277+
},
278+
),
279+
280+
dataAbstraction().delete('user', userId, { dtrx }),
281+
]);
282+
283+
// Delete Stripe customer
284+
285+
if (user?.customer_id != null) {
286+
await stripe.customers.del(user.customer_id);
287+
}
288+
});
289+
}
290+
92291
function requestCommand() {
93292
readlineInterface.question('', async (command) => {
94293
if (command === 'exit') {

apps/manager/src/stripe.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Stripe from 'stripe';
2+
3+
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
4+
apiVersion: '2023-10-16',
5+
maxNetworkRetries: 2,
6+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)