rhythia-api 215.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.
@@ -0,0 +1,78 @@
1
+ import { NextResponse } from "next/server";
2
+ import z from "zod";
3
+ import { protectedApi } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+
6
+ const ONLINE_WINDOW_MS = 30 * 60 * 1000;
7
+
8
+ export const Schema = {
9
+ input: z.strictObject({}),
10
+ output: z.object({
11
+ error: z.string().optional(),
12
+ players: z.array(
13
+ z.object({
14
+ id: z.number(),
15
+ name: z.string().nullable(),
16
+ profilePictureUrl: z.string().nullable(),
17
+ })
18
+ ),
19
+ }),
20
+ };
21
+
22
+ export async function POST(request: Request): Promise<NextResponse> {
23
+ return protectedApi({
24
+ request,
25
+ schema: Schema,
26
+ authorization: () => {},
27
+ activity: handler,
28
+ });
29
+ }
30
+
31
+ export async function handler(
32
+ _data: (typeof Schema)["input"]["_type"]
33
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
34
+ const cutoff = Date.now() - ONLINE_WINDOW_MS;
35
+
36
+ const { data: activityRows, error: activityError } = await supabase
37
+ .from("profileActivities")
38
+ .select("uid")
39
+ .gt("last_activity", cutoff);
40
+
41
+ if (activityError) {
42
+ return NextResponse.json(
43
+ { players: [], error: "Failed to load online players" },
44
+ { status: 500 }
45
+ );
46
+ }
47
+
48
+ const onlineUids = Array.from(
49
+ new Set((activityRows || []).map((row) => row.uid).filter(Boolean))
50
+ );
51
+
52
+ if (!onlineUids.length) {
53
+ return NextResponse.json({ players: [] });
54
+ }
55
+
56
+ const { data: profiles, error: profilesError } = await supabase
57
+ .from("profiles")
58
+ .select("id,username,avatar_url,profile_image,skill_points")
59
+ .in("uid", onlineUids)
60
+ .neq("ban", "excluded")
61
+ .order("skill_points", { ascending: false });
62
+
63
+ if (profilesError) {
64
+ return NextResponse.json(
65
+ { players: [], error: "Failed to load online players" },
66
+ { status: 500 }
67
+ );
68
+ }
69
+
70
+ const players =
71
+ profiles?.map((profile) => ({
72
+ id: profile.id,
73
+ name: profile.username,
74
+ profilePictureUrl: profile.profile_image || profile.avatar_url,
75
+ })) || [];
76
+
77
+ return NextResponse.json({ players });
78
+ }
@@ -1,7 +1,8 @@
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 { getCacheValue, setCacheValue } from "../utils/cache";
4
+ import { protectedApi } from "../utils/requestUtils";
5
+ import { supabase } from "../utils/supabase";
5
6
 
