rhythia-api 237.0.0 → 239.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.
@@ -2,25 +2,57 @@ import { NextResponse } from "../utils/response";
2
2
  import z from "zod";
3
3
  import { Database } from "../types/database";
4
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 validator from "validator";
9
- import removeZeroWidth from "zero-width";
10
-
11
- export const Schema = {
12
- input: z.strictObject({
13
- session: z.string(),
5
+ import { supabase } from "../utils/supabase";
6
+ import { getUserBySession } from "../utils/getUserBySession";
7
+ import { User } from "@supabase/supabase-js";
8
+ import validator from "validator";
9
+ import removeZeroWidth from "zero-width";
10
+
11
+ const USERNAME_CHANGE_COOLDOWN_ERROR =
12
+ "Username can only be changed once every 6 months";
13
+
14
+ function getNextUsernameChangeAt(
15
+ lastChangedAt: string | null | undefined
16
+ ): string | null {
17
+ if (!lastChangedAt) {
18
+ return null;
19
+ }
20
+
21
+ const nextAllowedAt = new Date(lastChangedAt);
22
+ nextAllowedAt.setUTCMonth(nextAllowedAt.getUTCMonth() + 6);
23
+ return nextAllowedAt.toISOString();
24
+ }
25
+
26
+ function formatUsernameChangeDate(date: string): string {
27
+ return new Intl.DateTimeFormat("en-US", {
28
+ year: "numeric",
29
+ month: "short",
30
+ day: "numeric",
31
+ }).format(new Date(date));
32
+ }
33
+
34
+ function isUsernameChangeLocked(
35
+ lastChangedAt: string | null | undefined,
36
+ now = Date.now()
37
+ ): boolean {
38
+ const nextAllowedAt = getNextUsernameChangeAt(lastChangedAt);
39
+ return nextAllowedAt !== null && new Date(nextAllowedAt).getTime() > now;
40
+ }
41
+
42
+ export const Schema = {
43
+ input: z.strictObject({
44
+ session: z.string(),
14
45
  data: z.object({
15
46
  avatar_url: z.string().optional(),
16
47
  profile_image: z.string().optional(),
17
48
  username: z.string().optional(),
18
49
  }),
19
- }),
20
- output: z.object({
21
- error: z.string().optional(),
22
- }),
23
- };
50
+ }),
51
+ output: z.object({
52
+ error: z.string().optional(),
53
+ next_username_change_at: z.string().nullable().optional(),
54
+ }),
55
+ };
24
56
 
25
57
  export async function POST(request: Request): Promise<NextResponse> {
26
58
  return protectedApi({
@@ -70,9 +102,9 @@ export async function handler(
70
102
  },
71
103
  { status: 404 }
72
104
  );
73
- }
74
-
75
- data.data.username = removeZeroWidth(data.data.username || "");
105
+ }
106
+
107
+ data.data.username = removeZeroWidth(data.data.username || "");
76
108
 
77
109
  const user = (await getUserBySession(data.session)) as User;
78
110
 
@@ -96,24 +128,62 @@ export async function handler(
96
128
  userData = queryUserData[0];
97
129
  }
98
130
 
