rhythia-api 236.0.0 → 238.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.
@@ -7,6 +7,56 @@ import { getUserBySession } from "../utils/getUserBySession";
7
7
  import { User } from "@supabase/supabase-js";
8
8
  import { invalidateCache, invalidateCachePrefix } from "../utils/cache";
9
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
+
10
60
  // Define supported admin operations and their parameter types
11
61
  const adminOperations = {
12
62
  deleteUser: z.object({ userId: z.number() }),
@@ -28,6 +78,7 @@ const adminOperations = {
28
78
  userId: z.number().optional(),
29
79
  includeAdditionalData: z.boolean().default(true),
30
80
  }),
81
+ getMultiaccountInvestigation: z.object({ userId: z.number() }),
31
82
  } as const;
32
83
 
33
84
  // Create a discriminated union type for operation parameters
@@ -88,6 +139,10 @@ const OperationParam = z.discriminatedUnion("operation", [
88
139
  operation: z.literal("getScoresPaginated"),
89
140
  params: adminOperations.getScoresPaginated,
90
141
  }),
142
+ z.object({
143
+ operation: z.literal("getMultiaccountInvestigation"),
144
+ params: adminOperations.getMultiaccountInvestigation,
145
+ }),
91
146
  ]);
92
147
 
93
148
  export const Schema = {
@@ -346,6 +401,242 @@ export async function handler(
346
401
  };
347
402
  }
348
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;
349
640
  }
350
641
 
351
642
  // Log the admin action
@@ -366,7 +657,11 @@ export async function handler(
366
657
  );
367
658
  }
368
659
 
369
- if (targetUserId !== null && !result?.error) {
660
+ if (
661
+ targetUserId !== null &&
662
+ !result?.error &&
663
+ !SCORE_CACHE_READ_OPERATIONS.has(operation)
664
+ ) {
370
665
  await invalidateCachePrefix(`userscore:${targetUserId}`);
371
666
  }
372
667
 
@@ -9,16 +9,17 @@ export const Schema = {
9
9
  collection: z.number(),
10
10
  }),
