Skip to content

Commit 6f9a96e

Browse files
committed
cleanup
1 parent 63951a5 commit 6f9a96e

File tree

8 files changed

+329
-8
lines changed

8 files changed

+329
-8
lines changed

helpers/cleanupOrphanedUserAuth.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import dbConnect from '../lib/dbConnect';
2+
import { UserAuthModel, UserModel } from '../models/mongoose';
3+
import { logger } from './logger';
4+
5+
/**
6+
* Find UserAuth records that reference non-existent users
7+
* @returns Array of orphaned UserAuth records
8+
*/
9+
export async function findOrphanedUserAuthRecords() {
10+
await dbConnect();
11+
12+
const orphanedRecords = await UserAuthModel.aggregate([
13+
{
14+
$lookup: {
15+
from: UserModel.collection.name,
16+
localField: 'userId',
17+
foreignField: '_id',
18+
as: 'user'
19+
}
20+
},
21+
{
22+
$match: {
23+
user: { $size: 0 } // No matching user found
24+
}
25+
},
26+
{
27+
$project: {
28+
_id: 1,
29+
userId: 1,
30+
provider: 1,
31+
providerId: 1,
32+
providerUsername: 1,
33+
connectedAt: 1
34+
}
35+
}
36+
]);
37+
38+
return orphanedRecords;
39+
}
40+
41+
/**
42+
* Remove orphaned UserAuth records that reference non-existent users
43+
* @param dryRun If true, only log what would be deleted without actually deleting
44+
* @returns Number of records deleted (or that would be deleted in dry run)
45+
*/
46+
export async function cleanupOrphanedUserAuthRecords(dryRun = true): Promise<number> {
47+
await dbConnect();
48+
49+
const orphanedRecords = await findOrphanedUserAuthRecords();
50+
51+
if (orphanedRecords.length === 0) {
52+
logger.info('No orphaned UserAuth records found');
53+
54+
return 0;
55+
}
56+
57+
logger.info(`Found ${orphanedRecords.length} orphaned UserAuth records:`);
58+
orphanedRecords.forEach(record => {
59+
logger.info(`- ${record.provider} (${record.providerUsername || record.providerId}) for non-existent user ${record.userId}`);
60+
});
61+
62+
if (dryRun) {
63+
logger.info('DRY RUN: Would delete the above records. Run with dryRun=false to actually delete.');
64+
65+
return orphanedRecords.length;
66+
}
67+
68+
// Actually delete the orphaned records
69+
const userIds = orphanedRecords.map(record => record.userId);
70+
const result = await UserAuthModel.deleteMany({
71+
userId: { $in: userIds }
72+
});
73+
74+
logger.info(`Deleted ${result.deletedCount} orphaned UserAuth records`);
75+
76+
return result.deletedCount;
77+
}
78+
79+
/**
80+
* CLI-style function to run cleanup
81+
*/
82+
export async function runCleanupOrphanedUserAuth() {
83+
const isDryRun = process.argv.includes('--dry-run') || !process.argv.includes('--execute');
84+
85+
logger.info('🧹 Cleaning up orphaned UserAuth records...');
86+
logger.info(`Mode: ${isDryRun ? 'DRY RUN' : 'EXECUTE'}`);
87+
88+
try {
89+
const deletedCount = await cleanupOrphanedUserAuthRecords(isDryRun);
90+
91+
if (isDryRun && deletedCount > 0) {
92+
logger.info('\n💡 To actually delete these records, run:');
93+
logger.info('npm run cleanup:userauth --execute');
94+
} else if (!isDryRun && deletedCount > 0) {
95+
logger.info('✅ Cleanup completed successfully!');
96+
}
97+
} catch (error) {
98+
logger.error('❌ Error during cleanup:', error);
99+
throw error;
100+
}
101+
}