99
- if (
100
- userData.ban == "excluded" ||
101
- userData.ban == "restricted" ||
102
- userData.ban == "silenced"
103
- ) {
131
+ if (
132
+ userData.ban == "excluded" ||
133
+ userData.ban == "restricted" ||
134
+ userData.ban == "silenced"
135
+ ) {
104
136
  return NextResponse.json(
105
137
  {
106
138
  error:
107
139
  "Silenced, restricted or excluded players can't update their profile.",
108
140
  },
109
141
  { status: 404 }
110
- );
111
- }
112
-
113
- const upsertPayload: Database["public"]["Tables"]["profiles"]["Update"] = {
114
- id: userData.id,
115
- computedUsername: data.data.username?.toLowerCase(),
116
- ...data.data,
142
+ );
143
+ }
144
+
145
+ const usernameHasChanged =
146
+ data.data.username !== undefined && data.data.username !== userData.username;
147
+
148
+ let nextUsernameChangeAt: string | null = null;
149
+
150
+ if (usernameHasChanged) {
151
+ const { data: usernameHistory, error: usernameHistoryError } = await supabase
152
+ .from("profileUsernames")
153
+ .select("changed_at")
154
+ .eq("profile_id", userData.id!)
155
+ .order("changed_at", { ascending: false })
156
+ .limit(1);
157
+
158
+ if (usernameHistoryError) {
159
+ return NextResponse.json(
160
+ {
161
+ error: "Could not verify username change availability.",
162
+ },
163
+ { status: 500 }
164
+ );
165
+ }
166
+
167
+ const lastChangedAt = usernameHistory?.[0]?.changed_at ?? null;
168
+ nextUsernameChangeAt = getNextUsernameChangeAt(lastChangedAt);
169
+
170
+ if (isUsernameChangeLocked(lastChangedAt)) {
171
+ return NextResponse.json(
172
+ {
173
+ error: `${USERNAME_CHANGE_COOLDOWN_ERROR}. Next change available ${formatUsernameChangeDate(
174
+ nextUsernameChangeAt!
175
+ )}.`,
176
+ next_username_change_at: nextUsernameChangeAt,
177
+ },
178
+ { status: 404 }
179
+ );
180
+ }
181
+ }
182
+
183
+ const upsertPayload: Database["public"]["Tables"]["profiles"]["Update"] = {
184
+ id: userData.id,
185
+ computedUsername: data.data.username?.toLowerCase(),
186
+ ...data.data,
117
187
  };
118
188
 
119
189
  const upsertResult = await supabase
