rhythia-api 216.0.0 → 225.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.
- package/.env +12 -0
- package/.yarnrc +1 -0
- package/README.md +2 -0
- package/api/createBeatmap.ts +1 -1
- package/api/deleteBeatmapPage.ts +12 -5
- package/api/executeAdminOperation.ts +39 -3
- package/api/getBadgeLeaders.ts +25 -3
- package/api/getBadgedUsers.ts +2 -1
- package/api/getBeatmapComments.ts +14 -0
- package/api/getBeatmapPage.ts +32 -9
- package/api/getBeatmapPageById.ts +39 -7
- package/api/getLeaderboard.ts +173 -44
- package/api/getOnlinePlayers.ts +78 -0
- package/api/getProfile.ts +21 -7
- package/api/getPublicStats.ts +5 -2
- package/api/getUserScores.ts +46 -17
- package/api/postBeatmapComment.ts +4 -0
- package/api/submitScore.ts +30 -0
- package/api/submitScoreInternal.ts +426 -0
- package/handleApi.ts +7 -3
- package/index.ts +54 -0
- package/package.json +7 -4
- package/types/database.ts +1179 -1084
- package/utils/activityStatus.ts +36 -0
- package/utils/cache.ts +60 -0
- package/utils/leaderboardCache.ts +8 -0
- package/utils/requestUtils.ts +2 -1
package/.env
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
REDIS_PORT=18304
|
|
2
|
+
REDIS_USERNAME=default
|
|
3
|
+
REDIS_PASSWORD=yg7hYF9LPhGJRmORVIjMB8FWP2axPGto
|
|
4
|
+
REDIS_HOST=redis-18304.c55.eu-central-1-1.ec2.cloud.redislabs.com
|
|
5
|
+
CF_NAMESPACE_ID=571cf88650514eeba649517a75ede74a
|
|
6
|
+
CF_API_TOKEN=Ldsh-UKJZw0feHmZOvhSCxaurwJBVW12LgnV_v38
|
|
7
|
+
CF_ACCOUNT_ID=571cf88650514eeba649517a75ede74a
|
|
8
|
+
TOKEN_SECRET=fa686bfdffd3758f6377abbc23bf3d9bdc1a0dda4a6e7f8dbdd579fa1ff6d7e2
|
|
9
|
+
ACCESS_BUCKET=003c245e893e8060000000003
|
|
10
|
+
SECRET_BUCKET=K003LpAu8X+2lJ09EB8NtPL/OZXV8ts
|
|
11
|
+
ADMIN_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBma2FqbmdibGxjYmR6b3lscnZwIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTcyOTAxNTMwNSwiZXhwIjoyMDQ0NTkxMzA1fQ.dKg8Wq3zZEBAH63V7q1D8a8N-d6qkB5Inm524Vmso3k
|
|
12
|
+
PROD_DO_NOT_WRITE_PG=postgresql://postgres:huaUpTz3d3p7w6VU@db.pfkajngbllcbdzoylrvp.supabase.co:5432/postgres
|
package/.yarnrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
registry: https://registry.npmjs.org/
|
package/README.md
CHANGED
|
@@ -7,3 +7,5 @@ Install dependencies with Bun, then reach for the scripts in `package.json`; `bu
|
|
|
7
7
|
Runtime secrets live in environment variables. Upload endpoints expect `ACCESS_BUCKET` and `SECRET_BUCKET` for S3, the purchase flow checks `BUY_SECRET`, auth helpers derive tokens from `TOKEN_SECRET`, and the Supabase admin calls need `ADMIN_KEY`. The deploy script also looks for `GIT_USER`, `GIT_KEY`, `SOURCE_BRANCH`, and `TARGET_BRANCH` when the CI job mirrors changes upstream.
|
|
8
8
|
|
|
9
9
|
If you need to point at a different stack, call `setEnvironment` with `development`, `testing`, or `production` before making requests so the helper talks to the right Rhythia host.
|
|
10
|
+
|
|
11
|
+
//
|
package/api/createBeatmap.ts
CHANGED
package/api/deleteBeatmapPage.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { protectedApi, validUser } from "../utils/requestUtils";
|
|
|
4
4
|
import { supabase } from "../utils/supabase";
|
|
5
5
|
import { getUserBySession } from "../utils/getUserBySession";
|
|
6
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,12 +55,13 @@ 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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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")
|
|
@@ -73,5 +75,10 @@ export async function handler({
|
|
|
73
75
|
.delete()
|
|
74
76
|
.eq("beatmapHash", beatmapData.beatmapHash);
|
|
75
77
|
|
|
78
|
+
await invalidateCache(`beatmap-comments:${id}`);
|
|
79
|
+
if (pageData.latestBeatmapHash) {
|
|
80
|
+
await invalidateCachePrefix(`beatmap-scores:${pageData.latestBeatmapHash}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
return NextResponse.json({});
|
|
77
84
|
}
|
|
@@ -5,6 +5,7 @@ import { protectedApi, validUser } from "../utils/requestUtils";
|
|
|
5
5
|
import { supabase } from "../utils/supabase";
|
|
6
6
|
import { getUserBySession } from "../utils/getUserBySession";
|
|
7
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 = {
|
|
@@ -148,9 +149,13 @@ export async function handler(
|
|
|
148
149
|
|
|
149
150
|
// Execute the requested admin operation
|
|
150
151
|
try {
|
|
151
|
-
let result;
|
|
152
|
+
let result: { data?: any; error?: any } | null = null;
|
|
152
153
|
const operation = data.data.operation;
|
|
153
154
|
const params = data.data.params as any;
|
|
155
|
+
const targetUserId =
|
|
156
|
+
"userId" in params && typeof params.userId === "number"
|
|
157
|
+
? params.userId
|
|
158
|
+
: null;
|
|
154
159
|
|
|
155
160
|
switch (operation) {
|
|
156
161
|
case "deleteUser":
|
|
@@ -226,6 +231,8 @@ export async function handler(
|
|
|
226
231
|
badges: JSON.parse(params.badges),
|
|
227
232
|
})
|
|
228
233
|
.select();
|
|
234
|
+
} else {
|
|
235
|
+
result = { data: null, error: { message: "Unauthorized" } };
|
|
229
236
|
}
|
|
230
237
|
break;
|
|
231
238
|
|
|
@@ -252,6 +259,8 @@ export async function handler(
|
|
|
252
259
|
} else {
|
|
253
260
|
result = { data: targetUser, error: null };
|
|
254
261
|
}
|
|
262
|
+
} else {
|
|
263
|
+
result = { data: null, error: { message: "Unauthorized" } };
|
|
255
264
|
}
|
|
256
265
|
break;
|
|
257
266
|
|
|
@@ -275,6 +284,8 @@ export async function handler(
|
|
|
275
284
|
badges: updatedBadges,
|
|
276
285
|
})
|
|
277
286
|
.select();
|
|
287
|
+
} else {
|
|
288
|
+
result = { data: null, error: { message: "Unauthorized" } };
|
|
278
289
|
}
|
|
279
290
|
break;
|
|
280
291
|
|
|
@@ -345,7 +356,7 @@ export async function handler(
|
|
|
345
356
|
details: { params },
|
|
346
357
|
});
|
|
347
358
|
|
|
348
|
-
if (result
|
|
359
|
+
if (result?.error) {
|
|
349
360
|
return NextResponse.json(
|
|
350
361
|
{
|
|
351
362
|
success: false,
|
|
@@ -355,9 +366,34 @@ export async function handler(
|
|
|
355
366
|
);
|
|
356
367
|
}
|
|
357
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
|
+
|
|
358
394
|
return NextResponse.json({
|
|
359
395
|
success: true,
|
|
360
|
-
result: result
|
|
396
|
+
result: result?.data,
|
|
361
397
|
});
|
|
362
398
|
} catch (err: any) {
|
|
363
399
|
return NextResponse.json(
|
package/api/getBadgeLeaders.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import z from "zod";
|
|
3
3
|
import { supabase } from "../utils/supabase";
|
|
4
|
+
import { getActiveProfileIdSet } from "../utils/activityStatus";
|
|
4
5
|
|
|
5
6
|
export const Schema = {
|
|
6
7
|
input: z.strictObject({
|
|
7
8
|
limit: z.number().min(1).max(100).optional().default(100),
|
|
9
|
+
include_inactive: z.boolean().optional().default(false),
|
|
8
10
|
}),
|
|
9
11
|
output: z.object({
|
|
10
12
|
leaderboard: z.array(
|
|
@@ -21,13 +23,20 @@ export const Schema = {
|
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
24
|
-
|
|
26
|
+
let body: unknown = {};
|
|
27
|
+
try {
|
|
28
|
+
body = await request.json();
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
return handler(Schema.input.parse(body));
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export async function handler({
|
|
28
35
|
limit = 100,
|
|
36
|
+
include_inactive = false,
|
|
29
37
|
}: {
|
|
30
38
|
limit?: number;
|
|
39
|
+
include_inactive?: boolean;
|
|
31
40
|
}): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
32
41
|
try {
|
|
33
42
|
const { data: leaderboard, error } = await supabase.rpc(
|
|
@@ -49,9 +58,22 @@ export async function handler({
|
|
|
49
58
|
);
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
const entries = leaderboard || [];
|
|
62
|
+
|
|
63
|
+
if (include_inactive) {
|
|
64
|
+
return NextResponse.json({
|
|
65
|
+
leaderboard: entries,
|
|
66
|
+
total_count: entries.length,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const activeIds = await getActiveProfileIdSet(entries.map((entry) => entry.id));
|
|
71
|
+
|
|
72
|
+
const filteredLeaderboard = entries.filter((entry) => activeIds.has(entry.id));
|
|
73
|
+
|
|
52
74
|
return NextResponse.json({
|
|
53
|
-
leaderboard:
|
|
54
|
-
total_count:
|
|
75
|
+
leaderboard: filteredLeaderboard,
|
|
76
|
+
total_count: filteredLeaderboard.length,
|
|
55
77
|
});
|
|
56
78
|
} catch (error) {
|
|
57
79
|
console.error("Badge leaderboard exception:", error);
|
package/api/getBadgedUsers.ts
CHANGED
|
@@ -40,7 +40,8 @@ export async function handler(
|
|
|
40
40
|
export async function getLeaderboard(badge: string) {
|
|
41
41
|
let { data: queryData, error } = await supabase
|
|
42
42
|
.from("profiles")
|
|
43
|
-
.select("flag,id,username,badges")
|
|
43
|
+
.select("flag,id,username,badges,scores!inner(id)")
|
|
44
|
+
.limit(1, { foreignTable: "scores" });
|
|
44
45
|
|
|
45
46
|
const users = queryData?.filter((e) =>
|
|
46
47
|
((e.badges || []) as string[]).includes(badge)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import z from "zod";
|
|
3
3
|
import { protectedApi, validUser } from "../utils/requestUtils";
|
|
4
|
+
import { getCacheValue, setCacheValue } from "../utils/cache";
|
|
4
5
|
import { supabase } from "../utils/supabase";
|
|
5
6
|
|
|
6
7
|
export const Schema = {
|
|
@@ -39,6 +40,15 @@ export async function handler({
|
|
|
39
40
|
}: (typeof Schema)["input"]["_type"]): Promise<
|
|
40
41
|
NextResponse<(typeof Schema)["output"]["_type"]>
|
|
41
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
|
+
|
|
42
52
|
let { data: userData, error: userError } = await supabase
|
|
43
53
|
.from("beatmapPageComments")
|
|
44
54
|
.select(
|
|
@@ -53,5 +63,9 @@ export async function handler({
|
|
|
53
63
|
)
|
|
54
64
|
.eq("beatmapPage", page);
|
|
55
65
|
|
|
66
|
+
if (userData) {
|
|
67
|
+
await setCacheValue(cacheKey, userData);
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
return NextResponse.json({ comments: userData! });
|
|
57
71
|
}
|
package/api/getBeatmapPage.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import z from "zod";
|
|
3
3
|
import { protectedApi } from "../utils/requestUtils";
|
|
4
|
+
import { getCacheValue, setCacheValue } from "../utils/cache";
|
|
4
5
|
import { supabase } from "../utils/supabase";
|
|
5
6
|
|
|
6
7
|
export const Schema = {
|
|
7
8
|
input: z.strictObject({
|
|
8
9
|
session: z.string(),
|
|
9
10
|
id: z.number(),
|
|
11
|
+
limit: z.number().min(1).max(200).default(50),
|
|
10
12
|
}),
|
|
11
13
|
output: z.object({
|
|
12
14
|
error: z.string().optional(),
|
|
@@ -70,6 +72,8 @@ export async function handler(
|
|
|
70
72
|
data: (typeof Schema)["input"]["_type"],
|
|
71
73
|
req: Request
|
|
72
74
|
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
75
|
+
const limit = data.limit ?? 50;
|
|
76
|
+
|
|
73
77
|
let { data: beatmapPage, error: errorlast } = await supabase
|
|
74
78
|
.from("beatmapPages")
|
|
75
79
|
.select(
|
|
@@ -97,19 +101,38 @@ export async function handler(
|
|
|
97
101
|
.eq("id", data.id)
|
|
98
102
|
.single();
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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}`;
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
return NextResponse.json({ error: JSON.stringify(error) });
|
|
107
|
-
}
|
|
111
|
+
// let scoreData: any[] | null = null;
|
|
108
112
|
|
|
109
|
-
if (
|
|
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
|
+
// }
|
|
110
133
|
|
|
111
134
|
return NextResponse.json({
|
|
112
|
-
scores:
|
|
135
|
+
scores: [].map((score: any) => ({
|
|
113
136
|
id: score.id,
|
|
114
137
|
awarded_sp: score.awarded_sp,
|
|
115
138
|
created_at: score.created_at,
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import z from "zod";
|
|
3
3
|
import { protectedApi } from "../utils/requestUtils";
|
|
4
|
+
import { getCacheValue, setCacheValue } from "../utils/cache";
|
|
4
5
|
import { supabase } from "../utils/supabase";
|
|
6
|
+
import { getActiveProfileIdSet } from "../utils/activityStatus";
|
|
5
7
|
|
|
6
8
|
export const Schema = {
|
|
7
9
|
input: z.strictObject({
|
|
8
10
|
session: z.string(),
|
|
9
11
|
mapId: z.string(),
|
|
12
|
+
limit: z.number().min(1).max(200).default(50),
|
|
10
13
|
}),
|
|
11
14
|
output: z.object({
|
|
12
15
|
error: z.string().optional(),
|
|
@@ -66,6 +69,8 @@ export async function handler(
|
|
|
66
69
|
data: (typeof Schema)["input"]["_type"],
|
|
67
70
|
req: Request
|
|
68
71
|
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
72
|
+
const limit = data.limit ?? 50;
|
|
73
|
+
|
|
69
74
|
let { data: beatmapPage, error: errorlast } = await supabase
|
|
70
75
|
.from("beatmapPages")
|
|
71
76
|
.select(
|
|
@@ -94,17 +99,44 @@ export async function handler(
|
|
|
94
99
|
|
|
95
100
|
if (!beatmapPage) return NextResponse.json({});
|
|
96
101
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
const beatmapHash = beatmapPage?.latestBeatmapHash || "";
|
|
103
|
+
const isCacheable =
|
|
104
|
+
beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
|
|
105
|
+
const cacheKey = `beatmap-scores:${beatmapHash}`;
|
|
106
|
+
|
|
107
|
+
let scoreData: any[] | null = null;
|
|
108
|
+
|
|
109
|
+
if (isCacheable && beatmapHash) {
|
|
110
|
+
scoreData = await getCacheValue<any[]>(cacheKey);
|
|
111
|
+
}
|
|
101
112
|
|
|
102
|
-
if (
|
|
103
|
-
|
|
113
|
+
if (!scoreData) {
|
|
114
|
+
const { data: rpcScores, error } = await supabase.rpc(
|
|
115
|
+
"get_top_scores_for_beatmap",
|
|
116
|
+
{ beatmap_hash: beatmapHash }
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (error) {
|
|
120
|
+
return NextResponse.json({ error: JSON.stringify(error) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scoreData = (rpcScores || []).slice(0, 200);
|
|
124
|
+
|
|
125
|
+
if (isCacheable && beatmapHash) {
|
|
126
|
+
await setCacheValue(cacheKey, scoreData);
|
|
127
|
+
}
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
const userIds = Array.from(
|
|
131
|
+
new Set((scoreData || []).map((score) => score.userid).filter(Boolean))
|
|
132
|
+
);
|
|
133
|
+
const activeUserIds = await getActiveProfileIdSet(userIds);
|
|
134
|
+
const visibleScores = (scoreData || [])
|
|
135
|
+
.filter((score) => activeUserIds.has(score.userid))
|
|
136
|
+
.slice(0, limit);
|
|
137
|
+
|
|
106
138
|
return NextResponse.json({
|
|
107
|
-
scores:
|
|
139
|
+
scores: visibleScores.map((score: any) => ({
|
|
108
140
|
id: score.id,
|
|
109
141
|
awarded_sp: score.awarded_sp,
|
|
110
142
|
created_at: score.created_at,
|