rhythia-api 241.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.
@@ -72,44 +72,55 @@ export async function handler(
72
72
  data: (typeof Schema)["input"]["_type"]
73
73
  ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
74
74
  if (data.data.username !== undefined) {
75
- if (data.data.username.length < 3) {
75
+ data.data.username = removeZeroWidth(data.data.username);
76
+
77
+ if (validator.trim(data.data.username) !== data.data.username) {
76
78
  return NextResponse.json(
77
79
  {
78
- error: "Username must be at least 3 characters long",
80
+ error: "Username can't start or end with spaces.",
79
81
  },
80
82
  { status: 404 }
81
83
  );
82
84
  }
83
85
 
84
- if (data.data.username.length > 20) {
86
+ if (data.data.username.length < 2) {
85
87
  return NextResponse.json(
86
88
  {
87
- error: "Username too long.",
89
+ error: "Username must be at least 2 characters long",
88
90
  },
89
91
  { status: 404 }
90
92
  );
91
93
  }
92
94
 
93
- if (!/^[a-z0-9]+$/i.test(data.data.username)) {
95
+ if (data.data.username.length > 16) {
94
96
  return NextResponse.json(
95
97
  {
96
- error: "Username can only contain letters (a-z) and numbers (0-9)",
98
+ error: "Username must be 16 characters or fewer.",
97
99
  },
98
100
  { status: 404 }
99
101
  );
100
102
  }
101
- }
102
103
 
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
- }
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
+ }
111
113
 
112
- data.data.username = removeZeroWidth(data.data.username || "");
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();
@@ -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
+ }
package/api/getProfile.ts CHANGED
@@ -1,16 +1,18 @@
1
- import { geolocation } from "../utils/requestGeo";
2
- import { NextResponse } from "../utils/response";
3
- import z from "zod";
4
- import { Database } from "../types/database";
5
- import { protectedApi } from "../utils/requestUtils";
6
- import { supabase } from "../utils/supabase";
7
- import { getUserBySession } from "../utils/getUserBySession";
1
+ import { geolocation } from "../utils/requestGeo";
2
+ import { NextResponse } from "../utils/response";
3
+ import z from "zod";
4
+ import { Database } from "../types/database";
5
+ import { protectedApi } from "../utils/requestUtils";
6
+ import { supabase } from "../utils/supabase";
7
+ import { getUserBySession } from "../utils/getUserBySession";
8
8
  import { User } from "@supabase/supabase-js";
9
9
  import {
10
10
  getActivityStatusForUserId,
11
11
  getScoreActivityCutoffIso,
12
12
  } from "../utils/activityStatus";
13
13
 
14
+ const friendStateSchema = z.enum(["none", "added", "mutual"]);
15
+
14
16
  function getNextUsernameChangeAt(
15
17
  lastChangedAt: string | null | undefined
16
18
  ): string | null {
@@ -23,28 +25,73 @@ function getNextUsernameChangeAt(
23
25
  return nextAllowedAt.toISOString();
24
26
  }
25
27
 
28
+ async function getViewerProfileId(session: string) {
29
+ if (!session) {
30
+ return null;
31
+ }
32
+
33
+ const viewer = await getUserBySession(session);
34
+
35
+ if (!viewer) {
36
+ return null;
37
+ }
38
+
39
+ const { data: viewerProfile } = await supabase
40
+ .from("profiles")
41
+ .select("id")
42
+ .eq("uid", viewer.id)
43
+ .single();
44
+
45
+ return viewerProfile?.id ?? null;
46
+ }
47
+
48
+ async function getFriendState(viewerProfileId: number | null, profileId: number) {
49
+ if (viewerProfileId === null || viewerProfileId === profileId) {
50
+ return "none" as const;
51
+ }
52
+
53
+ const [{ count: addedCount }, { count: mutualCount }] = await Promise.all([
54
+ supabase
55
+ .from("profileFriends")
56
+ .select("*", { count: "exact", head: true })
57
+ .eq("profile_id", viewerProfileId)
58
+ .eq("friend_id", profileId),
59
+ supabase
60
+ .from("profileFriends")
61
+ .select("*", { count: "exact", head: true })
62
+ .eq("profile_id", profileId)
63
+ .eq("friend_id", viewerProfileId),
64
+ ]);
65
+
66
+ if (!addedCount) {
67
+ return "none" as const;
68
+ }
69
+
70
+ return mutualCount ? ("mutual" as const) : ("added" as const);
71
+ }
72
+
26
73
  export const Schema = {
27
74
  input: z.strictObject({
28
- session: z.string(),
29
- id: z.number().nullable().optional(),
30
- }),
31
- output: z.object({
32
- error: z.string().optional(),
33
- user: z
34
- .object({
35
- about_me: z.string().nullable(),
36
- avatar_url: z.string().nullable(),
37
- profile_image: z.string().nullable(),
38
- badges: z.any().nullable(),
39
- created_at: z.number().nullable(),
40
- flag: z.string().nullable(),
41
- id: z.number(),
42
- uid: z.string().nullable(),
43
- ban: z.string().nullable(),
44
- username: z.string().nullable(),
45
- verified: z.boolean().nullable(),
46
- verificationDeadline: z.number().nullable(),
47
- play_count: z.number().nullable(),
75
+ session: z.string(),
76
+ id: z.number().nullable().optional(),
77
+ }),
78
+ output: z.object({
79
+ error: z.string().optional(),
80
+ user: z
81
+ .object({
82
+ about_me: z.string().nullable(),
83
+ avatar_url: z.string().nullable(),
84
+ profile_image: z.string().nullable(),
85
+ badges: z.any().nullable(),
86
+ created_at: z.number().nullable(),
87
+ flag: z.string().nullable(),
88
+ id: z.number(),
89
+ uid: z.string().nullable(),
90
+ ban: z.string().nullable(),
91
+ username: z.string().nullable(),
92
+ verified: z.boolean().nullable(),
93
+ verificationDeadline: z.number().nullable(),
94
+ play_count: z.number().nullable(),
48
95
  skill_points: z.number().nullable(),
49
96
  squares_hit: z.number().nullable(),
50
97
  total_score: z.number().nullable(),
@@ -53,6 +100,8 @@ export const Schema = {
53
100
  activity_status: z.enum(["active", "inactive"]),
54
101
  is_online: z.boolean(),
55
102
  last_active_timestamp: z.number().nullable(),
103
+ friend_count: z.number(),
104
+ friend_state: friendStateSchema,
56
105
  can_change_flag: z.boolean(),
57
106
  next_username_change_at: z.string().nullable(),
58
107
  previous_usernames: z.array(
@@ -66,105 +115,122 @@ export const Schema = {
66
115
  id: z.number(),
67
116
  acronym: z.string(),
68
117
  })
69
- .optional()
70
- .nullable(),
71
- })
72
- .optional(),
73
- }),
74
- };
75
-
76
- export async function POST(request: Request): Promise<NextResponse> {
77
- return protectedApi({
78
- request,
79
- schema: Schema,
80
- authorization: () => {},
81
- activity: handler,
82
- });
83
- }
84
-
85
- export async function handler(
86
- data: (typeof Schema)["input"]["_type"],
87
- req: Request
88
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
89
- let profiles: Database["public"]["Tables"]["profiles"]["Row"][] = [];
90
- let isOnline = false;
91
- // Fetch by id
92
- if (data.id !== undefined && data.id !== null) {
93
- let { data: queryData, error } = await supabase
94
- .from("profiles")
95
- .select(`*,clans:clan(id,acronym)`)
96
- .eq("id", data.id);
97
-
98
- console.log(profiles, error);
99
-
100
- if (!queryData?.length) {
101
- return NextResponse.json(
102
- {
103
- error: "User not found",
104
- },
105
- { status: 404 }
106
- );
107
- }
108
-
109
- profiles = queryData;
110
- } else {
111
- // Fetch by session id
112
- const user = (await getUserBySession(data.session)) as User;
113
-
114
- if (user) {
115
- let { data: queryData, error } = await supabase
116
- .from("profiles")
117
- .select("*")
118
- .eq("uid", user.id);
119
-
120
- if (!queryData?.length) {
121
- const geo = geolocation(req);
122
- const data = await supabase.from("profiles").upsert({
123
- uid: user.id,
124
- about_me: "",
125
- avatar_url:
126
- "https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/user-avatar-1725309193296-72002e6b-321c-4f60-a692-568e0e75147d",
127
- badges: [],
128
- username: `user${Math.round(Math.random() * 900000 + 100000)}`,
129
- computedUsername: `user${Math.round(Math.random() * 900000 + 100000)}`.toLowerCase(),
130
- flag: (geo.country || "US").toUpperCase(),
131
- created_at: Date.now(),
132
- }).select(`
133
- *,clans:clan(id,acronym)`);
134
-
135
- profiles = data.data!;
136
- } else {
137
- profiles = queryData;
138
- }
139
- }
140
- }
141
-
142
- const user = profiles[0];
143
-
144
- const activityStatus = await getActivityStatusForUserId(user.id);
145
-
146
- const { data: activityData } = await supabase
147
- .from("profileActivities")
148
- .select("last_activity")
149
- .eq("uid", user.uid || "")
150
- .single();
118
+ .optional()
119
+ .nullable(),
120
+ })
121
+ .optional(),
122
+ }),
123
+ };
151
124
 
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 });
125
+ export async function POST(request: Request): Promise<NextResponse> {
126
+ return protectedApi({
127
+ request,
128
+ schema: Schema,
129
+ authorization: () => {},
130
+ activity: handler,
131
+ });
132
+ }
157
133
 