11
11
  output: z.object({
12
- collection: z.object({
13
- title: z.string(),
14
- description: z.string(),
15
- owner: z.object({
16
- id: z.number(),
17
- username: z.string(),
18
- }),
19
- isList: z.boolean(),
20
- beatmaps: z.array(
21
- z.object({
12
+ collection: z.object({
13
+ title: z.string(),
14
+ description: z.string(),
15
+ owner: z.object({
16
+ id: z.number(),
17
+ username: z.string(),
18
+ avatar_url: z.string().nullable(),
19
+ }),
20
+ isList: z.boolean(),
21
+ beatmaps: z.array(
22
+ z.object({
22
23
  id: z.number(),
23
24
  playcount: z.number().nullable().optional(),
24
25
  created_at: z.string().nullable().optional(),
@@ -54,13 +55,14 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
54
55
  .from("beatmapCollections")
55
56
  .select(
56
57
  `
57
- *,
58
- profiles!inner(
59
- id,
60
- username
61
- )
62
- `
63
- )
58
+ *,
59
+ profiles!inner(
60
+ id,
61
+ username,
62
+ avatar_url
63
+ )
64
+ `
65
+ )
64
66
  .eq("id", data.collection)
65
67
  .single();
66
68
 
@@ -117,12 +119,13 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
117
119
 
118
120
  return NextResponse.json({
119
121
  collection: {
120
- owner: {
121
- username: queryCollectionData.profiles.username,
122
- id: queryCollectionData.profiles.id,
123
- },
124
- isList: queryCollectionData.is_list,
125
- title: queryCollectionData.title,
122
+ owner: {
123
+ username: queryCollectionData.profiles.username,
124
+ id: queryCollectionData.profiles.id,
125
+ avatar_url: queryCollectionData.profiles.avatar_url,
126
+ },
127
+ isList: queryCollectionData.is_list,
128
+ title: queryCollectionData.title,
126
129
  description: queryCollectionData.description,
127
130
  beatmaps: formattedBeatmaps,
128
131
  },
package/api/getProfile.ts CHANGED
@@ -40,11 +40,12 @@ export const Schema = {
40
40
  country_position: z.number().nullable(),
41
41
  activity_status: z.enum(["active", "inactive"]),
42
42
  is_online: z.boolean(),
43
+ last_active_timestamp: z.number().nullable(),
43
44
  clans: z
44
45
  .object({
45
46
  id: z.number(),
46
- acronym: z.string(),
47
- })
47
+ acronym: z.string(),
48
+ })
48
49
  .optional()
49
50
  .nullable(),
50
51
  })
@@ -122,11 +123,11 @@ export async function handler(
122
123
 
123
124
  const activityStatus = await getActivityStatusForUserId(user.id);
124
125
 
125
- const { data: activityData } = await supabase
126
- .from("profileActivities")
127
- .select("*")
128
- .eq("uid", user.uid || "")
129
- .single();
126
+ const { data: activityData } = await supabase
127
+ .from("profileActivities")
128
+ .select("last_activity")
129
+ .eq("uid", user.uid || "")
130
+ .single();
130
131
 
131
132
  //last 30 minutes
132
133
  if (activityData && activityData.last_activity) {
@@ -181,6 +182,7 @@ export async function handler(
181
182
  country_position: countryPosition,
182
183
  activity_status: activityStatus,
183
184
  is_online: isOnline,
185
+ last_active_timestamp: activityData?.last_activity ?? null,
184
186
  },
185
187
  });
186
188
  }
@@ -4,6 +4,7 @@ import { protectedApi, validUser } from "../utils/requestUtils";
4
4
  import { supabase } from "../utils/supabase";
5
5
  import { getUserBySession } from "../utils/getUserBySession";
6
6
  import { User } from "@supabase/supabase-js";
7
+ import { postProfileReportWebhook } from "../utils/profileReportWebhook";
7
8
 
8
9
  const MAX_DESCRIPTION_LENGTH = 1000;
9
10
 
@@ -50,7 +51,7 @@ export async function handler({
50
51
  const user = (await getUserBySession(session)) as User;
51
52
  const { data: reporterProfile } = await supabase
52
53
  .from("profiles")
53
- .select("id,ban")
54
+ .select("id,ban,username,computedUsername,avatar_url")
54
55
  .eq("uid", user.id)
55
56
  .single();
56
57
 
@@ -68,7 +69,7 @@ export async function handler({
68
69
 
69
70
  const { data: reportedProfile } = await supabase
70
71
  .from("profiles")
71
- .select("id")
72
+ .select("id,username,computedUsername,avatar_url")
72
73
  .eq("id", profileId)
73
74
  .single();
74
75
 
@@ -83,12 +84,20 @@ export async function handler({
83
84
  reported: reportedProfile.id,
84
85
  description: trimmedDescription,
85
86
  })
86
- .select("id")
87
+ .select("id,created_at")
87
88
  .single();
88
89
 
89
90
  if (insertResult.error) {
90
91
  return NextResponse.json({ error: insertResult.error.message });
91
92
  }
92
93
 
94
+ await postProfileReportWebhook({
95
+ reportId: insertResult.data.id,
96
+ reporter: reporterProfile,
97
+ reported: reportedProfile,
98
+ description: trimmedDescription,
99
+ createdAt: insertResult.data.created_at,
100
+ });
101
+
93
102
  return NextResponse.json({ id: insertResult.data?.id });
94
103
  }
package/index.ts CHANGED
@@ -362,17 +362,7 @@ export const enhancedSearch = handleApi({url:"/api/enhancedSearch",...EnhancedSe
362
362
  // ./api/executeAdminOperation.ts API
363
363
 
364
364
  /*
365
- export const Schema = {
366
- input: z.strictObject({
367
- session: z.string(),
368
- data: OperationParam,
369
- }),
370
- output: z.object({
371
- success: z.boolean(),
372
- result: z.any().optional(),
373
- error: z.string().optional(),
374
- }),
375
- };*/
365
+ */
376
366
  import { Schema as ExecuteAdminOperation } from "./api/executeAdminOperation"
377
367
  export { Schema as SchemaExecuteAdminOperation } from "./api/executeAdminOperation"
378
368
  export const executeAdminOperation = handleApi({url:"/api/executeAdminOperation",...ExecuteAdminOperation})
@@ -773,16 +763,17 @@ export const Schema = {
773
763
  collection: z.number(),
774
764
  }),
775
765
  output: z.object({
776
- collection: z.object({
777
- title: z.string(),
778
- description: z.string(),
779
- owner: z.object({
780
- id: z.number(),
781
- username: z.string(),
782
- }),
783
- isList: z.boolean(),
784
- beatmaps: z.array(
785
- z.object({
766
+ collection: z.object({
767
+ title: z.string(),
768
+ description: z.string(),
769
+ owner: z.object({
770
+ id: z.number(),
771
+ username: z.string(),
772
+ avatar_url: z.string().nullable(),
773
+ }),
774
+ isList: z.boolean(),
775
+ beatmaps: z.array(
776
+ z.object({
786
777
  id: z.number(),
787
778
  playcount: z.number().nullable().optional(),
788
779
  created_at: z.string().nullable().optional(),
@@ -1000,11 +991,12 @@ export const Schema = {
1000
991
  country_position: z.number().nullable(),
1001
992
  activity_status: z.enum(["active", "inactive"]),
1002
993
  is_online: z.boolean(),
994
+ last_active_timestamp: z.number().nullable(),
1003
995
  clans: z
1004
996
  .object({
1005
997
  id: z.number(),
1006
- acronym: z.string(),
1007
- })
998
+ acronym: z.string(),
999
+ })
1008
1000
  .optional()
1009
1001
  .nullable(),
1010
1002
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "236.0.0",
3
+ "version": "238.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {