rhythia-api 230.0.0 → 231.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,78 +1,94 @@
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 { getUserBySession } from "../utils/getUserBySession";
6
- import { User } from "@supabase/supabase-js";
7
-
8
- export const Schema = {
9
- input: z.strictObject({
10
- session: z.string(),
11
- mapId: z.number(),
12
- }),
13
- output: z.object({
14
- error: z.string().optional(),
15
- }),
16
- };
17
-
18
- export async function POST(request: Request) {
19
- return protectedApi({
20
- request,
21
- schema: Schema,
22
- authorization: validUser,
23
- activity: handler,
24
- });
25
- }
26
-
27
- export async function handler(data: (typeof Schema)["input"]["_type"]) {
28
- const user = (await getUserBySession(data.session)) as User;
29
- let { data: queryUserData, error: userError } = await supabase
30
- .from("profiles")
31
- .select("*")
32
- .eq("uid", user.id)
33
- .single();
34
-
35
- if (!queryUserData) {
36
- return NextResponse.json({ error: "Can't find user" });
37
- }
38
-
39
- const tags = (queryUserData?.badges || []) as string[];
40
-
41
- if (!tags.includes("MMT")) {
42
- return NextResponse.json({ error: "Only MMTs can approve maps!" });
43
- }
44
-
45
- const { data: mapData, error } = await supabase
46
- .from("beatmapPages")
47
- .select("id,nominations,owner")
48
- .eq("id", data.mapId)
49
- .single();
50
-
51
- if (!mapData) {
52
- return NextResponse.json({ error: "Bad map" });
53
- }
54
-
55
- if (mapData.owner == queryUserData.id) {
56
- return NextResponse.json({ error: "Can't approve own map" });
57
- }
58
-
59
- if ((mapData.nominations as number[])!.length < 2) {
60
- return NextResponse.json({
61
- error: "Maps can get approved only if they have 2 nominations",
62
- });
63
- }
64
-
65
- if ((mapData.nominations as number[]).includes(queryUserData.id)) {
66
- return NextResponse.json({
67
- error: "Can't nominate and approve",
68
- });
69
- }
70
-
71
- await supabase.from("beatmapPages").upsert({
72
- id: data.mapId,
73
- status: "RANKED",
74
- ranked_at: Date.now(),
75
- });
76
-
77
- return NextResponse.json({});
78
- }
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 { getUserBySession } from "../utils/getUserBySession";
6
+ import { User } from "@supabase/supabase-js";
7
+
8
+ const VETO_BADGES = ["Team Ranked"];
9
+
10
+ export const Schema = {
11
+ input: z.strictObject({
12
+ session: z.string(),
13
+ mapId: z.number(),
14
+ reason: z.string().min(1).max(1000),
15
+ }),
16
+ output: z.object({
17
+ error: z.string().optional(),
18
+ }),
19
+ };
20
+
21
+ export async function POST(request: Request) {
22
+ return protectedApi({
23
+ request,
24
+ schema: Schema,
25
+ authorization: validUser,
26
+ activity: handler,
27
+ });
28
+ }
29
+
30
+ export async function handler(data: (typeof Schema)["input"]["_type"]) {
31
+ const user = (await getUserBySession(data.session)) as User;
32
+ const { data: queryUserData } = await supabase
33
+ .from("profiles")
34
+ .select("*")
35
+ .eq("uid", user.id)
36
+ .single();
37
+
38
+ if (!queryUserData) {
39
+ return NextResponse.json({ error: "Can't find user" });
40
+ }
41
+
42
+ const tags = (queryUserData?.badges || []) as string[];
43
+ const hasVetoAccess = VETO_BADGES.some((badge) => tags.includes(badge));
44
+
45
+ if (!hasVetoAccess) {
46
+ return NextResponse.json({ error: "Only management can veto maps!" });
47
+ }
48
+
49
+ const { data: mapData } = await supabase
50
+ .from("beatmapPages")
51
+ .select("id,qualified")
52
+ .eq("id", data.mapId)
53
+ .single();
54
+
55
+ if (!mapData) {
56
+ return NextResponse.json({ error: "Bad map" });
57
+ }
58
+
59
+ if (!mapData.qualified) {
60
+ return NextResponse.json({
61
+ error: "Only qualified maps can be vetoed",
62
+ });
63
+ }
64
+
65
+ const { data: vetoData, error: vetoError } = await supabase
66
+ .from("vetos")
67
+ .insert({
68
+ beatmapPage: data.mapId,
69
+ user: queryUserData.id,
70
+ veto_reason: data.reason,
71
+ })
72
+ .select("id")
73
+ .single();
74
+
75
+ if (vetoError) {
76
+ return NextResponse.json({ error: vetoError.message });
77
+ }
78
+
79
+ const { error: updateError } = await supabase.from("beatmapPages").upsert({
80
+ id: data.mapId,
81
+ qualified: false,
82
+ qualifiedAt: null,
83
+ });
84
+
85
+ if (updateError) {
86
+ if (vetoData?.id) {
87
+ await supabase.from("vetos").delete().eq("id", vetoData.id);
88
+ }
89
+
90
+ return NextResponse.json({ error: updateError.message });
91
+ }
92
+
93
+ return NextResponse.json({});
94
+ }
package/index.ts CHANGED
@@ -33,22 +33,6 @@ import { Schema as AddCollectionMap } from "./api/addCollectionMap"
33
33
  export { Schema as SchemaAddCollectionMap } from "./api/addCollectionMap"