158
- const { data: flagHistoryData } = await supabase
159
- .from("profileFlags")
160
- .select("id")
161
- .eq("profile_id", user.id)
162
- .limit(1);
163
-
164
- //last 30 minutes
165
- if (activityData && activityData.last_activity) {
166
- isOnline = Date.now() - activityData.last_activity < 1800000;
167
- }
134
+ export async function handler(
135
+ data: (typeof Schema)["input"]["_type"],
136
+ req: Request
137
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
138
+ let profiles: Database["public"]["Tables"]["profiles"]["Row"][] = [];
139
+ let isOnline = false;
140
+ let viewerProfileId =
141
+ data.id !== undefined && data.id !== null
142
+ ? await getViewerProfileId(data.session)
143
+ : null;
144
+ // Fetch by id
145
+ if (data.id !== undefined && data.id !== null) {
146
+ let { data: queryData, error } = await supabase
147
+ .from("profiles")
148
+ .select(`*,clans:clan(id,acronym)`)
149
+ .eq("id", data.id);
150
+
151
+ console.log(profiles, error);
152
+
153
+ if (!queryData?.length) {
154
+ return NextResponse.json(
155
+ {
156
+ error: "User not found",
157
+ },
158
+ { status: 404 }
159
+ );
160
+ }
161
+
162
+ profiles = queryData;
163
+ } else {
164
+ // Fetch by session id
165
+ const user = (await getUserBySession(data.session)) as User;
166
+
167
+ if (user) {
168
+ let { data: queryData, error } = await supabase
169
+ .from("profiles")
170
+ .select("*")
171
+ .eq("uid", user.id);
172
+
173
+ if (!queryData?.length) {
174
+ const geo = geolocation(req);
175
+ const data = await supabase.from("profiles").upsert({
176
+ uid: user.id,
177
+ about_me: "",
178
+ avatar_url:
179
+ "https://rhthia-avatars.s3.eu-central-003.backblazeb2.com/user-avatar-1725309193296-72002e6b-321c-4f60-a692-568e0e75147d",
180
+ badges: [],
181
+ username: `user${Math.round(Math.random() * 900000 + 100000)}`,
182
+ computedUsername: `user${Math.round(Math.random() * 900000 + 100000)}`.toLowerCase(),
183
+ flag: (geo.country || "US").toUpperCase(),
184
+ created_at: Date.now(),
185
+ }).select(`
186
+ *,clans:clan(id,acronym)`);
187
+
188
+ profiles = data.data!;
189
+ } else {
190
+ profiles = queryData;
191
+ }
192
+
193
+ viewerProfileId = profiles[0]?.id ?? null;
194
+ }
195
+ }
196
+
197
+ const user = profiles[0];
198
+
199
+ const activityStatus = await getActivityStatusForUserId(user.id);
200
+
201
+ const [
202
+ { data: activityData },
203
+ { data: usernameHistoryData },
204
+ { data: flagHistoryData },
205
+ { count: friendCount },
206
+ friendState,
207
+ ] = await Promise.all([
208
+ supabase
209
+ .from("profileActivities")
210
+ .select("last_activity")
211
+ .eq("uid", user.uid || "")
212
+ .single(),
213
+ supabase
214
+ .from("profileUsernames")
215
+ .select("username,changed_at")
216
+ .eq("profile_id", user.id)
217
+ .order("changed_at", { ascending: false }),
218
+ supabase
219
+ .from("profileFlags")
220
+ .select("id")
221
+ .eq("profile_id", user.id)
222
+ .limit(1),
223
+ supabase
224
+ .from("profileFriends")
225
+ .select("*", { count: "exact", head: true })
226
+ .eq("friend_id", user.id),
227
+ getFriendState(viewerProfileId, user.id),
228
+ ]);
229
+
230
+ //last 30 minutes
231
+ if (activityData && activityData.last_activity) {
232
+ isOnline = Date.now() - activityData.last_activity < 1800000;
233
+ }
168
234
 
169
235
  let position: number | null = null;
170
236
  let countryPosition: number | null = null;
@@ -196,13 +262,13 @@ export async function handler(
196
262
  ? (countryPlayersWithMorePoints || 0) + 1
197
263
  : null;
198
264
  }
199
-
265
+
200
266
  if (user.verificationDeadline < Date.now()) {
201
267
  await supabase
202
268
  .from("profiles")
203
269
  .upsert({
204
- id: user.id,
205
- verified: false,
270
+ id: user.id,
271
+ verified: false,
206
272
  })
207
273
  .select();
208
274
  }
@@ -221,6 +287,8 @@ export async function handler(
221
287
  activity_status: activityStatus,
222
288
  is_online: isOnline,
223
289
  last_active_timestamp: activityData?.last_activity ?? null,
290
+ friend_count: friendCount || 0,
291
+ friend_state: friendState,
224
292
  can_change_flag: !flagHistoryData?.length,
225
293
  next_username_change_at: getNextUsernameChangeAt(latestUsernameChangeAt),
226
294
  previous_usernames: previousUsernames,
package/index.ts CHANGED
@@ -321,6 +321,25 @@ import { Schema as EditProfile } from "./api/editProfile"
321
321
  export { Schema as SchemaEditProfile } from "./api/editProfile"
322
322
  export const editProfile = handleApi({url:"/api/editProfile",...EditProfile})
323
323
 
324
+ // ./api/editProfileFriend.ts API
325
+
326
+ /*
327
+ export const Schema = {
328
+ input: z.strictObject({
329
+ session: z.string(),
330
+ profileId: z.number(),
331
+ action: z.enum(["add", "remove"]),
332
+ }),
333
+ output: z.strictObject({
334
+ error: z.string().optional(),
335
+ friend_count: z.number().optional(),
336
+ friend_state: friendStateSchema.optional(),
337
+ }),
338
+ };*/
339
+ import { Schema as EditProfileFriend } from "./api/editProfileFriend"
340
+ export { Schema as SchemaEditProfileFriend } from "./api/editProfileFriend"
341
+ export const editProfileFriend = handleApi({url:"/api/editProfileFriend",...EditProfileFriend})
342
+
324
343
  // ./api/enhancedSearch.ts API
325
344
 
326
345
  /*
@@ -840,6 +859,22 @@ import { Schema as GetCollections } from "./api/getCollections"
840
859
  export { Schema as SchemaGetCollections } from "./api/getCollections"
841
860
  export const getCollections = handleApi({url:"/api/getCollections",...GetCollections})
842
861
 
862
+ // ./api/getFriends.ts API
863
+
864
+ /*
865
+ export const Schema = {
866
+ input: z.strictObject({
867
+ session: z.string(),
868
+ }),
869
+ output: z.strictObject({
870
+ error: z.string().optional(),
871
+ friends: z.array(friendUserSchema),
872
+ }),
873
+ };*/
874
+ import { Schema as GetFriends } from "./api/getFriends"
875
+ export { Schema as SchemaGetFriends } from "./api/getFriends"
876
+ export const getFriends = handleApi({url:"/api/getFriends",...GetFriends})
877
+
843
878
  // ./api/getInventory.ts API
844
879
 
845
880
  /*
@@ -967,26 +1002,26 @@ export const getPassToken = handleApi({url:"/api/getPassToken",...GetPassToken})
967
1002
  /*
968
1003
  export const Schema = {
969
1004
  input: z.strictObject({
970
- session: z.string(),
971
- id: z.number().nullable().optional(),
972
- }),
973
- output: z.object({
974
- error: z.string().optional(),
975
- user: z
976
- .object({
977
- about_me: z.string().nullable(),
978
- avatar_url: z.string().nullable(),
979
- profile_image: z.string().nullable(),
980
- badges: z.any().nullable(),
981
- created_at: z.number().nullable(),
982
- flag: z.string().nullable(),
983
- id: z.number(),
984
- uid: z.string().nullable(),
985
- ban: z.string().nullable(),
986
- username: z.string().nullable(),
987
- verified: z.boolean().nullable(),
988
- verificationDeadline: z.number().nullable(),
989
- play_count: z.number().nullable(),
1005
+ session: z.string(),
1006
+ id: z.number().nullable().optional(),
1007
+ }),
1008
+ output: z.object({
1009
+ error: z.string().optional(),
1010
+ user: z
1011
+ .object({
1012
+ about_me: z.string().nullable(),
1013
+ avatar_url: z.string().nullable(),
1014
+ profile_image: z.string().nullable(),
1015
+ badges: z.any().nullable(),
1016
+ created_at: z.number().nullable(),
1017
+ flag: z.string().nullable(),
1018
+ id: z.number(),
1019
+ uid: z.string().nullable(),
1020
+ ban: z.string().nullable(),
1021
+ username: z.string().nullable(),
1022
+ verified: z.boolean().nullable(),
1023
+ verificationDeadline: z.number().nullable(),
1024
+ play_count: z.number().nullable(),
990
1025
  skill_points: z.number().nullable(),
991
1026
  squares_hit: z.number().nullable(),
992
1027
  total_score: z.number().nullable(),
@@ -995,6 +1030,8 @@ export const Schema = {
995
1030
  activity_status: z.enum(["active", "inactive"]),
996
1031
  is_online: z.boolean(),
997
1032
  last_active_timestamp: z.number().nullable(),
1033
+ friend_count: z.number(),
1034
+ friend_state: friendStateSchema,
998
1035
  can_change_flag: z.boolean(),
999
1036
  next_username_change_at: z.string().nullable(),
1000
1037
  previous_usernames: z.array(
@@ -1008,11 +1045,11 @@ export const Schema = {
1008
1045
  id: z.number(),
1009
1046
  acronym: z.string(),
1010
1047
  })
1011
- .optional()
1012
- .nullable(),
1013
- })
1014
- .optional(),
1015
- }),
1048
+ .optional()
1049
+ .nullable(),
1050
+ })
1051
+ .optional(),
1052
+ }),
1016
1053
  };*/
1017
1054
  import { Schema as GetProfile } from "./api/getProfile"
1018
1055
  export { Schema as SchemaGetProfile } from "./api/getProfile"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "241.0.0",
3
+ "version": "242.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
@@ -11,6 +11,7 @@
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
13
  "db:create-profile-flags": "node scripts/create-profile-flags-table.ts",
14
+ "db:create-profile-friends": "node scripts/create-profile-friends-table.ts",
14
15
  "db:create-profile-reports": "node scripts/create-profile-reports-table.ts",
15
16
  "query-pull": "bun run scripts/pull-queries.ts",
16
17
  "query-push": "bun run scripts/deploy-queries.ts",
package/types/database.ts CHANGED
@@ -558,6 +558,42 @@ export type Database = {
558
558
  },
559
559
  ]
560
560
  }