6
7
  export const Schema = {
7
8
  input: z.strictObject({
@@ -87,33 +88,61 @@ export async function POST(request: Request): Promise<NextResponse> {
87
88
  });
88
89
  }
89
90
 
90
- export async function handler(
91
- data: (typeof Schema)["input"]["_type"],
92
- req: Request
93
- ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
94
- // Call the RPC created earlier
95
- const { data: result, error } = await supabase.rpc(
96
- "get_user_scores_summary",
97
- {
98
- userid: data.id,
99
- limit_param: data.limit,
100
- }
101
- );
91
+ export async function handler(
92
+ data: (typeof Schema)["input"]["_type"],
93
+ req: Request
94
+ ): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
95
+ const limit = data.limit ?? 10;
96
+ const cacheKey = `userscore:${data.id}:limit=${limit}`;
97
+ const cachedValue = await getCacheValue<(typeof Schema)["output"]["_type"]>(
98
+ cacheKey
99
+ );
100
+
101
+ if (cachedValue !== null) {
102
+ return NextResponse.json(cachedValue);
103
+ }
104
+
105
+ // parallel RPCs
106
+ const [
107
+ { data: lastDay, error: err1 },
108
+ { data: topAndStats, error: err2 },
109
+ { data: reign, error: err3 },
110
+ ] = await Promise.all([
111
+ supabase.rpc("get_user_scores_lastday", {
112
+ userid: data.id,
113
+ limit_param: limit,
114
+ }),
115
+ supabase.rpc("get_user_scores_top_and_stats", {
116
+ userid: data.id,
117
+ limit_param: limit,
118
+ }),
119
+ supabase.rpc("get_user_scores_reign", {
120
+ userid: data.id,
121
+ }),
122
+ ]);
102
123
 
103
- if (error) {
104
- return NextResponse.json({ error: JSON.stringify(error) });
124
+ const err = err1 || err2 || err3;
125
+ if (err) {
126
+ return NextResponse.json({ error: JSON.stringify(err) });
105
127
  }
106
128
 
107
- if (!result) {
108
- return NextResponse.json({ error: "No data returned" });
109
- }
110
-
111
- // The RPC may return { error: "..." } as a JSON object
112
- if (typeof result === "object" && result !== null && "error" in result) {
113
- return NextResponse.json({ error: String((result as any).error) });
114
- }
129
+ // Extract pieces from the { top, stats } object
130
+ let top: unknown[] | undefined;
131
+ let stats: { totalScores: number; spinScores: number } | undefined;
115
132
 
116
- // Trust the RPC's JSON shape to match Schema.output
117
- const typedResult = result as (typeof Schema)["output"]["_type"];
118
- return NextResponse.json(typedResult);
119
- }
133
+ if (topAndStats && typeof topAndStats === "object") {
134
+ top = (topAndStats as any).top ?? [];
135
+ stats = (topAndStats as any).stats;
136
+ }
137
+
138
+ const responseBody = {
139
+ lastDay: (lastDay as any) ?? [],
140
+ top: (top as any) ?? [],
141
+ stats,
142
+ reign: (reign as any) ?? [],
143
+ };
144
+
145
+ await setCacheValue(cacheKey, responseBody);
146
+
147
+ return NextResponse.json(responseBody);
148
+ }
@@ -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 { User } from "@supabase/supabase-js";
6
- import { getUserBySession } from "../utils/getUserBySession";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+ import { User } from "@supabase/supabase-js";
6
+ import { getUserBySession } from "../utils/getUserBySession";
7
+ import { invalidateCache } from "../utils/cache";
7
8
 
8
9
  export const Schema = {
9
10
  input: z.strictObject({
@@ -45,18 +46,21 @@ export async function handler({
45
46
  if (content.length > 256)
46
47
  return NextResponse.json({ error: "Comment exceeds length." });
47
48
 
48
- const upserted = await supabase
49
- .from("beatmapPageComments")
50
- .upsert({
51
- beatmapPage: page,
52
- owner: userData.id,
53
- content,
54
- })
55
- .select("*")
56
- .single();
57
-
58
- if (upserted.error?.message.length) {
59
- return NextResponse.json({ error: upserted.error.message });
60
- }
61
- return NextResponse.json({});
62
- }
49
+ const upserted = await supabase
50
+ .from("beatmapPageComments")
51
+ .upsert({
52
+ beatmapPage: page,
53
+ owner: userData.id,
54
+ content,
55
+ })
56
+ .select("*")
57
+ .single();
58
+
59
+ if (upserted.error?.message.length) {
60
+ return NextResponse.json({ error: upserted.error.message });
61
+ }
62
+
63
+ await invalidateCache(`beatmap-comments:${page}`);
64
+
65
+ return NextResponse.json({});
66
+ }
@@ -1,11 +1,12 @@
1
1
  import { NextResponse } from "next/server";
2
- import z from "zod";
3
- import { protectedApi, validUser } from "../utils/requestUtils";
4
- import { supabase } from "../utils/supabase";
5
- import { decryptString } from "../utils/security";
6
- import { isEqual } from "lodash";
7
- import { getUserBySession } from "../utils/getUserBySession";
8
- import { User } from "@supabase/supabase-js";
2
+ import z from "zod";
3
+ import { protectedApi, validUser } from "../utils/requestUtils";
4
+ import { supabase } from "../utils/supabase";
5
+ import { decryptString } from "../utils/security";
6
+ import { isEqual } from "lodash";
7
+ import { getUserBySession } from "../utils/getUserBySession";
8
+ import { User } from "@supabase/supabase-js";
9
+ import { invalidateCache, invalidateCachePrefix } from "../utils/cache";
9
10
 
10
11
  export const Schema = {
11
12
  input: z.strictObject({
@@ -214,6 +215,27 @@ export async function handler({
214
215
 
215
216
  console.log("p1");
216
217
 
218
+ // auto-exclude: if a newly-created account (>600 RP) submits a score
219
+ try {
220
+ const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
221
+ if (
222
+ awarded_sp > 600 &&
223
+ userData?.created_at &&
224
+ Date.now() - userData.created_at < ONE_WEEK
225
+ ) {
226
+ await supabase
227
+ .from("profiles")
228
+ .upsert({ id: userData.id, ban: "excluded", bannedAt: Date.now() });
229
+
230
+ return NextResponse.json(
231
+ { error: "User excluded due to suspicious activity." },
232
+ { status: 400 }
233
+ );
234
+ }
235
+ } catch (e) {
236
+ console.error("safen/ auto-exclude check failed:", e);
237
+ }
238
+
217
239
  let parsed = [];
218
240
 
219
241
  try {
@@ -271,16 +293,24 @@ export async function handler({
271
293
  const spinTotalSp = weightCalculate(spinHashMap);
272
294
 
273
295
  console.log("VERSION: " + version);
274
- await supabase.from("profiles").upsert({
275
- id: userData.id,
276
- play_count: (userData.play_count || 0) + 1,
277
- skill_points: Math.round(totalSp * 100) / 100,
278
- spin_skill_points: Math.round(spinTotalSp * 100) / 100,
279
- squares_hit: (userData.squares_hit || 0) + data.hits,
280
- });
281
- console.log("p3");
282
- // Grant special badges if applicable
283
- if (passed && beatmapPages && !data.mods.includes("mod_nofail")) {
296
+ await supabase.from("profiles").upsert({
297
+ id: userData.id,
298
+ play_count: (userData.play_count || 0) + 1,
299
+ skill_points: Math.round(totalSp * 100) / 100,
300
+ spin_skill_points: Math.round(spinTotalSp * 100) / 100,
301
+ squares_hit: (userData.squares_hit || 0) + data.hits,
302
+ });
303
+ console.log("p3");
304
+
305
+ await invalidateCachePrefix(`userscore:${userData.id}`);
306
+ const beatmapIsRanked =
307
+ beatmapPages?.status === "RANKED" || beatmapPages?.status === "APPROVED";
308
+ if (beatmapIsRanked) {
309
+ await invalidateCachePrefix(`beatmap-scores:${data.mapHash}`);
310
+ }
311
+
312
+ // Grant special badges if applicable
313
+ if (passed && beatmapPages && !data.mods.includes("mod_nofail")) {
284
314
  try {
285
315
  const { data: badgeResult, error: badgeError } = await supabase.rpc(
286
316
  "grant_special_badges",
@@ -74,21 +74,23 @@ export async function handler({
74
74
  if (userData.id !== pageData?.owner)
75
75
  return NextResponse.json({ error: "Non-authz user." });
76
76
 
77
- if (pageData?.status !== "UNRANKED")
78
- return NextResponse.json({ error: "Only unranked maps can be updated" });
79
-
80
- const upsertPayload = {
77
+ const upsertPayload: any = {
81
78
  id,
82
- genre: "",
83
- status: "UNRANKED",
84
79
  owner: userData.id,
85
- description: description ? description : pageData.description,
86
- tags: tags ? tags : pageData.tags,
87
- video_url: videoUrl ? videoUrl : pageData.video_url,
88
80
  updated_at: Date.now(),
89
- } as any;
81
+ };
82
+
83
+ if (typeof description !== "undefined")
84
+ upsertPayload.description = description;
85
+ if (typeof tags !== "undefined") upsertPayload.tags = tags;
86
+ if (typeof videoUrl !== "undefined") upsertPayload.video_url = videoUrl;
90
87
 
91
88
  if (beatmapHash && beatmapData) {
89
+ if (pageData?.status !== "UNRANKED") {
90
+ return NextResponse.json({
91
+ error: "Only unranked maps can be updated",
92
+ });
93
+ }
92
94
  upsertPayload["title"] = beatmapData.title;
93
95
  upsertPayload["latestBeatmapHash"] = beatmapHash;
94
96
  upsertPayload["nominations"] = [];
package/index.ts CHANGED
@@ -425,15 +425,16 @@ export const getBeatmapComments = handleApi({url:"/api/getBeatmapComments",...Ge
425
425
  // ./api/getBeatmapPage.ts API
426
426
 
427
427
  /*
428
- export const Schema = {
429
- input: z.strictObject({
430
- session: z.string(),
431
- id: z.number(),
432
- }),
433
- output: z.object({
434
- error: z.string().optional(),
435
- scores: z
436
- .array(
428
+ export const Schema = {
429
+ input: z.strictObject({
430
+ session: z.string(),
431
+ id: z.number(),
432
+ limit: z.number().min(1).max(200).default(50),
433
+ }),
434
+ output: z.object({
435
+ error: z.string().optional(),
436
+ scores: z
437
+ .array(
437
438
  z.object({
438
439
  id: z.number(),
439
440
  awarded_sp: z.number().nullable(),
@@ -485,15 +486,16 @@ export const getBeatmapPage = handleApi({url:"/api/getBeatmapPage",...GetBeatmap
485
486
  // ./api/getBeatmapPageById.ts API
486
487
 
487
488
  /*
488
- export const Schema = {
489
- input: z.strictObject({
490
- session: z.string(),
491
- mapId: z.string(),
492
- }),
493
- output: z.object({
494
- error: z.string().optional(),
495
- scores: z
496
- .array(
489
+ export const Schema = {
490
+ input: z.strictObject({
491
+ session: z.string(),
492
+ mapId: z.string(),
493
+ limit: z.number().min(1).max(200).default(50),
494
+ }),
495
+ output: z.object({
496
+ error: z.string().optional(),
497
+ scores: z
498
+ .array(
497
499
  z.object({
498
500
  id: z.number(),
499
501
  awarded_sp: z.number().nullable(),
@@ -579,6 +581,7 @@ export const Schema = {
579
581
  ownerAvatar: z.string().nullable().optional(),
580
582
  status: z.string().nullable().optional(),
581
583
  tags: z.string().nullable().optional(),
584
+ videoUrl: z.string().nullable().optional(),
582
585
  })
583
586
  )
584
587
  .optional(),
@@ -841,6 +844,26 @@ import { Schema as GetMapUploadUrl } from "./api/getMapUploadUrl"
841
844
  export { Schema as SchemaGetMapUploadUrl } from "./api/getMapUploadUrl"
842
845
  export const getMapUploadUrl = handleApi({url:"/api/getMapUploadUrl",...GetMapUploadUrl})
843
846
 
847
+ // ./api/getOnlinePlayers.ts API
848
+
849
+ /*
850
+ export const Schema = {
851
+ input: z.strictObject({}),
852
+ output: z.object({
853
+ error: z.string().optional(),
854
+ players: z.array(
855
+ z.object({
856
+ id: z.number(),
857
+ name: z.string().nullable(),
858
+ profilePictureUrl: z.string().nullable(),
859
+ })
860
+ ),
861
+ }),
862
+ };*/
863
+ import { Schema as GetOnlinePlayers } from "./api/getOnlinePlayers"
864
+ export { Schema as SchemaGetOnlinePlayers } from "./api/getOnlinePlayers"
865
+ export const getOnlinePlayers = handleApi({url:"/api/getOnlinePlayers",...GetOnlinePlayers})
866
+
844
867
  // ./api/getPassToken.ts API
845
868
 
846
869
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "215.0.0",
3
+ "version": "217.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
@@ -40,6 +40,7 @@
40
40
  "osu-classes": "^3.1.0",
41
41
  "osu-parsers": "^4.1.7",
42
42
  "osu-standard-stable": "^5.0.0",
43
+ "remote-cloudflare-kv": "^1.0.1",
43
44
  "sharp": "^0.33.5",
44
45
  "short-uuid": "^5.2.0",
45
46
  "simple-git": "^3.25.0",
@@ -50,5 +51,6 @@
50
51
  "validator": "^13.12.0",
51
52
  "zero-width": "^1.0.29",
52
53
  "zod": "^3.24.2"
53
- }
54
+ },
55
+ "packageManager": "yarn@1.22.22"
54
56
  }