34
34
  export const addCollectionMap = handleApi({url:"/api/addCollectionMap",...AddCollectionMap})
35
35
 
36
- // ./api/approveMap.ts API
37
-
38
- /*
39
- export const Schema = {
40
- input: z.strictObject({
41
- session: z.string(),
42
- mapId: z.number(),
43
- }),
44
- output: z.object({
45
- error: z.string().optional(),
46
- }),
47
- };*/
48
- import { Schema as ApproveMap } from "./api/approveMap"
49
- export { Schema as SchemaApproveMap } from "./api/approveMap"
50
- export const approveMap = handleApi({url:"/api/approveMap",...ApproveMap})
51
-
52
36
  // ./api/chartPublicStats.ts API
53
37
 
54
38
  /*
@@ -60,6 +44,22 @@ import { Schema as ChartPublicStats } from "./api/chartPublicStats"
60
44
  export { Schema as SchemaChartPublicStats } from "./api/chartPublicStats"
61
45
  export const chartPublicStats = handleApi({url:"/api/chartPublicStats",...ChartPublicStats})
62
46
 
47
+ // ./api/checkQualified.ts API
48
+
49
+ /*
50
+ export const Schema = {
51
+ input: z.strictObject({
52
+ secret: z.string(),
53
+ }),
54
+ output: z.object({
55
+ error: z.string().optional(),
56
+ updated: z.number(),
57
+ }),
58
+ };*/
59
+ import { Schema as CheckQualified } from "./api/checkQualified"
60
+ export { Schema as SchemaCheckQualified } from "./api/checkQualified"
61
+ export const checkQualified = handleApi({url:"/api/checkQualified",...CheckQualified})
62
+
63
63
  // ./api/createBeatmap.ts API
64
64
 
