rhythia-api 238.0.0 → 240.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,26 +1,63 @@
1
1
  import { NextResponse } from "../utils/response";
2
2
  import z from "zod";
3
3
  import { Database } from "../types/database";
4
- import { protectedApi, validUser } from "../utils/requestUtils";
5
- import { supabase } from "../utils/supabase";
6
- import { getUserBySession } from "../utils/getUserBySession";
7
- import { User } from "@supabase/supabase-js";
8
- import validator from "validator";
9
- import removeZeroWidth from "zero-width";
10
-
11
- export const Schema = {
12
- input: z.strictObject({
13
- session: z.string(),
14
- data: z.object({
15
- avatar_url: z.string().optional(),
16
- profile_image: z.string().optional(),
17
- username: z.string().optional(),
18
- }),
19
- }),
20
- output: z.object({
21
- error: z.string().optional(),
22
- }),
23
- };
4
+ import { protectedApi, validUser } from "../utils/requestUtils";
5
+ import { supabase } from "../utils/supabase";
6
+ import { getUserBySession } from "../utils/getUserBySession";
7
+ import { COUNTRY_LIST } from "../utils/countryList";
8
+ import { User } from "@supabase/supabase-js";
9
+ import validator from "validator";
10
+ import removeZeroWidth from "zero-width";
11
+
12
+ const USERNAME_CHANGE_COOLDOWN_ERROR =
13
+ "Username can only be changed once every 6 months";
14
+ const FLAG_CHANGE_ERROR = "Flag can only be changed once";
15
+ const countryCodes = new Set(COUNTRY_LIST);
16
+
17
+ function getNextUsernameChangeAt(
18
+ lastChangedAt: string | null | undefined
19
+ ): string | null {
20
+ if (!lastChangedAt) {
21
+ return null;
22
+ }
23
+
24
+ const nextAllowedAt = new Date(lastChangedAt);
25
+ nextAllowedAt.setUTCMonth(nextAllowedAt.getUTCMonth() + 6);
26
+ return nextAllowedAt.toISOString();
27
+ }
28
+
29
+ function formatUsernameChangeDate(date: string): string {
30
+ return new Intl.DateTimeFormat("en-US", {
31
+ year: "numeric",
32
+ month: "short",
33
+ day: "numeric",
34
+ }).format(new Date(date));
35
+ }
36
+
37
+ function isUsernameChangeLocked(
38
+ lastChangedAt: string | null | undefined,
39
+ now = Date.now()
40
+ ): boolean {
41
+ const nextAllowedAt = getNextUsernameChangeAt(lastChangedAt);
42
+ return nextAllowedAt !== null && new Date(nextAllowedAt).getTime() > now;
43
+ }
44
+
45
+ export const Schema = {
46
+ input: z.strictObject({
47
+ session: z.string(),
48
+ data: z.object({
49
+ avatar_url: z.string().optional(),
50
+ flag: z.string().optional(),
51
+ profile_image: z.string().optional(),
52
+ username: z.string().optional(),
53
+ }),
54
+ }),
55
+ output: z.object({
56
+ error: z.string().optional(),
57
+ can_change_flag: z.boolean().optional(),
58
+ next_username_change_at: z.string().nullable().optional(),
59
+ }),
60
+ };
24
61
 
