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.
@@ -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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "231.0.0",
3
+ "version": "233.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
@@ -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
+ }