65
65
  /*
@@ -469,16 +469,30 @@ export const Schema = {
469
469
  image: z.string().nullable().optional(),
470
470
  imageLarge: z.string().nullable().optional(),
471
471
  starRating: z.number().nullable().optional(),
472
- owner: z.number().nullable().optional(),
473
- ownerUsername: z.string().nullable().optional(),
474
- ownerAvatar: z.string().nullable().optional(),
475
- status: z.string().nullable().optional(),
476
- description: z.string().nullable().optional(),
477
- tags: z.string().nullable().optional(),
478
- videoUrl: z.string().nullable().optional(),
479
- })
480
- .optional(),
481
- }),
472
+ owner: z.number().nullable().optional(),
473
+ ownerUsername: z.string().nullable().optional(),
474
+ ownerAvatar: z.string().nullable().optional(),
475
+ status: z.string().nullable().optional(),
476
+ qualified: z.boolean().nullable().optional(),
477
+ qualifiedAt: z.string().nullable().optional(),
478
+ description: z.string().nullable().optional(),
479
+ tags: z.string().nullable().optional(),
480
+ videoUrl: z.string().nullable().optional(),
481
+ vetos: z
482
+ .array(
483
+ z.object({
484
+ id: z.number(),
485
+ userId: z.number().nullable().optional(),
486
+ username: z.string().nullable().optional(),
487
+ avatar_url: z.string().nullable().optional(),
488
+ veto_reason: z.string().nullable().optional(),
489
+ created_at: z.string().nullable().optional(),
490
+ })
491
+ )
492
+ .optional(),
493
+ })
494
+ .optional(),
495
+ }),
482
496
  };*/
483
497
  import { Schema as GetBeatmapPage } from "./api/getBeatmapPage"
484
498
  export { Schema as SchemaGetBeatmapPage } from "./api/getBeatmapPage"
@@ -528,14 +542,28 @@ export const Schema = {
528
542
  beatmapFile: z.string().nullable().optional(),
529
543
  image: z.string().nullable().optional(),
530
544
  starRating: z.number().nullable().optional(),
531
- owner: z.number().nullable().optional(),
532
- ownerUsername: z.string().nullable().optional(),
533
- ownerAvatar: z.string().nullable().optional(),
534
- status: z.string().nullable().optional(),
535
- videoUrl: z.string().nullable(),
536
- })
537
- .optional(),
538
- }),
545
+ owner: z.number().nullable().optional(),
546
+ ownerUsername: z.string().nullable().optional(),
547
+ ownerAvatar: z.string().nullable().optional(),
548
+ status: z.string().nullable().optional(),
549
+ qualified: z.boolean().nullable().optional(),
550
+ qualifiedAt: z.string().nullable().optional(),
551
+ videoUrl: z.string().nullable(),
552
+ vetos: z
553
+ .array(
554
+ z.object({
555
+ id: z.number(),
556
+ userId: z.number().nullable().optional(),
557
+ username: z.string().nullable().optional(),
558
+ avatar_url: z.string().nullable().optional(),
559
+ veto_reason: z.string().nullable().optional(),
560
+ created_at: z.string().nullable().optional(),
561
+ })
562
+ )
563
+ .optional(),
564
+ })
565
+ .optional(),
566
+ }),
539
567
  };*/
540
568
  import { Schema as GetBeatmapPageById } from "./api/getBeatmapPageById"
541
569
  export { Schema as SchemaGetBeatmapPageById } from "./api/getBeatmapPageById"
@@ -1216,22 +1244,6 @@ import { Schema as GetVideoUploadUrl } from "./api/getVideoUploadUrl"
1216
1244
  export { Schema as SchemaGetVideoUploadUrl } from "./api/getVideoUploadUrl"
1217
1245
  export const getVideoUploadUrl = handleApi({url:"/api/getVideoUploadUrl",...GetVideoUploadUrl})
1218
1246
 
1219
- // ./api/nominateMap.ts API
1220
-
1221
- /*
1222
- export const Schema = {
1223
- input: z.strictObject({
1224
- session: z.string(),
1225
- mapId: z.number(),
1226
- }),
1227
- output: z.object({
1228
- error: z.string().optional(),
1229
- }),
1230
- };*/
1231
- import { Schema as NominateMap } from "./api/nominateMap"
1232
- export { Schema as SchemaNominateMap } from "./api/nominateMap"
1233
- export const nominateMap = handleApi({url:"/api/nominateMap",...NominateMap})
1234
-
1235
1247
  // ./api/postBeatmapComment.ts API