helpers/userAuthHelpers.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,47 @@ export async function getUserByProviderId(
155155
): Promise<UserAuth | null> {
156156
await dbConnect();
157157

158-
return UserAuthModel.findOne({
159-
provider: provider,
160-
providerId: providerId
161-
}).lean<UserAuth>();
158+
const result = await UserAuthModel.aggregate([
159+
{
160+
$match: {
161+
provider: provider,
162+
providerId: providerId
163+
}
164+
},
165+
{
166+
$lookup: {
167+
from: 'users',
168+
localField: 'userId',
169+
foreignField: '_id',
170+
as: 'user'
171+
}
172+
},
173+
{
174+
$unwind: {
175+
path: '$user',
176+
preserveNullAndEmptyArrays: true
177+
}
178+
},
179+
{
180+
$project: {
181+
_id: 1,
182+
userId: 1,
183+
provider: 1,
184+
providerId: 1,
185+
providerUsername: 1,
186+
providerEmail: 1,
187+
providerAvatarUrl: 1,
188+
accessToken: 1,
189+
refreshToken: 1,
190+
connectedAt: 1,
191+
updatedAt: 1,
192+
'user.name': 1,
193+
'user.email': 1
194+
}
195+
}
196+
]);
197+
198+
return result[0] || null;
162199
}
163200

