rhythia-api 242.0.0 → 243.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,681 +1,1030 @@
1
- import { NextResponse } from "../utils/response";
2
- import z from "zod";
3
- import { Database } from "../types/database";
4
- import { protectedApi, validUser } from "../utils/requestUtils";
5
- import { supabase } from "../utils/supabase";
6
- import { getUserBySession } from "../utils/getUserBySession";
7
- import { User } from "@supabase/supabase-js";
8
- import { invalidateCache, invalidateCachePrefix } from "../utils/cache";
9
-
10
- type AdminProfileSummary = Pick<
11
- Database["public"]["Tables"]["profiles"]["Row"],
12
- "id" | "username" | "avatar_url" | "flag" | "badges" | "ban"
13
- >;
14
-
15
- type InvestigationDateRange = {
16
- firstSeenAt: string | null;
17
- lastSeenAt: string | null;
18
- };
19
-
20
- type LinkedAccountAggregate = AdminProfileSummary &
21
- InvestigationDateRange & {
22
- occurrences: number;
23
- sharedHwids: Set<string>;
24
- };
25
-
26
- type LinkedProfileAggregate = AdminProfileSummary &
27
- InvestigationDateRange & {
28
- occurrences: number;
29
- };
30
-
31
- const MULTIACCOUNT_PROFILE_SELECT =
32
- "id, username, avatar_url, flag, badges, ban";
33
-
34
- const SCORE_CACHE_READ_OPERATIONS = new Set([
35
- "getScoresPaginated",
36
- "getMultiaccountInvestigation",
37
- ]);
38
-
39
- function timestampOrZero(value: string | null) {
40
- return value ? new Date(value).getTime() : 0;
41
- }
42
-
43
- function updateDateRange(
44
- range: InvestigationDateRange,
45
- createdAt: string | null
46
- ) {
47
- if (!createdAt) {
48
- return;
49
- }
50
-
51
- if (!range.firstSeenAt || createdAt < range.firstSeenAt) {
52
- range.firstSeenAt = createdAt;
53
- }
54
-
55
- if (!range.lastSeenAt || createdAt > range.lastSeenAt) {
56
- range.lastSeenAt = createdAt;
57
- }
58
- }
59
-
60
- // Define supported admin operations and their parameter types
61
- const adminOperations = {
62
- deleteUser: z.object({ userId: z.number() }),
63
- changeFlag: z.object({ userId: z.number(), flag: z.string() }),
64
- changeBadges: z.object({ userId: z.number(), badges: z.string() }),
65
- addBadge: z.object({ userId: z.number(), badge: z.string() }),
66
- removeBadge: z.object({ userId: z.number(), badge: z.string() }),
67
- excludeUser: z.object({ userId: z.number() }),
68
- restrictUser: z.object({ userId: z.number() }),
69
- silenceUser: z.object({ userId: z.number() }),
70
- profanityClear: z.object({ userId: z.number() }),
71
- searchUsers: z.object({ searchText: z.string() }),
72
- removeAllScores: z.object({ userId: z.number() }),
73
- invalidateRankedScores: z.object({ userId: z.number() }),
74
- unbanUser: z.object({ userId: z.number() }),
75
- getScoresPaginated: z.object({
76
- page: z.number().min(1).default(1),
77
- limit: z.number().min(1).max(100).default(50),
78
- userId: z.number().optional(),
79
- includeAdditionalData: z.boolean().default(true),
80
- }),
81
- getMultiaccountInvestigation: z.object({ userId: z.number() }),
82
- } as const;
83
-
84
- // Create a discriminated union type for operation parameters
85
- const OperationParam = z.discriminatedUnion("operation", [
86
- z.object({
87
- operation: z.literal("deleteUser"),
88
- params: adminOperations.deleteUser,
89
- }),
90
- z.object({
91
- operation: z.literal("excludeUser"),
92
- params: adminOperations.excludeUser,
93
- }),
94
- z.object({
95
- operation: z.literal("restrictUser"),
96
- params: adminOperations.restrictUser,
97
- }),
98
- z.object({
99
- operation: z.literal("silenceUser"),
100
- params: adminOperations.silenceUser,
101
- }),
102
- z.object({
103
- operation: z.literal("searchUsers"),
104
- params: adminOperations.searchUsers,
105
- }),
106
- z.object({
107
- operation: z.literal("profanityClear"),
108
- params: adminOperations.profanityClear,
109
- }),
110
- z.object({
111
- operation: z.literal("removeAllScores"),
112
- params: adminOperations.removeAllScores,
113
- }),
114
- z.object({
115
- operation: z.literal("invalidateRankedScores"),
116
- params: adminOperations.invalidateRankedScores,
117
- }),
118
- z.object({
119
- operation: z.literal("unbanUser"),
120
- params: adminOperations.unbanUser,
121
- }),
122
- z.object({
123
- operation: z.literal("changeFlag"),
124
- params: adminOperations.changeFlag,
125
- }),
126
- z.object({
127
- operation: z.literal("changeBadges"),
128
- params: adminOperations.changeBadges,
129
- }),
130
- z.object({
131
- operation: z.literal("addBadge"),
132
- params: adminOperations.addBadge,
133
- }),
134
- z.object({
135
- operation: z.literal("removeBadge"),
136
- params: adminOperations.removeBadge,
137
- }),
138
- z.object({
139
- operation: z.literal("getScoresPaginated"),
140
- params: adminOperations.getScoresPaginated,
141
- }),
142
- z.object({
143
- operation: z.literal("getMultiaccountInvestigation"),
144
- params: adminOperations.getMultiaccountInvestigation,
145
- }),
146
- ]);
147
-
148
- export const Schema = {
149
- input: z.strictObject({
150
- session: z.string(),
151
- data: OperationParam,
152
- }),
153
- output: z.object({
154
- success: z.boolean(),
155
- result: z.any().optional(),
156
- error: z.string().optional(),
157
- }),
158
- };
159
-
160
- export async function POST(request: Request): Promise<NextResponse> {
161
- return protectedApi({
162
- request,
163
- schema: Schema,
164
- authorization: () => {},
165
- activity: handler,
166
- });
167
- }
168
-
169
- export async function handler(
170
- data: (typeof Schema)["input"]["_type"]
171
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
172
- // Get user from session
173
- const user = (await getUserBySession(data.session)) as User;
174
-
175
- // Get user's profile data
176
- const { data: queryUserData, error: userError } = await supabase
177
- .from("profiles")
178
- .select("*")
179
- .eq("uid", user.id)
180
- .single();
181
-
182
- if (userError || !queryUserData) {
183
- return NextResponse.json(
184
- {
185
- success: false,
186
- error: "User cannot be retrieved from session",
187
- },
188
- { status: 404 }
189
- );
190
- }
191
- const tags = (queryUserData?.badges || []) as string[];
192
- // Check if user has "Global Moderator" badge
193
- const isGlobalModerator = tags.includes("Global Moderator");
194
-
195
- if (!isGlobalModerator) {
196
- return NextResponse.json(
197
- {
198
- success: false,
199
- error: "Unauthorized. Only Global Moderators can perform this action.",
200
- },
201
- { status: 403 }
202
- );
203
- }
204
-
205
- // Execute the requested admin operation
206
- try {
207
- let result: { data?: any; error?: any } | null = null;
208
- const operation = data.data.operation;
209
- const params = data.data.params as any;
210
- const targetUserId =
211
- "userId" in params && typeof params.userId === "number"
212
- ? params.userId
213
- : null;
214
-
215
- switch (operation) {
216
- case "deleteUser":
217
- result = await supabase.rpc("admin_delete_user", {
218
- user_id: params.userId,
219
- });
220
- break;
221
-
222
- case "excludeUser":
223
- result = await supabase.rpc("admin_exclude_user", {
224
- user_id: params.userId,
225
- });
226
- break;
227
-
228
- case "restrictUser":
229
- result = await supabase.rpc("admin_restrict_user", {
230
- user_id: params.userId,
231
- });
232
- break;
233
-
234
- case "silenceUser":
235
- result = await supabase.rpc("admin_silence_user", {
236
- user_id: params.userId,
237
- });
238
- break;
239
-
240
- case "searchUsers":
241
- result = await supabase.rpc("admin_search_users", {
242
- search_text: params.searchText,
243
- });
244
- break;
245
-
246
- case "profanityClear":
247
- result = await supabase.rpc("admin_profanity_clear", {
248
- user_id: params.userId,
249
- });
250
- break;
251
-
252
- case "removeAllScores":
253
- result = await supabase.rpc("admin_remove_all_scores", {
254
- user_id: params.userId,
255
- });
256
- break;
257
-
258
- case "invalidateRankedScores":
259
- result = await supabase.rpc("admin_invalidate_ranked_scores", {
260
- user_id: params.userId,
261
- });
262
- break;
263
-
264
- case "unbanUser":
265
- result = await supabase.rpc("admin_unban_user", {
266
- user_id: params.userId,
267
- });
268
- break;
269
-
270
- case "changeFlag":
271
- result = await supabase
272
- .from("profiles")
273
- .upsert({
274
- id: params.userId,
275
- flag: params.flag,
276
- })
277
- .select();
278
- break;
279
- case "changeBadges":
280
- // Allow only developers to modify badges.
281
- if ((queryUserData.badges as string[]).includes("Developer")) {
282
- result = await supabase
283
- .from("profiles")
284
- .upsert({
285
- id: params.userId,
286
- badges: JSON.parse(params.badges),
287
- })
288
- .select();
289
- } else {
290
- result = { data: null, error: { message: "Unauthorized" } };
291
- }
292
- break;
293
-
294
- case "addBadge":
295
- // Allow only developers to modify badges.
296
- if ((queryUserData.badges as string[]).includes("Developer")) {
297
- // Get current badges
298
- const { data: targetUser } = await supabase
299
- .from("profiles")
300
- .select("badges")
301
- .eq("id", params.userId)
302
- .single();
303
-
304
- const currentBadges = (targetUser?.badges || []) as string[];
305
- if (!currentBadges.includes(params.badge)) {
306
- currentBadges.push(params.badge);
307
- result = await supabase
308
- .from("profiles")
309
- .upsert({
310
- id: params.userId,
311
- badges: currentBadges,
312
- })
313
- .select();
314
- } else {
315
- result = { data: targetUser, error: null };
316
- }
317
- } else {
318
- result = { data: null, error: { message: "Unauthorized" } };
319
- }
320
- break;
321
-
322
- case "removeBadge":
323
- // Allow only developers to modify badges.
324
- if ((queryUserData.badges as string[]).includes("Developer")) {
325
- // Get current badges
326
- const { data: targetUser } = await supabase
327
- .from("profiles")
328
- .select("badges")
329
- .eq("id", params.userId)
330
- .single();
331
-
332
- const currentBadges = (targetUser?.badges || []) as string[];
333
- const updatedBadges = currentBadges.filter(b => b !== params.badge);
334
-
335
- result = await supabase
336
- .from("profiles")
337
- .upsert({
338
- id: params.userId,
339
- badges: updatedBadges,
340
- })
341
- .select();
342
- } else {
343
- result = { data: null, error: { message: "Unauthorized" } };
344
- }
345
- break;
346
-
347
- case "getScoresPaginated":
348
- const offset = (params.page - 1) * params.limit;
349
- let query = supabase
350
- .from("scores")
351
- .select(
352
- `
353
- id,
354
- awarded_sp,
355
- userId,
356
- additional_data,
357
- profiles (
358
- username
359
- )
360
- `
361
- )
362
- .eq("passed", true)
363
- .order("created_at", { ascending: false })
364
- .range(offset, offset + params.limit - 1);
365
-
366
- if (params.userId) {
367
- query = query.eq("userId", params.userId);
368
- }
369
-
370
- const { data: scoresData, error: scoresError, count } = await query;
371
-
372
- if (scoresError) {
373
- result = { error: scoresError, data: null };
374
- } else {
375
- const transformedScores = scoresData?.map((score) => {
376
- const transformed: any = {
377
- id: score.id,
378
- awarded_sp: score.awarded_sp,
379
- userId: score.userId,
380
- username: score.profiles?.username || null,
381
- };
382
-
383
- if (params.includeAdditionalData) {
384
- transformed.additional_data = score.additional_data;
385
- }
386
-
387
- return transformed;
388
- });
389
-
390
- result = {
391
- data: {
392
- scores: transformedScores,
393
- pagination: {
394
- page: params.page,
395
- limit: params.limit,
396
- total: count || 0,
397
- totalPages: Math.ceil((count || 0) / params.limit),
398
- },
399
- },
400
- error: null,
401
- };
402
- }
403
- break;
404
-
405
- case "getMultiaccountInvestigation":
406
- const { data: investigatedUser, error: investigatedUserError } =
407
- await supabase
408
- .from("profiles")
409
- .select(MULTIACCOUNT_PROFILE_SELECT)
410
- .eq("id", params.userId)
411
- .maybeSingle();
412
-
413
- if (investigatedUserError) {
414
- result = { error: investigatedUserError, data: null };
415
- break;
416
- }
417
-
418
- if (!investigatedUser) {
419
- result = {
420
- error: { message: "User not found" },
421
- data: null,
422
- };
423
- break;
424
- }
425
-
426
- const { data: investigatedUserHwids, error: investigatedUserHwidsError } =
427
- await supabase
428
- .from("user_hwids")
429
- .select("id, hwid, created_at")
430
- .eq("id", params.userId)
431
- .order("created_at", { ascending: false });
432
-
433
- if (investigatedUserHwidsError) {
434
- result = { error: investigatedUserHwidsError, data: null };
435
- break;
436
- }
437
-
438
- const targetRows = investigatedUserHwids || [];
439
- const uniqueHwids = Array.from(
440
- new Set(targetRows.map((row) => row.hwid).filter(Boolean))
441
- );
442
-
443
- const dateRange: InvestigationDateRange = {
444
- firstSeenAt: null,
445
- lastSeenAt: null,
446
- };
447
-
448
- const hwidAggregates = new Map<
449
- string,
450
- InvestigationDateRange & {
451
- hwid: string;
452
- occurrences: number;
453
- linkedProfiles: Map<number, LinkedProfileAggregate>;
454
- }
455
- >();
456
-
457
- for (const row of targetRows) {
458
- updateDateRange(dateRange, row.created_at);
459
-
460
- const existing = hwidAggregates.get(row.hwid) || {
461
- hwid: row.hwid,
462
- occurrences: 0,
463
- firstSeenAt: null,
464
- lastSeenAt: null,
465
- linkedProfiles: new Map<number, LinkedProfileAggregate>(),
466
- };
467
-
468
- existing.occurrences += 1;
469
- updateDateRange(existing, row.created_at);
470
- hwidAggregates.set(row.hwid, existing);
471
- }
472
-
473
- if (uniqueHwids.length === 0) {
474
- result = {
475
- data: {
476
- user: investigatedUser,
477
- summary: {
478
- totalRows: 0,
479
- uniqueHwids: 0,
480
- sharedHwids: 0,
481
- linkedAccounts: 0,
482
- firstSeenAt: null,
483
- lastSeenAt: null,
484
- },
485
- accounts: [],
486
- hwids: [],
487
- },
488
- error: null,
489
- };
490
- break;
491
- }
492
-
493
- const { data: relatedRows, error: relatedRowsError } = await supabase
494
- .from("user_hwids")
495
- .select("id, hwid, created_at")
496
- .in("hwid", uniqueHwids)
497
- .order("created_at", { ascending: false });
498
-
499
- if (relatedRowsError) {
500
- result = { error: relatedRowsError, data: null };
501
- break;
502
- }
503
-
504
- const relatedProfileIds = Array.from(
505
- new Set(
506
- (relatedRows || [])
507
- .map((row) => row.id)
508
- .filter((id) => id !== params.userId)
509
- )
510
- );
511
-
512
- const { data: relatedProfiles, error: relatedProfilesError } =
513
- relatedProfileIds.length > 0
514
- ? await supabase
515
- .from("profiles")
516
- .select(MULTIACCOUNT_PROFILE_SELECT)
517
- .in("id", relatedProfileIds)
518
- : { data: [], error: null };
519
-
520
- if (relatedProfilesError) {
521
- result = { error: relatedProfilesError, data: null };
522
- break;
523
- }
524
-
525
- const profileMap = new Map<number, AdminProfileSummary>([
526
- [investigatedUser.id, investigatedUser],
527
- ...((relatedProfiles || []) as AdminProfileSummary[]).map((profile) => [
528
- profile.id,
529
- profile,
530
- ]),
531
- ]);
532
-
533
- const linkedAccountAggregates = new Map<number, LinkedAccountAggregate>();
534
-
535
- for (const row of relatedRows || []) {
536
- if (row.id === params.userId) {
537
- continue;
538
- }
539
-
540
- const hwidAggregate = hwidAggregates.get(row.hwid);
541
- const relatedProfile = profileMap.get(row.id);
542
-
543
- if (!hwidAggregate || !relatedProfile) {
544
- continue;
545
- }
546
-
547
- const linkedProfile =
548
- hwidAggregate.linkedProfiles.get(row.id) || {
549
- ...relatedProfile,
550
- occurrences: 0,
551
- firstSeenAt: null,
552
- lastSeenAt: null,
553
- };
554
-
555
- linkedProfile.occurrences += 1;
556
- updateDateRange(linkedProfile, row.created_at);
557
- hwidAggregate.linkedProfiles.set(row.id, linkedProfile);
558
-
559
- const linkedAccount = linkedAccountAggregates.get(row.id) || {
560
- ...relatedProfile,
561
- occurrences: 0,
562
- sharedHwids: new Set<string>(),
563
- firstSeenAt: null,
564
- lastSeenAt: null,
565
- };
566
-
567
- linkedAccount.occurrences += 1;
568
- linkedAccount.sharedHwids.add(row.hwid);
569
- updateDateRange(linkedAccount, row.created_at);
570
- linkedAccountAggregates.set(row.id, linkedAccount);
571
- }
572
-
573
- const accounts = Array.from(linkedAccountAggregates.values())
574
- .map((account) => ({
575
- ...account,
576
- sharedHwids: Array.from(account.sharedHwids).sort(),
577
- sharedHwidCount: account.sharedHwids.size,
578
- }))
579
- .sort((a, b) => {
580
- if (b.sharedHwidCount !== a.sharedHwidCount) {
581
- return b.sharedHwidCount - a.sharedHwidCount;
582
- }
583
-
584
- if (b.occurrences !== a.occurrences) {
585
- return b.occurrences - a.occurrences;
586
- }
587
-
588
- return timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt);
589
- });
590
-
591
- const hwids = Array.from(hwidAggregates.values())
592
- .map((aggregate) => ({
593
- hwid: aggregate.hwid,
594
- occurrences: aggregate.occurrences,
595
- firstSeenAt: aggregate.firstSeenAt,
596
- lastSeenAt: aggregate.lastSeenAt,
597
- linkedAccountCount: aggregate.linkedProfiles.size,
598
- linkedProfiles: Array.from(aggregate.linkedProfiles.values()).sort(
599
- (a, b) => {
600
- if (b.occurrences !== a.occurrences) {
601
- return b.occurrences - a.occurrences;
602
- }
603
-
604
- return (
605
- timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt)
606
- );
607
- }
608
- ),
609
- }))
610
- .sort((a, b) => {
611
- if (b.linkedAccountCount !== a.linkedAccountCount) {
612
- return b.linkedAccountCount - a.linkedAccountCount;
613
- }
614
-
615
- if (b.occurrences !== a.occurrences) {
616
- return b.occurrences - a.occurrences;
617
- }
618
-
619
- return timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt);
620
- });
621
-
622
- result = {
623
- data: {
624
- user: investigatedUser,
625
- summary: {
626
- totalRows: targetRows.length,
627
- uniqueHwids: uniqueHwids.length,
628
- sharedHwids: hwids.filter((hwid) => hwid.linkedAccountCount > 0)
629
- .length,
630
- linkedAccounts: accounts.length,
631
- firstSeenAt: dateRange.firstSeenAt,
632
- lastSeenAt: dateRange.lastSeenAt,
633
- },
634
- accounts,
635
- hwids,
636
- },
637
- error: null,
638
- };
639
- break;
640
- }
641
-
642
- // Log the admin action
643
- await supabase.rpc("admin_log_action", {
644
- admin_id: queryUserData.id,
645
- action_type: operation,
646
- target_id: "userId" in params ? params.userId : null,
647
- details: { params },
648
- });
649
-
650
- if (result?.error) {
651
- return NextResponse.json(
652
- {
653
- success: false,
654
- error: result.error.message,
655
- },
656
- { status: 500 }
657
- );
658
- }
659
-
660
- if (
661
- targetUserId !== null &&
662
- !result?.error &&
663
- !SCORE_CACHE_READ_OPERATIONS.has(operation)
664
- ) {
665
- await invalidateCachePrefix(`userscore:${targetUserId}`);
666
- }
667
-
668
- return NextResponse.json({
669
- success: true,
670
- result: result?.data,
671
- });
672
- } catch (err: any) {
673
- return NextResponse.json(
674
- {
675
- success: false,
676
- error: err.message || "An error occurred during the operation",
677
- },
678
- { status: 500 }
679
- );
680
- }
681
- }
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { Database } from "../types/database";
4
+ import { protectedApi } from "../utils/requestUtils";
5
+ import { supabase } from "../utils/supabase";
6
+ import { getUserBySession } from "../utils/getUserBySession";
7
+ import { User } from "@supabase/supabase-js";
8
+ import { invalidateCachePrefix } from "../utils/cache";
9
+ import { normalizeProfileUpdateData } from "../utils/profileUpdateValidation";
10
+ import { getRedis } from "../utils/redis";
11
+ import { parseReplaySubmitData } from "../utils/rhrReplay";
12
+ import { submitScoreForUser } from "./submitScoreInternal";
13
+
14
+ type AdminProfileSummary = Pick<
15
+ Database["public"]["Tables"]["profiles"]["Row"],
16
+ "id" | "username" | "avatar_url" | "flag" | "badges" | "ban"
17
+ >;
18
+
19
+ type InvestigationDateRange = {
20
+ firstSeenAt: string | null;
21
+ lastSeenAt: string | null;
22
+ };
23
+
24
+ type LinkedAccountAggregate = AdminProfileSummary &
25
+ InvestigationDateRange & {
26
+ occurrences: number;
27
+ sharedHwids: Set<string>;
28
+ };
29
+
30
+ type LinkedProfileAggregate = AdminProfileSummary &
31
+ InvestigationDateRange & {
32
+ occurrences: number;
33
+ };
34
+
35
+ const MULTIACCOUNT_PROFILE_SELECT =
36
+ "id, username, avatar_url, flag, badges, ban";
37
+
38
+ const ADMIN_READ_OPERATIONS = new Set([
39
+ "searchUsers",
40
+ "getScoresPaginated",
41
+ "getFriendLinks",
42
+ "getMultiaccountInvestigation",
43
+ "getModeratedUsersPaginated",
44
+ ]);
45
+
46
+ function clampWebhookText(value: string, maxLength: number) {
47
+ const sanitized = value.replace(/[\u0000-\u001F\u007F]/g, "");
48
+ return sanitized.length <= maxLength
49
+ ? sanitized
50
+ : `${sanitized.slice(0, maxLength - 3)}...`;
51
+ }
52
+
53
+ function getAdminActionDetails(operation: string, params: any) {
54
+ return operation === "addScoreViaReplay"
55
+ ? { userId: params.userId, replayBytes: "<Long>" }
56
+ : params;
57
+ }
58
+
59
+ async function postStaffAdminWebhook({
60
+ admin,
61
+ operation,
62
+ targetUserId,
63
+ details,
64
+ error,
65
+ }: {
66
+ admin: Database["public"]["Tables"]["profiles"]["Row"];
67
+ operation: string;
68
+ targetUserId: number | null;
69
+ details: any;
70
+ error?: string;
71
+ }) {
72
+ const webhookUrl = process.env.WEBHOOK_STAFF_DISCORD;
73
+ if (!webhookUrl) {
74
+ console.log("WEBHOOK_STAFF_DISCORD is not configured");
75
+ return;
76
+ }
77
+
78
+ try {
79
+ const adminName = admin.username || `User #${admin.id}`;
80
+ const payload = {
81
+ content: `Admin action: ${operation}`,
82
+ embeds: [
83
+ {
84
+ title: "Admin Action",
85
+ color: error ? 0xe74c3c : 0x3498db,
86
+ fields: [
87
+ {
88
+ name: "Moderator",
89
+ value: clampWebhookText(`${adminName} (#${admin.id})`, 1024),
90
+ inline: true,
91
+ },
92
+ {
93
+ name: "Action",
94
+ value: clampWebhookText(operation, 1024),
95
+ inline: true,
96
+ },
97
+ {
98
+ name: "Target",
99
+ value: targetUserId === null ? "-" : `#${targetUserId}`,
100
+ inline: true,
101
+ },
102
+ {
103
+ name: "Status",
104
+ value: error ? `Failed: ${clampWebhookText(error, 900)}` : "Succeeded",
105
+ },
106
+ {
107
+ name: "Details",
108
+ value: clampWebhookText(JSON.stringify(details), 1024) || "-",
109
+ },
110
+ ],
111
+ footer: {
112
+ text: new Date().toUTCString(),
113
+ },
114
+ },
115
+ ],
116
+ };
117
+
118
+ const response = await fetch(webhookUrl, {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/json",
122
+ },
123
+ body: JSON.stringify(payload),
124
+ });
125
+
126
+ if (!response.ok) {
127
+ const responseBody = await response.text();
128
+ console.log("Staff admin webhook failed", {
129
+ operation,
130
+ status: response.status,
131
+ statusText: response.statusText,
132
+ responseBody: clampWebhookText(responseBody || "-", 4000),
133
+ });
134
+ }
135
+ } catch (error) {
136
+ console.log("Failed to post staff admin webhook", error);
137
+ }
138
+ }
139
+
140
+ function timestampOrZero(value: string | null) {
141
+ return value ? new Date(value).getTime() : 0;
142
+ }
143
+
144
+ function updateDateRange(
145
+ range: InvestigationDateRange,
146
+ createdAt: string | null
147
+ ) {
148
+ if (!createdAt) {
149
+ return;
150
+ }
151
+
152
+ if (!range.firstSeenAt || createdAt < range.firstSeenAt) {
153
+ range.firstSeenAt = createdAt;
154
+ }
155
+
156
+ if (!range.lastSeenAt || createdAt > range.lastSeenAt) {
157
+ range.lastSeenAt = createdAt;
158
+ }
159
+ }
160
+
161
+ // Define supported admin operations and their parameter types
162
+ const adminOperations = {
163
+ deleteUser: z.object({ userId: z.number() }),
164
+ updateProfile: z.object({
165
+ userId: z.number(),
166
+ avatar_url: z.string().optional(),
167
+ flag: z.string().optional(),
168
+ profile_image: z.string().optional(),
169
+ username: z.string().optional(),
170
+ verified: z.boolean().optional(),
171
+ }),
172
+ changeFlag: z.object({ userId: z.number(), flag: z.string() }),
173
+ changeBadges: z.object({ userId: z.number(), badges: z.array(z.string()) }),
174
+ addBadge: z.object({ userId: z.number(), badge: z.string() }),
175
+ removeBadge: z.object({ userId: z.number(), badge: z.string() }),
176
+ excludeUser: z.object({ userId: z.number(), reason: z.string() }),
177
+ restrictUser: z.object({ userId: z.number(), reason: z.string() }),
178
+ silenceUser: z.object({ userId: z.number(), expiresAt: z.string(), reason: z.string() }),
179
+ profanityClear: z.object({ userId: z.number() }),
180
+ searchUsers: z.object({ searchText: z.string() }),
181
+ getFriendLinks: z.object({ userId: z.number() }),
182
+ removeAllScores: z.object({ userId: z.number() }),
183
+ removeScore: z.object({ userId: z.number(), scoreId: z.number() }),
184
+ addScoreViaReplay: z.object({ userId: z.number(), replayBytes: z.string() }),
185
+ invalidateRankedScores: z.object({ userId: z.number() }),
186
+ unbanUser: z.object({ userId: z.number() }),
187
+ getModeratedUsersPaginated: z.object({
188
+ page: z.number().min(1).default(1),
189
+ limit: z.number().min(1).max(100).default(25),
190
+ }),
191
+ getScoresPaginated: z.object({
192
+ page: z.number().min(1).default(1),
193
+ limit: z.number().min(1).max(100).default(50),
194
+ userId: z.number().optional(),
195
+ includeAdditionalData: z.boolean().default(true),
196
+ }),
197
+ getMultiaccountInvestigation: z.object({ userId: z.number() }),
198
+ } as const;
199
+
200
+ // Create a discriminated union type for operation parameters
201
+ const OperationParam = z.discriminatedUnion("operation", [
202
+ z.object({
203
+ operation: z.literal("deleteUser"),
204
+ params: adminOperations.deleteUser,
205
+ }),
206
+ z.object({
207
+ operation: z.literal("excludeUser"),
208
+ params: adminOperations.excludeUser,
209
+ }),
210
+ z.object({
211
+ operation: z.literal("restrictUser"),
212
+ params: adminOperations.restrictUser,
213
+ }),
214
+ z.object({
215
+ operation: z.literal("silenceUser"),
216
+ params: adminOperations.silenceUser,
217
+ }),
218
+ z.object({
219
+ operation: z.literal("searchUsers"),
220
+ params: adminOperations.searchUsers,
221
+ }),
222
+ z.object({
223
+ operation: z.literal("profanityClear"),
224
+ params: adminOperations.profanityClear,
225
+ }),
226
+ z.object({
227
+ operation: z.literal("removeAllScores"),
228
+ params: adminOperations.removeAllScores,
229
+ }),
230
+ z.object({
231
+ operation: z.literal("invalidateRankedScores"),
232
+ params: adminOperations.invalidateRankedScores,
233
+ }),
234
+ z.object({
235
+ operation: z.literal("unbanUser"),
236
+ params: adminOperations.unbanUser,
237
+ }),
238
+ z.object({
239
+ operation: z.literal("updateProfile"),
240
+ params: adminOperations.updateProfile,
241
+ }),
242
+ z.object({
243
+ operation: z.literal("changeFlag"),
244
+ params: adminOperations.changeFlag,
245
+ }),
246
+ z.object({
247
+ operation: z.literal("changeBadges"),
248
+ params: adminOperations.changeBadges,
249
+ }),
250
+ z.object({
251
+ operation: z.literal("addBadge"),
252
+ params: adminOperations.addBadge,
253
+ }),
254
+ z.object({
255
+ operation: z.literal("removeBadge"),
256
+ params: adminOperations.removeBadge,
257
+ }),
258
+ z.object({
259
+ operation: z.literal("getFriendLinks"),
260
+ params: adminOperations.getFriendLinks,
261
+ }),
262
+ z.object({
263
+ operation: z.literal("removeScore"),
264
+ params: adminOperations.removeScore,
265
+ }),
266
+ z.object({
267
+ operation: z.literal("addScoreViaReplay"),
268
+ params: adminOperations.addScoreViaReplay,
269
+ }),
270
+ z.object({
271
+ operation: z.literal("getModeratedUsersPaginated"),
272
+ params: adminOperations.getModeratedUsersPaginated,
273
+ }),
274
+ z.object({
275
+ operation: z.literal("getScoresPaginated"),
276
+ params: adminOperations.getScoresPaginated,
277
+ }),
278
+ z.object({
279
+ operation: z.literal("getMultiaccountInvestigation"),
280
+ params: adminOperations.getMultiaccountInvestigation,
281
+ }),
282
+ ]);
283
+
284
+ export const Schema = {
285
+ input: z.strictObject({
286
+ session: z.string(),
287
+ data: OperationParam,
288
+ }),
289
+ output: z.object({
290
+ success: z.boolean(),
291
+ result: z.any().optional(),
292
+ error: z.string().optional(),
293
+ }),
294
+ };
295
+
296
+ export const ExpiresNever = new Date(99999999999999).toISOString();
297
+
298
+ async function publishUserAction(userId: number, reason: string, type: string, expiresAt: string, moderatedBy: number) {
299
+ try {
300
+ const redis = await getRedis();
301
+ await redis.publish("user_actions", JSON.stringify({
302
+ userId,
303
+ reason,
304
+ type,
305
+ expiresAt,
306
+ moderatedBy
307
+ }));
308
+ }
309
+ catch (e) {
310
+ console.error("Failed to publish user action: " + e);
311
+ }
312
+ }
313
+
314
+ export async function POST(request: Request): Promise<NextResponse> {
315
+ return protectedApi({
316
+ request,
317
+ schema: Schema,
318
+ authorization: () => { },
319
+ activity: handler,
320
+ });
321
+ }
322
+
323
+ export async function handler(
324
+ data: (typeof Schema)["input"]["_type"]
325
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
326
+ // Get user from session
327
+ const user = (await getUserBySession(data.session)) as User;
328
+
329
+ // Get user's profile data
330
+ const { data: queryUserData, error: userError } = await supabase
331
+ .from("profiles")
332
+ .select("*")
333
+ .eq("uid", user.id)
334
+ .single();
335
+
336
+ if (userError || !queryUserData) {
337
+ return NextResponse.json(
338
+ {
339
+ success: false,
340
+ error: "User cannot be retrieved from session",
341
+ },
342
+ { status: 404 }
343
+ );
344
+ }
345
+ const tags = (queryUserData?.badges || []) as string[];
346
+ // Check if user has "Global Moderator" badge
347
+ const isGlobalModerator = tags.includes("Global Moderator");
348
+
349
+ if (!isGlobalModerator) {
350
+ return NextResponse.json(
351
+ {
352
+ success: false,
353
+ error: "Unauthorized. Only Global Moderators can perform this action.",
354
+ },
355
+ { status: 403 }
356
+ );
357
+ }
358
+
359
+ // Execute the requested admin operation
360
+ try {
361
+ let result: { data?: any; error?: any } | null = null;
362
+ const operation = data.data.operation;
363
+ const params = data.data.params as any;
364
+
365
+ const targetUserId =
366
+ "userId" in params && typeof params.userId === "number"
367
+ ? params.userId
368
+ : null;
369
+
370
+ switch (operation) {
371
+ case "deleteUser":
372
+ result = await supabase.rpc("admin_delete_user", {
373
+ user_id: params.userId,
374
+ });
375
+ break;
376
+
377
+ case "excludeUser":
378
+ await publishUserAction(params.userId, params.reason, "excluded", ExpiresNever, queryUserData.id);
379
+ result = await supabase.rpc("admin_exclude_user", {
380
+ user_id: params.userId,
381
+ });
382
+ break;
383
+
384
+ case "restrictUser":
385
+ await publishUserAction(params.userId, params.reason, "restricted", ExpiresNever, queryUserData.id);
386
+ result = await supabase.rpc("admin_restrict_user", {
387
+ user_id: params.userId,
388
+ });
389
+ break;
390
+
391
+ case "silenceUser":
392
+ await publishUserAction(params.userId, params.reason, "silenced", params.expiresAt, queryUserData.id);
393
+ result = await supabase.rpc("admin_silence_user", {
394
+ user_id: params.userId,
395
+ });
396
+ break;
397
+
398
+ case "searchUsers":
399
+ result = await supabase.rpc("admin_search_users", {
400
+ search_text: params.searchText,
401
+ });
402
+ break;
403
+
404
+ case "profanityClear":
405
+ result = await supabase.rpc("admin_profanity_clear", {
406
+ user_id: params.userId,
407
+ });
408
+ break;
409
+
410
+ case "removeAllScores":
411
+ result = await supabase.rpc("admin_remove_all_scores", {
412
+ user_id: params.userId,
413
+ });
414
+ break;
415
+
416
+ case "invalidateRankedScores":
417
+ result = await supabase.rpc("admin_invalidate_ranked_scores", {
418
+ user_id: params.userId,
419
+ });
420
+ break;
421
+
422
+ case "unbanUser":
423
+ result = await supabase.rpc("admin_unban_user", {
424
+ user_id: params.userId,
425
+ });
426
+ break;
427
+
428
+ case "updateProfile":
429
+ const profileData: {
430
+ avatar_url?: string;
431
+ flag?: string;
432
+ profile_image?: string;
433
+ username?: string;
434
+ verified?: boolean;
435
+ } = {};
436
+
437
+ if (params.avatar_url !== undefined) profileData.avatar_url = params.avatar_url;
438
+ if (params.flag !== undefined) profileData.flag = params.flag;
439
+ if (params.profile_image !== undefined) {
440
+ profileData.profile_image = params.profile_image;
441
+ }
442
+ if (params.username !== undefined) profileData.username = params.username;
443
+ if (params.verified !== undefined) profileData.verified = params.verified;
444
+
445
+ const profileValidationError = normalizeProfileUpdateData(profileData);
446
+
447
+ if (profileValidationError) {
448
+ result = {
449
+ data: null,
450
+ error: { message: profileValidationError },
451
+ };
452
+ break;
453
+ }
454
+
455
+ result = await supabase.rpc("admin_update_profile", {
456
+ user_id: params.userId,
457
+ profile_data: profileData,
458
+ });
459
+ break;
460
+
461
+ case "changeFlag":
462
+ const flagData = { flag: params.flag };
463
+ const flagValidationError = normalizeProfileUpdateData(flagData);
464
+
465
+ if (flagValidationError) {
466
+ result = { data: null, error: { message: flagValidationError } };
467
+ break;
468
+ }
469
+
470
+ result = await supabase.rpc("admin_update_profile", {
471
+ user_id: params.userId,
472
+ profile_data: flagData,
473
+ });
474
+ break;
475
+ case "changeBadges":
476
+ // Allow only developers to modify badges.
477
+ if ((queryUserData.badges as string[]).includes("Developer")) {
478
+ result = await supabase
479
+ .from("profiles")
480
+ .upsert({
481
+ id: params.userId,
482
+ badges: params.badges,
483
+ })
484
+ .select();
485
+ } else {
486
+ result = { data: null, error: { message: "Unauthorized" } };
487
+ }
488
+ break;
489
+
490
+ case "addBadge":
491
+ // Allow only developers to modify badges.
492
+ if ((queryUserData.badges as string[]).includes("Developer")) {
493
+ // Get current badges
494
+ const { data: targetUser } = await supabase
495
+ .from("profiles")
496
+ .select("badges")
497
+ .eq("id", params.userId)
498
+ .single();
499
+
500
+ const currentBadges = (targetUser?.badges || []) as string[];
501
+ if (!currentBadges.includes(params.badge)) {
502
+ currentBadges.push(params.badge);
503
+ result = await supabase
504
+ .from("profiles")
505
+ .upsert({
506
+ id: params.userId,
507
+ badges: currentBadges,
508
+ })
509
+ .select();
510
+ } else {
511
+ result = { data: targetUser, error: null };
512
+ }
513
+ } else {
514
+ result = { data: null, error: { message: "Unauthorized" } };
515
+ }
516
+ break;
517
+
518
+ case "removeBadge":
519
+ // Allow only developers to modify badges.
520
+ if ((queryUserData.badges as string[]).includes("Developer")) {
521
+ // Get current badges
522
+ const { data: targetUser } = await supabase
523
+ .from("profiles")
524
+ .select("badges")
525
+ .eq("id", params.userId)
526
+ .single();
527
+
528
+ const currentBadges = (targetUser?.badges || []) as string[];
529
+ const updatedBadges = currentBadges.filter(b => b !== params.badge);
530
+
531
+ result = await supabase
532
+ .from("profiles")
533
+ .upsert({
534
+ id: params.userId,
535
+ badges: updatedBadges,
536
+ })
537
+ .select();
538
+ } else {
539
+ result = { data: null, error: { message: "Unauthorized" } };
540
+ }
541
+ break;
542
+
543
+ case "getFriendLinks":
544
+ const [
545
+ { data: outgoingRows, error: outgoingError },
546
+ { data: incomingRows, error: incomingError },
547
+ ] = await Promise.all([
548
+ supabase
549
+ .from("profileFriends")
550
+ .select("profile_id,friend_id,created_at")
551
+ .eq("profile_id", params.userId)
552
+ .order("created_at", { ascending: false }),
553
+ supabase
554
+ .from("profileFriends")
555
+ .select("profile_id,friend_id,created_at")
556
+ .eq("friend_id", params.userId)
557
+ .order("created_at", { ascending: false }),
558
+ ]);
559
+
560
+ if (outgoingError || incomingError) {
561
+ result = { data: null, error: outgoingError || incomingError };
562
+ break;
563
+ }
564
+
565
+ const friendProfileIds = Array.from(
566
+ new Set([
567
+ ...((outgoingRows || []).map((row) => row.friend_id) || []),
568
+ ...((incomingRows || []).map((row) => row.profile_id) || []),
569
+ ])
570
+ );
571
+
572
+ const { data: friendProfiles, error: friendProfilesError } =
573
+ friendProfileIds.length > 0
574
+ ? await supabase
575
+ .from("profiles")
576
+ .select("id,username,avatar_url,flag")
577
+ .in("id", friendProfileIds)
578
+ : { data: [], error: null };
579
+
580
+ if (friendProfilesError) {
581
+ result = { data: null, error: friendProfilesError };
582
+ break;
583
+ }
584
+
585
+ const friendProfileMap = new Map(
586
+ (friendProfiles || []).map((profile) => [profile.id, profile])
587
+ );
588
+ const mapFriendLink = (profileId: number, createdAt: string) => {
589
+ const profile = friendProfileMap.get(profileId);
590
+ return profile ? { ...profile, created_at: createdAt } : null;
591
+ };
592
+
593
+ result = {
594
+ data: {
595
+ friends: (outgoingRows || [])
596
+ .map((row) => mapFriendLink(row.friend_id, row.created_at))
597
+ .filter(Boolean),
598
+ friendedBy: (incomingRows || [])
599
+ .map((row) => mapFriendLink(row.profile_id, row.created_at))
600
+ .filter(Boolean),
601
+ },
602
+ error: null,
603
+ };
604
+ break;
605
+
606
+ case "removeScore":
607
+ result = await supabase.rpc("admin_remove_score", {
608
+ user_id: params.userId,
609
+ score_id: params.scoreId,
610
+ });
611
+
612
+ if (!result.error && result.data !== true) {
613
+ result = { data: null, error: { message: "Score not found" } };
614
+ }
615
+ break;
616
+
617
+ case "addScoreViaReplay":
618
+ const { data: scoreUser, error: scoreUserError } = await supabase
619
+ .from("profiles")
620
+ .select("*")
621
+ .eq("id", params.userId)
622
+ .single();
623
+
624
+ if (scoreUserError || !scoreUser) {
625
+ result = {
626
+ data: null,
627
+ error: { message: "User not found" },
628
+ };
629
+ break;
630
+ }
631
+
632
+ const scoreResponse = await submitScoreForUser(
633
+ parseReplaySubmitData(Buffer.from(params.replayBytes, "base64")),
634
+ scoreUser,
635
+ scoreUser.uid || String(scoreUser.id),
636
+ null
637
+ );
638
+ const scoreResult = (await scoreResponse.json()) as { error?: string };
639
+ result =
640
+ scoreResponse.status >= 400 || scoreResult.error
641
+ ? {
642
+ data: null,
643
+ error: { message: scoreResult.error || "Failed to add score" },
644
+ }
645
+ : { data: scoreResult, error: null };
646
+ break;
647
+
648
+ case "getModeratedUsersPaginated":
649
+ const moderatedOffset = (params.page - 1) * params.limit;
650
+ const {
651
+ data: moderatedUsers,
652
+ error: moderatedUsersError,
653
+ count: moderatedUsersCount,
654
+ } = await supabase
655
+ .from("profiles")
656
+ .select(
657
+ "id,username,avatar_url,flag,ban,bannedAt,skill_points,play_count,created_at",
658
+ { count: "exact" }
659
+ )
660
+ .in("ban", ["excluded", "restricted", "silenced"])
661
+ .order("bannedAt", { ascending: false, nullsFirst: false })
662
+ .order("id", { ascending: false })
663
+ .range(moderatedOffset, moderatedOffset + params.limit - 1);
664
+
665
+ result = moderatedUsersError
666
+ ? { error: moderatedUsersError, data: null }
667
+ : {
668
+ data: {
669
+ users: moderatedUsers || [],
670
+ pagination: {
671
+ page: params.page,
672
+ limit: params.limit,
673
+ total: moderatedUsersCount || 0,
674
+ totalPages: Math.ceil(
675
+ (moderatedUsersCount || 0) / params.limit
676
+ ),
677
+ },
678
+ },
679
+ error: null,
680
+ };
681
+ break;
682
+
683
+ case "getScoresPaginated":
684
+ const offset = (params.page - 1) * params.limit;
685
+ let query = supabase
686
+ .from("scores")
687
+ .select(
688
+ `
689
+ id,
690
+ awarded_sp,
691
+ userId,
692
+ additional_data,
693
+ profiles (
694
+ username
695
+ )
696
+ `,
697
+ { count: "exact" }
698
+ )
699
+ .eq("passed", true)
700
+ .order("created_at", { ascending: false })
701
+ .range(offset, offset + params.limit - 1);
702
+
703
+ if (params.userId) {
704
+ query = query.eq("userId", params.userId);
705
+ }
706
+
707
+ const { data: scoresData, error: scoresError, count } = await query;
708
+
709
+ if (scoresError) {
710
+ result = { error: scoresError, data: null };
711
+ } else {
712
+ const transformedScores = scoresData?.map((score) => {
713
+ const transformed: any = {
714
+ id: score.id,
715
+ awarded_sp: score.awarded_sp,
716
+ userId: score.userId,
717
+ username: score.profiles?.username || null,
718
+ };
719
+
720
+ if (params.includeAdditionalData) {
721
+ transformed.additional_data = score.additional_data;
722
+ }
723
+
724
+ return transformed;
725
+ });
726
+
727
+ result = {
728
+ data: {
729
+ scores: transformedScores,
730
+ pagination: {
731
+ page: params.page,
732
+ limit: params.limit,
733
+ total: count || 0,
734
+ totalPages: Math.ceil((count || 0) / params.limit),
735
+ },
736
+ },
737
+ error: null,
738
+ };
739
+ }
740
+ break;
741
+
742
+ case "getMultiaccountInvestigation":
743
+ const { data: investigatedUser, error: investigatedUserError } =
744
+ await supabase
745
+ .from("profiles")
746
+ .select(MULTIACCOUNT_PROFILE_SELECT)
747
+ .eq("id", params.userId)
748
+ .maybeSingle();
749
+
750
+ if (investigatedUserError) {
751
+ result = { error: investigatedUserError, data: null };
752
+ break;
753
+ }
754
+
755
+ if (!investigatedUser) {
756
+ result = {
757
+ error: { message: "User not found" },
758
+ data: null,
759
+ };
760
+ break;
761
+ }
762
+
763
+ const { data: investigatedUserHwids, error: investigatedUserHwidsError } =
764
+ await supabase
765
+ .from("user_hwids")
766
+ .select("id, hwid, created_at")
767
+ .eq("id", params.userId)
768
+ .order("created_at", { ascending: false });
769
+
770
+ if (investigatedUserHwidsError) {
771
+ result = { error: investigatedUserHwidsError, data: null };
772
+ break;
773
+ }
774
+
775
+ const targetRows = investigatedUserHwids || [];
776
+ const uniqueHwids = Array.from(
777
+ new Set(targetRows.map((row) => row.hwid).filter(Boolean))
778
+ );
779
+
780
+ const dateRange: InvestigationDateRange = {
781
+ firstSeenAt: null,
782
+ lastSeenAt: null,
783
+ };
784
+
785
+ const hwidAggregates = new Map<
786
+ string,
787
+ InvestigationDateRange & {
788
+ hwid: string;
789
+ occurrences: number;
790
+ linkedProfiles: Map<number, LinkedProfileAggregate>;
791
+ }
792
+ >();
793
+
794
+ for (const row of targetRows) {
795
+ updateDateRange(dateRange, row.created_at);
796
+
797
+ const existing = hwidAggregates.get(row.hwid) || {
798
+ hwid: row.hwid,
799
+ occurrences: 0,
800
+ firstSeenAt: null,
801
+ lastSeenAt: null,
802
+ linkedProfiles: new Map<number, LinkedProfileAggregate>(),
803
+ };
804
+
805
+ existing.occurrences += 1;
806
+ updateDateRange(existing, row.created_at);
807
+ hwidAggregates.set(row.hwid, existing);
808
+ }
809
+
810
+ if (uniqueHwids.length === 0) {
811
+ result = {
812
+ data: {
813
+ user: investigatedUser,
814
+ summary: {
815
+ totalRows: 0,
816
+ uniqueHwids: 0,
817
+ sharedHwids: 0,
818
+ linkedAccounts: 0,
819
+ firstSeenAt: null,
820
+ lastSeenAt: null,
821
+ },
822
+ accounts: [],
823
+ hwids: [],
824
+ },
825
+ error: null,
826
+ };
827
+ break;
828
+ }
829
+
830
+ const { data: relatedRows, error: relatedRowsError } = await supabase
831
+ .from("user_hwids")
832
+ .select("id, hwid, created_at")
833
+ .in("hwid", uniqueHwids)
834
+ .order("created_at", { ascending: false });
835
+
836
+ if (relatedRowsError) {
837
+ result = { error: relatedRowsError, data: null };
838
+ break;
839
+ }
840
+
841
+ const relatedProfileIds = Array.from(
842
+ new Set(
843
+ (relatedRows || [])
844
+ .map((row) => row.id)
845
+ .filter((id) => id !== params.userId)
846
+ )
847
+ );
848
+
849
+ const { data: relatedProfiles, error: relatedProfilesError } =
850
+ relatedProfileIds.length > 0
851
+ ? await supabase
852
+ .from("profiles")
853
+ .select(MULTIACCOUNT_PROFILE_SELECT)
854
+ .in("id", relatedProfileIds)
855
+ : { data: [], error: null };
856
+
857
+ if (relatedProfilesError) {
858
+ result = { error: relatedProfilesError, data: null };
859
+ break;
860
+ }
861
+
862
+ const profileMap = new Map<number, AdminProfileSummary>([
863
+ [investigatedUser.id, investigatedUser],
864
+ ...((relatedProfiles || []) as AdminProfileSummary[]).map((profile) => [
865
+ profile.id,
866
+ profile,
867
+ ]),
868
+ ]);
869
+
870
+ const linkedAccountAggregates = new Map<number, LinkedAccountAggregate>();
871
+
872
+ for (const row of relatedRows || []) {
873
+ if (row.id === params.userId) {
874
+ continue;
875
+ }
876
+
877
+ const hwidAggregate = hwidAggregates.get(row.hwid);
878
+ const relatedProfile = profileMap.get(row.id);
879
+
880
+ if (!hwidAggregate || !relatedProfile) {
881
+ continue;
882
+ }
883
+
884
+ const linkedProfile =
885
+ hwidAggregate.linkedProfiles.get(row.id) || {
886
+ ...relatedProfile,
887
+ occurrences: 0,
888
+ firstSeenAt: null,
889
+ lastSeenAt: null,
890
+ };
891
+
892
+ linkedProfile.occurrences += 1;
893
+ updateDateRange(linkedProfile, row.created_at);
894
+ hwidAggregate.linkedProfiles.set(row.id, linkedProfile);
895
+
896
+ const linkedAccount = linkedAccountAggregates.get(row.id) || {
897
+ ...relatedProfile,
898
+ occurrences: 0,
899
+ sharedHwids: new Set<string>(),
900
+ firstSeenAt: null,
901
+ lastSeenAt: null,
902
+ };
903
+
904
+ linkedAccount.occurrences += 1;
905
+ linkedAccount.sharedHwids.add(row.hwid);
906
+ updateDateRange(linkedAccount, row.created_at);
907
+ linkedAccountAggregates.set(row.id, linkedAccount);
908
+ }
909
+
910
+ const accounts = Array.from(linkedAccountAggregates.values())
911
+ .map((account) => ({
912
+ ...account,
913
+ sharedHwids: Array.from(account.sharedHwids).sort(),
914
+ sharedHwidCount: account.sharedHwids.size,
915
+ }))
916
+ .sort((a, b) => {
917
+ if (b.sharedHwidCount !== a.sharedHwidCount) {
918
+ return b.sharedHwidCount - a.sharedHwidCount;
919
+ }
920
+
921
+ if (b.occurrences !== a.occurrences) {
922
+ return b.occurrences - a.occurrences;
923
+ }
924
+
925
+ return timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt);
926
+ });
927
+
928
+ const hwids = Array.from(hwidAggregates.values())
929
+ .map((aggregate) => ({
930
+ hwid: aggregate.hwid,
931
+ occurrences: aggregate.occurrences,
932
+ firstSeenAt: aggregate.firstSeenAt,
933
+ lastSeenAt: aggregate.lastSeenAt,
934
+ linkedAccountCount: aggregate.linkedProfiles.size,
935
+ linkedProfiles: Array.from(aggregate.linkedProfiles.values()).sort(
936
+ (a, b) => {
937
+ if (b.occurrences !== a.occurrences) {
938
+ return b.occurrences - a.occurrences;
939
+ }
940
+
941
+ return (
942
+ timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt)
943
+ );
944
+ }
945
+ ),
946
+ }))
947
+ .sort((a, b) => {
948
+ if (b.linkedAccountCount !== a.linkedAccountCount) {
949
+ return b.linkedAccountCount - a.linkedAccountCount;
950
+ }
951
+
952
+ if (b.occurrences !== a.occurrences) {
953
+ return b.occurrences - a.occurrences;
954
+ }
955
+
956
+ return timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt);
957
+ });
958
+
959
+ result = {
960
+ data: {
961
+ user: investigatedUser,
962
+ summary: {
963
+ totalRows: targetRows.length,
964
+ uniqueHwids: uniqueHwids.length,
965
+ sharedHwids: hwids.filter((hwid) => hwid.linkedAccountCount > 0)
966
+ .length,
967
+ linkedAccounts: accounts.length,
968
+ firstSeenAt: dateRange.firstSeenAt,
969
+ lastSeenAt: dateRange.lastSeenAt,
970
+ },
971
+ accounts,
972
+ hwids,
973
+ },
974
+ error: null,
975
+ };
976
+ break;
977
+ }
978
+
979
+ const adminActionDetails = getAdminActionDetails(operation, params);
980
+
981
+ if (!ADMIN_READ_OPERATIONS.has(operation)) {
982
+ await postStaffAdminWebhook({
983
+ admin: queryUserData,
984
+ operation,
985
+ targetUserId,
986
+ details: adminActionDetails,
987
+ error: result?.error?.message,
988
+ });
989
+
990
+ // Log the admin action
991
+ await supabase.rpc("admin_log_action", {
992
+ admin_id: queryUserData.id,
993
+ action_type: operation,
994
+ target_id: "userId" in params ? params.userId : null,
995
+ details: { params: adminActionDetails },
996
+ });
997
+ }
998
+
999
+ if (result?.error) {
1000
+ return NextResponse.json(
1001
+ {
1002
+ success: false,
1003
+ error: result.error.message,
1004
+ },
1005
+ { status: 500 }
1006
+ );
1007
+ }
1008
+
1009
+ if (
1010
+ targetUserId !== null &&
1011
+ !result?.error &&
1012
+ !ADMIN_READ_OPERATIONS.has(operation)
1013
+ ) {
1014
+ await invalidateCachePrefix(`userscore:${targetUserId}`);
1015
+ }
1016
+
1017
+ return NextResponse.json({
1018
+ success: true,
1019
+ result: result?.data,
1020
+ });
1021
+ } catch (err: any) {
1022
+ return NextResponse.json(
1023
+ {
1024
+ success: false,
1025
+ error: err.message || "An error occurred during the operation",
1026
+ },
1027
+ { status: 500 }
1028
+ );
1029
+ }
1030
+ }