1236
1248
 
1237
1249
  /*
@@ -1249,6 +1261,23 @@ import { Schema as PostBeatmapComment } from "./api/postBeatmapComment"
1249
1261
  export { Schema as SchemaPostBeatmapComment } from "./api/postBeatmapComment"
1250
1262
  export const postBeatmapComment = handleApi({url:"/api/postBeatmapComment",...PostBeatmapComment})
1251
1263
 
1264
+ // ./api/qualifyMap.ts API
1265
+
1266
+ /*
1267
+ export const Schema = {
1268
+ input: z.strictObject({
1269
+ session: z.string(),
1270
+ mapId: z.number(),
1271
+ }),
1272
+ output: z.object({
1273
+ error: z.string().optional(),
1274
+ qualifiedAt: z.string().optional(),
1275
+ }),
1276
+ };*/
1277
+ import { Schema as QualifyMap } from "./api/qualifyMap"
1278
+ export { Schema as SchemaQualifyMap } from "./api/qualifyMap"
1279
+ export const qualifyMap = handleApi({url:"/api/qualifyMap",...QualifyMap})
1280
+
1252
1281
  // ./api/rankMapsArchive.ts API
1253
1282
 
1254
1283
  /*
@@ -1338,26 +1367,26 @@ export const submitScore = handleApi({url:"/api/submitScore",...SubmitScore})
1338
1367
  // ./api/submitScoreInternal.ts API
1339
1368
 
1340
1369
  /*
1341
- export const Schema = {
1342
- input: z.strictObject({
1343
- session: z.string(),
1344
- secret: z.string(),
1345
- token: z.string(),
1346
- data: z.strictObject({
1347
- onlineMapId: z.number(),
1348
- misses: z.number(),
1349
- hits: z.number(),
1350
- speed: z.number(),
1351
- mods: z.array(z.string()),
1352
- spin: z.boolean(),
1353
- replayBytes: z.string().nullable().optional(),
1354
- pauses: z.number().nullable().optional(),
1355
- failTime: z.number().nullable().optional(),
1356
- }),
1357
- }),
1358
- output: z.object({
1359
- error: z.string().optional(),
1360
- }),
1370
+ export const Schema = {
1371
+ input: z.strictObject({
1372
+ session: z.string(),
1373
+ secret: z.string(),
1374
+ token: z.string(),
1375
+ data: z.strictObject({
1376
+ onlineMapId: z.number(),
1377
+ misses: z.number(),
1378
+ hits: z.number(),
1379
+ speed: z.number(),
1380
+ mods: z.array(z.string()),
1381
+ spin: z.boolean(),
1382
+ replayBytes: z.string().nullable().optional(),
1383
+ pauses: z.number().nullable().optional(),
1384
+ failTime: z.number().nullable().optional(),
1385
+ }),
1386
+ }),
1387
+ output: z.object({
1388
+ error: z.string().optional(),
1389
+ }),
1361
1390
  };*/
1362
1391
  import { Schema as SubmitScoreInternal } from "./api/submitScoreInternal"
1363
1392
  export { Schema as SchemaSubmitScoreInternal } from "./api/submitScoreInternal"