164201
/**

models/db/userAuth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Types } from 'mongoose';
2+
import User from './user';
23

34
export enum AuthProvider {
45
DISCORD = 'discord',
@@ -9,7 +10,7 @@ export enum AuthProvider {
910

1011
interface UserAuth {
1112
_id: Types.ObjectId;
12-
userId: Types.ObjectId;
13+
userId: Types.ObjectId & User;
1314
provider: AuthProvider;
1415
providerId: string;
1516
providerUsername?: string;

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@
8787
"test:oauth": "NODE_ENV=test jest --verbose --testPathPattern=\"(oauth|auth)\"",
8888
"test:coverage": "NODE_ENV=test jest --coverage",
8989
"init_test": "jest --init",
90-
"compile_jobs": "tsc --isolatedModules --esModuleInterop --jsx react --outDir ./server/jobs/output/ server/jobs/email-digest.tsx"
90+
"compile_jobs": "tsc --isolatedModules --esModuleInterop --jsx react --outDir ./server/jobs/output/ server/jobs/email-digest.tsx",
91+
"cleanup:userauth": "ts-node -r tsconfig-paths/register --files server/scripts/cleanup-orphaned-userauth.ts"
9192
},
9293
"eslintConfig": {
9394
"extends": [

pages/api/user/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import clearTokenCookie from '../../../lib/clearTokenCookie';
1818
import dbConnect from '../../../lib/dbConnect';
1919
import withAuth, { NextApiRequestWithAuth } from '../../../lib/withAuth';
2020
import Level from '../../../models/db/level';
21-
import { AchievementModel, CollectionModel, CommentModel, DeviceModel, GraphModel, KeyValueModel, LevelModel, MultiplayerProfileModel, NotificationModel, UserConfigModel, UserModel } from '../../../models/mongoose';
21+
import { AchievementModel, CollectionModel, CommentModel, DeviceModel, GraphModel, KeyValueModel, LevelModel, MultiplayerProfileModel, NotificationModel, UserAuthModel, UserConfigModel, UserModel } from '../../../models/mongoose';
2222
import { getSubscriptions, SubscriptionData } from '../subscription';
2323
import { getUserConfig } from '../user-config';
2424

@@ -305,6 +305,7 @@ export default withAuth({
305305
{ userId: req.user._id },
306306
] }, { session: session }),
307307
UserConfigModel.deleteMany({ userId: req.user._id }, { session: session }),
308+
UserAuthModel.deleteMany({ userId: req.user._id }, { session: session }),
308309
UserModel.deleteOne({ _id: req.user._id }, { session: session }), // TODO, should make this soft delete...
309310
]);
310311
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// ts-node -r tsconfig-paths/register --files scripts/cleanup-orphaned-userauth.ts
2+
3+
import { runCleanupOrphanedUserAuth } from '@root/helpers/cleanupOrphanedUserAuth';
4+
import dbConnect from '@root/lib/dbConnect';
5+
import dotenv from 'dotenv';
6+
7+
'use strict';
8+
9+
dotenv.config();
10+
console.log('loaded env vars');
11+
12+
async function init() {
13+
console.log('connecting to db...');
14+
await dbConnect();
15+
console.log('connected');
16+
17+
await runCleanupOrphanedUserAuth();
18+
19+
process.exit(0);
20+
}
21+
22+
init();

server/scripts/delete-user.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import cliProgress from 'cli-progress';
1818
import dotenv from 'dotenv';
1919
import { Types } from 'mongoose';
2020
import dbConnect from '../../lib/dbConnect';
21-
import { AchievementModel, CollectionModel, CommentModel, GraphModel, ImageModel, KeyValueModel, LevelModel, MultiplayerMatchModel, NotificationModel, PlayAttemptModel, RecordModel, ReviewModel, StatModel, UserConfigModel, UserModel } from '../../models/mongoose';
21+
import { AchievementModel, CollectionModel, CommentModel, GraphModel, ImageModel, KeyValueModel, LevelModel, MultiplayerMatchModel, NotificationModel, PlayAttemptModel, RecordModel, ReviewModel, StatModel, UserAuthModel, UserConfigModel, UserModel } from '../../models/mongoose';
2222

2323
'use strict';
2424

@@ -211,6 +211,7 @@ async function deleteUser(userName: string) {
211211
{ userId: user._id },
212212
] }),
213213
UserConfigModel.deleteOne({ userId: user._id }),
214+
UserAuthModel.deleteMany({ userId: user._id }),
214215
UserModel.deleteOne({ _id: user._id }), // TODO, should make this soft delete...
215216
]);
216217

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import TestId from '@root/constants/testId';
2+
import { cleanupOrphanedUserAuthRecords, findOrphanedUserAuthRecords } from '@root/helpers/cleanupOrphanedUserAuth';
3+
import dbConnect, { dbDisconnect } from '@root/lib/dbConnect';
4+
import { AuthProvider } from '@root/models/db/userAuth';
5+
import { UserAuthModel, UserModel } from '@root/models/mongoose';
6+
import { Types } from 'mongoose';
7+
8+
beforeAll(async () => {
9+
await dbConnect();
10+
});
11+
12+
afterAll(async () => {
13+
await dbDisconnect();
14+
});
15+
16+
afterEach(async () => {
17+
// Clean up test data
18+
await UserAuthModel.deleteMany({ userId: { $in: [TestId.USER_B, TestId.USER_C, new Types.ObjectId('507f1f77bcf86cd799439011')] } });
19+
});
20+
21+
describe('helpers/cleanupOrphanedUserAuth.ts', () => {
22+
describe('findOrphanedUserAuthRecords', () => {
23+
test('Should return empty array when no orphaned records exist', async () => {
24+
const orphanedRecords = await findOrphanedUserAuthRecords();
25+
26+
expect(orphanedRecords).toEqual([]);
27+
});
28+
29+
test('Should find orphaned UserAuth records', async () => {
30+
const fakeUserId = new Types.ObjectId('507f1f77bcf86cd799439011');
31+
32+
// Create a UserAuth record with a non-existent user ID
33+
await UserAuthModel.create({
34+
_id: new Types.ObjectId(),
35+
userId: fakeUserId,
36+
provider: AuthProvider.DISCORD,
37+
providerId: 'discord123',
38+
providerUsername: 'testuser',
39+
connectedAt: Math.floor(Date.now() / 1000),
40+
updatedAt: Math.floor(Date.now() / 1000),
41+
});
42+
43+
// Create a normal UserAuth record for an existing user (should not be returned)
44+
await UserAuthModel.create({
45+
_id: new Types.ObjectId(),
46+
userId: TestId.USER,
47+
provider: AuthProvider.GOOGLE,
48+
providerId: 'google456',
49+
providerUsername: 'normaluser',
50+
connectedAt: Math.floor(Date.now() / 1000),
51+
updatedAt: Math.floor(Date.now() / 1000),
52+
});
53+
54+
const orphanedRecords = await findOrphanedUserAuthRecords();
55+
56+
expect(orphanedRecords).toHaveLength(1);
57+
expect(orphanedRecords[0].userId.toString()).toBe(fakeUserId.toString());
58+
expect(orphanedRecords[0].provider).toBe(AuthProvider.DISCORD);
59+
expect(orphanedRecords[0].providerUsername).toBe('testuser');
60+
61+
// Clean up the normal record too
62+
await UserAuthModel.deleteOne({ userId: TestId.USER, provider: AuthProvider.GOOGLE });
63+
});
64+
});
65+
66+
describe('cleanupOrphanedUserAuthRecords', () => {
67+
test('Should return 0 when no orphaned records exist', async () => {
68+
const deletedCount = await cleanupOrphanedUserAuthRecords(true);
69+
70+
expect(deletedCount).toBe(0);
71+
});
72+
73+
test('Should report correct count in dry run mode', async () => {
74+
const fakeUserId = new Types.ObjectId('507f1f77bcf86cd799439011');
75+
76+
// Create orphaned records
77+
await UserAuthModel.insertMany([
78+
{
79+
_id: new Types.ObjectId(),
80+
userId: fakeUserId,
81+
provider: AuthProvider.DISCORD,
82+
providerId: 'discord123',
83+
connectedAt: Math.floor(Date.now() / 1000),
84+
updatedAt: Math.floor(Date.now() / 1000),
85+
},
86+
{
87+
_id: new Types.ObjectId(),
88+
userId: fakeUserId,
89+
provider: AuthProvider.GOOGLE,
90+
providerId: 'google456',
91+
connectedAt: Math.floor(Date.now() / 1000),
92+
updatedAt: Math.floor(Date.now() / 1000),
93+
}
94+
]);
95+
96+
const deletedCount = await cleanupOrphanedUserAuthRecords(true);
97+
98+
expect(deletedCount).toBe(2);
99+
100+
// Verify records still exist (dry run should not delete)
101+
const remainingRecords = await UserAuthModel.find({ userId: fakeUserId });
102+
103+
expect(remainingRecords).toHaveLength(2);
104+
});
105+
106+
test('Should actually delete orphaned records when not in dry run mode', async () => {
107+
const fakeUserId = new Types.ObjectId('507f1f77bcf86cd799439011');
108+
109+
// Create orphaned records
110+
await UserAuthModel.insertMany([
111+
{
112+
_id: new Types.ObjectId(),
113+
userId: fakeUserId,
114+
provider: AuthProvider.DISCORD,
115+
providerId: 'discord123',
116+
connectedAt: Math.floor(Date.now() / 1000),
117+
updatedAt: Math.floor(Date.now() / 1000),
118+
},
119+
{
120+
_id: new Types.ObjectId(),
121+
userId: fakeUserId,
122+
provider: AuthProvider.GOOGLE,
123+
providerId: 'google456',
124+
connectedAt: Math.floor(Date.now() / 1000),
125+
updatedAt: Math.floor(Date.now() / 1000),
126+
}
127+
]);
128+
129+
// Create a normal record that should NOT be deleted
130+
await UserAuthModel.create({
131+
_id: new Types.ObjectId(),
132+
userId: TestId.USER,
133+
provider: AuthProvider.DISCORD,
134+
providerId: 'discord789',
135+
connectedAt: Math.floor(Date.now() / 1000),
136+
updatedAt: Math.floor(Date.now() / 1000),
137+
});
138+
139+
const deletedCount = await cleanupOrphanedUserAuthRecords(false);
140+
141+
expect(deletedCount).toBe(2);
142+
143+
// Verify orphaned records are deleted
144+
const orphanedRecords = await UserAuthModel.find({ userId: fakeUserId });
145+
146+
expect(orphanedRecords).toHaveLength(0);
147+
148+
// Verify normal record still exists
149+
const normalRecord = await UserAuthModel.findOne({ userId: TestId.USER });
150+
151+
expect(normalRecord).toBeTruthy();
152+
153+
// Clean up normal record
154+
await UserAuthModel.deleteOne({ userId: TestId.USER });
155+
});
156+
});
157+
});

0 commit comments

Comments
 (0)