rhythia-api 240.0.0 → 242.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,6 +1,6 @@
1
- import { NextResponse } from "../utils/response";
2
- import z from "zod";
3
- import { Database } from "../types/database";
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { Database } from "../types/database";
4
4
  import { protectedApi, validUser } from "../utils/requestUtils";
5
5
  import { supabase } from "../utils/supabase";
6
6
  import { getUserBySession } from "../utils/getUserBySession";
@@ -58,58 +58,69 @@ export const Schema = {
58
58
  next_username_change_at: z.string().nullable().optional(),
59
59
  }),
60
60
  };
61
-
62
- export async function POST(request: Request): Promise<NextResponse> {
63
- return protectedApi({
64
- request,
65
- schema: Schema,
66
- authorization: validUser,
67
- activity: handler,
68
- });
69
- }
70
-
71
- export async function handler(
72
- data: (typeof Schema)["input"]["_type"]
73
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
74
- if (data.data.username !== undefined) {
75
- if (data.data.username.length < 3) {
76
- return NextResponse.json(
77
- {
78
- error: "Username must be at least 3 characters long",
79
- },
80
- { status: 404 }
81
- );
82
- }
83
-
84
- if (data.data.username.length > 20) {
85
- return NextResponse.json(
86
- {
87
- error: "Username too long.",
88
- },
89
- { status: 404 }
90
- );
91
- }
92
-
93
- if (!/^[a-z0-9]+$/i.test(data.data.username)) {
94
- return NextResponse.json(
95
- {
96
- error: "Username can only contain letters (a-z) and numbers (0-9)",
97
- },
98
- { status: 404 }
99
- );
100
- }
101
- }
102
-
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
- },
108
- { status: 404 }
109
- );
110
- }
111
61
 