25
62
  export async function POST(request: Request): Promise<NextResponse> {
26
63
  return protectedApi({
@@ -63,18 +100,33 @@ export async function handler(
63
100
  }
64
101
  }
65
102
 
66
- if (validator.trim(data.data.username || "") !== (data.data.username || "")) {
67
- return NextResponse.json(
68
- {
69
- error: "Username can't start or end with spaces.",
70
- },
103
+ if (validator.trim(data.data.username || "") !== (data.data.username || "")) {
104
+ return NextResponse.json(
105
+ {
106
+ error: "Username can't start or end with spaces.",
107
+ },
71
108
  { status: 404 }
72
109
  );
73
- }
74
-
75
- data.data.username = removeZeroWidth(data.data.username || "");
76
-
77
- const user = (await getUserBySession(data.session)) as User;
110
+ }
111
+
112
+ data.data.username = removeZeroWidth(data.data.username || "");
113
+
114
+ if (data.data.flag !== undefined) {
115
+ const normalizedFlag = validator.trim(data.data.flag).toUpperCase();
116
+
117
+ if (!countryCodes.has(normalizedFlag as (typeof COUNTRY_LIST)[number])) {
118
+ return NextResponse.json(
119
+ {
120
+ error: "Flag must be a valid country code.",
121
+ },
122
+ { status: 404 }
123
+ );
124
+ }
125
+
126
+ data.data.flag = normalizedFlag;
127
+ }
128
+
129
+ const user = (await getUserBySession(data.session)) as User;
78
130
 
79
131
  let userData: Database["public"]["Tables"]["profiles"]["Update"];
80
132
 
@@ -96,24 +148,94 @@ export async function handler(
96
148
  userData = queryUserData[0];
97
149
  }
98
150
 
99
- if (
100
- userData.ban == "excluded" ||
101
- userData.ban == "restricted" ||
102
- userData.ban == "silenced"
103
- ) {
151
+ if (
152
+ userData.ban == "excluded" ||
153
+ userData.ban == "restricted" ||
154
+ userData.ban == "silenced"
155
+ ) {
104
156
  return NextResponse.json(
105
157
  {
106
158
  error:
107
159
  "Silenced, restricted or excluded players can't update their profile.",
108
160
  },
109
161
  { 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,
162
+ );
163
+ }
164
+
165
+ const usernameHasChanged =
166
+ data.data.username !== undefined && data.data.username !== userData.username;
167
+ const flagHasChanged =
168
+ data.data.flag !== undefined && data.data.flag !== userData.flag;
169
+
170
+ let nextUsernameChangeAt: string | null = null;
171
+ let canChangeFlag = true;
172
+
173
+ if (usernameHasChanged) {
174
+ const { data: usernameHistory, error: usernameHistoryError } = await supabase
175
+ .from("profileUsernames")
176
+ .select("changed_at")
177
+ .eq("profile_id", userData.id!)
178
+ .order("changed_at", { ascending: false })
179
+ .limit(1);
180
+
181
+ if (usernameHistoryError) {
182
+ return NextResponse.json(
183
+ {
184
+ error: "Could not verify username change availability.",
185
+ },
186
+ { status: 500 }
187
+ );
188
+ }
189
+
190
+ const lastChangedAt = usernameHistory?.[0]?.changed_at ?? null;
191
+ nextUsernameChangeAt = getNextUsernameChangeAt(lastChangedAt);
192
+
193
+ if (isUsernameChangeLocked(lastChangedAt)) {
194
+ return NextResponse.json(
195
+ {
196
+ error: `${USERNAME_CHANGE_COOLDOWN_ERROR}. Next change available ${formatUsernameChangeDate(
197
+ nextUsernameChangeAt!
198
+ )}.`,
199
+ next_username_change_at: nextUsernameChangeAt,
200
+ },
201
+ { status: 404 }
202
+ );
203
+ }
204
+ }
205
+
206
+ if (flagHasChanged) {
207
+ const { data: flagHistory, error: flagHistoryError } = await supabase
208
+ .from("profileFlags")
209
+ .select("id")
210
+ .eq("profile_id", userData.id!)
211
+ .limit(1);
212
+
213
+ if (flagHistoryError) {
214
+ return NextResponse.json(
215
+ {
216
+ error: "Could not verify flag change availability.",
217
+ },
218
+ { status: 500 }
219
+ );
220
+ }
221
+
222
+ canChangeFlag = !flagHistory?.length;
223
+
224
+ if (!canChangeFlag) {
225
+ return NextResponse.json(
226
+ {
227
+ error: FLAG_CHANGE_ERROR,
228
+ can_change_flag: false,
229
+ },
230
+ { status: 404 }
231
+ );
232
+ }
233
+ }
234
+
235
+ const upsertPayload: Database["public"]["Tables"]["profiles"]["Update"] = {
236
+ id: userData.id,
237
+ computedUsername: data.data.username?.toLowerCase(),
238
+ ...data.data,
117
239
  };
118
240
 
119
241
  const upsertResult = await supabase
@@ -121,14 +243,54 @@ export async function handler(
121
243
  .upsert(upsertPayload)
122
244
  .select();
123
245
 
124
- if (upsertResult.error) {
125
- return NextResponse.json(
126
- {
127
- error: "Can't update, username might be used by someone else!",
246
+ if (upsertResult.error) {
247
+ if (
248
+ usernameHasChanged &&
249
+ upsertResult.error.message.includes(USERNAME_CHANGE_COOLDOWN_ERROR)
250
+ ) {
251
+ if (!nextUsernameChangeAt) {
252
+ const { data: usernameHistory } = await supabase
253
+ .from("profileUsernames")
254
+ .select("changed_at")
255
+ .eq("profile_id", userData.id!)
256
+ .order("changed_at", { ascending: false })
257
+ .limit(1);
258
+
259
+ nextUsernameChangeAt = getNextUsernameChangeAt(
260
+ usernameHistory?.[0]?.changed_at ?? null
261
+ );
262
+ }
263
+
264
+ return NextResponse.json(
265
+ {
266
+ error: `${USERNAME_CHANGE_COOLDOWN_ERROR}. Next change available ${
267
+ nextUsernameChangeAt
268
+ ? formatUsernameChangeDate(nextUsernameChangeAt)
269
+ : "later"
270
+ }.`,
271
+ next_username_change_at: nextUsernameChangeAt,
272
+ },
273
+ { status: 404 }
274
+ );
275
+ }
276
+
277
+ if (flagHasChanged && upsertResult.error.message.includes(FLAG_CHANGE_ERROR)) {
278
+ return NextResponse.json(
279
+ {
280
+ error: FLAG_CHANGE_ERROR,
281
+ can_change_flag: false,
282
+ },
283
+ { status: 404 }
284
+ );
285
+ }
286
+
287
+ return NextResponse.json(
288
+ {
289
+ error: "Can't update, username might be used by someone else!",
128
290
  },
129
291
  { status: 404 }
130
292
  );
131
293
  }
132
-
133
- return NextResponse.json({});
134
- }
294
+
295
+ return NextResponse.json({});
296
+ }
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
  }),
@@ -41,6 +53,14 @@ export const Schema = {
41
53
  activity_status: z.enum(["active", "inactive"]),
42
54
  is_online: z.boolean(),
43
55
  last_active_timestamp: z.number().nullable(),
56
+ can_change_flag: z.boolean(),
57
+ next_username_change_at: z.string().nullable(),
58
+ previous_usernames: z.array(
59
+ z.object({
60
+ username: z.string(),
61
+ changed_at: z.string(),
62
+ })
63
+ ),
44
64
  clans: z
45
65
  .object({
46
66
  id: z.number(),
@@ -128,6 +148,18 @@ export async function handler(
128
148
  .select("last_activity")
129
149
  .eq("uid", user.uid || "")
130
150
  .single();
151
+
152
+ const { data: usernameHistoryData } = await supabase
153
+ .from("profileUsernames")
154
+ .select("username,changed_at")
155
+ .eq("profile_id", user.id)
156
+ .order("changed_at", { ascending: false });
157
+
158
+ const { data: flagHistoryData } = await supabase
159
+ .from("profileFlags")
160
+ .select("id")
161
+ .eq("profile_id", user.id)
162
+ .limit(1);
131
163
 
132
164
  //last 30 minutes
133
165
  if (activityData && activityData.last_activity) {
@@ -165,24 +197,33 @@ export async function handler(
165
197
  : null;
166
198
  }
167
199
 
168
- if (user.verificationDeadline < Date.now()) {
169
- await supabase
170
- .from("profiles")
171
- .upsert({
200
+ if (user.verificationDeadline < Date.now()) {
201
+ await supabase
202
+ .from("profiles")
203
+ .upsert({
172
204
  id: user.id,
173
205
  verified: false,
174
- })
175
- .select();
176
- }
177
-
178
- return NextResponse.json({
179
- user: {
206
+ })
207
+ .select();
208
+ }
209
+
210
+ const previousUsernames = (usernameHistoryData || []).filter((entry) => {
211
+ return entry.username.toLowerCase() !== (user.username || "").toLowerCase();
212
+ });
213
+
214
+ const latestUsernameChangeAt = usernameHistoryData?.[0]?.changed_at ?? null;
215
+
216
+ return NextResponse.json({
217
+ user: {
180
218
  ...user,
181
219
  position,
182
220
  country_position: countryPosition,
183
221
  activity_status: activityStatus,
184
222
  is_online: isOnline,
185
223
  last_active_timestamp: activityData?.last_activity ?? null,
224
+ can_change_flag: !flagHistoryData?.length,
225
+ next_username_change_at: getNextUsernameChangeAt(latestUsernameChangeAt),
226
+ previous_usernames: previousUsernames,
186
227
  },
187
228
  });
188
229
  }
package/index.ts CHANGED
@@ -301,18 +301,21 @@ export const editCollection = handleApi({url:"/api/editCollection",...EditCollec
301
301
  // ./api/editProfile.ts API
302
302
 
303
303
  /*
304
- export const Schema = {
305
- input: z.strictObject({
306
- session: z.string(),
307
- data: z.object({
308
- avatar_url: z.string().optional(),
309
- profile_image: z.string().optional(),
310
- username: z.string().optional(),
311
- }),
312
- }),
313
- output: z.object({
314
- error: z.string().optional(),
315
- }),
304
+ export const Schema = {
305
+ input: z.strictObject({
306
+ session: z.string(),
307
+ data: z.object({
308
+ avatar_url: z.string().optional(),
309
+ flag: z.string().optional(),
310
+ profile_image: z.string().optional(),
311
+ username: z.string().optional(),
312
+ }),
313
+ }),
314
+ output: z.object({
315
+ error: z.string().optional(),
316
+ can_change_flag: z.boolean().optional(),
317
+ next_username_change_at: z.string().nullable().optional(),
318
+ }),
316
319
  };*/
317
320
  import { Schema as EditProfile } from "./api/editProfile"
318
321
  export { Schema as SchemaEditProfile } from "./api/editProfile"
@@ -962,8 +965,8 @@ export const getPassToken = handleApi({url:"/api/getPassToken",...GetPassToken})
962
965
  // ./api/getProfile.ts API
963
966
 
964
967
  /*
965
- export const Schema = {
966
- input: z.strictObject({
968
+ export const Schema = {
969
+ input: z.strictObject({
967
970
  session: z.string(),
968
971
  id: z.number().nullable().optional(),
969
972
  }),
@@ -992,6 +995,14 @@ export const Schema = {
992
995
  activity_status: z.enum(["active", "inactive"]),
993
996
  is_online: z.boolean(),
994
997
  last_active_timestamp: z.number().nullable(),
998
+ can_change_flag: z.boolean(),
999
+ next_username_change_at: z.string().nullable(),
1000
+ previous_usernames: z.array(
1001
+ z.object({
1002
+ username: z.string(),
1003
+ changed_at: z.string(),
1004
+ })
1005
+ ),
995
1006
  clans: z
996
1007
  .object({
997
1008
  id: z.number(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "238.0.0",
3
+ "version": "240.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
@@ -10,6 +10,7 @@
10
10
  "test": "tsx ./scripts/test.ts",
11
11
  "cache:clear-user-scores": "bun run scripts/clear-user-score-cache.ts",
12
12
  "db:optimize-user-scores": "bun run scripts/optimize-user-scores-indexes.ts",
13
+ "db:create-profile-flags": "node scripts/create-profile-flags-table.ts",
13
14
  "db:create-profile-reports": "node scripts/create-profile-reports-table.ts",
14
15
  "query-pull": "bun run scripts/pull-queries.ts",
15
16
  "query-push": "bun run scripts/deploy-queries.ts",
package/types/database.ts CHANGED
@@ -558,6 +558,64 @@ export type Database = {
558
558
  },
559
559
  ]
560
560
  }
561
+ profileFlags: {
562
+ Row: {
563
+ changed_at: string
564
+ flag: string | null
565
+ id: number
566
+ profile_id: number
567
+ }
568
+ Insert: {
569
+ changed_at?: string
570
+ flag?: string | null
571
+ id?: number
572
+ profile_id: number
573
+ }
574
+ Update: {
575
+ changed_at?: string
576
+ flag?: string | null
577
+ id?: number
578
+ profile_id?: number
579
+ }
580
+ Relationships: [
581
+ {
582
+ foreignKeyName: "profileFlags_profile_id_fkey"
583
+ columns: ["profile_id"]
584
+ isOneToOne: false
585
+ referencedRelation: "profiles"
586
+ referencedColumns: ["id"]
587
+ },
588
+ ]
589
+ }
590
+ profileUsernames: {
591
+ Row: {
592
+ changed_at: string
593
+ id: number
594
+ profile_id: number
595
+ username: string
596
+ }
597
+ Insert: {
598
+ changed_at?: string
599
+ id?: number
600
+ profile_id: number
601
+ username: string
602
+ }
603
+ Update: {
604
+ changed_at?: string
605
+ id?: number
606
+ profile_id?: number
607
+ username?: string
608
+ }
609
+ Relationships: [
610
+ {
611
+ foreignKeyName: "profileUsernames_profile_id_fkey"
612
+ columns: ["profile_id"]
613
+ isOneToOne: false
614
+ referencedRelation: "profiles"
615
+ referencedColumns: ["id"]
616
+ },
617
+ ]
618
+ }
561
619
  profiles: {
562
620
  Row: {
563
621
  about_me: string | null