rhythia-api 231.0.0 → 233.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/api/checkQualified.ts +10 -0
- package/api/enhancedSearch.ts +113 -0
- package/api/qualifyMap.ts +6 -0
- package/api/rankMapsArchive.ts +19 -12
- package/api/vetoMap.ts +7 -0
- package/index.ts +41 -0
- package/package.json +1 -1
- package/queries/enhanced_search.sql +217 -0
- package/types/database.ts +24 -0
- package/utils/mapLifecycleWebhook.ts +277 -0
package/api/checkQualified.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import z from "zod";
|
|
3
3
|
import { protectedApi } from "../utils/requestUtils";
|
|
4
4
|
import { supabase } from "../utils/supabase";
|
|
5
|
+
import { postMapLifecycleWebhook } from "../utils/mapLifecycleWebhook";
|
|
5
6
|
|
|
6
7
|
const INTERNAL_SECRET = "testing-1";
|
|
7
8
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
|
@@ -79,5 +80,14 @@ export async function handler({
|
|
|
79
80
|
return NextResponse.json({ error: updateError.message, updated: 0 });
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
await Promise.allSettled(
|
|
84
|
+
mapsToRank.map((mapData) =>
|
|
85
|
+
postMapLifecycleWebhook({
|
|
86
|
+
mapId: mapData.id,
|
|
87
|
+
event: "ranked",
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
|
|
82
92
|
return NextResponse.json({ updated: mapsToRank.length });
|
|
83
93
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { Database } from "../types/database";
|
|
4
|
+
import { protectedApi } from "../utils/requestUtils";
|
|
5
|
+
import { supabase } from "../utils/supabase";
|
|
6
|
+
|
|
7
|
+
type EnhancedSearchRow =
|
|
8
|
+
Database["public"]["Functions"]["enhanced_search"]["Returns"][number];
|
|
9
|
+
|
|
10
|
+
export const Schema = {
|
|
11
|
+
input: z.strictObject({
|
|
12
|
+
text: z.string().trim().min(1),
|
|
13
|
+
limit: z.number().int().min(1).max(25).default(10),
|
|
14
|
+
}),
|
|
15
|
+
output: z.object({
|
|
16
|
+
error: z.string().optional(),
|
|
17
|
+
users: z.array(
|
|
18
|
+
z.object({
|
|
19
|
+
id: z.number(),
|
|
20
|
+
username: z.string().nullable(),
|
|
21
|
+
avatar_url: z.string().nullable(),
|
|
22
|
+
about_me: z.string().nullable(),
|
|
23
|
+
flag: z.string().nullable(),
|
|
24
|
+
})
|
|
25
|
+
),
|
|
26
|
+
beatmaps: z.array(
|
|
27
|
+
z.object({
|
|
28
|
+
id: z.number(),
|
|
29
|
+
mapId: z.string().nullable(),
|
|
30
|
+
title: z.string().nullable(),
|
|
31
|
+
description: z.string().nullable(),
|
|
32
|
+
image: z.string().nullable(),
|
|
33
|
+
starRating: z.number().nullable(),
|
|
34
|
+
length: z.number().nullable(),
|
|
35
|
+
status: z.string().nullable(),
|
|
36
|
+
tags: z.string().nullable(),
|
|
37
|
+
owner: z.number().nullable(),
|
|
38
|
+
ownerUsername: z.string().nullable(),
|
|
39
|
+
ownerAvatar: z.string().nullable(),
|
|
40
|
+
})
|
|
41
|
+
),
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
46
|
+
return protectedApi({
|
|
47
|
+
request,
|
|
48
|
+
schema: Schema,
|
|
49
|
+
authorization: () => {},
|
|
50
|
+
activity: handler,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function handler(
|
|
55
|
+
data: (typeof Schema)["input"]["_type"]
|
|
56
|
+
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
57
|
+
const { data: results, error } = await supabase.rpc("enhanced_search", {
|
|
58
|
+
search_text: data.text,
|
|
59
|
+
result_limit: data.limit,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (error) {
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{
|
|
65
|
+
error: JSON.stringify(error),
|
|
66
|
+
users: [],
|
|
67
|
+
beatmaps: [],
|
|
68
|
+
},
|
|
69
|
+
{ status: 500 }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const users: (typeof Schema)["output"]["_type"]["users"] = [];
|
|
74
|
+
const beatmaps: (typeof Schema)["output"]["_type"]["beatmaps"] = [];
|
|
75
|
+
|
|
76
|
+
for (const result of (results || []) as EnhancedSearchRow[]) {
|
|
77
|
+
if (result.result_type === "user" && result.user_id !== null) {
|
|
78
|
+
users.push({
|
|
79
|
+
id: result.user_id,
|
|
80
|
+
username: result.user_username,
|
|
81
|
+
avatar_url: result.user_avatar_url,
|
|
82
|
+
about_me: result.user_about_me,
|
|
83
|
+
flag: result.user_flag,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
result.result_type === "beatmap" &&
|
|
90
|
+
result.beatmap_page_id !== null
|
|
91
|
+
) {
|
|
92
|
+
beatmaps.push({
|
|
93
|
+
id: result.beatmap_page_id,
|
|
94
|
+
mapId: result.beatmap_map_id,
|
|
95
|
+
title: result.beatmap_title,
|
|
96
|
+
description: result.beatmap_description,
|
|
97
|
+
image: result.beatmap_image,
|
|
98
|
+
starRating: result.beatmap_star_rating,
|
|
99
|
+
length: result.beatmap_length,
|
|
100
|
+
status: result.beatmap_status,
|
|
101
|
+
tags: result.beatmap_tags,
|
|
102
|
+
owner: result.beatmap_owner,
|
|
103
|
+
ownerUsername: result.beatmap_owner_username,
|
|
104
|
+
ownerAvatar: result.beatmap_owner_avatar,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return NextResponse.json({
|
|
110
|
+
users,
|
|
111
|
+
beatmaps,
|
|
112
|
+
});
|
|
113
|
+
}
|
package/api/qualifyMap.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 { postMapLifecycleWebhook } from "../utils/mapLifecycleWebhook";
|
|
7
8
|
|
|
8
9
|
const QUALIFY_BADGES = ["Team Ranked"];
|
|
9
10
|
|
|
@@ -82,5 +83,10 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
|
|
|
82
83
|
return NextResponse.json({ error: updateError.message });
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
await postMapLifecycleWebhook({
|
|
87
|
+
mapId: data.mapId,
|
|
88
|
+
event: "qualified",
|
|
89
|
+
});
|
|
90
|
+
|
|
85
91
|
return NextResponse.json({ qualifiedAt });
|
|
86
92
|
}
|
package/api/rankMapsArchive.ts
CHANGED
|
@@ -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 { postMapLifecycleWebhook } from "../utils/mapLifecycleWebhook";
|
|
7
8
|
|
|
8
9
|
export const Schema = {
|
|
9
10
|
input: z.strictObject({
|
|
@@ -58,14 +59,20 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
|
|
|
58
59
|
return NextResponse.json({ error: "Bad map" });
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
for (const element of mapData) {
|
|
62
|
-
await supabase.from("beatmapPages").upsert({
|
|
63
|
-
id: element.id,
|
|
64
|
-
nominations: [queryUserData.id, queryUserData.id],
|
|
65
|
-
status: "RANKED",
|
|
66
|
-
ranked_at: Date.now(),
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
for (const element of mapData) {
|
|
63
|
+
await supabase.from("beatmapPages").upsert({
|
|
64
|
+
id: element.id,
|
|
65
|
+
nominations: [queryUserData.id, queryUserData.id],
|
|
66
|
+
status: "RANKED",
|
|
67
|
+
ranked_at: Date.now(),
|
|
68
|
+
qualified: false,
|
|
69
|
+
qualifiedAt: null,
|
|
70
|
+
});
|
|
71
|
+
await postMapLifecycleWebhook({
|
|
72
|
+
mapId: element.id,
|
|
73
|
+
event: "ranked",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
69
76
|
|
|
70
77
|
return NextResponse.json({});
|
|
71
78
|
}
|
package/api/vetoMap.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 { postMapLifecycleWebhook } from "../utils/mapLifecycleWebhook";
|
|
7
8
|
|
|
8
9
|
const VETO_BADGES = ["Team Ranked"];
|
|
9
10
|
|
|
@@ -90,5 +91,11 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
|
|
|
90
91
|
return NextResponse.json({ error: updateError.message });
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
await postMapLifecycleWebhook({
|
|
95
|
+
mapId: data.mapId,
|
|
96
|
+
event: "vetoed",
|
|
97
|
+
vetoReason: data.reason,
|
|
98
|
+
});
|
|
99
|
+
|
|
93
100
|
return NextResponse.json({});
|
|
94
101
|
}
|
package/index.ts
CHANGED
|
@@ -308,6 +308,47 @@ import { Schema as EditProfile } from "./api/editProfile"
|
|
|
308
308
|
export { Schema as SchemaEditProfile } from "./api/editProfile"
|
|
309
309
|
export const editProfile = handleApi({url:"/api/editProfile",...EditProfile})
|
|
310
310
|
|
|
311
|
+
// ./api/enhancedSearch.ts API
|
|
312
|
+
|
|
313
|
+
/*
|
|
314
|
+
export const Schema = {
|
|
315
|
+
input: z.strictObject({
|
|
316
|
+
text: z.string().trim().min(1),
|
|
317
|
+
limit: z.number().int().min(1).max(25).default(10),
|
|
318
|
+
}),
|
|
319
|
+
output: z.object({
|
|
320
|
+
error: z.string().optional(),
|
|
321
|
+
users: z.array(
|
|
322
|
+
z.object({
|
|
323
|
+
id: z.number(),
|
|
324
|
+
username: z.string().nullable(),
|
|
325
|
+
avatar_url: z.string().nullable(),
|
|
326
|
+
about_me: z.string().nullable(),
|
|
327
|
+
flag: z.string().nullable(),
|
|
328
|
+
})
|
|
329
|
+
),
|
|
330
|
+
beatmaps: z.array(
|
|
331
|
+
z.object({
|
|
332
|
+
id: z.number(),
|
|
333
|
+
mapId: z.string().nullable(),
|
|
334
|
+
title: z.string().nullable(),
|
|
335
|
+
description: z.string().nullable(),
|
|
336
|
+
image: z.string().nullable(),
|
|
337
|
+
starRating: z.number().nullable(),
|
|
338
|
+
length: z.number().nullable(),
|
|
339
|
+
status: z.string().nullable(),
|
|
340
|
+
tags: z.string().nullable(),
|
|
341
|
+
owner: z.number().nullable(),
|
|
342
|
+
ownerUsername: z.string().nullable(),
|
|
343
|
+
ownerAvatar: z.string().nullable(),
|
|
344
|
+
})
|
|
345
|
+
),
|
|
346
|
+
}),
|
|
347
|
+
};*/
|
|
348
|
+
import { Schema as EnhancedSearch } from "./api/enhancedSearch"
|
|
349
|
+
export { Schema as SchemaEnhancedSearch } from "./api/enhancedSearch"
|
|
350
|
+
export const enhancedSearch = handleApi({url:"/api/enhancedSearch",...EnhancedSearch})
|
|
351
|
+
|
|
311
352
|
// ./api/executeAdminOperation.ts API
|
|
312
353
|
|
|
313
354
|
/*
|
package/package.json
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
CREATE OR REPLACE FUNCTION public.enhanced_search(search_text text, result_limit integer DEFAULT 10)
|
|
2
|
+
RETURNS TABLE(result_type text, relevance double precision, user_id integer, user_username text, user_avatar_url text, user_about_me text, user_flag text, beatmap_page_id integer, beatmap_map_id text, beatmap_title text, beatmap_description text, beatmap_image text, beatmap_star_rating double precision, beatmap_length double precision, beatmap_status text, beatmap_tags text, beatmap_owner integer, beatmap_owner_username text, beatmap_owner_avatar text)
|
|
3
|
+
LANGUAGE sql
|
|
4
|
+
STABLE
|
|
5
|
+
AS $function$
|
|
6
|
+
WITH raw_params AS (
|
|
7
|
+
SELECT
|
|
8
|
+
NULLIF(BTRIM(search_text), '') AS raw_search,
|
|
9
|
+
GREATEST(COALESCE(result_limit, 10), 1) AS limited_result_count
|
|
10
|
+
),
|
|
11
|
+
params AS (
|
|
12
|
+
SELECT
|
|
13
|
+
raw_search,
|
|
14
|
+
LOWER(raw_search) AS lower_search,
|
|
15
|
+
raw_search || '%' AS prefix_search,
|
|
16
|
+
'%' || raw_search || '%' AS contains_search,
|
|
17
|
+
CASE
|
|
18
|
+
WHEN raw_search IS NULL THEN NULL
|
|
19
|
+
ELSE plainto_tsquery('simple', raw_search)
|
|
20
|
+
END AS ts_query,
|
|
21
|
+
limited_result_count
|
|
22
|
+
FROM raw_params
|
|
23
|
+
),
|
|
24
|
+
user_base AS (
|
|
25
|
+
SELECT
|
|
26
|
+
p.id,
|
|
27
|
+
p.username,
|
|
28
|
+
p."computedUsername" AS computed_username,
|
|
29
|
+
p.avatar_url,
|
|
30
|
+
p.about_me,
|
|
31
|
+
p.flag,
|
|
32
|
+
COALESCE(p.badges::text, '') AS badges_text,
|
|
33
|
+
setweight(to_tsvector('simple', COALESCE(p.username, '')), 'A') ||
|
|
34
|
+
setweight(to_tsvector('simple', COALESCE(p."computedUsername", '')), 'A') ||
|
|
35
|
+
setweight(to_tsvector('simple', COALESCE(p.about_me, '')), 'B') ||
|
|
36
|
+
setweight(to_tsvector('simple', COALESCE(p.flag, '')), 'C') ||
|
|
37
|
+
setweight(to_tsvector('simple', COALESCE(p.badges::text, '')), 'C') AS search_vector
|
|
38
|
+
FROM public.profiles p
|
|
39
|
+
WHERE p.ban IS DISTINCT FROM 'excluded'
|
|
40
|
+
),
|
|
41
|
+
user_scored AS (
|
|
42
|
+
SELECT
|
|
43
|
+
'user'::text AS result_type,
|
|
44
|
+
(
|
|
45
|
+
CASE WHEN u.id::text = params.raw_search THEN 250 ELSE 0 END +
|
|
46
|
+
CASE WHEN COALESCE(u.username, '') = params.raw_search THEN 220 ELSE 0 END +
|
|
47
|
+
CASE WHEN COALESCE(u.computed_username, '') = params.lower_search THEN 215 ELSE 0 END +
|
|
48
|
+
CASE WHEN LOWER(COALESCE(u.username, '')) = params.lower_search THEN 210 ELSE 0 END +
|
|
49
|
+
CASE WHEN COALESCE(u.username, '') ILIKE params.prefix_search THEN 160 ELSE 0 END +
|
|
50
|
+
CASE WHEN COALESCE(u.computed_username, '') ILIKE params.prefix_search THEN 150 ELSE 0 END +
|
|
51
|
+
CASE WHEN COALESCE(u.username, '') ILIKE params.contains_search THEN 90 ELSE 0 END +
|
|
52
|
+
CASE WHEN COALESCE(u.computed_username, '') ILIKE params.contains_search THEN 85 ELSE 0 END +
|
|
53
|
+
CASE WHEN COALESCE(u.about_me, '') ILIKE params.contains_search THEN 25 ELSE 0 END +
|
|
54
|
+
CASE WHEN COALESCE(u.flag, '') ILIKE params.prefix_search THEN 20 ELSE 0 END +
|
|
55
|
+
CASE WHEN u.badges_text ILIKE params.contains_search THEN 15 ELSE 0 END +
|
|
56
|
+
CASE
|
|
57
|
+
WHEN params.ts_query IS NULL THEN 0
|
|
58
|
+
ELSE ts_rank_cd(u.search_vector, params.ts_query) * 100
|
|
59
|
+
END
|
|
60
|
+
)::double precision AS relevance,
|
|
61
|
+
u.id AS user_id,
|
|
62
|
+
u.username AS user_username,
|
|
63
|
+
u.avatar_url AS user_avatar_url,
|
|
64
|
+
u.about_me AS user_about_me,
|
|
65
|
+
u.flag AS user_flag,
|
|
66
|
+
NULL::integer AS beatmap_page_id,
|
|
67
|
+
NULL::text AS beatmap_map_id,
|
|
68
|
+
NULL::text AS beatmap_title,
|
|
69
|
+
NULL::text AS beatmap_description,
|
|
70
|
+
NULL::text AS beatmap_image,
|
|
71
|
+
NULL::double precision AS beatmap_star_rating,
|
|
72
|
+
NULL::double precision AS beatmap_length,
|
|
73
|
+
NULL::text AS beatmap_status,
|
|
74
|
+
NULL::text AS beatmap_tags,
|
|
75
|
+
NULL::integer AS beatmap_owner,
|
|
76
|
+
NULL::text AS beatmap_owner_username,
|
|
77
|
+
NULL::text AS beatmap_owner_avatar
|
|
78
|
+
FROM user_base u
|
|
79
|
+
CROSS JOIN params
|
|
80
|
+
WHERE params.raw_search IS NOT NULL
|
|
81
|
+
AND (
|
|
82
|
+
u.id::text = params.raw_search
|
|
83
|
+
OR COALESCE(u.username, '') ILIKE params.contains_search
|
|
84
|
+
OR COALESCE(u.computed_username, '') ILIKE params.contains_search
|
|
85
|
+
OR COALESCE(u.about_me, '') ILIKE params.contains_search
|
|
86
|
+
OR COALESCE(u.flag, '') ILIKE params.contains_search
|
|
87
|
+
OR u.badges_text ILIKE params.contains_search
|
|
88
|
+
OR (params.ts_query IS NOT NULL AND u.search_vector @@ params.ts_query)
|
|
89
|
+
)
|
|
90
|
+
),
|
|
91
|
+
limited_users AS (
|
|
92
|
+
SELECT *
|
|
93
|
+
FROM user_scored
|
|
94
|
+
WHERE relevance > 0
|
|
95
|
+
ORDER BY relevance DESC, user_username NULLS LAST, user_id
|
|
96
|
+
LIMIT (SELECT limited_result_count FROM params)
|
|
97
|
+
),
|
|
98
|
+
beatmap_base AS (
|
|
99
|
+
SELECT
|
|
100
|
+
bp.id,
|
|
101
|
+
bp."latestBeatmapHash" AS map_id,
|
|
102
|
+
COALESCE(b.title, bp.title) AS display_title,
|
|
103
|
+
bp.description,
|
|
104
|
+
bp.tags,
|
|
105
|
+
bp.genre,
|
|
106
|
+
bp.status,
|
|
107
|
+
bp.owner,
|
|
108
|
+
p.username AS owner_username,
|
|
109
|
+
p.avatar_url AS owner_avatar_url,
|
|
110
|
+
b.image,
|
|
111
|
+
b."starRating"::double precision AS star_rating,
|
|
112
|
+
b.length::double precision AS beatmap_length,
|
|
113
|
+
COALESCE(b."beatmapFile", '') AS beatmap_file,
|
|
114
|
+
COALESCE(b."beatmapHash", '') AS beatmap_hash,
|
|
115
|
+
COALESCE(b.difficulty::text, '') AS difficulty_text,
|
|
116
|
+
COALESCE(b."noteCount"::text, '') AS note_count_text,
|
|
117
|
+
COALESCE(b.playcount::text, '') AS playcount_text,
|
|
118
|
+
setweight(to_tsvector('simple', COALESCE(COALESCE(b.title, bp.title), '')), 'A') ||
|
|
119
|
+
setweight(to_tsvector('simple', COALESCE(bp.title, '')), 'A') ||
|
|
120
|
+
setweight(to_tsvector('simple', COALESCE(p.username, '')), 'A') ||
|
|
121
|
+
setweight(to_tsvector('simple', COALESCE(bp.description, '')), 'B') ||
|
|
122
|
+
setweight(to_tsvector('simple', COALESCE(bp.tags, '')), 'B') ||
|
|
123
|
+
setweight(to_tsvector('simple', COALESCE(bp.genre, '')), 'C') ||
|
|
124
|
+
setweight(to_tsvector('simple', COALESCE(bp.status, '')), 'C') ||
|
|
125
|
+
setweight(to_tsvector('simple', COALESCE(b."beatmapFile", '')), 'C') ||
|
|
126
|
+
setweight(to_tsvector('simple', COALESCE(b."beatmapHash", '')), 'A') AS search_vector
|
|
127
|
+
FROM public."beatmapPages" bp
|
|
128
|
+
LEFT JOIN public.beatmaps b ON b."beatmapHash" = bp."latestBeatmapHash"
|
|
129
|
+
LEFT JOIN public.profiles p ON p.id = bp.owner
|
|
130
|
+
),
|
|
131
|
+
beatmap_scored AS (
|
|
132
|
+
SELECT
|
|
133
|
+
'beatmap'::text AS result_type,
|
|
134
|
+
(
|
|
135
|
+
CASE WHEN bb.id::text = params.raw_search THEN 260 ELSE 0 END +
|
|
136
|
+
CASE WHEN COALESCE(bb.map_id, '') = params.raw_search THEN 240 ELSE 0 END +
|
|
137
|
+
CASE WHEN COALESCE(bb.display_title, '') = params.raw_search THEN 220 ELSE 0 END +
|
|
138
|
+
CASE WHEN LOWER(COALESCE(bb.display_title, '')) = params.lower_search THEN 215 ELSE 0 END +
|
|
139
|
+
CASE WHEN COALESCE(bb.display_title, '') ILIKE params.prefix_search THEN 160 ELSE 0 END +
|
|
140
|
+
CASE WHEN COALESCE(bb.display_title, '') ILIKE params.contains_search THEN 90 ELSE 0 END +
|
|
141
|
+
CASE WHEN COALESCE(bb.owner_username, '') = params.raw_search THEN 80 ELSE 0 END +
|
|
142
|
+
CASE WHEN COALESCE(bb.owner_username, '') ILIKE params.prefix_search THEN 70 ELSE 0 END +
|
|
143
|
+
CASE WHEN COALESCE(bb.owner_username, '') ILIKE params.contains_search THEN 50 ELSE 0 END +
|
|
144
|
+
CASE WHEN COALESCE(bb.tags, '') ILIKE params.contains_search THEN 35 ELSE 0 END +
|
|
145
|
+
CASE WHEN COALESCE(bb.description, '') ILIKE params.contains_search THEN 25 ELSE 0 END +
|
|
146
|
+
CASE WHEN COALESCE(bb.genre, '') ILIKE params.contains_search THEN 15 ELSE 0 END +
|
|
147
|
+
CASE WHEN COALESCE(bb.status, '') ILIKE params.contains_search THEN 15 ELSE 0 END +
|
|
148
|
+
CASE WHEN COALESCE(bb.beatmap_file, '') ILIKE params.contains_search THEN 15 ELSE 0 END +
|
|
149
|
+
CASE WHEN COALESCE(bb.beatmap_hash, '') ILIKE params.contains_search THEN 30 ELSE 0 END +
|
|
150
|
+
CASE WHEN COALESCE(bb.owner::text, '') = params.raw_search THEN 40 ELSE 0 END +
|
|
151
|
+
CASE WHEN COALESCE(bb.star_rating::text, '') = params.raw_search THEN 20 ELSE 0 END +
|
|
152
|
+
CASE WHEN COALESCE(bb.beatmap_length::text, '') = params.raw_search THEN 15 ELSE 0 END +
|
|
153
|
+
CASE WHEN COALESCE(bb.difficulty_text, '') = params.raw_search THEN 10 ELSE 0 END +
|
|
154
|
+
CASE WHEN COALESCE(bb.note_count_text, '') = params.raw_search THEN 10 ELSE 0 END +
|
|
155
|
+
CASE WHEN COALESCE(bb.playcount_text, '') = params.raw_search THEN 10 ELSE 0 END +
|
|
156
|
+
CASE
|
|
157
|
+
WHEN params.ts_query IS NULL THEN 0
|
|
158
|
+
ELSE ts_rank_cd(bb.search_vector, params.ts_query) * 110
|
|
159
|
+
END
|
|
160
|
+
)::double precision AS relevance,
|
|
161
|
+
NULL::integer AS user_id,
|
|
162
|
+
NULL::text AS user_username,
|
|
163
|
+
NULL::text AS user_avatar_url,
|
|
164
|
+
NULL::text AS user_about_me,
|
|
165
|
+
NULL::text AS user_flag,
|
|
166
|
+
bb.id AS beatmap_page_id,
|
|
167
|
+
bb.map_id AS beatmap_map_id,
|
|
168
|
+
bb.display_title AS beatmap_title,
|
|
169
|
+
bb.description AS beatmap_description,
|
|
170
|
+
bb.image AS beatmap_image,
|
|
171
|
+
bb.star_rating AS beatmap_star_rating,
|
|
172
|
+
bb.beatmap_length AS beatmap_length,
|
|
173
|
+
bb.status AS beatmap_status,
|
|
174
|
+
bb.tags AS beatmap_tags,
|
|
175
|
+
bb.owner AS beatmap_owner,
|
|
176
|
+
bb.owner_username AS beatmap_owner_username,
|
|
177
|
+
bb.owner_avatar_url AS beatmap_owner_avatar
|
|
178
|
+
FROM beatmap_base bb
|
|
179
|
+
CROSS JOIN params
|
|
180
|
+
WHERE params.raw_search IS NOT NULL
|
|
181
|
+
AND (
|
|
182
|
+
bb.id::text = params.raw_search
|
|
183
|
+
OR COALESCE(bb.map_id, '') ILIKE params.contains_search
|
|
184
|
+
OR COALESCE(bb.display_title, '') ILIKE params.contains_search
|
|
185
|
+
OR COALESCE(bb.description, '') ILIKE params.contains_search
|
|
186
|
+
OR COALESCE(bb.tags, '') ILIKE params.contains_search
|
|
187
|
+
OR COALESCE(bb.genre, '') ILIKE params.contains_search
|
|
188
|
+
OR COALESCE(bb.status, '') ILIKE params.contains_search
|
|
189
|
+
OR COALESCE(bb.owner_username, '') ILIKE params.contains_search
|
|
190
|
+
OR COALESCE(bb.beatmap_file, '') ILIKE params.contains_search
|
|
191
|
+
OR COALESCE(bb.beatmap_hash, '') ILIKE params.contains_search
|
|
192
|
+
OR COALESCE(bb.owner::text, '') = params.raw_search
|
|
193
|
+
OR COALESCE(bb.star_rating::text, '') = params.raw_search
|
|
194
|
+
OR COALESCE(bb.beatmap_length::text, '') = params.raw_search
|
|
195
|
+
OR COALESCE(bb.difficulty_text, '') = params.raw_search
|
|
196
|
+
OR COALESCE(bb.note_count_text, '') = params.raw_search
|
|
197
|
+
OR COALESCE(bb.playcount_text, '') = params.raw_search
|
|
198
|
+
OR (params.ts_query IS NOT NULL AND bb.search_vector @@ params.ts_query)
|
|
199
|
+
)
|
|
200
|
+
),
|
|
201
|
+
limited_beatmaps AS (
|
|
202
|
+
SELECT *
|
|
203
|
+
FROM beatmap_scored
|
|
204
|
+
WHERE relevance > 0
|
|
205
|
+
ORDER BY relevance DESC, beatmap_title NULLS LAST, beatmap_page_id
|
|
206
|
+
LIMIT (SELECT limited_result_count FROM params)
|
|
207
|
+
)
|
|
208
|
+
SELECT *
|
|
209
|
+
FROM (
|
|
210
|
+
SELECT *
|
|
211
|
+
FROM limited_users
|
|
212
|
+
UNION ALL
|
|
213
|
+
SELECT *
|
|
214
|
+
FROM limited_beatmaps
|
|
215
|
+
) AS combined_results
|
|
216
|
+
ORDER BY result_type DESC, relevance DESC, COALESCE(user_username, beatmap_title) NULLS LAST, COALESCE(user_id, beatmap_page_id);
|
|
217
|
+
$function$;
|
package/types/database.ts
CHANGED
|
@@ -785,6 +785,30 @@ export type Database = {
|
|
|
785
785
|
}
|
|
786
786
|
admin_silence_user: { Args: { user_id: number }; Returns: boolean }
|
|
787
787
|
admin_unban_user: { Args: { user_id: number }; Returns: boolean }
|
|
788
|
+
enhanced_search: {
|
|
789
|
+
Args: { result_limit?: number; search_text: string }
|
|
790
|
+
Returns: {
|
|
791
|
+
beatmap_description: string | null
|
|
792
|
+
beatmap_image: string | null
|
|
793
|
+
beatmap_length: number | null
|
|
794
|
+
beatmap_map_id: string | null
|
|
795
|
+
beatmap_owner: number | null
|
|
796
|
+
beatmap_owner_avatar: string | null
|
|
797
|
+
beatmap_owner_username: string | null
|
|
798
|
+
beatmap_page_id: number | null
|
|
799
|
+
beatmap_star_rating: number | null
|
|
800
|
+
beatmap_status: string | null
|
|
801
|
+
beatmap_tags: string | null
|
|
802
|
+
beatmap_title: string | null
|
|
803
|
+
relevance: number | null
|
|
804
|
+
result_type: string | null
|
|
805
|
+
user_about_me: string | null
|
|
806
|
+
user_avatar_url: string | null
|
|
807
|
+
user_flag: string | null
|
|
808
|
+
user_id: number | null
|
|
809
|
+
user_username: string | null
|
|
810
|
+
}[]
|
|
811
|
+
}
|
|
788
812
|
get_badge_leaderboard: {
|
|
789
813
|
Args: { p_limit?: number }
|
|
790
814
|
Returns: {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { supabase } from "./supabase";
|
|
2
|
+
|
|
3
|
+
type MapLifecycleEvent = "qualified" | "ranked" | "vetoed";
|
|
4
|
+
|
|
5
|
+
const WEBHOOK_COLORS: Record<MapLifecycleEvent, number> = {
|
|
6
|
+
qualified: 0x3498db,
|
|
7
|
+
ranked: 0x2ecc71,
|
|
8
|
+
vetoed: 0xe74c3c,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const WEBHOOK_TITLES: Record<MapLifecycleEvent, string> = {
|
|
12
|
+
qualified: "Map Qualified",
|
|
13
|
+
ranked: "Map Ranked",
|
|
14
|
+
vetoed: "Map Vetoed",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const WEBHOOK_MESSAGES: Record<MapLifecycleEvent, string> = {
|
|
18
|
+
qualified: "A fresh map just reached qualification.",
|
|
19
|
+
ranked: "This map cleared qualification and is now ranked.",
|
|
20
|
+
vetoed: "This map was vetoed and sent back for improvements.",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function clampText(value: string, maxLength: number) {
|
|
24
|
+
const sanitized = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
25
|
+
|
|
26
|
+
if (sanitized.length <= maxLength) {
|
|
27
|
+
return sanitized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (maxLength <= 3) {
|
|
31
|
+
return sanitized.slice(0, maxLength);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return `${sanitized.slice(0, maxLength - 3)}...`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getSafeHttpUrl(value: string | null | undefined) {
|
|
38
|
+
if (!value) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(value.trim());
|
|
44
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const serialized = parsed.toString();
|
|
48
|
+
if (serialized.length > 2048) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return serialized;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatLength(milliseconds: number | null | undefined) {
|
|
58
|
+
if (!milliseconds || milliseconds < 0) {
|
|
59
|
+
return "-";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
63
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
64
|
+
const seconds = totalSeconds % 60;
|
|
65
|
+
return `${minutes.toString().padStart(2, "0")}:${seconds
|
|
66
|
+
.toString()
|
|
67
|
+
.padStart(2, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function postMapLifecycleWebhook({
|
|
71
|
+
mapId,
|
|
72
|
+
event,
|
|
73
|
+
vetoReason,
|
|
74
|
+
}: {
|
|
75
|
+
mapId: number;
|
|
76
|
+
event: MapLifecycleEvent;
|
|
77
|
+
vetoReason?: string;
|
|
78
|
+
}) {
|
|
79
|
+
const webhookUrl = process.env.WEBHOOK_MSG_DISCORD;
|
|
80
|
+
if (!webhookUrl) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const { data: beatmapPage } = await supabase
|
|
86
|
+
.from("beatmapPages")
|
|
87
|
+
.select(
|
|
88
|
+
`
|
|
89
|
+
id,
|
|
90
|
+
owner,
|
|
91
|
+
title,
|
|
92
|
+
tags,
|
|
93
|
+
status,
|
|
94
|
+
qualified,
|
|
95
|
+
qualifiedAt,
|
|
96
|
+
beatmaps (
|
|
97
|
+
title,
|
|
98
|
+
starRating,
|
|
99
|
+
length,
|
|
100
|
+
difficulty,
|
|
101
|
+
noteCount,
|
|
102
|
+
image,
|
|
103
|
+
imageLarge
|
|
104
|
+
),
|
|
105
|
+
profiles (
|
|
106
|
+
id,
|
|
107
|
+
username,
|
|
108
|
+
avatar_url
|
|
109
|
+
)
|
|
110
|
+
`
|
|
111
|
+
)
|
|
112
|
+
.eq("id", mapId)
|
|
113
|
+
.single();
|
|
114
|
+
|
|
115
|
+
if (!beatmapPage) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const beatmapData = (beatmapPage as any).beatmaps;
|
|
120
|
+
const profileData = (beatmapPage as any).profiles;
|
|
121
|
+
const mapTitle =
|
|
122
|
+
beatmapData?.title || beatmapPage.title || `Beatmap Page #${mapId}`;
|
|
123
|
+
const creatorName = profileData?.username || "Unknown";
|
|
124
|
+
const creatorId = beatmapPage.owner || profileData?.id || 0;
|
|
125
|
+
const rawImage =
|
|
126
|
+
beatmapData?.imageLarge || beatmapData?.image || "https://www.rhythia.com/unkimg.png";
|
|
127
|
+
const mapImage = rawImage.includes("backfill")
|
|
128
|
+
? "https://www.rhythia.com/unkimg.png"
|
|
129
|
+
: rawImage;
|
|
130
|
+
const safeMapImageUrl = getSafeHttpUrl(mapImage);
|
|
131
|
+
const safeAvatarUrl = getSafeHttpUrl(profileData?.avatar_url);
|
|
132
|
+
|
|
133
|
+
const fields: Array<{ name: string; value: string; inline?: boolean }> = [
|
|
134
|
+
{
|
|
135
|
+
name: "Map ID",
|
|
136
|
+
value: clampText(`${beatmapPage.id}`, 1024),
|
|
137
|
+
inline: true,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "Creator",
|
|
141
|
+
value: clampText(creatorName, 1024),
|
|
142
|
+
inline: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "Stars",
|
|
146
|
+
value: clampText(
|
|
147
|
+
beatmapData?.starRating !== null && beatmapData?.starRating !== undefined
|
|
148
|
+
? `${Math.round(beatmapData.starRating * 100) / 100}*`
|
|
149
|
+
: "-",
|
|
150
|
+
1024
|
|
151
|
+
),
|
|
152
|
+
inline: true,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "Length",
|
|
156
|
+
value: clampText(formatLength(beatmapData?.length), 1024),
|
|
157
|
+
inline: true,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "Notes",
|
|
161
|
+
value: clampText(
|
|
162
|
+
beatmapData?.noteCount !== null && beatmapData?.noteCount !== undefined
|
|
163
|
+
? `${beatmapData.noteCount}`
|
|
164
|
+
: "-",
|
|
165
|
+
1024
|
|
166
|
+
),
|
|
167
|
+
inline: true,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "Tags",
|
|
171
|
+
value: clampText(beatmapPage.tags || "-", 1024),
|
|
172
|
+
inline: false,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
if (event === "vetoed") {
|
|
177
|
+
fields.push({
|
|
178
|
+
name: "Veto Reason",
|
|
179
|
+
value: clampText(vetoReason || "No reason provided", 1024),
|
|
180
|
+
inline: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const embed: Record<string, any> = {
|
|
185
|
+
title: clampText(`${WEBHOOK_TITLES[event]}: ${mapTitle}`, 256),
|
|
186
|
+
url: `https://www.rhythia.com/maps/${beatmapPage.id}`,
|
|
187
|
+
description: clampText(WEBHOOK_MESSAGES[event], 4096),
|
|
188
|
+
color: WEBHOOK_COLORS[event],
|
|
189
|
+
fields: fields.map((field) => ({
|
|
190
|
+
...field,
|
|
191
|
+
name: clampText(field.name || "-", 256),
|
|
192
|
+
value: clampText(field.value || "-", 1024),
|
|
193
|
+
})),
|
|
194
|
+
author: {
|
|
195
|
+
name: clampText(creatorName, 256),
|
|
196
|
+
url: `https://www.rhythia.com/player/${creatorId}`,
|
|
197
|
+
icon_url: safeAvatarUrl || "https://www.rhythia.com/unkimg.png",
|
|
198
|
+
},
|
|
199
|
+
footer: {
|
|
200
|
+
text: clampText(
|
|
201
|
+
`Status: ${beatmapPage.status || "-"} | ${new Date().toUTCString()}`,
|
|
202
|
+
2048
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (safeMapImageUrl) {
|
|
208
|
+
embed.thumbnail = {
|
|
209
|
+
url: safeMapImageUrl,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const payload = {
|
|
214
|
+
content: clampText(WEBHOOK_MESSAGES[event], 2000),
|
|
215
|
+
embeds: [embed],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let response = await fetch(webhookUrl, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify(payload),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
!response.ok &&
|
|
228
|
+
response.status === 400 &&
|
|
229
|
+
(payload.embeds?.[0]?.image?.url || payload.embeds?.[0]?.thumbnail?.url)
|
|
230
|
+
) {
|
|
231
|
+
// Most common Discord embed 400 here is a bad media URL. Retry without media.
|
|
232
|
+
const retryPayload = {
|
|
233
|
+
...payload,
|
|
234
|
+
embeds: payload.embeds.map((embed: any) => {
|
|
235
|
+
const clone = { ...embed };
|
|
236
|
+
delete clone.image;
|
|
237
|
+
delete clone.thumbnail;
|
|
238
|
+
return clone;
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
response = await fetch(webhookUrl, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify(retryPayload),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
const responseBody = await response.text();
|
|
253
|
+
console.log("Discord webhook failed", {
|
|
254
|
+
event,
|
|
255
|
+
mapId,
|
|
256
|
+
status: response.status,
|
|
257
|
+
statusText: response.statusText,
|
|
258
|
+
responseBody: clampText(responseBody || "-", 4000),
|
|
259
|
+
payloadPreview: {
|
|
260
|
+
content: payload.content,
|
|
261
|
+
title: payload.embeds?.[0]?.title,
|
|
262
|
+
fields: payload.embeds?.[0]?.fields?.map((field: any) => ({
|
|
263
|
+
name: field.name,
|
|
264
|
+
value: clampText(field.value || "-", 120),
|
|
265
|
+
})),
|
|
266
|
+
imageUrl: payload.embeds?.[0]?.image?.url || null,
|
|
267
|
+
thumbnailUrl: payload.embeds?.[0]?.thumbnail?.url || null,
|
|
268
|
+
authorIconUrl: payload.embeds?.[0]?.author?.icon_url || null,
|
|
269
|
+
hasImage: Boolean(payload.embeds?.[0]?.image?.url),
|
|
270
|
+
hasThumbnail: Boolean(payload.embeds?.[0]?.thumbnail?.url),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.log("Failed to post map lifecycle webhook", error);
|
|
276
|
+
}
|
|
277
|
+
}
|