@@ -1382,4 +1411,21 @@ export const Schema = {
1382
1411
  import { Schema as UpdateBeatmapPage } from "./api/updateBeatmapPage"
1383
1412
  export { Schema as SchemaUpdateBeatmapPage } from "./api/updateBeatmapPage"
1384
1413
  export const updateBeatmapPage = handleApi({url:"/api/updateBeatmapPage",...UpdateBeatmapPage})
1414
+
1415
+ // ./api/vetoMap.ts API
1416
+
1417
+ /*
1418
+ export const Schema = {
1419
+ input: z.strictObject({
1420
+ session: z.string(),
1421
+ mapId: z.number(),
1422
+ reason: z.string().min(1).max(1000),
1423
+ }),
1424
+ output: z.object({
1425
+ error: z.string().optional(),
1426
+ }),
1427
+ };*/
1428
+ import { Schema as VetoMap } from "./api/vetoMap"
1429
+ export { Schema as SchemaVetoMap } from "./api/vetoMap"
1430
+ export const vetoMap = handleApi({url:"/api/vetoMap",...VetoMap})
1385
1431
  export { handleApi } from "./handleApi"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "230.0.0",
3
+ "version": "231.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
@@ -8,6 +8,7 @@
8
8
  "ci-deploy": "tsx ./scripts/ci-deploy.ts",
9
9
  "test": "tsx ./scripts/test.ts",
10
10
  "cache:clear-user-scores": "bun run scripts/clear-user-score-cache.ts",
11
+ "db:optimize-user-scores": "bun run scripts/optimize-user-scores-indexes.ts",
11
12
  "query-pull": "bun run scripts/pull-queries.ts",
12
13
  "query-push": "bun run scripts/deploy-queries.ts",
13
14
  "queries:pull": "bun run scripts/pull-queries.ts",
@@ -52,7 +53,7 @@
52
53
  "sharp": "^0.33.5",
53
54
  "short-uuid": "^5.2.0",
54
55
  "simple-git": "^3.25.0",
55
- "supabase": "^2.76.15",
56
+ "supabase": "^2.76.16",
56
57
  "tsx": "^4.17.0",
57
58
  "utf-8-validate": "^6.0.4",
58
59
  "uuid": "^11.1.0",
@@ -61,4 +62,4 @@
61
62
  "zod": "^3.24.2"
62
63
  },
63
64
  "packageManager": "yarn@1.22.22"
