rhythia-api 216.0.0 → 217.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,9 +1,10 @@
1
1
  import { NextResponse } from "next/server";
2
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";
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
+ import { invalidateCache, invalidateCachePrefix } from "../utils/cache";
7
8
 
8
9
  export const Schema = {
9
10
  input: z.strictObject({
@@ -54,24 +55,30 @@ export async function handler({
54
55
  if (!userData) return NextResponse.json({ error: "No user." });
55
56
  if (!beatmapData) return NextResponse.json({ error: "No beatmap." });
56
57
 
57
- if (userData.id !== pageData.owner) {
58
- const isDev =
59
- (userData.badges as string[]).includes("Developer") ||
60
- (userData.badges as string[]).includes("Global Moderator");
58
+ const badges = (userData.badges || []) as string[];
59
+ const hasDeletionRole = badges.includes("RCT") || badges.includes("MMT");
61
60
 
62
- if (!isDev) return NextResponse.json({ error: "Non-authz user." });
61
+ if (!hasDeletionRole) {
62
+ return NextResponse.json({
63
+ error: "Only RCT or MMT members can delete beatmaps.",
64
+ });
63
65
  }
64
66
 
65
67
  if (pageData.status !== "UNRANKED")
66
68
  return NextResponse.json({ error: "Only unranked maps can be updated" });
67
69
 
68
- await supabase.from("beatmapPageComments").delete().eq("beatmapPage", id);
69
- await supabase.from("collectionRelations").delete().eq("beatmapPage", id);
70
- await supabase.from("beatmapPages").delete().eq("id", id);
71
- await supabase
72
- .from("beatmaps")
73
- .delete()
74
- .eq("beatmapHash", beatmapData.beatmapHash);
75
-
76
- return NextResponse.json({});
77
- }
70
+ await supabase.from("beatmapPageComments").delete().eq("beatmapPage", id);
71
+ await supabase.from("collectionRelations").delete().eq("beatmapPage", id);
72
+ await supabase.from("beatmapPages").delete().eq("id", id);
73
+ await supabase
74
+ .from("beatmaps")
75
+ .delete()
76
+ .eq("beatmapHash", beatmapData.beatmapHash);
77
+
78
+ await invalidateCache(`beatmap-comments:${id}`);
79
+ if (pageData.latestBeatmapHash) {
80
+ await invalidateCachePrefix(`beatmap-scores:${pageData.latestBeatmapHash}`);
81
+ }
82
+
83
+ return NextResponse.json({});
84
+ }
@@ -1,10 +1,11 @@
1
1
  import { NextResponse } from "next/server";
2
- import z from "zod";
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";
2
+ import z from "zod";
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 { invalidateCache, invalidateCachePrefix } from "../utils/cache";
8
9
 
9
10
  // Define supported admin operations and their parameter types
10
11
  const adminOperations = {
@@ -145,17 +146,21 @@ export async function handler(
145
146
  { status: 403 }
146
147
  );
147
148
  }
148
-
149
- // Execute the requested admin operation
150
- try {
151
- let result;
152
- const operation = data.data.operation;
153
- const params = data.data.params as any;
154
-
155
- switch (operation) {
156
- case "deleteUser":
157
- result = await supabase.rpc("admin_delete_user", {
158
- user_id: params.userId,
149
+
150
+ // Execute the requested admin operation
151
+ try {
152
+ let result: { data?: any; error?: any } | null = null;
153
+ const operation = data.data.operation;
154
+ const params = data.data.params as any;
155
+ const targetUserId =
156
+ "userId" in params && typeof params.userId === "number"
157
+ ? params.userId
158
+ : null;
159
+
160
+ switch (operation) {
161
+ case "deleteUser":
162
+ result = await supabase.rpc("admin_delete_user", {
163
+ user_id: params.userId,
159
164
  });
160
165
  break;
161
166
 
@@ -216,21 +221,23 @@ export async function handler(
216
221
  })
217
222
  .select();
218
223
  break;
219
- case "changeBadges":
220
- // Allow only developers to modify badges.
221
- if ((queryUserData.badges as string[]).includes("Developer")) {
222
- result = await supabase
223
- .from("profiles")
224
- .upsert({
225
- id: params.userId,
226
- badges: JSON.parse(params.badges),
227
- })
228
- .select();
229
- }
230
- break;
231
-
232
- case "addBadge":
233
- // Allow only developers to modify badges.
224
+ case "changeBadges":
225
+ // Allow only developers to modify badges.
226
+ if ((queryUserData.badges as string[]).includes("Developer")) {
227
+ result = await supabase
228
+ .from("profiles")
229
+ .upsert({
230
+ id: params.userId,
231
+ badges: JSON.parse(params.badges),
232
+ })
233
+ .select();
234
+ } else {
235
+ result = { data: null, error: { message: "Unauthorized" } };
236
+ }
237
+ break;
238
+
239
+ case "addBadge":
240
+ // Allow only developers to modify badges.
234
241
  if ((queryUserData.badges as string[]).includes("Developer")) {
235
242
  // Get current badges
236
243
  const { data: targetUser } = await supabase
@@ -242,21 +249,23 @@ export async function handler(
242
249
  const currentBadges = (targetUser?.badges || []) as string[];
243
250
  if (!currentBadges.includes(params.badge)) {
244
251
  currentBadges.push(params.badge);
245
- result = await supabase
246
- .from("profiles")
247
- .upsert({
248
- id: params.userId,
249
- badges: currentBadges,
250
- })
251
- .select();
252
- } else {
253
- result = { data: targetUser, error: null };
254
- }
255
- }
256
- break;
257
-
258
- case "removeBadge":
259
- // Allow only developers to modify badges.
252
+ result = await supabase
253
+ .from("profiles")
254
+ .upsert({
255
+ id: params.userId,
256
+ badges: currentBadges,
257
+ })
258
+ .select();
259
+ } else {
260
+ result = { data: targetUser, error: null };
261
+ }
262
+ } else {
263
+ result = { data: null, error: { message: "Unauthorized" } };
264
+ }
265
+ break;
266
+
267
+ case "removeBadge":
268
+ // Allow only developers to modify badges.
260
269
  if ((queryUserData.badges as string[]).includes("Developer")) {
261
270
  // Get current badges
262
271
  const { data: targetUser } = await supabase
@@ -268,15 +277,17 @@ export async function handler(
268
277
  const currentBadges = (targetUser?.badges || []) as string[];
269
278
  const updatedBadges = currentBadges.filter(b => b !== params.badge);
270
279
 
271
- result = await supabase
272
- .from("profiles")
273
- .upsert({
274
- id: params.userId,
275
- badges: updatedBadges,
276
- })
277
- .select();
278
- }
279
- break;
280
+ result = await supabase
281
+ .from("profiles")
282
+ .upsert({
283
+ id: params.userId,
284
+ badges: updatedBadges,
285
+ })
286
+ .select();
287
+ } else {
288
+ result = { data: null, error: { message: "Unauthorized" } };
289
+ }
290
+ break;
280
291
 
281
292
  case "getScoresPaginated":
282
293
  const offset = (params.page - 1) * params.limit;
@@ -338,27 +349,52 @@ export async function handler(
338
349
  }
339
350
 
340
351
  // Log the admin action
341
- await supabase.rpc("admin_log_action", {
342
- admin_id: queryUserData.id,
343
- action_type: operation,
344
- target_id: "userId" in params ? params.userId : null,
345
- details: { params },
346
- });
347
-
348
- if (result.error) {
349
- return NextResponse.json(
350
- {
351
- success: false,
352
- error: result.error.message,
353
- },
354
- { status: 500 }
355
- );
356
- }
357
-
358
- return NextResponse.json({
359
- success: true,
360
- result: result.data,
361
- });
352
+ await supabase.rpc("admin_log_action", {
353
+ admin_id: queryUserData.id,
354
+ action_type: operation,
355
+ target_id: "userId" in params ? params.userId : null,
356
+ details: { params },
357
+ });
358
+
359
+ if (result?.error) {
360
+ return NextResponse.json(
361
+ {
362
+ success: false,
363
+ error: result.error.message,
364
+ },
365
+ { status: 500 }
366
+ );
367
+ }
368
+
369
+ if (targetUserId !== null && !result?.error) {
370
+ await invalidateCachePrefix(`userscore:${targetUserId}`);
371
+
372
+ if (
373
+ operation === "removeAllScores" ||
374
+ operation === "invalidateRankedScores" ||
375
+ operation === "deleteUser"
376
+ ) {
377
+ const { data: beatmapHashes } = await supabase
378
+ .from("scores")
379
+ .select("beatmapHash")
380
+ .eq("userId", targetUserId);
381
+
382
+ const uniqueHashes = new Set(
383
+ (beatmapHashes || [])
384
+ .map((row) => row.beatmapHash)
385
+ .filter((hash): hash is string => Boolean(hash))
386
+ );
387
+
388
+ for (const hash of uniqueHashes) {
389
+ await invalidateCachePrefix(`beatmap-scores:${hash}`);
390
+ }
391
+ }
392
+ }
393
+
394
+ return NextResponse.json({
395
+ success: true,
396
+ result: result?.data,
397
+ });
362
398
  } catch (err: any) {
363
399
  return NextResponse.json(
364
400
  {
@@ -1,7 +1,8 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi, validUser } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { getCacheValue, setCacheValue } from "../utils/cache";
5
+ import { supabase } from "../utils/supabase";
5
6
 
6
7
  export const Schema = {
7
8
  input: z.strictObject({
@@ -34,16 +35,25 @@ export async function POST(request: Request): Promise<NextResponse> {
34
35
  });
35
36
  }
36
37
 
37
- export async function handler({
38
- page,
39
- }: (typeof Schema)["input"]["_type"]): Promise<
40
- NextResponse<(typeof Schema)["output"]["_type"]>
41
- > {
42
- let { data: userData, error: userError } = await supabase
43
- .from("beatmapPageComments")
44
- .select(
45
- `
46
- *,
38
+ export async function handler({
39
+ page,
40
+ }: (typeof Schema)["input"]["_type"]): Promise<
41
+ NextResponse<(typeof Schema)["output"]["_type"]>
42
+ > {
43
+ const cacheKey = `beatmap-comments:${page}`;
44
+ const cachedComments = await getCacheValue<
45
+ (typeof Schema)["output"]["_type"]["comments"]
46
+ >(cacheKey);
47
+
48
+ if (cachedComments) {
49
+ return NextResponse.json({ comments: cachedComments });
50
+ }
51
+
52
+ let { data: userData, error: userError } = await supabase
53
+ .from("beatmapPageComments")
54
+ .select(
55
+ `
56
+ *,
47
57
  profiles!inner(
48
58
  username,
49
59
  avatar_url,
@@ -51,7 +61,11 @@ export async function handler({
51
61
  )
52
62
  `
53
63
  )
54
- .eq("beatmapPage", page);
55
-
56
- return NextResponse.json({ comments: userData! });
57
- }
64
+ .eq("beatmapPage", page);
65
+
66
+ if (userData) {
67
+ await setCacheValue(cacheKey, userData);
68
+ }
69
+
70
+ return NextResponse.json({ comments: userData! });
71
+ }
@@ -1,17 +1,19 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi } from "../utils/requestUtils";
4
+ import { getCacheValue, setCacheValue } from "../utils/cache";
5
+ import { supabase } from "../utils/supabase";
5
6
 
6
- export const Schema = {
7
- input: z.strictObject({
8
- session: z.string(),
9
- id: z.number(),
10
- }),
11
- output: z.object({
12
- error: z.string().optional(),
13
- scores: z
14
- .array(
7
+ export const Schema = {
8
+ input: z.strictObject({
9
+ session: z.string(),
10
+ id: z.number(),
11
+ limit: z.number().min(1).max(200).default(50),
12
+ }),
13
+ output: z.object({
14
+ error: z.string().optional(),
15
+ scores: z
16
+ .array(
15
17
  z.object({
16
18
  id: z.number(),
17
19
  awarded_sp: z.number().nullable(),
@@ -66,15 +68,17 @@ export async function POST(request: Request): Promise<NextResponse> {
66
68
  });
67
69
  }
68
70
 
69
- export async function handler(
70
- data: (typeof Schema)["input"]["_type"],
71
- req: Request
72
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
73
- let { data: beatmapPage, error: errorlast } = await supabase
74
- .from("beatmapPages")
75
- .select(
76
- `
77
- *,
71
+ export async function handler(
72
+ data: (typeof Schema)["input"]["_type"],
73
+ req: Request
74
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
75
+ const limit = data.limit ?? 50;
76
+
77
+ let { data: beatmapPage, error: errorlast } = await supabase
78
+ .from("beatmapPages")
79
+ .select(
80
+ `
81
+ *,
78
82
  beatmaps (
79
83
  created_at,
80
84
  playcount,
@@ -93,28 +97,47 @@ export async function handler(
93
97
  avatar_url
94
98
  )
95
99
  `
96
- )
97
- .eq("id", data.id)
98
- .single();
99
-
100
- const { data: scoreData, error } = await supabase.rpc(
101
- "get_top_scores_for_beatmap",
102
- { beatmap_hash: beatmapPage?.latestBeatmapHash || "" }
103
- );
104
-
105
- if (error) {
106
- return NextResponse.json({ error: JSON.stringify(error) });
107
- }
108
-
109
- if (!beatmapPage) return NextResponse.json({});
110
-
111
- return NextResponse.json({
112
- scores: scoreData.map((score: any) => ({
113
- id: score.id,
114
- awarded_sp: score.awarded_sp,
115
- created_at: score.created_at,
116
- misses: score.misses,
117
- mods: score.mods,
100
+ )
101
+ .eq("id", data.id)
102
+ .single();
103
+
104
+ if (!beatmapPage) return NextResponse.json({});
105
+
106
+ const beatmapHash = beatmapPage?.latestBeatmapHash || "";
107
+ const isCacheable =
108
+ beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
109
+ const cacheKey = `beatmap-scores:${beatmapHash}:limit=${limit}`;
110
+
111
+ let scoreData: any[] | null = null;
112
+
113
+ if (isCacheable && beatmapHash) {
114
+ scoreData = await getCacheValue<any[]>(cacheKey);
115
+ }
116
+
117
+ if (!scoreData) {
118
+ const { data: rpcScores, error } = await supabase.rpc(
119
+ "get_top_scores_for_beatmap",
120
+ { beatmap_hash: beatmapHash }
121
+ );
122
+
123
+ if (error) {
124
+ return NextResponse.json({ error: JSON.stringify(error) });
125
+ }
126
+
127
+ scoreData = (rpcScores || []).slice(0, limit);
128
+
129
+ if (isCacheable && beatmapHash) {
130
+ await setCacheValue(cacheKey, scoreData);
131
+ }
132
+ }
133
+
134
+ return NextResponse.json({
135
+ scores: (scoreData || []).map((score: any) => ({
136
+ id: score.id,
137
+ awarded_sp: score.awarded_sp,
138
+ created_at: score.created_at,
139
+ misses: score.misses,
140
+ mods: score.mods,
118
141
  passed: score.passed,
119
142
  songId: score.songid,
120
143
  speed: score.speed,
@@ -1,17 +1,19 @@
1
- import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi } from "../utils/requestUtils";
4
+ import { getCacheValue, setCacheValue } from "../utils/cache";
5
+ import { supabase } from "../utils/supabase";
5
6
 
6
- export const Schema = {
7
- input: z.strictObject({
8
- session: z.string(),
9
- mapId: z.string(),
10
- }),
11
- output: z.object({
12
- error: z.string().optional(),
13
- scores: z
14
- .array(
7
+ export const Schema = {
8
+ input: z.strictObject({
9
+ session: z.string(),
10
+ mapId: z.string(),
11
+ limit: z.number().min(1).max(200).default(50),
12
+ }),
13
+ output: z.object({
14
+ error: z.string().optional(),
15
+ scores: z
16
+ .array(
15
17
  z.object({
16
18
  id: z.number(),
17
19
  awarded_sp: z.number().nullable(),
@@ -62,12 +64,14 @@ export async function POST(request: Request): Promise<NextResponse> {
62
64
  });
63
65
  }
64
66
 
65
- export async function handler(
66
- data: (typeof Schema)["input"]["_type"],
67
- req: Request
68
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
69
- let { data: beatmapPage, error: errorlast } = await supabase
70
- .from("beatmapPages")
67
+ export async function handler(
68
+ data: (typeof Schema)["input"]["_type"],
69
+ req: Request
70
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
71
+ const limit = data.limit ?? 50;
72
+
73
+ let { data: beatmapPage, error: errorlast } = await supabase
74
+ .from("beatmapPages")
71
75
  .select(
72
76
  `
73
77
  *,
@@ -89,27 +93,46 @@ export async function handler(
89
93
  )
90
94
  `
91
95
  )
92
- .eq("latestBeatmapHash", data.mapId)
93
- .single();
94
-
95
- if (!beatmapPage) return NextResponse.json({});
96
-
97
- const { data: scoreData, error } = await supabase.rpc(
98
- "get_top_scores_for_beatmap",
99
- { beatmap_hash: beatmapPage?.latestBeatmapHash || "" }
100
- );
101
-
102
- if (error) {
103
- return NextResponse.json({ error: JSON.stringify(error) });
104
- }
105
-
106
- return NextResponse.json({
107
- scores: scoreData.map((score: any) => ({
108
- id: score.id,
109
- awarded_sp: score.awarded_sp,
110
- created_at: score.created_at,
111
- misses: score.misses,
112
- mods: score.mods,
96
+ .eq("latestBeatmapHash", data.mapId)
97
+ .single();
98
+
99
+ if (!beatmapPage) return NextResponse.json({});
100
+
101
+ const beatmapHash = beatmapPage?.latestBeatmapHash || "";
102
+ const isCacheable =
103
+ beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
104
+ const cacheKey = `beatmap-scores:${beatmapHash}:limit=${limit}`;
105
+
106
+ let scoreData: any[] | null = null;
107
+
108
+ if (isCacheable && beatmapHash) {
109
+ scoreData = await getCacheValue<any[]>(cacheKey);
110
+ }
111
+
112
+ if (!scoreData) {
113
+ const { data: rpcScores, error } = await supabase.rpc(
114
+ "get_top_scores_for_beatmap",
115
+ { beatmap_hash: beatmapHash }
116
+ );
117
+
118
+ if (error) {
119
+ return NextResponse.json({ error: JSON.stringify(error) });
120
+ }
121
+
122
+ scoreData = (rpcScores || []).slice(0, limit);
123
+
124
+ if (isCacheable && beatmapHash) {
125
+ await setCacheValue(cacheKey, scoreData);
126
+ }
127
+ }
128
+
129
+ return NextResponse.json({
130
+ scores: (scoreData || []).map((score: any) => ({
131
+ id: score.id,
132
+ awarded_sp: score.awarded_sp,
133
+ created_at: score.created_at,
134
+ misses: score.misses,
135
+ mods: score.mods,
113
136
  passed: score.passed,
114
137
  songId: score.songid,
115
138
  speed: score.speed,