112
- data.data.username = removeZeroWidth(data.data.username || "");
62
+ export async function POST(request: Request): Promise<NextResponse> {
63
+ return protectedApi({
64
+ request,
65
+ schema: Schema,
66
+ authorization: validUser,
67
+ activity: handler,
68
+ });
69
+ }
70
+
71
+ export async function handler(
72
+ data: (typeof Schema)["input"]["_type"]
73
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
74
+ if (data.data.username !== undefined) {
75
+ data.data.username = removeZeroWidth(data.data.username);
76
+
77
+ if (validator.trim(data.data.username) !== data.data.username) {
78
+ return NextResponse.json(
79
+ {
80
+ error: "Username can't start or end with spaces.",
81
+ },
82
+ { status: 404 }
83
+ );
84
+ }
85
+
86
+ if (data.data.username.length < 2) {
87
+ return NextResponse.json(
88
+ {
89
+ error: "Username must be at least 2 characters long",
90
+ },
91
+ { status: 404 }
92
+ );
93
+ }
94
+
95
+ if (data.data.username.length > 16) {
96
+ return NextResponse.json(
97
+ {
98
+ error: "Username must be 16 characters or fewer.",
99
+ },
100
+ { status: 404 }
101
+ );
102
+ }
103
+
104
+ if (!/^[A-Za-z0-9._]+$/.test(data.data.username)) {
105
+ return NextResponse.json(
106
+ {
107
+ error:
108
+ "Username can only contain English letters (a-z), numbers (0-9), and up to one '_' or '.'",
109
+ },
110
+ { status: 404 }
111
+ );
112
+ }
113
+
114
+ const specialCharacters = data.data.username.match(/[._]/g) ?? [];
115
+ if (specialCharacters.length > 1) {
116
+ return NextResponse.json(
117
+ {
118
+ error: "Username can contain at most one '_' or one '.'",
119
+ },
120
+ { status: 404 }
121
+ );
122
+ }
123
+ }
113
124
 
114
125
  if (data.data.flag !== undefined) {
115
126
  const normalizedFlag = validator.trim(data.data.flag).toUpperCase();
@@ -127,38 +138,38 @@ export async function handler(
127
138
  }
128
139
 
129
140
  const user = (await getUserBySession(data.session)) as User;
130
-
131
- let userData: Database["public"]["Tables"]["profiles"]["Update"];
132
-
133
- // Find user's entry
134
- {
135
- let { data: queryUserData, error } = await supabase
136
- .from("profiles")
137
- .select("*")
138
- .eq("uid", user.id);
139
-
140
- if (!queryUserData?.length) {
141
- return NextResponse.json(
142
- {
143
- error: "User cannot be retrieved from session",
144
- },
145
- { status: 404 }
146
- );
147
- }
148
- userData = queryUserData[0];
149
- }
150
-
141
+
142
+ let userData: Database["public"]["Tables"]["profiles"]["Update"];
143
+
144
+ // Find user's entry
145
+ {
146
+ let { data: queryUserData, error } = await supabase
147
+ .from("profiles")
148
+ .select("*")
149
+ .eq("uid", user.id);
150
+
151
+ if (!queryUserData?.length) {
152
+ return NextResponse.json(
153
+ {
154
+ error: "User cannot be retrieved from session",
155
+ },
156
+ { status: 404 }
157
+ );
158
+ }
159
+ userData = queryUserData[0];
160
+ }
161
+
151
162
  if (
152
163
  userData.ban == "excluded" ||
153
164
  userData.ban == "restricted" ||
154
165
  userData.ban == "silenced"
155
166
  ) {
156
- return NextResponse.json(
157
- {
158
- error:
159
- "Silenced, restricted or excluded players can't update their profile.",
160
- },
161
- { status: 404 }
167
+ return NextResponse.json(
168
+ {
169
+ error:
170
+ "Silenced, restricted or excluded players can't update their profile.",
171
+ },
172
+ { status: 404 }
162
173
  );
163
174
  }
164
175
 
@@ -236,13 +247,13 @@ export async function handler(
236
247
  id: userData.id,
237
248
  computedUsername: data.data.username?.toLowerCase(),
238
249
  ...data.data,
239
- };
240
-
241
- const upsertResult = await supabase
242
- .from("profiles")
243
- .upsert(upsertPayload)
244
- .select();
245
-
250
+ };
251
+
252
+ const upsertResult = await supabase
253
+ .from("profiles")
254
+ .upsert(upsertPayload)
255
+ .select();
256
+
246
257
  if (upsertResult.error) {
247
258
  if (
248
259
  usernameHasChanged &&
@@ -287,10 +298,10 @@ export async function handler(
287
298
  return NextResponse.json(
288
299
  {
289
300
  error: "Can't update, username might be used by someone else!",
290
- },
291
- { status: 404 }
292
- );
293
- }
301
+ },
302
+ { status: 404 }
303
+ );
304
+ }
294
305
 
295
306
  return NextResponse.json({});
296
307
  }