64
- }
65
+ }
@@ -3,12 +3,23 @@ CREATE OR REPLACE FUNCTION public.get_user_scores_lastday(userid integer, limit_
3
3
  LANGUAGE sql
4
4
  AS $function$
5
5
  with s as (
6
- select *
7
- from public.scores
8
- where "userId" = userid
9
- and passed = true
10
- order by created_at desc
11
- limit least(limit_param, 100)
6
+ select
7
+ sc.created_at,
8
+ sc.id,
9
+ sc.passed,
10
+ sc."userId",
11
+ sc.awarded_sp,
12
+ sc."beatmapHash",
13
+ sc.misses,
14
+ sc.replay_url,
15
+ sc."songId",
16
+ sc.speed,
17
+ sc.spin
18
+ from public.scores sc
19
+ where sc."userId" = userid
20
+ and sc.passed = true
21
+ order by sc.created_at desc
22
+ limit least(coalesce(limit_param, 100), 100)
12
23
  )
13
24
  select coalesce(
14
25
  jsonb_agg(
@@ -1,24 +1,8 @@
1
1
  CREATE OR REPLACE FUNCTION public.get_user_scores_top_and_stats(userid integer, limit_param integer)
2
2
  RETURNS jsonb
3
- LANGUAGE plpgsql
3
+ LANGUAGE sql
4
4
  AS $function$
5
- declare
6
- top_json jsonb := '[]'::jsonb;
7
- total_scores int := 0;
8
- spin_scores int := 0;
9
- begin
10
- -- stats
11
- select
12
- count(*)::int,
13
- count(*) filter (where spin = true)::int
14
- into total_scores, spin_scores
15
- from public.scores s
16
- where s."userId" = userid
17
- and s.passed = true
18
- and s.awarded_sp is not null and s.awarded_sp <> 0;
19
-
20
- -- top
21
- with base as (
5
+ with filtered as materialized (
22
6
  select
23
7
  s.created_at,
24
8
  s.id,
@@ -34,51 +18,67 @@ begin
34
18
  from public.scores s
35
19
  where s."userId" = userid
36
20
  and s.passed = true
37
- and s.awarded_sp is not null and s.awarded_sp <> 0
21
+ and s.awarded_sp is not null
22
+ and s.awarded_sp <> 0
38
23
  ),
39
- best_per_hash as (
40
- select distinct on ("beatmapHash") *
41
- from base
42
- order by "beatmapHash", awarded_sp desc
24
+ stats as (
25
+ select
26
+ count(*)::int as total_scores,
27
+ count(*) filter (where spin = true)::int as spin_scores
28
+ from filtered
43
29
  ),
44
30
  top_rows as (
45
31
  select *
46
- from best_per_hash
32
+ from (
33
+ select distinct on (f."beatmapHash")
34
+ f.created_at,
35
+ f.id,
36
+ f.passed,
37
+ f."userId",
38
+ f.awarded_sp,
39
+ f."beatmapHash",
40
+ f.misses,
41
+ f.replay_url,
42
+ f."songId",
43
+ f.speed,
44
+ f.spin
45
+ from filtered f
46
+ order by f."beatmapHash", f.awarded_sp desc
47
+ ) best_per_hash
47
48
  order by awarded_sp desc
48
- limit least(limit_param, 100)
49
+ limit least(coalesce(limit_param, 100), 100)
50
+ ),
51
+ top_json as (
52
+ select coalesce(
53
+ jsonb_agg(
54
+ jsonb_build_object(
55
+ 'created_at', t.created_at,
56
+ 'id', t.id,
57
+ 'passed', t.passed,
58
+ 'userId', t."userId",
59
+ 'awarded_sp', t.awarded_sp,
60
+ 'beatmapHash', t."beatmapHash",
61
+ 'misses', t.misses,
62
+ 'replay_url', t.replay_url,
63
+ 'rank', null, -- keep key for Zod compatibility
64
+ 'songId', t."songId",
65
+ 'beatmapDifficulty', b.difficulty,
66
+ 'beatmapNotes', b."noteCount",
67
+ 'beatmapTitle', b.title,
68
+ 'speed', t.speed,
69
+ 'spin', t.spin
70
+ )
71
+ ),
72
+ '[]'::jsonb
73
+ ) as value
74
+ from top_rows t
75
+ left join public.beatmaps b on b."beatmapHash" = t."beatmapHash"
49
76
  )
50
- select coalesce(
51
- jsonb_agg(
52
- jsonb_build_object(
53
- 'created_at', t.created_at,
54
- 'id', t.id,
55
- 'passed', t.passed,
56
- 'userId', t."userId",
57
- 'awarded_sp', t.awarded_sp,
58
- 'beatmapHash', t."beatmapHash",
59
- 'misses', t.misses,
60
- 'replay_url', t.replay_url,
61
- 'rank', null, -- keep key for Zod compatibility
62
- 'songId', t."songId",
63
- 'beatmapDifficulty', b.difficulty,
64
- 'beatmapNotes', b."noteCount",
65
- 'beatmapTitle', b.title,
66
- 'speed', t.speed,
67
- 'spin', t.spin
68
- )
69
- ),
70
- '[]'::jsonb
71
- )
72
- into top_json
73
- from top_rows t
74
- left join public.beatmaps b on b."beatmapHash" = t."beatmapHash";
75
-
76
- return jsonb_build_object(
77
- 'top', top_json,
78
- 'stats', jsonb_build_object(
79
- 'totalScores', total_scores,
80
- 'spinScores', spin_scores
81
- )
82
- );
83
- end;
77
+ select jsonb_build_object(
78
+ 'top', (select value from top_json),
79
+ 'stats', jsonb_build_object(
80
+ 'totalScores', coalesce((select total_scores from stats), 0),
81
+ 'spinScores', coalesce((select spin_scores from stats), 0)
82
+ )
83
+ );
84
84
  $function$