@@ -121,14 +191,44 @@ export async function handler(
121
191
  .upsert(upsertPayload)
122
192
  .select();
123
193
 
124
- if (upsertResult.error) {
125
- return NextResponse.json(
126
- {
127
- error: "Can't update, username might be used by someone else!",
194
+ if (upsertResult.error) {
195
+ if (
196
+ usernameHasChanged &&
197
+ upsertResult.error.message.includes(USERNAME_CHANGE_COOLDOWN_ERROR)
198
+ ) {
199
+ if (!nextUsernameChangeAt) {
200
+ const { data: usernameHistory } = await supabase
201
+ .from("profileUsernames")
202
+ .select("changed_at")
203
+ .eq("profile_id", userData.id!)
204
+ .order("changed_at", { ascending: false })
205
+ .limit(1);
206
+
207
+ nextUsernameChangeAt = getNextUsernameChangeAt(
208
+ usernameHistory?.[0]?.changed_at ?? null
209
+ );
210
+ }
211
+
212
+ return NextResponse.json(
213
+ {
214
+ error: `${USERNAME_CHANGE_COOLDOWN_ERROR}. Next change available ${
215
+ nextUsernameChangeAt
216
+ ? formatUsernameChangeDate(nextUsernameChangeAt)
217
+ : "later"
218
+ }.`,
219
+ next_username_change_at: nextUsernameChangeAt,
220
+ },
221
+ { status: 404 }
222
+ );
223
+ }
224
+
225
+ return NextResponse.json(
226
+ {
227
+ error: "Can't update, username might be used by someone else!",
128
228
  },
129
229
  { status: 404 }
130
230
  );
131
231
  }
132
-
133
- return NextResponse.json({});
134
- }
232
+
233
+ return NextResponse.json({});
234
+ }
@@ -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
 
package/api/getProfile.ts CHANGED
@@ -5,14 +5,26 @@ import { Database } from "../types/database";
5
5
  import { protectedApi } from "../utils/requestUtils";
6
6
  import { supabase } from "../utils/supabase";
7
7
  import { getUserBySession } from "../utils/getUserBySession";
8
- import { User } from "@supabase/supabase-js";
9
- import {
10
- getActivityStatusForUserId,
11
- getScoreActivityCutoffIso,
12
- } from "../utils/activityStatus";
13
-
14
- export const Schema = {
15
- input: z.strictObject({
8
+ import { User } from "@supabase/supabase-js";
9
+ import {
10
+ getActivityStatusForUserId,
11
+ getScoreActivityCutoffIso,
12
+ } from "../utils/activityStatus";
13
+
14
+ function getNextUsernameChangeAt(
15
+ lastChangedAt: string | null | undefined
16
+ ): string | null {
17
+ if (!lastChangedAt) {
18
+ return null;
19
+ }
20
+
21
+ const nextAllowedAt = new Date(lastChangedAt);
22
+ nextAllowedAt.setUTCMonth(nextAllowedAt.getUTCMonth() + 6);
23
+ return nextAllowedAt.toISOString();
24
+ }
25
+
26
+ export const Schema = {
27
+ input: z.strictObject({
16
28
  session: z.string(),
17
29
  id: z.number().nullable().optional(),
18
30
  }),
@@ -40,11 +52,19 @@ export const Schema = {
40
52
  country_position: z.number().nullable(),
41
53
  activity_status: z.enum(["active", "inactive"]),
42
54
  is_online: z.boolean(),
55
+ last_active_timestamp: z.number().nullable(),
56
+ next_username_change_at: z.string().nullable(),
57
+ previous_usernames: z.array(
58
+ z.object({
59
+ username: z.string(),
60
+ changed_at: z.string(),
61
+ })
62
+ ),
43
63
  clans: z
44
64
  .object({
45
65
  id: z.number(),
46
- acronym: z.string(),
47
- })
66
+ acronym: z.string(),
67
+ })
48
68
  .optional()
49
69
  .nullable(),
50
70
  })
@@ -122,11 +142,17 @@ export async function handler(
122
142
 
123
143
  const activityStatus = await getActivityStatusForUserId(user.id);
124
144
 
125
- const { data: activityData } = await supabase
126
- .from("profileActivities")
127
- .select("*")
128
- .eq("uid", user.uid || "")
129
- .single();
145
+ const { data: activityData } = await supabase
146
+ .from("profileActivities")
147
+ .select("last_activity")
148
+ .eq("uid", user.uid || "")
149
+ .single();
150
+
151
+ const { data: usernameHistoryData } = await supabase
152
+ .from("profileUsernames")
153
+ .select("username,changed_at")
154
+ .eq("profile_id", user.id)
155
+ .order("changed_at", { ascending: false });
130
156
 
131
157
  //last 30 minutes
132
158
  if (activityData && activityData.last_activity) {
@@ -164,23 +190,32 @@ export async function handler(
164
190
  : null;
165
191
  }
166
192
 
167
- if (user.verificationDeadline < Date.now()) {
168
- await supabase
169
- .from("profiles")
170
- .upsert({
193
+ if (user.verificationDeadline < Date.now()) {
194
+ await supabase
195
+ .from("profiles")
196
+ .upsert({
171
197
  id: user.id,
172
198
  verified: false,
173
- })
174
- .select();
175
- }
176
-
177
- return NextResponse.json({
178
- user: {
199
+ })
200
+ .select();
201
+ }
202
+
203
+ const previousUsernames = (usernameHistoryData || []).filter((entry) => {
204
+ return entry.username.toLowerCase() !== (user.username || "").toLowerCase();
205
+ });
206
+
207
+ const latestUsernameChangeAt = usernameHistoryData?.[0]?.changed_at ?? null;
208
+
209
+ return NextResponse.json({
210
+ user: {
179
211
  ...user,
180
212
  position,
181
213
  country_position: countryPosition,
182
214
  activity_status: activityStatus,
183
215
  is_online: isOnline,
216
+ last_active_timestamp: activityData?.last_activity ?? null,
217
+ next_username_change_at: getNextUsernameChangeAt(latestUsernameChangeAt),
218
+ previous_usernames: previousUsernames,
184
219
  },
185
220
  });
186
221
  }