561
+ profileFriends: {
562
+ Row: {
563
+ created_at: string
564
+ friend_id: number
565
+ id: number
566
+ profile_id: number
567
+ }
568
+ Insert: {
569
+ created_at?: string
570
+ friend_id: number
571
+ id?: number
572
+ profile_id: number
573
+ }
574
+ Update: {
575
+ created_at?: string
576
+ friend_id?: number
577
+ id?: number
578
+ profile_id?: number
579
+ }
580
+ Relationships: [
581
+ {
582
+ foreignKeyName: "profileFriends_friend_id_fkey"
583
+ columns: ["friend_id"]
584
+ isOneToOne: false
585
+ referencedRelation: "profiles"
586
+ referencedColumns: ["id"]
587
+ },
588
+ {
589
+ foreignKeyName: "profileFriends_profile_id_fkey"
590
+ columns: ["profile_id"]
591
+ isOneToOne: false
592
+ referencedRelation: "profiles"
593
+ referencedColumns: ["id"]
594
+ },
595
+ ]
596
+ }
561
597
  profileFlags: {
562
598
  Row: {
563
599
  changed_at: string
package/worker.ts CHANGED
@@ -1,191 +1,195 @@
1
- import { POST as acceptInvite } from "./api/acceptInvite";
2
- import { POST as addCollectionMap } from "./api/addCollectionMap";
3
- import { POST as chartPublicStats } from "./api/chartPublicStats";
4
- import { POST as checkQualified } from "./api/checkQualified";
5
- import { POST as createBeatmap } from "./api/createBeatmap";
6
- import { POST as createBeatmapPage } from "./api/createBeatmapPage";
7
- import { POST as createClan } from "./api/createClan";
8
- import { POST as createCollection } from "./api/createCollection";
9
- import { POST as createInvite } from "./api/createInvite";
10
- import { POST as createSupporter } from "./api/createSupporter";
11
- import { POST as deleteBeatmapPage } from "./api/deleteBeatmapPage";
12
- import { POST as deleteCollection } from "./api/deleteCollection";
13
- import { POST as deleteCollectionMap } from "./api/deleteCollectionMap";
14
- import { POST as editAboutMe } from "./api/editAboutMe";
15
- import { POST as editClan } from "./api/editClan";
16
- import { POST as editCollection } from "./api/editCollection";
17
- import { POST as editProfile } from "./api/editProfile";
18
- import { POST as enhancedSearch } from "./api/enhancedSearch";
19
- import { POST as executeAdminOperation } from "./api/executeAdminOperation";
20
- import { POST as getAvatarUploadUrl } from "./api/getAvatarUploadUrl";
21
- import { POST as getBadgeLeaders } from "./api/getBadgeLeaders";
22
- import { POST as getBadgedUsers } from "./api/getBadgedUsers";
23
- import { POST as getBeatmapComments } from "./api/getBeatmapComments";
24
- import { POST as getBeatmapPage } from "./api/getBeatmapPage";
25
- import { POST as getBeatmapPageById } from "./api/getBeatmapPageById";
26
- import { POST as getBeatmapStarRating } from "./api/getBeatmapStarRating";
27
- import { POST as getBeatmaps } from "./api/getBeatmaps";
28
- import { POST as getClan } from "./api/getClan";
29
- import { POST as getClans } from "./api/getClans";
30
- import { POST as getCollection } from "./api/getCollection";
31
- import { POST as getCollections } from "./api/getCollections";
32
- import { POST as getInventory } from "./api/getInventory";
33
- import { POST as getLeaderboard } from "./api/getLeaderboard";
34
- import { POST as getMapUploadUrl } from "./api/getMapUploadUrl";
35
- import { POST as getOnlinePlayers } from "./api/getOnlinePlayers";
36
- import { POST as getPassToken } from "./api/getPassToken";
37
- import { POST as getProfile } from "./api/getProfile";
38
- import { POST as getPublicStats } from "./api/getPublicStats";
39
- import { POST as getRawStarRating } from "./api/getRawStarRating";
40
- import { POST as getScore } from "./api/getScore";
41
- import { POST as getStoryBeatmaps } from "./api/getStoryBeatmaps";
42
- import { POST as getTimestamp } from "./api/getTimestamp";
43
- import { POST as getUserScores } from "./api/getUserScores";
44
- import { POST as getVerified } from "./api/getVerified";
45
- import { POST as getVideoUploadUrl } from "./api/getVideoUploadUrl";
46
- import { POST as postBeatmapComment } from "./api/postBeatmapComment";
47
- import { POST as qualifyMap } from "./api/qualifyMap";
48
- import { POST as rankMapsArchive } from "./api/rankMapsArchive";
49
- import { POST as reportProfile } from "./api/reportProfile";
50
- import { POST as searchUsers } from "./api/searchUsers";
51
- import { POST as setPasskey } from "./api/setPasskey";
52
- import { POST as submitScore } from "./api/submitScore";
53
- import { POST as submitScoreInternal } from "./api/submitScoreInternal";
54
- import { POST as updateBeatmapPage } from "./api/updateBeatmapPage";
55
- import { POST as vetoMap } from "./api/vetoMap";
56
- import { NextResponse } from "./utils/response";
57
-
58
- type RouteHandler = (request: Request) => Promise<Response> | Response;
59
-
60
- const corsHeaders = {
61
- "Access-Control-Allow-Credentials": "true",
62
- "Access-Control-Allow-Origin": "*",
63
- "Access-Control-Allow-Methods": "GET,OPTIONS,PATCH,DELETE,POST,PUT",
64
- "Access-Control-Allow-Headers":
65
- "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
66
- };
67
-
68
- const apiRoutes: Record<string, RouteHandler> = {
69
- "/api/acceptInvite": acceptInvite,
70
- "/api/addCollectionMap": addCollectionMap,
71
- "/api/chartPublicStats": chartPublicStats,
72
- "/api/checkQualified": checkQualified,
73
- "/api/createBeatmap": createBeatmap,
74
- "/api/createBeatmapPage": createBeatmapPage,
75
- "/api/createClan": createClan,
76
- "/api/createCollection": createCollection,
77
- "/api/createInvite": createInvite,
78
- "/api/createSupporter": createSupporter,
79
- "/api/deleteBeatmapPage": deleteBeatmapPage,
80
- "/api/deleteCollection": deleteCollection,
81
- "/api/deleteCollectionMap": deleteCollectionMap,
82
- "/api/editAboutMe": editAboutMe,
83
- "/api/editClan": editClan,
84
- "/api/editCollection": editCollection,
85
- "/api/editProfile": editProfile,
86
- "/api/enhancedSearch": enhancedSearch,
87
- "/api/executeAdminOperation": executeAdminOperation,
88
- "/api/getAvatarUploadUrl": getAvatarUploadUrl,
89
- "/api/getBadgeLeaders": getBadgeLeaders,
90
- "/api/getBadgedUsers": getBadgedUsers,
91
- "/api/getBeatmapComments": getBeatmapComments,
92
- "/api/getBeatmapPage": getBeatmapPage,
93
- "/api/getBeatmapPageById": getBeatmapPageById,
94
- "/api/getBeatmapStarRating": getBeatmapStarRating,
95
- "/api/getBeatmaps": getBeatmaps,
96
- "/api/getClan": getClan,
97
- "/api/getClans": getClans,
98
- "/api/getCollection": getCollection,
99
- "/api/getCollections": getCollections,
100
- "/api/getInventory": getInventory,
101
- "/api/getLeaderboard": getLeaderboard,
102
- "/api/getMapUploadUrl": getMapUploadUrl,
103
- "/api/getOnlinePlayers": getOnlinePlayers,
104
- "/api/getPassToken": getPassToken,
105
- "/api/getProfile": getProfile,
106
- "/api/getPublicStats": getPublicStats,
107
- "/api/getRawStarRating": getRawStarRating,
108
- "/api/getScore": getScore,
109
- "/api/getStoryBeatmaps": getStoryBeatmaps,
110
- "/api/getTimestamp": getTimestamp,
111
- "/api/getUserScores": getUserScores,
112
- "/api/getVerified": getVerified,
113
- "/api/getVideoUploadUrl": getVideoUploadUrl,
114
- "/api/postBeatmapComment": postBeatmapComment,
115
- "/api/qualifyMap": qualifyMap,
116
- "/api/rankMapsArchive": rankMapsArchive,
117
- "/api/reportProfile": reportProfile,
118
- "/api/searchUsers": searchUsers,
119
- "/api/setPasskey": setPasskey,
120
- "/api/submitScore": submitScore,
121
- "/api/submitScoreInternal": submitScoreInternal,
122
- "/api/updateBeatmapPage": updateBeatmapPage,
123
- "/api/vetoMap": vetoMap,
124
- };
125
-
126
- function withCors(response: Response) {
127
- const headers = new Headers(response.headers);
128
-
129
- for (const [key, value] of Object.entries(corsHeaders)) {
130
- headers.set(key, value);
131
- }
132
-
133
- return new Response(response.body, {
134
- status: response.status,
135
- statusText: response.statusText,
136
- headers,
137
- });
138
- }
139
-
140
- function notFound() {
141
- return NextResponse.json({ error: "Not Found" }, { status: 404 });
142
- }
143
-
144
- export default {
145
- async fetch(request: Request) {
146
- const url = new URL(request.url);
147
- const pathname =
148
- url.pathname.endsWith("/") && url.pathname.length > 1
149
- ? url.pathname.slice(0, -1)
150
- : url.pathname;
151
-
152
- if (request.method === "OPTIONS") {
153
- return withCors(new Response(null, { status: 204 }));
154
- }
155
-
156
- if (pathname === "/") {
157
- return withCors(
158
- new Response("<html><div>Rhythia API</div></html>", {
159
- headers: {
160
- "content-type": "text/html; charset=utf-8",
161
- },
162
- })
163
- );
164
- }
165
-
166
- const handler = apiRoutes[pathname];
167
- if (!handler) {
168
- return withCors(notFound());
169
- }
170
-
171
- if (request.method !== "POST") {
172
- return withCors(
173
- NextResponse.json({ error: "Method Not Allowed" }, { status: 405 })
174
- );
175
- }
176
-
177
- try {
178
- return withCors(await handler(request));
179
- } catch (error) {
180
- console.error(error);
181
- return withCors(
182
- NextResponse.json(
183
- {
184
- error: "Internal Server Error",
185
- },
186
- { status: 500 }
187
- )
188
- );
189
- }
190
- },
191
- };
1
+ import { POST as acceptInvite } from "./api/acceptInvite";
2
+ import { POST as addCollectionMap } from "./api/addCollectionMap";
3
+ import { POST as chartPublicStats } from "./api/chartPublicStats";
4
+ import { POST as checkQualified } from "./api/checkQualified";
5
+ import { POST as createBeatmap } from "./api/createBeatmap";
6
+ import { POST as createBeatmapPage } from "./api/createBeatmapPage";
7
+ import { POST as createClan } from "./api/createClan";
8
+ import { POST as createCollection } from "./api/createCollection";
9
+ import { POST as createInvite } from "./api/createInvite";
10
+ import { POST as createSupporter } from "./api/createSupporter";
11
+ import { POST as deleteBeatmapPage } from "./api/deleteBeatmapPage";
12
+ import { POST as deleteCollection } from "./api/deleteCollection";
13
+ import { POST as deleteCollectionMap } from "./api/deleteCollectionMap";
14
+ import { POST as editAboutMe } from "./api/editAboutMe";
15
+ import { POST as editClan } from "./api/editClan";
16
+ import { POST as editCollection } from "./api/editCollection";
17
+ import { POST as editProfile } from "./api/editProfile";
18
+ import { POST as editProfileFriend } from "./api/editProfileFriend";
19
+ import { POST as enhancedSearch } from "./api/enhancedSearch";
20
+ import { POST as executeAdminOperation } from "./api/executeAdminOperation";
21
+ import { POST as getAvatarUploadUrl } from "./api/getAvatarUploadUrl";
22
+ import { POST as getBadgeLeaders } from "./api/getBadgeLeaders";
23
+ import { POST as getBadgedUsers } from "./api/getBadgedUsers";
24
+ import { POST as getBeatmapComments } from "./api/getBeatmapComments";
25
+ import { POST as getBeatmapPage } from "./api/getBeatmapPage";
26
+ import { POST as getBeatmapPageById } from "./api/getBeatmapPageById";
27
+ import { POST as getBeatmapStarRating } from "./api/getBeatmapStarRating";
28
+ import { POST as getBeatmaps } from "./api/getBeatmaps";
29
+ import { POST as getClan } from "./api/getClan";
30
+ import { POST as getClans } from "./api/getClans";
31
+ import { POST as getCollection } from "./api/getCollection";
32
+ import { POST as getCollections } from "./api/getCollections";
33
+ import { POST as getFriends } from "./api/getFriends";
34
+ import { POST as getInventory } from "./api/getInventory";
35
+ import { POST as getLeaderboard } from "./api/getLeaderboard";
36
+ import { POST as getMapUploadUrl } from "./api/getMapUploadUrl";
37
+ import { POST as getOnlinePlayers } from "./api/getOnlinePlayers";
38
+ import { POST as getPassToken } from "./api/getPassToken";
39
+ import { POST as getProfile } from "./api/getProfile";
40
+ import { POST as getPublicStats } from "./api/getPublicStats";
41
+ import { POST as getRawStarRating } from "./api/getRawStarRating";
42
+ import { POST as getScore } from "./api/getScore";
43
+ import { POST as getStoryBeatmaps } from "./api/getStoryBeatmaps";
44
+ import { POST as getTimestamp } from "./api/getTimestamp";
45
+ import { POST as getUserScores } from "./api/getUserScores";
46
+ import { POST as getVerified } from "./api/getVerified";
47
+ import { POST as getVideoUploadUrl } from "./api/getVideoUploadUrl";
48
+ import { POST as postBeatmapComment } from "./api/postBeatmapComment";
49
+ import { POST as qualifyMap } from "./api/qualifyMap";
50
+ import { POST as rankMapsArchive } from "./api/rankMapsArchive";
51
+ import { POST as reportProfile } from "./api/reportProfile";
52
+ import { POST as searchUsers } from "./api/searchUsers";
53
+ import { POST as setPasskey } from "./api/setPasskey";
54
+ import { POST as submitScore } from "./api/submitScore";
55
+ import { POST as submitScoreInternal } from "./api/submitScoreInternal";
56
+ import { POST as updateBeatmapPage } from "./api/updateBeatmapPage";
57
+ import { POST as vetoMap } from "./api/vetoMap";
58
+ import { NextResponse } from "./utils/response";
59
+
60
+ type RouteHandler = (request: Request) => Promise<Response> | Response;
61
+
62
+ const corsHeaders = {
63
+ "Access-Control-Allow-Credentials": "true",
64
+ "Access-Control-Allow-Origin": "*",
65
+ "Access-Control-Allow-Methods": "GET,OPTIONS,PATCH,DELETE,POST,PUT",
66
+ "Access-Control-Allow-Headers":
67
+ "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
68
+ };
69
+
70
+ const apiRoutes: Record<string, RouteHandler> = {
71
+ "/api/acceptInvite": acceptInvite,
72
+ "/api/addCollectionMap": addCollectionMap,
73
+ "/api/chartPublicStats": chartPublicStats,
74
+ "/api/checkQualified": checkQualified,
75
+ "/api/createBeatmap": createBeatmap,
76
+ "/api/createBeatmapPage": createBeatmapPage,
77
+ "/api/createClan": createClan,
78
+ "/api/createCollection": createCollection,
79
+ "/api/createInvite": createInvite,
80
+ "/api/createSupporter": createSupporter,
81
+ "/api/deleteBeatmapPage": deleteBeatmapPage,
82
+ "/api/deleteCollection": deleteCollection,
83
+ "/api/deleteCollectionMap": deleteCollectionMap,
84
+ "/api/editAboutMe": editAboutMe,
85
+ "/api/editClan": editClan,
86
+ "/api/editCollection": editCollection,
87
+ "/api/editProfile": editProfile,
88
+ "/api/editProfileFriend": editProfileFriend,
89
+ "/api/enhancedSearch": enhancedSearch,
90
+ "/api/executeAdminOperation": executeAdminOperation,
91
+ "/api/getAvatarUploadUrl": getAvatarUploadUrl,
92
+ "/api/getBadgeLeaders": getBadgeLeaders,
93
+ "/api/getBadgedUsers": getBadgedUsers,
94
+ "/api/getBeatmapComments": getBeatmapComments,
95
+ "/api/getBeatmapPage": getBeatmapPage,
96
+ "/api/getBeatmapPageById": getBeatmapPageById,
97
+ "/api/getBeatmapStarRating": getBeatmapStarRating,
98
+ "/api/getBeatmaps": getBeatmaps,
99
+ "/api/getClan": getClan,
100
+ "/api/getClans": getClans,
101
+ "/api/getCollection": getCollection,
102
+ "/api/getCollections": getCollections,
103
+ "/api/getFriends": getFriends,
104
+ "/api/getInventory": getInventory,
105
+ "/api/getLeaderboard": getLeaderboard,
106
+ "/api/getMapUploadUrl": getMapUploadUrl,
107
+ "/api/getOnlinePlayers": getOnlinePlayers,
108
+ "/api/getPassToken": getPassToken,
109
+ "/api/getProfile": getProfile,
110
+ "/api/getPublicStats": getPublicStats,
111
+ "/api/getRawStarRating": getRawStarRating,
112
+ "/api/getScore": getScore,
113
+ "/api/getStoryBeatmaps": getStoryBeatmaps,
114
+ "/api/getTimestamp": getTimestamp,
115
+ "/api/getUserScores": getUserScores,
116
+ "/api/getVerified": getVerified,
117
+ "/api/getVideoUploadUrl": getVideoUploadUrl,
118
+ "/api/postBeatmapComment": postBeatmapComment,
119
+ "/api/qualifyMap": qualifyMap,
120
+ "/api/rankMapsArchive": rankMapsArchive,
121
+ "/api/reportProfile": reportProfile,
122
+ "/api/searchUsers": searchUsers,
123
+ "/api/setPasskey": setPasskey,
124
+ "/api/submitScore": submitScore,
125
+ "/api/submitScoreInternal": submitScoreInternal,
126
+ "/api/updateBeatmapPage": updateBeatmapPage,
127
+ "/api/vetoMap": vetoMap,
128
+ };
129
+
130
+ function withCors(response: Response) {
131
+ const headers = new Headers(response.headers);
132
+
133
+ for (const [key, value] of Object.entries(corsHeaders)) {
134
+ headers.set(key, value);
135
+ }
136
+
137
+ return new Response(response.body, {
138
+ status: response.status,
139
+ statusText: response.statusText,
140
+ headers,
141
+ });
142
+ }
143
+
144
+ function notFound() {
145
+ return NextResponse.json({ error: "Not Found" }, { status: 404 });
146
+ }
147
+
148
+ export default {
149
+ async fetch(request: Request) {
150
+ const url = new URL(request.url);
151
+ const pathname =
152
+ url.pathname.endsWith("/") && url.pathname.length > 1
153
+ ? url.pathname.slice(0, -1)
154
+ : url.pathname;
155
+
156
+ if (request.method === "OPTIONS") {
157
+ return withCors(new Response(null, { status: 204 }));
158
+ }
159
+
160
+ if (pathname === "/") {
161
+ return withCors(
162
+ new Response("<html><div>Rhythia API</div></html>", {
163
+ headers: {
164
+ "content-type": "text/html; charset=utf-8",
165
+ },
166
+ })
167
+ );
168
+ }
169
+
170
+ const handler = apiRoutes[pathname];
171
+ if (!handler) {
172
+ return withCors(notFound());
173
+ }
174
+
175
+ if (request.method !== "POST") {
176
+ return withCors(
177
+ NextResponse.json({ error: "Method Not Allowed" }, { status: 405 })
178
+ );
179
+ }
180
+
181
+ try {
182
+ return withCors(await handler(request));
183
+ } catch (error) {
184
+ console.error(error);
185
+ return withCors(
186
+ NextResponse.json(
187
+ {
188
+ error: "Internal Server Error",
189
+ },
190
+ { status: 500 }
191
+ )
192
+ );
193
+ }
194
+ },
195
+ };