rhythia-api 243.0.0 → 244.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,1030 +1,1291 @@
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
- }
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 { getModerationState } from "../utils/moderation";
11
+ import {
12
+ GetObjectCommand,
13
+ PutObjectCommand,
14
+ S3Client,
15
+ } from "@aws-sdk/client-s3";
16
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
17
+ import { getRedis } from "../utils/redis";
18
+ import { parseReplaySubmitData } from "../utils/rhrReplay";
19
+ import { submitScoreForUser } from "./submitScoreInternal";
20
+
21
+ type AdminProfileSummary = Pick<
22
+ Database["public"]["Tables"]["profiles"]["Row"],
23
+ "id" | "username" | "avatar_url" | "flag" | "badges" | "ban"
24
+ >;
25
+
26
+ type InvestigationDateRange = {
27
+ firstSeenAt: string | null;
28
+ lastSeenAt: string | null;
29
+ };
30
+
31
+ type LinkedAccountAggregate = AdminProfileSummary &
32
+ InvestigationDateRange & {
33
+ occurrences: number;
34
+ sharedHwids: Set<string>;
35
+ };
36
+
37
+ type LinkedProfileAggregate = AdminProfileSummary &
38
+ InvestigationDateRange & {
39
+ occurrences: number;
40
+ };
41
+
42
+ const MULTIACCOUNT_PROFILE_SELECT =
43
+ "id, username, avatar_url, flag, badges, ban";
44
+
45
+ const ADMIN_READ_OPERATIONS = new Set([
46
+ "searchUsers",
47
+ "getScoresPaginated",
48
+ "getFriendLinks",
49
+ "getMultiaccountInvestigation",
50
+ "getModeratedUsersPaginated",
51
+ "getUserViolationState",
52
+ "downloadACBuild",
53
+ "retrieveACBuilds",
54
+ ]);
55
+
56
+ const AC_BUILD_ADMIN_IDS = new Set([0, 13]);
57
+ const AC_BUILD_OPERATIONS = new Set([
58
+ "uploadACBuild",
59
+ "downloadACBuild",
60
+ "retrieveACBuilds",
61
+ "makeACBuildPrimary",
62
+ "setACBuildActive",
63
+ ]);
64
+
65
+ const acBuildBucket = "ac-builds";
66
+ const acBuildS3Endpoint = "https://s3.eu-central-003.backblazeb2.com";
67
+ const acBuildS3Client = new S3Client({
68
+ region: "eu-central-003",
69
+ endpoint: acBuildS3Endpoint,
70
+ credentials: {
71
+ secretAccessKey: process.env.B2_AC_SECRET_BUCKET || "",
72
+ accessKeyId: process.env.B2_AC_ACCESS_BUCKET || "",
73
+ },
74
+ requestChecksumCalculation: "WHEN_REQUIRED",
75
+ });
76
+
77
+ const acBuildGameBranch = z.enum(["test", "main"]);
78
+ const changelogType = z.enum(["public", "testing", "web"]);
79
+ function clampWebhookText(value: string, maxLength: number) {
80
+ const sanitized = value.replace(/[\u0000-\u001F\u007F]/g, "");
81
+ return sanitized.length <= maxLength
82
+ ? sanitized
83
+ : `${sanitized.slice(0, maxLength - 3)}...`;
84
+ }
85
+
86
+ function getAdminActionDetails(operation: string, params: any) {
87
+ return operation === "addScoreViaReplay"
88
+ ? { userId: params.userId, replayBytes: "<Long>" }
89
+ : params;
90
+ }
91
+
92
+ async function postStaffAdminWebhook({
93
+ admin,
94
+ operation,
95
+ targetUserId,
96
+ details,
97
+ error,
98
+ }: {
99
+ admin: Database["public"]["Tables"]["profiles"]["Row"];
100
+ operation: string;
101
+ targetUserId: number | null;
102
+ details: any;
103
+ error?: string;
104
+ }) {
105
+ const webhookUrl = process.env.WEBHOOK_STAFF_DISCORD;
106
+ if (!webhookUrl) {
107
+ console.log("WEBHOOK_STAFF_DISCORD is not configured");
108
+ return;
109
+ }
110
+
111
+ try {
112
+ const adminName = admin.username || `User #${admin.id}`;
113
+ const payload = {
114
+ content: `Admin action: ${operation}`,
115
+ embeds: [
116
+ {
117
+ title: "Admin Action",
118
+ color: error ? 0xe74c3c : 0x3498db,
119
+ fields: [
120
+ {
121
+ name: "Moderator",
122
+ value: clampWebhookText(`${adminName} (#${admin.id})`, 1024),
123
+ inline: true,
124
+ },
125
+ {
126
+ name: "Action",
127
+ value: clampWebhookText(operation, 1024),
128
+ inline: true,
129
+ },
130
+ {
131
+ name: "Target",
132
+ value: targetUserId === null ? "-" : `#${targetUserId}`,
133
+ inline: true,
134
+ },
135
+ {
136
+ name: "Status",
137
+ value: error ? `Failed: ${clampWebhookText(error, 900)}` : "Succeeded",
138
+ },
139
+ {
140
+ name: "Details",
141
+ value: clampWebhookText(JSON.stringify(details), 1024) || "-",
142
+ },
143
+ ],
144
+ footer: {
145
+ text: new Date().toUTCString(),
146
+ },
147
+ },
148
+ ],
149
+ };
150
+
151
+ const response = await fetch(webhookUrl, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ },
156
+ body: JSON.stringify(payload),
157
+ });
158
+
159
+ if (!response.ok) {
160
+ const responseBody = await response.text();
161
+ console.log("Staff admin webhook failed", {
162
+ operation,
163
+ status: response.status,
164
+ statusText: response.statusText,
165
+ responseBody: clampWebhookText(responseBody || "-", 4000),
166
+ });
167
+ }
168
+ } catch (error) {
169
+ console.log("Failed to post staff admin webhook", error);
170
+ }
171
+ }
172
+
173
+ function timestampOrZero(value: string | null) {
174
+ return value ? new Date(value).getTime() : 0;
175
+ }
176
+
177
+ function updateDateRange(
178
+ range: InvestigationDateRange,
179
+ createdAt: string | null
180
+ ) {
181
+ if (!createdAt) {
182
+ return;
183
+ }
184
+
185
+ if (!range.firstSeenAt || createdAt < range.firstSeenAt) {
186
+ range.firstSeenAt = createdAt;
187
+ }
188
+
189
+ if (!range.lastSeenAt || createdAt > range.lastSeenAt) {
190
+ range.lastSeenAt = createdAt;
191
+ }
192
+ }
193
+
194
+ async function setPrimaryACBuild(buildId: number) {
195
+ const { data: build, error: buildError } = await supabase
196
+ .from("ac_builds")
197
+ .select("id,platform,game_branch")
198
+ .eq("id", buildId)
199
+ .single();
200
+
201
+ if (buildError) {
202
+ return { data: null, error: buildError };
203
+ }
204
+
205
+ const inactiveBuilds = await supabase
206
+ .from("ac_builds")
207
+ .update({ active: false })
208
+ .eq("platform", build.platform)
209
+ .eq("game_branch", build.game_branch);
210
+
211
+ return inactiveBuilds.error
212
+ ? inactiveBuilds
213
+ : supabase
214
+ .from("ac_builds")
215
+ .update({ active: true })
216
+ .eq("id", buildId)
217
+ .select("*")
218
+ .single();
219
+ }
220
+
221
+ // Define supported admin operations and their parameter types
222
+ const adminOperations = {
223
+ deleteUser: z.object({ userId: z.number() }),
224
+ updateProfile: z.object({
225
+ userId: z.number(),
226
+ avatar_url: z.string().optional(),
227
+ flag: z.string().optional(),
228
+ profile_image: z.string().optional(),
229
+ username: z.string().optional(),
230
+ verified: z.boolean().optional(),
231
+ }),
232
+ changeFlag: z.object({ userId: z.number(), flag: z.string() }),
233
+ changeBadges: z.object({ userId: z.number(), badges: z.array(z.string()) }),
234
+ addBadge: z.object({ userId: z.number(), badge: z.string() }),
235
+ removeBadge: z.object({ userId: z.number(), badge: z.string() }),
236
+ excludeUser: z.object({ userId: z.number(), reason: z.string() }),
237
+ restrictUser: z.object({ userId: z.number(), reason: z.string() }),
238
+ silenceUser: z.object({
239
+ userId: z.number(),
240
+ expiresAt: z.string().nullable().optional(),
241
+ reason: z.string(),
242
+ }),
243
+ profanityClear: z.object({ userId: z.number() }),
244
+ searchUsers: z.object({ searchText: z.string() }),
245
+ getFriendLinks: z.object({ userId: z.number() }),
246
+ removeAllScores: z.object({ userId: z.number() }),
247
+ removeScore: z.object({ userId: z.number(), scoreId: z.number() }),
248
+ addScoreViaReplay: z.object({ userId: z.number(), replayBytes: z.string() }),
249
+ invalidateRankedScores: z.object({ userId: z.number() }),
250
+ unbanUser: z.object({ userId: z.number() }),
251
+ revokeViolation: z.object({
252
+ violationId: z.number(),
253
+ reason: z.string().optional(),
254
+ }),
255
+ getModeratedUsersPaginated: z.object({
256
+ page: z.number().min(1).default(1),
257
+ limit: z.number().min(1).max(100).default(25),
258
+ }),
259
+ getScoresPaginated: z.object({
260
+ page: z.number().min(1).default(1),
261
+ limit: z.number().min(1).max(100).default(50),
262
+ userId: z.number().optional(),
263
+ includeAdditionalData: z.boolean().default(true),
264
+ }),
265
+ getMultiaccountInvestigation: z.object({ userId: z.number() }),
266
+ getUserViolationState: z.object({ userId: z.number() }),
267
+ uploadACBuild: z.object({
268
+ contentLength: z.number(),
269
+ contentType: z.string().default("application/octet-stream"),
270
+ gameBranch: acBuildGameBranch,
271
+ hash: z.string(),
272
+ platform: z.string(),
273
+ }),
274
+ retrieveACBuilds: z.object({
275
+ gameBranch: acBuildGameBranch.optional(),
276
+ platform: z.string().optional(),
277
+ }),
278
+ downloadACBuild: z.object({ buildId: z.number() }),
279
+ makeACBuildPrimary: z.object({ buildId: z.number() }),
280
+ setACBuildActive: z.object({ buildId: z.number(), active: z.boolean() }),
281
+ addChangelog: z.object({
282
+ type: changelogType,
283
+ date: z.string(),
284
+ markdown: z.string(),
285
+ }),
286
+ removeChangelog: z.object({
287
+ type: changelogType,
288
+ date: z.string(),
289
+ }),
290
+ } as const;
291
+
292
+ // Create a discriminated union type for operation parameters
293
+ const OperationParam = z.discriminatedUnion("operation", [
294
+ z.object({
295
+ operation: z.literal("deleteUser"),
296
+ params: adminOperations.deleteUser,
297
+ }),
298
+ z.object({
299
+ operation: z.literal("excludeUser"),
300
+ params: adminOperations.excludeUser,
301
+ }),
302
+ z.object({
303
+ operation: z.literal("restrictUser"),
304
+ params: adminOperations.restrictUser,
305
+ }),
306
+ z.object({
307
+ operation: z.literal("silenceUser"),
308
+ params: adminOperations.silenceUser,
309
+ }),
310
+ z.object({
311
+ operation: z.literal("searchUsers"),
312
+ params: adminOperations.searchUsers,
313
+ }),
314
+ z.object({
315
+ operation: z.literal("profanityClear"),
316
+ params: adminOperations.profanityClear,
317
+ }),
318
+ z.object({
319
+ operation: z.literal("removeAllScores"),
320
+ params: adminOperations.removeAllScores,
321
+ }),
322
+ z.object({
323
+ operation: z.literal("invalidateRankedScores"),
324
+ params: adminOperations.invalidateRankedScores,
325
+ }),
326
+ z.object({
327
+ operation: z.literal("unbanUser"),
328
+ params: adminOperations.unbanUser,
329
+ }),
330
+ z.object({
331
+ operation: z.literal("revokeViolation"),
332
+ params: adminOperations.revokeViolation,
333
+ }),
334
+ z.object({
335
+ operation: z.literal("updateProfile"),
336
+ params: adminOperations.updateProfile,
337
+ }),
338
+ z.object({
339
+ operation: z.literal("changeFlag"),
340
+ params: adminOperations.changeFlag,
341
+ }),
342
+ z.object({
343
+ operation: z.literal("changeBadges"),
344
+ params: adminOperations.changeBadges,
345
+ }),
346
+ z.object({
347
+ operation: z.literal("addBadge"),
348
+ params: adminOperations.addBadge,
349
+ }),
350
+ z.object({
351
+ operation: z.literal("removeBadge"),
352
+ params: adminOperations.removeBadge,
353
+ }),
354
+ z.object({
355
+ operation: z.literal("getFriendLinks"),
356
+ params: adminOperations.getFriendLinks,
357
+ }),
358
+ z.object({
359
+ operation: z.literal("removeScore"),
360
+ params: adminOperations.removeScore,
361
+ }),
362
+ z.object({
363
+ operation: z.literal("addScoreViaReplay"),
364
+ params: adminOperations.addScoreViaReplay,
365
+ }),
366
+ z.object({
367
+ operation: z.literal("getModeratedUsersPaginated"),
368
+ params: adminOperations.getModeratedUsersPaginated,
369
+ }),
370
+ z.object({
371
+ operation: z.literal("getScoresPaginated"),
372
+ params: adminOperations.getScoresPaginated,
373
+ }),
374
+ z.object({
375
+ operation: z.literal("getMultiaccountInvestigation"),
376
+ params: adminOperations.getMultiaccountInvestigation,
377
+ }),
378
+ z.object({
379
+ operation: z.literal("getUserViolationState"),
380
+ params: adminOperations.getUserViolationState,
381
+ }),
382
+ z.object({
383
+ operation: z.literal("uploadACBuild"),
384
+ params: adminOperations.uploadACBuild,
385
+ }),
386
+ z.object({
387
+ operation: z.literal("retrieveACBuilds"),
388
+ params: adminOperations.retrieveACBuilds,
389
+ }),
390
+ z.object({
391
+ operation: z.literal("downloadACBuild"),
392
+ params: adminOperations.downloadACBuild,
393
+ }),
394
+ z.object({
395
+ operation: z.literal("makeACBuildPrimary"),
396
+ params: adminOperations.makeACBuildPrimary,
397
+ }),
398
+ z.object({
399
+ operation: z.literal("setACBuildActive"),
400
+ params: adminOperations.setACBuildActive,
401
+ }),
402
+ z.object({
403
+ operation: z.literal("addChangelog"),
404
+ params: adminOperations.addChangelog,
405
+ }),
406
+ z.object({
407
+ operation: z.literal("removeChangelog"),
408
+ params: adminOperations.removeChangelog,
409
+ }),
410
+ ]);
411
+
412
+ export const Schema = {
413
+ input: z.strictObject({
414
+ session: z.string(),
415
+ data: OperationParam,
416
+ }),
417
+ output: z.object({
418
+ success: z.boolean(),
419
+ result: z.any().optional(),
420
+ error: z.string().optional(),
421
+ }),
422
+ };
423
+
424
+ export async function POST(request: Request): Promise<NextResponse> {
425
+ return protectedApi({
426
+ request,
427
+ schema: Schema,
428
+ authorization: () => { },
429
+ activity: handler,
430
+ });
431
+ }
432
+
433
+ export async function handler(
434
+ data: (typeof Schema)["input"]["_type"]
435
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
436
+ // Get user from session
437
+ const user = (await getUserBySession(data.session)) as User;
438
+
439
+ // Get user's profile data
440
+ const { data: queryUserData, error: userError } = await supabase
441
+ .from("profiles")
442
+ .select("*")
443
+ .eq("uid", user.id)
444
+ .single();
445
+
446
+ if (userError || !queryUserData) {
447
+ return NextResponse.json(
448
+ {
449
+ success: false,
450
+ error: "User cannot be retrieved from session",
451
+ },
452
+ { status: 404 }
453
+ );
454
+ }
455
+ const operation = data.data.operation;
456
+ const tags = (queryUserData?.badges || []) as string[];
457
+ // Check if user has "Global Moderator" badge
458
+ const isGlobalModerator = tags.includes("Global Moderator");
459
+ const isACBuildOperation = AC_BUILD_OPERATIONS.has(operation);
460
+
461
+ if (isACBuildOperation && !AC_BUILD_ADMIN_IDS.has(queryUserData.id)) {
462
+ return NextResponse.json(
463
+ {
464
+ success: false,
465
+ error: "Access denied.",
466
+ },
467
+ { status: 403 }
468
+ );
469
+ }
470
+
471
+ if (!isACBuildOperation && !isGlobalModerator) {
472
+ return NextResponse.json(
473
+ {
474
+ success: false,
475
+ error: "Unauthorized. Only Global Moderators can perform this action.",
476
+ },
477
+ { status: 403 }
478
+ );
479
+ }
480
+
481
+ // Execute the requested admin operation
482
+ try {
483
+ let result: { data?: any; error?: any } | null = null;
484
+ const params = data.data.params as any;
485
+
486
+ const targetUserId =
487
+ "userId" in params && typeof params.userId === "number"
488
+ ? params.userId
489
+ : null;
490
+
491
+ switch (operation) {
492
+ case "deleteUser":
493
+ result = await supabase.rpc("admin_delete_user", {
494
+ user_id: params.userId,
495
+ });
496
+ break;
497
+
498
+ case "excludeUser":
499
+ result = await supabase.rpc("admin_exclude_user", {
500
+ user_id: params.userId,
501
+ });
502
+ break;
503
+
504
+ case "restrictUser":
505
+ result = await supabase.rpc("admin_restrict_user", {
506
+ user_id: params.userId,
507
+ });
508
+ break;
509
+
510
+ case "silenceUser":
511
+ result = await supabase.rpc("admin_silence_user", {
512
+ user_id: params.userId,
513
+ });
514
+ break;
515
+
516
+ case "searchUsers":
517
+ result = await supabase.rpc("admin_search_users", {
518
+ search_text: params.searchText,
519
+ });
520
+ break;
521
+
522
+ case "profanityClear":
523
+ result = await supabase.rpc("admin_profanity_clear", {
524
+ user_id: params.userId,
525
+ });
526
+ break;
527
+
528
+ case "removeAllScores":
529
+ result = await supabase.rpc("admin_remove_all_scores", {
530
+ user_id: params.userId,
531
+ });
532
+ break;
533
+
534
+ case "invalidateRankedScores":
535
+ result = await supabase.rpc("admin_invalidate_ranked_scores", {
536
+ user_id: params.userId,
537
+ });
538
+ break;
539
+
540
+ case "unbanUser":
541
+ result = await supabase.rpc("admin_unban_user", {
542
+ user_id: params.userId,
543
+ });
544
+ break;
545
+
546
+ case "updateProfile":
547
+ const profileData: {
548
+ avatar_url?: string;
549
+ flag?: string;
550
+ profile_image?: string;
551
+ username?: string;
552
+ verified?: boolean;
553
+ } = {};
554
+
555
+ if (params.avatar_url !== undefined) profileData.avatar_url = params.avatar_url;
556
+ if (params.flag !== undefined) profileData.flag = params.flag;
557
+ if (params.profile_image !== undefined) {
558
+ profileData.profile_image = params.profile_image;
559
+ }
560
+ if (params.username !== undefined) profileData.username = params.username;
561
+ if (params.verified !== undefined) profileData.verified = params.verified;
562
+
563
+ const profileValidationError = normalizeProfileUpdateData(profileData);
564
+
565
+ if (profileValidationError) {
566
+ result = {
567
+ data: null,
568
+ error: { message: profileValidationError },
569
+ };
570
+ break;
571
+ }
572
+
573
+ result = await supabase.rpc("admin_update_profile", {
574
+ user_id: params.userId,
575
+ profile_data: profileData,
576
+ });
577
+ break;
578
+
579
+ case "changeFlag":
580
+ const flagData = { flag: params.flag };
581
+ const flagValidationError = normalizeProfileUpdateData(flagData);
582
+
583
+ if (flagValidationError) {
584
+ result = { data: null, error: { message: flagValidationError } };
585
+ break;
586
+ }
587
+
588
+ result = await supabase.rpc("admin_update_profile", {
589
+ user_id: params.userId,
590
+ profile_data: flagData,
591
+ });
592
+ break;
593
+ case "changeBadges":
594
+ // Allow only developers to modify badges.
595
+ if ((queryUserData.badges as string[]).includes("Developer")) {
596
+ result = await supabase
597
+ .from("profiles")
598
+ .upsert({
599
+ id: params.userId,
600
+ badges: params.badges,
601
+ })
602
+ .select();
603
+ } else {
604
+ result = { data: null, error: { message: "Unauthorized" } };
605
+ }
606
+ break;
607
+
608
+ case "addBadge":
609
+ // Allow only developers to modify badges.
610
+ if ((queryUserData.badges as string[]).includes("Developer")) {
611
+ // Get current badges
612
+ const { data: targetUser } = await supabase
613
+ .from("profiles")
614
+ .select("badges")
615
+ .eq("id", params.userId)
616
+ .single();
617
+
618
+ const currentBadges = (targetUser?.badges || []) as string[];
619
+ if (!currentBadges.includes(params.badge)) {
620
+ currentBadges.push(params.badge);
621
+ result = await supabase
622
+ .from("profiles")
623
+ .upsert({
624
+ id: params.userId,
625
+ badges: currentBadges,
626
+ })
627
+ .select();
628
+ } else {
629
+ result = { data: targetUser, error: null };
630
+ }
631
+ } else {
632
+ result = { data: null, error: { message: "Unauthorized" } };
633
+ }
634
+ break;
635
+
636
+ case "removeBadge":
637
+ // Allow only developers to modify badges.
638
+ if ((queryUserData.badges as string[]).includes("Developer")) {
639
+ // Get current badges
640
+ const { data: targetUser } = await supabase
641
+ .from("profiles")
642
+ .select("badges")
643
+ .eq("id", params.userId)
644
+ .single();
645
+
646
+ const currentBadges = (targetUser?.badges || []) as string[];
647
+ const updatedBadges = currentBadges.filter(b => b !== params.badge);
648
+
649
+ result = await supabase
650
+ .from("profiles")
651
+ .upsert({
652
+ id: params.userId,
653
+ badges: updatedBadges,
654
+ })
655
+ .select();
656
+ } else {
657
+ result = { data: null, error: { message: "Unauthorized" } };
658
+ }
659
+ break;
660
+
661
+ case "getFriendLinks":
662
+ const [
663
+ { data: outgoingRows, error: outgoingError },
664
+ { data: incomingRows, error: incomingError },
665
+ ] = await Promise.all([
666
+ supabase
667
+ .from("profileFriends")
668
+ .select("profile_id,friend_id,created_at")
669
+ .eq("profile_id", params.userId)
670
+ .order("created_at", { ascending: false }),
671
+ supabase
672
+ .from("profileFriends")
673
+ .select("profile_id,friend_id,created_at")
674
+ .eq("friend_id", params.userId)
675
+ .order("created_at", { ascending: false }),
676
+ ]);
677
+
678
+ if (outgoingError || incomingError) {
679
+ result = { data: null, error: outgoingError || incomingError };
680
+ break;
681
+ }
682
+
683
+ const friendProfileIds = Array.from(
684
+ new Set([
685
+ ...((outgoingRows || []).map((row) => row.friend_id) || []),
686
+ ...((incomingRows || []).map((row) => row.profile_id) || []),
687
+ ])
688
+ );
689
+
690
+ const { data: friendProfiles, error: friendProfilesError } =
691
+ friendProfileIds.length > 0
692
+ ? await supabase
693
+ .from("profiles")
694
+ .select("id,username,avatar_url,flag")
695
+ .in("id", friendProfileIds)
696
+ : { data: [], error: null };
697
+
698
+ if (friendProfilesError) {
699
+ result = { data: null, error: friendProfilesError };
700
+ break;
701
+ }
702
+
703
+ const friendProfileMap = new Map(
704
+ (friendProfiles || []).map((profile) => [profile.id, profile])
705
+ );
706
+ const mapFriendLink = (profileId: number, createdAt: string) => {
707
+ const profile = friendProfileMap.get(profileId);
708
+ return profile ? { ...profile, created_at: createdAt } : null;
709
+ };
710
+
711
+ result = {
712
+ data: {
713
+ friends: (outgoingRows || [])
714
+ .map((row) => mapFriendLink(row.friend_id, row.created_at))
715
+ .filter(Boolean),
716
+ friendedBy: (incomingRows || [])
717
+ .map((row) => mapFriendLink(row.profile_id, row.created_at))
718
+ .filter(Boolean),
719
+ },
720
+ error: null,
721
+ };
722
+ break;
723
+
724
+ case "removeScore":
725
+ result = await supabase.rpc("admin_remove_score", {
726
+ user_id: params.userId,
727
+ score_id: params.scoreId,
728
+ });
729
+
730
+ if (!result.error && result.data !== true) {
731
+ result = { data: null, error: { message: "Score not found" } };
732
+ }
733
+ break;
734
+
735
+ case "addScoreViaReplay":
736
+ const { data: scoreUser, error: scoreUserError } = await supabase
737
+ .from("profiles")
738
+ .select("*")
739
+ .eq("id", params.userId)
740
+ .single();
741
+
742
+ if (scoreUserError || !scoreUser) {
743
+ result = {
744
+ data: null,
745
+ error: { message: "User not found" },
746
+ };
747
+ break;
748
+ }
749
+
750
+ const scoreResponse = await submitScoreForUser(
751
+ parseReplaySubmitData(Buffer.from(params.replayBytes, "base64")),
752
+ scoreUser,
753
+ scoreUser.uid || String(scoreUser.id),
754
+ null
755
+ );
756
+ const scoreResult = (await scoreResponse.json()) as { error?: string };
757
+ result =
758
+ scoreResponse.status >= 400 || scoreResult.error
759
+ ? {
760
+ data: null,
761
+ error: { message: scoreResult.error || "Failed to add score" },
762
+ }
763
+ : { data: scoreResult, error: null };
764
+ break;
765
+
766
+ case "getModeratedUsersPaginated":
767
+ const moderatedOffset = (params.page - 1) * params.limit;
768
+ const {
769
+ data: moderatedUsers,
770
+ error: moderatedUsersError,
771
+ count: moderatedUsersCount,
772
+ } = await supabase
773
+ .from("profiles")
774
+ .select(
775
+ "id,username,avatar_url,flag,ban,bannedAt,skill_points,play_count,created_at",
776
+ { count: "exact" }
777
+ )
778
+ .in("ban", ["excluded", "restricted", "silenced"])
779
+ .order("bannedAt", { ascending: false, nullsFirst: false })
780
+ .order("id", { ascending: false })
781
+ .range(moderatedOffset, moderatedOffset + params.limit - 1);
782
+
783
+ result = moderatedUsersError
784
+ ? { error: moderatedUsersError, data: null }
785
+ : {
786
+ data: {
787
+ users: moderatedUsers || [],
788
+ pagination: {
789
+ page: params.page,
790
+ limit: params.limit,
791
+ total: moderatedUsersCount || 0,
792
+ totalPages: Math.ceil(
793
+ (moderatedUsersCount || 0) / params.limit
794
+ ),
795
+ },
796
+ },
797
+ error: null,
798
+ };
799
+ break;
800
+
801
+ case "getScoresPaginated":
802
+ const offset = (params.page - 1) * params.limit;
803
+ let query = supabase
804
+ .from("scores")
805
+ .select(
806
+ `
807
+ id,
808
+ awarded_sp,
809
+ userId,
810
+ additional_data,
811
+ profiles (
812
+ username
813
+ )
814
+ `,
815
+ { count: "exact" }
816
+ )
817
+ .eq("passed", true)
818
+ .order("created_at", { ascending: false })
819
+ .range(offset, offset + params.limit - 1);
820
+
821
+ if (params.userId) {
822
+ query = query.eq("userId", params.userId);
823
+ }
824
+
825
+ const { data: scoresData, error: scoresError, count } = await query;
826
+
827
+ if (scoresError) {
828
+ result = { error: scoresError, data: null };
829
+ } else {
830
+ const transformedScores = scoresData?.map((score) => {
831
+ const transformed: any = {
832
+ id: score.id,
833
+ awarded_sp: score.awarded_sp,
834
+ userId: score.userId,
835
+ username: score.profiles?.username || null,
836
+ };
837
+
838
+ if (params.includeAdditionalData) {
839
+ transformed.additional_data = score.additional_data;
840
+ }
841
+
842
+ return transformed;
843
+ });
844
+
845
+ result = {
846
+ data: {
847
+ scores: transformedScores,
848
+ pagination: {
849
+ page: params.page,
850
+ limit: params.limit,
851
+ total: count || 0,
852
+ totalPages: Math.ceil((count || 0) / params.limit),
853
+ },
854
+ },
855
+ error: null,
856
+ };
857
+ }
858
+ break;
859
+
860
+ case "getMultiaccountInvestigation":
861
+ const { data: investigatedUser, error: investigatedUserError } =
862
+ await supabase
863
+ .from("profiles")
864
+ .select(MULTIACCOUNT_PROFILE_SELECT)
865
+ .eq("id", params.userId)
866
+ .maybeSingle();
867
+
868
+ if (investigatedUserError) {
869
+ result = { error: investigatedUserError, data: null };
870
+ break;
871
+ }
872
+
873
+ if (!investigatedUser) {
874
+ result = {
875
+ error: { message: "User not found" },
876
+ data: null,
877
+ };
878
+ break;
879
+ }
880
+
881
+ const { data: investigatedUserHwids, error: investigatedUserHwidsError } =
882
+ await supabase
883
+ .from("user_hwids")
884
+ .select("id, hwid, created_at")
885
+ .eq("id", params.userId)
886
+ .order("created_at", { ascending: false });
887
+
888
+ if (investigatedUserHwidsError) {
889
+ result = { error: investigatedUserHwidsError, data: null };
890
+ break;
891
+ }
892
+
893
+ const targetRows = investigatedUserHwids || [];
894
+ const uniqueHwids = Array.from(
895
+ new Set(targetRows.map((row) => row.hwid).filter(Boolean))
896
+ );
897
+
898
+ const dateRange: InvestigationDateRange = {
899
+ firstSeenAt: null,
900
+ lastSeenAt: null,
901
+ };
902
+
903
+ const hwidAggregates = new Map<
904
+ string,
905
+ InvestigationDateRange & {
906
+ hwid: string;
907
+ occurrences: number;
908
+ linkedProfiles: Map<number, LinkedProfileAggregate>;
909
+ }
910
+ >();
911
+
912
+ for (const row of targetRows) {
913
+ updateDateRange(dateRange, row.created_at);
914
+
915
+ const existing = hwidAggregates.get(row.hwid) || {
916
+ hwid: row.hwid,
917
+ occurrences: 0,
918
+ firstSeenAt: null,
919
+ lastSeenAt: null,
920
+ linkedProfiles: new Map<number, LinkedProfileAggregate>(),
921
+ };
922
+
923
+ existing.occurrences += 1;
924
+ updateDateRange(existing, row.created_at);
925
+ hwidAggregates.set(row.hwid, existing);
926
+ }
927
+
928
+ if (uniqueHwids.length === 0) {
929
+ result = {
930
+ data: {
931
+ user: investigatedUser,
932
+ summary: {
933
+ totalRows: 0,
934
+ uniqueHwids: 0,
935
+ sharedHwids: 0,
936
+ linkedAccounts: 0,
937
+ firstSeenAt: null,
938
+ lastSeenAt: null,
939
+ },
940
+ accounts: [],
941
+ hwids: [],
942
+ },
943
+ error: null,
944
+ };
945
+ break;
946
+ }
947
+
948
+ const { data: relatedRows, error: relatedRowsError } = await supabase
949
+ .from("user_hwids")
950
+ .select("id, hwid, created_at")
951
+ .in("hwid", uniqueHwids)
952
+ .order("created_at", { ascending: false });
953
+
954
+ if (relatedRowsError) {
955
+ result = { error: relatedRowsError, data: null };
956
+ break;
957
+ }
958
+
959
+ const relatedProfileIds = Array.from(
960
+ new Set(
961
+ (relatedRows || [])
962
+ .map((row) => row.id)
963
+ .filter((id) => id !== params.userId)
964
+ )
965
+ );
966
+
967
+ const { data: relatedProfiles, error: relatedProfilesError } =
968
+ relatedProfileIds.length > 0
969
+ ? await supabase
970
+ .from("profiles")
971
+ .select(MULTIACCOUNT_PROFILE_SELECT)
972
+ .in("id", relatedProfileIds)
973
+ : { data: [], error: null };
974
+
975
+ if (relatedProfilesError) {
976
+ result = { error: relatedProfilesError, data: null };
977
+ break;
978
+ }
979
+
980
+ const profileMap = new Map<number, AdminProfileSummary>([
981
+ [investigatedUser.id, investigatedUser],
982
+ ...((relatedProfiles || []) as AdminProfileSummary[]).map((profile) => [
983
+ profile.id,
984
+ profile,
985
+ ]),
986
+ ]);
987
+
988
+ const linkedAccountAggregates = new Map<number, LinkedAccountAggregate>();
989
+
990
+ for (const row of relatedRows || []) {
991
+ if (row.id === params.userId) {
992
+ continue;
993
+ }
994
+
995
+ const hwidAggregate = hwidAggregates.get(row.hwid);
996
+ const relatedProfile = profileMap.get(row.id);
997
+
998
+ if (!hwidAggregate || !relatedProfile) {
999
+ continue;
1000
+ }
1001
+
1002
+ const linkedProfile =
1003
+ hwidAggregate.linkedProfiles.get(row.id) || {
1004
+ ...relatedProfile,
1005
+ occurrences: 0,
1006
+ firstSeenAt: null,
1007
+ lastSeenAt: null,
1008
+ };
1009
+
1010
+ linkedProfile.occurrences += 1;
1011
+ updateDateRange(linkedProfile, row.created_at);
1012
+ hwidAggregate.linkedProfiles.set(row.id, linkedProfile);
1013
+
1014
+ const linkedAccount = linkedAccountAggregates.get(row.id) || {
1015
+ ...relatedProfile,
1016
+ occurrences: 0,
1017
+ sharedHwids: new Set<string>(),
1018
+ firstSeenAt: null,
1019
+ lastSeenAt: null,
1020
+ };
1021
+
1022
+ linkedAccount.occurrences += 1;
1023
+ linkedAccount.sharedHwids.add(row.hwid);
1024
+ updateDateRange(linkedAccount, row.created_at);
1025
+ linkedAccountAggregates.set(row.id, linkedAccount);
1026
+ }
1027
+
1028
+ const accounts = Array.from(linkedAccountAggregates.values())
1029
+ .map((account) => ({
1030
+ ...account,
1031
+ sharedHwids: Array.from(account.sharedHwids).sort(),
1032
+ sharedHwidCount: account.sharedHwids.size,
1033
+ }))
1034
+ .sort((a, b) => {
1035
+ if (b.sharedHwidCount !== a.sharedHwidCount) {
1036
+ return b.sharedHwidCount - a.sharedHwidCount;
1037
+ }
1038
+
1039
+ if (b.occurrences !== a.occurrences) {
1040
+ return b.occurrences - a.occurrences;
1041
+ }
1042
+
1043
+ return timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt);
1044
+ });
1045
+
1046
+ const hwids = Array.from(hwidAggregates.values())
1047
+ .map((aggregate) => ({
1048
+ hwid: aggregate.hwid,
1049
+ occurrences: aggregate.occurrences,
1050
+ firstSeenAt: aggregate.firstSeenAt,
1051
+ lastSeenAt: aggregate.lastSeenAt,
1052
+ linkedAccountCount: aggregate.linkedProfiles.size,
1053
+ linkedProfiles: Array.from(aggregate.linkedProfiles.values()).sort(
1054
+ (a, b) => {
1055
+ if (b.occurrences !== a.occurrences) {
1056
+ return b.occurrences - a.occurrences;
1057
+ }
1058
+
1059
+ return (
1060
+ timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt)
1061
+ );
1062
+ }
1063
+ ),
1064
+ }))
1065
+ .sort((a, b) => {
1066
+ if (b.linkedAccountCount !== a.linkedAccountCount) {
1067
+ return b.linkedAccountCount - a.linkedAccountCount;
1068
+ }
1069
+
1070
+ if (b.occurrences !== a.occurrences) {
1071
+ return b.occurrences - a.occurrences;
1072
+ }
1073
+
1074
+ return timestampOrZero(b.lastSeenAt) - timestampOrZero(a.lastSeenAt);
1075
+ });
1076
+
1077
+ result = {
1078
+ data: {
1079
+ user: investigatedUser,
1080
+ summary: {
1081
+ totalRows: targetRows.length,
1082
+ uniqueHwids: uniqueHwids.length,
1083
+ sharedHwids: hwids.filter((hwid) => hwid.linkedAccountCount > 0)
1084
+ .length,
1085
+ linkedAccounts: accounts.length,
1086
+ firstSeenAt: dateRange.firstSeenAt,
1087
+ lastSeenAt: dateRange.lastSeenAt,
1088
+ },
1089
+ accounts,
1090
+ hwids,
1091
+ },
1092
+ error: null,
1093
+ };
1094
+ break;
1095
+
1096
+ case "getUserViolationState":
1097
+ result = await getModerationState(params.userId);
1098
+ break;
1099
+
1100
+ case "uploadACBuild":
1101
+ const acBuildKey = `rsign-${params.gameBranch}-${params.platform}-${Date.now()}`;
1102
+ const acBuildUrl = `${acBuildS3Endpoint}/${acBuildBucket}/${acBuildKey}`;
1103
+ const uploadUrl = await getSignedUrl(
1104
+ acBuildS3Client,
1105
+ new PutObjectCommand({
1106
+ Bucket: acBuildBucket,
1107
+ Key: acBuildKey,
1108
+ ContentType: params.contentType,
1109
+ }),
1110
+ {
1111
+ expiresIn: 3600,
1112
+ signableHeaders: new Set(["content-type"]),
1113
+ }
1114
+ );
1115
+ const insertedACBuild = await supabase
1116
+ .from("ac_builds")
1117
+ .insert({
1118
+ active: false,
1119
+ game_branch: params.gameBranch,
1120
+ hash: params.hash,
1121
+ platform: params.platform,
1122
+ url: acBuildUrl,
1123
+ })
1124
+ .select("*")
1125
+ .single();
1126
+
1127
+ result = insertedACBuild.error
1128
+ ? insertedACBuild
1129
+ : {
1130
+ data: {
1131
+ build: insertedACBuild.data,
1132
+ objectKey: acBuildKey,
1133
+ url: uploadUrl,
1134
+ },
1135
+ error: null,
1136
+ };
1137
+ break;
1138
+
1139
+ case "retrieveACBuilds":
1140
+ let acBuildsQuery = supabase
1141
+ .from("ac_builds")
1142
+ .select("*")
1143
+ .order("game_branch")
1144
+ .order("platform")
1145
+ .order("created_at", { ascending: false });
1146
+
1147
+ if (params.gameBranch) {
1148
+ acBuildsQuery = acBuildsQuery.eq("game_branch", params.gameBranch);
1149
+ }
1150
+
1151
+ if (params.platform) {
1152
+ acBuildsQuery = acBuildsQuery.eq("platform", params.platform);
1153
+ }
1154
+
1155
+ result = await acBuildsQuery;
1156
+ break;
1157
+
1158
+ case "downloadACBuild":
1159
+ const { data: build, error: buildError } = await supabase
1160
+ .from("ac_builds")
1161
+ .select("*")
1162
+ .eq("id", params.buildId)
1163
+ .single();
1164
+
1165
+ if (buildError) {
1166
+ result = { data: null, error: buildError };
1167
+ break;
1168
+ }
1169
+
1170
+ const objectKey = new URL(build.url).pathname.replace(
1171
+ `/${acBuildBucket}/`,
1172
+ ""
1173
+ );
1174
+ result = {
1175
+ data: {
1176
+ build,
1177
+ url: await getSignedUrl(
1178
+ acBuildS3Client,
1179
+ new GetObjectCommand({
1180
+ Bucket: acBuildBucket,
1181
+ Key: objectKey,
1182
+ }),
1183
+ { expiresIn: 3600 }
1184
+ ),
1185
+ },
1186
+ error: null,
1187
+ };
1188
+ break;
1189
+
1190
+ case "makeACBuildPrimary":
1191
+ result = await setPrimaryACBuild(params.buildId);
1192
+ break;
1193
+
1194
+ case "setACBuildActive":
1195
+ result = params.active
1196
+ ? await setPrimaryACBuild(params.buildId)
1197
+ : await supabase
1198
+ .from("ac_builds")
1199
+ .update({ active: false })
1200
+ .eq("id", params.buildId)
1201
+ .select("*")
1202
+ .single();
1203
+ break;
1204
+
1205
+ case "addChangelog":
1206
+ result = await supabase
1207
+ .from("changelogs")
1208
+ .insert({
1209
+ date: params.date,
1210
+ markdown: decodeURIComponent(params.markdown),
1211
+ name: params.date,
1212
+ type: params.type,
1213
+ })
1214
+ .select("*")
1215
+ .single();
1216
+ break;
1217
+
1218
+ case "removeChangelog":
1219
+ result = await supabase
1220
+ .from("changelogs")
1221
+ .delete()
1222
+ .eq("type", params.type)
1223
+ .eq("date", params.date)
1224
+ .select("*");
1225
+ break;
1226
+ }
1227
+
1228
+ // Log the admin action
1229
+ await supabase.rpc("admin_log_action", {
1230
+ admin_id: queryUserData.id,
1231
+ action_type: operation,
1232
+ target_id: "userId" in params ? params.userId : null,
1233
+ details: {
1234
+ params:
1235
+ operation === "addScoreViaReplay"
1236
+ ? { ...params, replayBytes: "<Long>" }
1237
+ : params,
1238
+ },
1239
+ });
1240
+ const adminActionDetails = getAdminActionDetails(operation, params);
1241
+
1242
+ if (!ADMIN_READ_OPERATIONS.has(operation)) {
1243
+ await postStaffAdminWebhook({
1244
+ admin: queryUserData,
1245
+ operation,
1246
+ targetUserId,
1247
+ details: adminActionDetails,
1248
+ error: result?.error?.message,
1249
+ });
1250
+
1251
+ // Log the admin action
1252
+ await supabase.rpc("admin_log_action", {
1253
+ admin_id: queryUserData.id,
1254
+ action_type: operation,
1255
+ target_id: "userId" in params ? params.userId : null,
1256
+ details: { params: adminActionDetails },
1257
+ });
1258
+ }
1259
+
1260
+ if (result?.error) {
1261
+ return NextResponse.json(
1262
+ {
1263
+ success: false,
1264
+ error: result.error.message,
1265
+ },
1266
+ { status: 500 }
1267
+ );
1268
+ }
1269
+
1270
+ if (
1271
+ targetUserId !== null &&
1272
+ !result?.error &&
1273
+ !ADMIN_READ_OPERATIONS.has(operation)
1274
+ ) {
1275
+ await invalidateCachePrefix(`userscore:${targetUserId}`);
1276
+ }
1277
+
1278
+ return NextResponse.json({
1279
+ success: true,
1280
+ result: result?.data,
1281
+ });
1282
+ } catch (err: any) {
1283
+ return NextResponse.json(
1284
+ {
1285
+ success: false,
1286
+ error: err.message || "An error occurred during the operation",
1287
+ },
1288
+ { status: 500 }
1289
+ );
1290
+ }
1291
+ }