@@ -0,0 +1,122 @@
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+ import { getUserBySession } from "../utils/getUserBySession";
6
+ import { User } from "@supabase/supabase-js";
7
+
8
+ const friendStateSchema = z.enum(["none", "added", "mutual"]);
9
+
10
+ export const Schema = {
11
+ input: z.strictObject({
12
+ session: z.string(),
13
+ profileId: z.number(),
14
+ action: z.enum(["add", "remove"]),
15
+ }),
16
+ output: z.strictObject({
17
+ error: z.string().optional(),
18
+ friend_count: z.number().optional(),
19
+ friend_state: friendStateSchema.optional(),
20
+ }),
21
+ };
22
+
23
+ async function getFriendState(viewerProfileId: number, profileId: number) {
24
+ const [{ count: addedCount }, { count: mutualCount }] = await Promise.all([
25
+ supabase
26
+ .from("profileFriends")
27
+ .select("*", { count: "exact", head: true })
28
+ .eq("profile_id", viewerProfileId)
29
+ .eq("friend_id", profileId),
30
+ supabase
31
+ .from("profileFriends")
32
+ .select("*", { count: "exact", head: true })
33
+ .eq("profile_id", profileId)
34
+ .eq("friend_id", viewerProfileId),
35
+ ]);
36
+
37
+ if (!addedCount) {
38
+ return "none";
39
+ }
40
+
41
+ return mutualCount ? "mutual" : "added";
42
+ }
43
+
44
+ export async function POST(request: Request): Promise<NextResponse> {
45
+ return protectedApi({
46
+ request,
47
+ schema: Schema,
48
+ authorization: validUser,
49
+ activity: handler,
50
+ });
51
+ }
52
+
53
+ export async function handler({
54
+ session,
55
+ profileId,
56
+ action,
57
+ }: (typeof Schema)["input"]["_type"]): Promise<
58
+ NextResponse<(typeof Schema)["output"]["_type"]>
59
+ > {
60
+ const user = (await getUserBySession(session)) as User;
61
+
62
+ const { data: viewerProfile } = await supabase
63
+ .from("profiles")
64
+ .select("id")
65
+ .eq("uid", user.id)
66
+ .single();
67
+
68
+ if (!viewerProfile) {
69
+ return NextResponse.json({ error: "Can't find user" });
70
+ }
71
+
72
+ if (viewerProfile.id === profileId) {
73
+ return NextResponse.json({ error: "You can't friend yourself." });
74
+ }
75
+
76
+ const { data: targetProfile } = await supabase
77
+ .from("profiles")
78
+ .select("id")
79
+ .eq("id", profileId)
80
+ .single();
81
+
82
+ if (!targetProfile) {
83
+ return NextResponse.json({ error: "Player not found." });
84
+ }
85
+
86
+ const relation = {
87
+ profile_id: viewerProfile.id,
88
+ friend_id: targetProfile.id,
89
+ };
90
+
91
+ const mutation =
92
+ action === "add"
93
+ ? await supabase
94
+ .from("profileFriends")
95
+ .upsert(relation, {
96
+ onConflict: "profile_id,friend_id",
97
+ ignoreDuplicates: true,
98
+ })
99
+ : await supabase
100
+ .from("profileFriends")
101
+ .delete()
102
+ .eq("profile_id", relation.profile_id)
103
+ .eq("friend_id", relation.friend_id);
104
+
105
+ if (mutation.error) {
106
+ return NextResponse.json({ error: mutation.error.message });
107
+ }
108
+
109
+ const [friendCount, friendState] = await Promise.all([
110
+ supabase
111
+ .from("profileFriends")
112
+ .select("*", { count: "exact", head: true })
113
+ .eq("friend_id", targetProfile.id)
114
+ .then((result) => result.count || 0),
115
+ getFriendState(viewerProfile.id, targetProfile.id),
116
+ ]);
117
+
118
+ return NextResponse.json({
119
+ friend_count: friendCount,
120
+ friend_state: friendState,
121
+ });
122
+ }
@@ -0,0 +1,154 @@
1
+ import { NextResponse } from "../utils/response";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+ import { getUserBySession } from "../utils/getUserBySession";
6
+ import { User } from "@supabase/supabase-js";
7
+
8
+ const friendUserSchema = z.object({
9
+ id: z.number(),
10
+ username: z.string().nullable(),
11
+ avatar_url: z.string().nullable(),
12
+ flag: z.string().nullable(),
13
+ profile_image: z.string().nullable(),
14
+ is_online: z.boolean(),
15
+ last_active_timestamp: z.number().nullable(),
16
+ is_mutual: z.boolean(),
17
+ });
18
+
19
+ export const Schema = {
20
+ input: z.strictObject({
21
+ session: z.string(),
22
+ }),
23
+ output: z.strictObject({
24
+ error: z.string().optional(),
25
+ friends: z.array(friendUserSchema),
26
+ }),
27
+ };
28
+
29
+ export async function POST(request: Request): Promise<NextResponse> {
30
+ return protectedApi({
31
+ request,
32
+ schema: Schema,
33
+ authorization: validUser,
34
+ activity: handler,
35
+ });
36
+ }
37
+
38
+ export async function handler({
39
+ session,
40
+ }: (typeof Schema)["input"]["_type"]): Promise<
41
+ NextResponse<(typeof Schema)["output"]["_type"]>
42
+ > {
43
+ const user = (await getUserBySession(session)) as User;
44
+
45
+ const { data: viewerProfile } = await supabase
46
+ .from("profiles")
47
+ .select("id")
48
+ .eq("uid", user.id)
49
+ .single();
50
+
51
+ if (!viewerProfile) {
52
+ return NextResponse.json({ error: "Can't find user", friends: [] });
53
+ }
54
+
55
+ const { data: outgoingRows, error: outgoingError } = await supabase
56
+ .from("profileFriends")
57
+ .select("friend_id,created_at")
58
+ .eq("profile_id", viewerProfile.id)
59
+ .order("created_at", { ascending: false });
60
+
61
+ if (outgoingError) {
62
+ return NextResponse.json({
63
+ error: outgoingError.message,
64
+ friends: [],
65
+ });
66
+ }
67
+
68
+ const outgoingIds = (outgoingRows || []).map((row) => row.friend_id);
69
+
70
+ if (!outgoingIds.length) {
71
+ return NextResponse.json({ friends: [] });
72
+ }
73
+
74
+ const [{ data: reverseRows, error: reverseError }, { data: profiles, error: profilesError }] =
75
+ await Promise.all([
76
+ supabase
77
+ .from("profileFriends")
78
+ .select("profile_id")
79
+ .in("profile_id", outgoingIds)
80
+ .eq("friend_id", viewerProfile.id),
81
+ supabase
82
+ .from("profiles")
83
+ .select("id,uid,username,avatar_url,flag,profile_image")
84
+ .in("id", outgoingIds)
85
+ .neq("ban", "excluded"),
86
+ ]);
87
+
88
+ if (reverseError) {
89
+ return NextResponse.json({
90
+ error: reverseError.message,
91
+ friends: [],
92
+ });
93
+ }
94
+
95
+ if (profilesError) {
96
+ return NextResponse.json({
97
+ error: profilesError.message,
98
+ friends: [],
99
+ });
100
+ }
101
+
102
+ const activityUids = (profiles || [])
103
+ .map((profile) => profile.uid)
104
+ .filter((uid): uid is string => !!uid);
105
+
106
+ const { data: activities, error: activitiesError } = activityUids.length
107
+ ? await supabase
108
+ .from("profileActivities")
109
+ .select("uid,last_activity")
110
+ .in("uid", activityUids)
111
+ : { data: [], error: null };
112
+
113
+ if (activitiesError) {
114
+ return NextResponse.json({
115
+ error: activitiesError.message,
116
+ friends: [],
117
+ });
118
+ }
119
+
120
+ const profileMap = new Map((profiles || []).map((profile) => [profile.id, profile]));
121
+ const mutualIds = new Set((reverseRows || []).map((row) => row.profile_id));
122
+ const activityMap = new Map(
123
+ (activities || []).map((activity) => [activity.uid, activity.last_activity])
124
+ );
125
+ const addedUsers = outgoingIds
126
+ .map((friendId) => profileMap.get(friendId))
127
+ .filter(
128
+ (
129
+ profile
130
+ ): profile is {
131
+ id: number;
132
+ uid: string | null;
133
+ username: string | null;
134
+ avatar_url: string | null;
135
+ flag: string | null;
136
+ profile_image: string | null;
137
+ } => !!profile
138
+ );
139
+
140
+ return NextResponse.json({
141
+ friends: addedUsers.map((profile) => ({
142
+ id: profile.id,
143
+ username: profile.username,
144
+ avatar_url: profile.avatar_url,
145
+ flag: profile.flag,
146
+ profile_image: profile.profile_image,
147
+ is_online:
148
+ typeof activityMap.get(profile.uid || "") === "number" &&
149
+ Date.now() - (activityMap.get(profile.uid || "") || 0) < 1800000,
150
+ last_active_timestamp: activityMap.get(profile.uid || "") ?? null,
151
+ is_mutual: mutualIds.has(profile.id),
152
+ })),
153
+ });
154
+ }