rhythia-api 226.0.0 → 230.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.
@@ -18,11 +18,12 @@ export const Schema = {
18
18
  awarded_sp: z.number().nullable(),
19
19
  beatmapHash: z.string().nullable(),
20
20
  created_at: z.string(),
21
- id: z.number(),
22
- misses: z.number().nullable(),
23
- passed: z.boolean().nullable(),
24
- songId: z.string().nullable(),
25
- userId: z.number().nullable(),
21
+ id: z.number(),
22
+ misses: z.number().nullable(),
23
+ passed: z.boolean().nullable(),
24
+ replay_url: z.string().nullable().optional(),
25
+ songId: z.string().nullable(),
26
+ userId: z.number().nullable(),
26
27
  beatmapDifficulty: z.number().optional().nullable(),
27
28
  beatmapNotes: z.number().optional().nullable(),
28
29
  beatmapTitle: z.string().optional().nullable(),
@@ -37,11 +38,12 @@ export const Schema = {
37
38
  id: z.number(),
38
39
  awarded_sp: z.number().nullable(),
39
40
  created_at: z.string(),
40
- misses: z.number().nullable(),
41
- mods: z.record(z.unknown()),
42
- passed: z.boolean().nullable(),
43
- songId: z.string().nullable(),
44
- speed: z.number().nullable(),
41
+ misses: z.number().nullable(),
42
+ mods: z.record(z.unknown()),
43
+ passed: z.boolean().nullable(),
44
+ replay_url: z.string().nullable().optional(),
45
+ songId: z.string().nullable(),
46
+ speed: z.number().nullable(),
45
47
  spin: z.boolean(),
46
48
  beatmapHash: z.string().nullable(),
47
49
  beatmapTitle: z.string().nullable(),
@@ -56,11 +58,12 @@ export const Schema = {
56
58
  awarded_sp: z.number().nullable(),
57
59
  beatmapHash: z.string().nullable(),
58
60
  created_at: z.string(),
59
- id: z.number(),
60
- misses: z.number().nullable(),
61
- passed: z.boolean().nullable(),
62
- rank: z.string().nullable(),
63
- songId: z.string().nullable(),
61
+ id: z.number(),
62
+ misses: z.number().nullable(),
63
+ passed: z.boolean().nullable(),
64
+ replay_url: z.string().nullable().optional(),
65
+ rank: z.string().nullable(),
66
+ songId: z.string().nullable(),
64
67
  userId: z.number().nullable(),
65
68
  beatmapDifficulty: z.number().optional().nullable(),
66
69
  beatmapNotes: z.number().optional().nullable(),
package/handleApi.ts CHANGED
@@ -2,20 +2,16 @@ import { z } from "zod";
2
2
  let env = "development";
3
3
  import { profanity, CensorType } from "@2toad/profanity";
4
4
  export function setEnvironment(
5
- stage: "development" | "testing" | "production" | "local"
5
+ stage: "development" | "testing" | "production"
6
6
  ) {
7
7
  env = stage;
8
8
  }
9
9
  export function handleApi<
10
- T extends { url: string; input: z.ZodObject<any>; output: z.ZodObject<any> },
10
+ T extends { url: string; input: z.ZodObject<any>; output: z.ZodObject<any> }
11
11
  >(apiSchema: T) {
12
12
  profanity.whitelist.addWords(["willy"]);
13
13
  return async (input: T["input"]["_type"]): Promise<T["output"]["_type"]> => {
14
- let url = `https://${env}.rhythia.com${apiSchema.url}`;
15
- if (env == "local") {
16
- url = `https://localhost:3000${apiSchema.url}`;
17
- }
18
- const response = await fetch(url, {
14
+ const response = await fetch(`https://${env}.rhythia.com${apiSchema.url}`, {
19
15
  method: "POST",
20
16
  body: JSON.stringify(input),
21
17
  });
package/index.ts CHANGED
@@ -1116,11 +1116,12 @@ export const Schema = {
1116
1116
  awarded_sp: z.number().nullable(),
1117
1117
  beatmapHash: z.string().nullable(),
1118
1118
  created_at: z.string(),
1119
- id: z.number(),
1120
- misses: z.number().nullable(),
1121
- passed: z.boolean().nullable(),
1122
- songId: z.string().nullable(),
1123
- userId: z.number().nullable(),
1119
+ id: z.number(),
1120
+ misses: z.number().nullable(),
1121
+ passed: z.boolean().nullable(),
1122
+ replay_url: z.string().nullable().optional(),
1123
+ songId: z.string().nullable(),
1124
+ userId: z.number().nullable(),
1124
1125
  beatmapDifficulty: z.number().optional().nullable(),
1125
1126
  beatmapNotes: z.number().optional().nullable(),
1126
1127
  beatmapTitle: z.string().optional().nullable(),
@@ -1135,11 +1136,12 @@ export const Schema = {
1135
1136
  id: z.number(),
1136
1137
  awarded_sp: z.number().nullable(),
1137
1138
  created_at: z.string(),
1138
- misses: z.number().nullable(),
1139
- mods: z.record(z.unknown()),
1140
- passed: z.boolean().nullable(),
1141
- songId: z.string().nullable(),
1142
- speed: z.number().nullable(),
1139
+ misses: z.number().nullable(),
1140
+ mods: z.record(z.unknown()),
1141
+ passed: z.boolean().nullable(),
1142
+ replay_url: z.string().nullable().optional(),
1143
+ songId: z.string().nullable(),
1144
+ speed: z.number().nullable(),
1143
1145
  spin: z.boolean(),
1144
1146
  beatmapHash: z.string().nullable(),
1145
1147
  beatmapTitle: z.string().nullable(),
@@ -1154,11 +1156,12 @@ export const Schema = {
1154
1156
  awarded_sp: z.number().nullable(),
1155
1157
  beatmapHash: z.string().nullable(),
1156
1158
  created_at: z.string(),
1157
- id: z.number(),
1158
- misses: z.number().nullable(),
1159
- passed: z.boolean().nullable(),
1160
- rank: z.string().nullable(),
1161
- songId: z.string().nullable(),
1159
+ id: z.number(),
1160
+ misses: z.number().nullable(),
1161
+ passed: z.boolean().nullable(),
1162
+ replay_url: z.string().nullable().optional(),
1163
+ rank: z.string().nullable(),
1164
+ songId: z.string().nullable(),
1162
1165
  userId: z.number().nullable(),
1163
1166
  beatmapDifficulty: z.number().optional().nullable(),
1164
1167
  beatmapNotes: z.number().optional().nullable(),
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "rhythia-api",
3
- "version": "226.0.0",
3
+ "version": "230.0.0",
4
4
  "main": "index.ts",
5
5
  "author": "online-contributors-cunev",
6
6
  "scripts": {
7
7
  "update": "bun ./scripts/update.ts",
8
8
  "ci-deploy": "tsx ./scripts/ci-deploy.ts",
9
9
  "test": "tsx ./scripts/test.ts",
10
+ "cache:clear-user-scores": "bun run scripts/clear-user-score-cache.ts",
11
+ "query-pull": "bun run scripts/pull-queries.ts",
12
+ "query-push": "bun run scripts/deploy-queries.ts",
13
+ "queries:pull": "bun run scripts/pull-queries.ts",
14
+ "queries:deploy": "bun run scripts/deploy-queries.ts",
10
15
  "sync": "npx supabase gen types typescript --project-id \"pfkajngbllcbdzoylrvp\" --schema public > types/database.ts",
11
16
  "pipeline:build-api": "tsx ./scripts/build.ts",
12
17
  "pipeline:deploy-testing": "tsx ./scripts/build.ts"
@@ -24,6 +29,7 @@
24
29
  "@types/bun": "^1.1.6",
25
30
  "@types/lodash": "^4.17.7",
26
31
  "@types/node": "^22.2.0",
32
+ "@types/pg": "^8.15.5",
27
33
  "@types/validator": "^13.12.2",
28
34
  "@vercel/edge": "^1.1.2",
29
35
  "@vercel/node": "^3.2.8",
@@ -40,6 +46,8 @@
40
46
  "osu-classes": "^3.1.0",
41
47
  "osu-parsers": "^4.1.7",
42
48
  "osu-standard-stable": "^5.0.0",
49
+ "pg": "^8.18.0",
50
+ "redis": "^5.10.0",
43
51
  "remote-cloudflare-kv": "^1.0.1",
44
52
  "sharp": "^0.33.5",
45
53
  "short-uuid": "^5.2.0",
@@ -53,4 +61,4 @@
53
61
  "zod": "^3.24.2"
54
62
  },
55
63
  "packageManager": "yarn@1.22.22"
56
- }
64
+ }
@@ -0,0 +1,39 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_delete_user(user_id integer)
2
+ RETURNS boolean
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ success BOOLEAN;
8
+ BEGIN
9
+ -- Delete scores first due to foreign key constraints
10
+ DELETE FROM public.scores WHERE "userId" = user_id;
11
+
12
+ -- Delete beatmapPageComments
13
+ DELETE FROM public."beatmapPageComments" WHERE owner = user_id;
14
+
15
+ -- Delete beatmapPages owned by user
16
+ DELETE FROM public."beatmapPages" WHERE owner = user_id;
17
+
18
+ -- Delete collections owned by user
19
+ DELETE FROM public."beatmapCollections" WHERE owner = user_id;
20
+
21
+ -- Delete collection relations related to user's collections
22
+ -- (this is handled by cascade if you have it set up)
23
+
24
+ -- Delete passkeys
25
+ DELETE FROM public.passkeys WHERE id = user_id;
26
+
27
+ -- Delete profile activity
28
+ DELETE FROM public."profileActivities"
29
+ WHERE uid = (SELECT uid FROM public.profiles WHERE id = user_id);
30
+
31
+ -- Finally, delete the profile
32
+ DELETE FROM public.profiles WHERE id = user_id;
33
+
34
+ -- Check if deletion was successful
35
+ success := NOT EXISTS (SELECT 1 FROM public.profiles WHERE id = user_id);
36
+
37
+ RETURN success;
38
+ END;
39
+ $function$
@@ -0,0 +1,21 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_exclude_user(user_id integer)
2
+ RETURNS boolean
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ success BOOLEAN;
8
+ BEGIN
9
+ UPDATE public.profiles
10
+ SET ban = 'excluded'::"banTypes",
11
+ "bannedAt" = EXTRACT(EPOCH FROM NOW())::bigint
12
+ WHERE id = user_id;
13
+
14
+ success := EXISTS (
15
+ SELECT 1 FROM public.profiles
16
+ WHERE id = user_id AND ban = 'excluded'::"banTypes"
17
+ );
18
+
19
+ RETURN success;
20
+ END;
21
+ $function$
@@ -0,0 +1,18 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_invalidate_ranked_scores(user_id integer)
2
+ RETURNS integer
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ updated_count INTEGER;
8
+ BEGIN
9
+ -- Update user stats to reflect score invalidation
10
+ UPDATE public.profiles
11
+ SET
12
+ skill_points = 0,
13
+ spin_skill_points = 0
14
+ WHERE id = user_id;
15
+
16
+ RETURN updated_count;
17
+ END;
18
+ $function$
@@ -0,0 +1,10 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_log_action(admin_id integer, action_type text, target_id integer, details jsonb DEFAULT NULL::jsonb)
2
+ RETURNS void
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ BEGIN
7
+ INSERT INTO public.admin_actions (admin_id, action_type, target_id, details)
8
+ VALUES (admin_id, action_type, target_id, details);
9
+ END;
10
+ $function$
@@ -0,0 +1,29 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_profanity_clear(user_id integer)
2
+ RETURNS boolean
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ success BOOLEAN;
8
+ random_username TEXT;
9
+ BEGIN
10
+ -- Generate a random username (Player + random number between 10000-99999)
11
+ random_username := 'Player' || (10000 + floor(random() * 90000)::int)::text;
12
+
13
+ -- Update the profile
14
+ UPDATE public.profiles
15
+ SET
16
+ username = random_username,
17
+ "computedUsername" = LOWER(random_username),
18
+ about_me = NULL
19
+ WHERE id = user_id;
20
+
21
+ -- Check if update was successful
22
+ success := EXISTS (
23
+ SELECT 1 FROM public.profiles
24
+ WHERE id = user_id AND username = random_username AND about_me IS NULL
25
+ );
26
+
27
+ RETURN success;
28
+ END;
29
+ $function$
@@ -0,0 +1,29 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_remove_all_scores(user_id integer)
2
+ RETURNS integer
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ deleted_count INTEGER;
8
+ BEGIN
9
+ -- Delete all scores for this user and count them
10
+ WITH deleted AS (
11
+ DELETE FROM public.scores
12
+ WHERE "userId" = user_id
13
+ RETURNING *
14
+ )
15
+ SELECT COUNT(*) INTO deleted_count FROM deleted;
16
+
17
+ -- Update user stats to reflect score removal
18
+ UPDATE public.profiles
19
+ SET
20
+ play_count = 0,
21
+ squares_hit = 0,
22
+ total_score = 0,
23
+ skill_points = 0,
24
+ spin_skill_points = 0
25
+ WHERE id = user_id;
26
+
27
+ RETURN deleted_count;
28
+ END;
29
+ $function$
@@ -0,0 +1,21 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_restrict_user(user_id integer)
2
+ RETURNS boolean
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ success BOOLEAN;
8
+ BEGIN
9
+ UPDATE public.profiles
10
+ SET ban = 'restricted'::"banTypes",
11
+ "bannedAt" = EXTRACT(EPOCH FROM NOW())::bigint
12
+ WHERE id = user_id;
13
+
14
+ success := EXISTS (
15
+ SELECT 1 FROM public.profiles
16
+ WHERE id = user_id AND ban = 'restricted'::"banTypes"
17
+ );
18
+
19
+ RETURN success;
20
+ END;
21
+ $function$
@@ -0,0 +1,24 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_search_users(search_text text)
2
+ RETURNS SETOF profiles
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ BEGIN
7
+ RETURN QUERY
8
+ SELECT *
9
+ FROM public.profiles
10
+ WHERE
11
+ username ILIKE '%' || search_text || '%'
12
+ OR "computedUsername" ILIKE '%' || search_text || '%'
13
+ OR CAST(uid AS TEXT) ILIKE '%' || search_text || '%'
14
+ OR CAST(id AS TEXT) = search_text
15
+ OR about_me ILIKE '%' || search_text || '%'
16
+ OR flag ILIKE '%' || search_text || '%'
17
+ OR EXISTS (
18
+ SELECT 1
19
+ FROM public.passkeys
20
+ WHERE passkeys.id = profiles.id AND passkeys.email ILIKE '%' || search_text || '%'
21
+ )
22
+ ORDER BY id;
23
+ END;
24
+ $function$
@@ -0,0 +1,21 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_silence_user(user_id integer)
2
+ RETURNS boolean
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ success BOOLEAN;
8
+ BEGIN
9
+ UPDATE public.profiles
10
+ SET ban = 'silenced'::"banTypes",
11
+ "bannedAt" = EXTRACT(EPOCH FROM NOW())::bigint
12
+ WHERE id = user_id;
13
+
14
+ success := EXISTS (
15
+ SELECT 1 FROM public.profiles
16
+ WHERE id = user_id AND ban = 'silenced'::"banTypes"
17
+ );
18
+
19
+ RETURN success;
20
+ END;
21
+ $function$
@@ -0,0 +1,21 @@
1
+ CREATE OR REPLACE FUNCTION public.admin_unban_user(user_id integer)
2
+ RETURNS boolean
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ success BOOLEAN;
8
+ BEGIN
9
+ UPDATE public.profiles
10
+ SET ban = 'cool',
11
+ "bannedAt" = NULL
12
+ WHERE id = user_id;
13
+
14
+ success := EXISTS (
15
+ SELECT 1 FROM public.profiles
16
+ WHERE id = user_id AND ban IS NULL
17
+ );
18
+
19
+ RETURN success;
20
+ END;
21
+ $function$
@@ -0,0 +1,50 @@
1
+ CREATE OR REPLACE FUNCTION public.get_badge_leaderboard(p_limit integer DEFAULT 100)
2
+ RETURNS TABLE(id integer, display_name text, avatar_url text, special_badge_count integer)
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ BEGIN
7
+ RETURN QUERY
8
+ WITH special_badges AS (
9
+ SELECT unnest(ARRAY[
10
+ 'The Start of an Era',
11
+ 'New Farm',
12
+ 'Spinnin',
13
+ 'Old Farm',
14
+ 'Birb',
15
+ 'Flamingos',
16
+ 'Cats!'
17
+ ]) AS badge_name
18
+ ),
19
+ user_badge_data AS (
20
+ SELECT
21
+ p.id::INTEGER as id,
22
+ p.username,
23
+ p.avatar_url,
24
+ p."computedUsername",
25
+ json_array_elements_text(COALESCE(p.badges, '[]'::json)) as user_badge
26
+ FROM profiles p
27
+ WHERE p.username IS NOT NULL
28
+ AND (p.ban IS NULL OR p.ban != 'excluded')
29
+ ),
30
+ user_special_badge_counts AS (
31
+ SELECT
32
+ ubd.id,
33
+ ubd.username,
34
+ ubd.avatar_url,
35
+ ubd."computedUsername",
36
+ COUNT(DISTINCT sb.badge_name)::INTEGER as special_badge_count
37
+ FROM user_badge_data ubd
38
+ INNER JOIN special_badges sb ON ubd.user_badge = sb.badge_name
39
+ GROUP BY ubd.id, ubd.username, ubd.avatar_url, ubd."computedUsername"
40
+ )
41
+ SELECT
42
+ usbc.id,
43
+ COALESCE(usbc."computedUsername", usbc.username) as display_name,
44
+ usbc.avatar_url,
45
+ usbc.special_badge_count
46
+ FROM user_special_badge_counts usbc
47
+ ORDER BY usbc.special_badge_count DESC, display_name ASC
48
+ LIMIT p_limit;
49
+ END;
50
+ $function$
@@ -0,0 +1,68 @@
1
+ CREATE OR REPLACE FUNCTION public.get_clan_leaderboard(page_number integer DEFAULT 1, items_per_page integer DEFAULT 10)
2
+ RETURNS TABLE(id integer, name text, acronym text, avatar_url text, description text, member_count bigint, total_skill_points numeric, total_pages integer)
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ SET search_path TO 'public'
6
+ AS $function$
7
+ DECLARE
8
+ total_clan_pages integer;
9
+ total_clan_count integer;
10
+ BEGIN
11
+ -- Calculate total clans
12
+ SELECT COUNT(*) INTO total_clan_count FROM clans;
13
+
14
+ -- Calculate total pages, ensuring at least 1 page even if no clans exist
15
+ SELECT GREATEST(1, CEIL(total_clan_count::numeric / items_per_page)) INTO total_clan_pages;
16
+
17
+ -- Set page_number to 1 if it's out of bounds
18
+ IF page_number <= 0 OR page_number > total_clan_pages THEN
19
+ page_number := 1;
20
+ END IF;
21
+
22
+ RETURN QUERY
23
+ WITH clan_stats AS (
24
+ SELECT
25
+ c.id,
26
+ c.name,
27
+ c.acronym,
28
+ c.avatar_url,
29
+ c.description,
30
+ COUNT(p.id) FILTER (WHERE p.ban != 'excluded' OR p.ban IS NULL) AS member_count,
31
+ COALESCE(SUM(p.skill_points) FILTER (WHERE p.ban != 'excluded' OR p.ban IS NULL), 0)::numeric AS total_skill_points
32
+ FROM
33
+ clans c
34
+ LEFT JOIN
35
+ profiles p ON p.clan = c.id
36
+ GROUP BY
37
+ c.id, c.name, c.acronym, c.avatar_url, c.description
38
+ ),
39
+ clan_ranking AS (
40
+ SELECT
41
+ cs.id::integer,
42
+ cs.name,
43
+ cs.acronym,
44
+ cs.avatar_url,
45
+ cs.description,
46
+ cs.member_count,
47
+ cs.total_skill_points,
48
+ ROW_NUMBER() OVER (ORDER BY cs.total_skill_points DESC, cs.member_count DESC, cs.name ASC) AS rank
49
+ FROM
50
+ clan_stats cs
51
+ )
52
+ SELECT
53
+ cr.id,
54
+ cr.name,
55
+ cr.acronym,
56
+ cr.avatar_url,
57
+ cr.description,
58
+ cr.member_count,
59
+ cr.total_skill_points,
60
+ total_clan_pages
61
+ FROM
62
+ clan_ranking cr
63
+ WHERE
64
+ (total_clan_count = 0 OR (cr.rank > (page_number - 1) * items_per_page AND cr.rank <= page_number * items_per_page))
65
+ ORDER BY
66
+ cr.total_skill_points DESC, cr.member_count DESC, cr.name ASC;
67
+ END;
68
+ $function$
@@ -0,0 +1,109 @@
1
+ CREATE OR REPLACE FUNCTION public.get_collections_v4(page_number integer DEFAULT 1, items_per_page integer DEFAULT 10, owner_filter bigint DEFAULT NULL::bigint, search_query text DEFAULT NULL::text, author_filter text DEFAULT NULL::text, min_beatmaps integer DEFAULT NULL::integer)
2
+ RETURNS TABLE(id bigint, title text, description text, created_at timestamp with time zone, owner bigint, owner_username text, owner_avatar_url text, beatmap_count bigint, star1 bigint, star2 bigint, star3 bigint, star4 bigint, star5 bigint, star6 bigint, star7 bigint, star8 bigint, star9 bigint, star10 bigint, star11 bigint, star12 bigint, star13 bigint, star14 bigint, star15 bigint, star16 bigint, star17 bigint, star18 bigint, total_pages bigint)
3
+ LANGUAGE plpgsql
4
+ AS $function$
5
+ DECLARE
6
+ total_count bigint;
7
+ BEGIN
8
+ -- Get total count with beatmap count filter
9
+ WITH collection_counts AS (
10
+ SELECT
11
+ bc.id,
12
+ COUNT(DISTINCT cr.id) as map_count
13
+ FROM "beatmapCollections" bc
14
+ LEFT JOIN "collectionRelations" cr ON cr.collection = bc.id
15
+ LEFT JOIN "profiles" p ON p."id" = bc."owner"
16
+ WHERE (owner_filter IS NULL OR bc.owner = owner_filter)
17
+ AND (author_filter IS NULL OR p.username ILIKE '%' || author_filter || '%')
18
+ AND (
19
+ search_query IS NULL
20
+ OR bc.title ILIKE '%' || search_query || '%'
21
+ OR bc.description ILIKE '%' || search_query || '%'
22
+ OR p.username ILIKE '%' || search_query || '%'
23
+ )
24
+ GROUP BY bc.id
25
+ )
26
+ SELECT COUNT(*)
27
+ INTO total_count
28
+ FROM collection_counts
29
+ WHERE (min_beatmaps IS NULL OR map_count >= min_beatmaps);
30
+
31
+ RETURN QUERY
32
+ WITH collection_counts AS (
33
+ SELECT
34
+ bc.id,
35
+ bc.title,
36
+ bc.description,
37
+ bc.created_at,
38
+ bc.owner,
39
+ p.username,
40
+ p."avatar_url",
41
+ COUNT(DISTINCT cr.id) as map_count,
42
+ COUNT(*) FILTER (WHERE round(b."starRating") = 1) as star1,
43
+ COUNT(*) FILTER (WHERE round(b."starRating") = 2) as star2,
44
+ COUNT(*) FILTER (WHERE round(b."starRating") = 3) as star3,
45
+ COUNT(*) FILTER (WHERE round(b."starRating") = 4) as star4,
46
+ COUNT(*) FILTER (WHERE round(b."starRating") = 5) as star5,
47
+ COUNT(*) FILTER (WHERE round(b."starRating") = 6) as star6,
48
+ COUNT(*) FILTER (WHERE round(b."starRating") = 7) as star7,
49
+ COUNT(*) FILTER (WHERE round(b."starRating") = 8) as star8,
50
+ COUNT(*) FILTER (WHERE round(b."starRating") = 9) as star9,
51
+ COUNT(*) FILTER (WHERE round(b."starRating") = 10) as star10,
52
+ COUNT(*) FILTER (WHERE round(b."starRating") = 11) as star11,
53
+ COUNT(*) FILTER (WHERE round(b."starRating") = 12) as star12,
54
+ COUNT(*) FILTER (WHERE round(b."starRating") = 13) as star13,
55
+ COUNT(*) FILTER (WHERE round(b."starRating") = 14) as star14,
56
+ COUNT(*) FILTER (WHERE round(b."starRating") = 15) as star15,
57
+ COUNT(*) FILTER (WHERE round(b."starRating") = 16) as star16,
58
+ COUNT(*) FILTER (WHERE round(b."starRating") = 17) as star17,
59
+ COUNT(*) FILTER (WHERE round(b."starRating") = 18) as star18
60
+ FROM "beatmapCollections" bc
61
+ LEFT JOIN "collectionRelations" cr ON cr.collection = bc.id
62
+ LEFT JOIN "beatmapPages" bp ON bp.id = cr."beatmapPage"
63
+ LEFT JOIN "beatmaps" b ON b."beatmapHash" = bp."latestBeatmapHash"
64
+ LEFT JOIN "profiles" p ON p."id" = bc."owner"
65
+ WHERE (owner_filter IS NULL OR bc.owner = owner_filter)
66
+ AND (author_filter IS NULL OR p.username ILIKE '%' || author_filter || '%')
67
+ AND (
68
+ search_query IS NULL
69
+ OR bc.title ILIKE '%' || search_query || '%'
70
+ OR bc.description ILIKE '%' || search_query || '%'
71
+ OR p.username ILIKE '%' || search_query || '%'
72
+ )
73
+ GROUP BY bc.id, bc.title, bc.description, bc.created_at, bc.owner, p.username, p."avatar_url"
74
+ HAVING (min_beatmaps IS NULL OR COUNT(DISTINCT cr.id) >= min_beatmaps)
75
+ )
76
+ SELECT
77
+ cc.id,
78
+ cc.title,
79
+ cc.description,
80
+ cc.created_at,
81
+ cc.owner,
82
+ cc.username as owner_username,
83
+ cc."avatar_url" as owner_avatar_url,
84
+ cc.map_count as beatmap_count,
85
+ cc.star1,
86
+ cc.star2,
87
+ cc.star3,
88
+ cc.star4,
89
+ cc.star5,
90
+ cc.star6,
91
+ cc.star7,
92
+ cc.star8,
93
+ cc.star9,
94
+ cc.star10,
95
+ cc.star11,
96
+ cc.star12,
97
+ cc.star13,
98
+ cc.star14,
99
+ cc.star15,
100
+ cc.star16,
101
+ cc.star17,
102
+ cc.star18,
103
+ CEILING(total_count::numeric / items_per_page)::bigint as total_pages
104
+ FROM collection_counts cc
105
+ ORDER BY cc.created_at DESC
106
+ LIMIT items_per_page
107
+ OFFSET ((page_number - 1) * items_per_page);
108
+ END;
109
+ $function$
@@ -0,0 +1,44 @@
1
+ CREATE OR REPLACE FUNCTION public.get_top_scores_for_beatmap(beatmap_hash text)
2
+ RETURNS TABLE(id bigint, awarded_sp numeric, created_at timestamp with time zone, misses numeric, mods json, passed boolean, replayhwid text, songid text, speed numeric, spin boolean, userid bigint, username text, avatar_url text, accuracy numeric)
3
+ LANGUAGE plpgsql
4
+ AS $function$
5
+ BEGIN
6
+ RETURN QUERY
7
+ WITH user_top_scores AS (
8
+ SELECT DISTINCT ON (s."userId")
9
+ s.id,
10
+ s.awarded_sp,
11
+ s.created_at,
12
+ s.misses,
13
+ s.mods,
14
+ s.passed,
15
+ s."replayHwid",
16
+ s."songId",
17
+ s.speed,
18
+ s.spin,
19
+ s."userId",
20
+ p.username,
21
+ p.avatar_url,
22
+ CASE
23
+ WHEN b."noteCount" > 0 THEN
24
+ ROUND(((b."noteCount" - s.misses) / b."noteCount" * 100)::numeric, 2)
25
+ ELSE 0
26
+ END AS accuracy
27
+ FROM
28
+ scores s
29
+ JOIN
30
+ profiles p ON s."userId" = p.id
31
+ JOIN
32
+ beatmaps b ON s."beatmapHash" = b."beatmapHash"
33
+ WHERE
34
+ s."beatmapHash" = beatmap_hash
35
+ AND p.ban <> 'excluded'
36
+ ORDER BY
37
+ s."userId", s.awarded_sp DESC
38
+ )
39
+ SELECT *
40
+ FROM user_top_scores
41
+ ORDER BY awarded_sp DESC
42
+ LIMIT 10;
43
+ END;
44
+ $function$
@@ -0,0 +1,32 @@
1
+ CREATE OR REPLACE FUNCTION public.get_user_by_email(email_address text)
2
+ RETURNS json
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ SET search_path TO 'public'
6
+ AS $function$
7
+ DECLARE
8
+ user_data json;
9
+ BEGIN
10
+ -- Check that the executing user has permission to access user data
11
+ -- You can add more permission checks here as needed
12
+
13
+ -- Query the auth.users table to find the user with the matching email
14
+ SELECT json_build_object(
15
+ 'id', id,
16
+ 'email', email,
17
+ 'created_at', created_at,
18
+ 'last_sign_in_at', last_sign_in_at,
19
+ 'user_metadata', raw_user_meta_data
20
+ -- Add or remove fields as needed
21
+ ) INTO user_data
22
+ FROM auth.users
23
+ WHERE email = email_address;
24
+
25
+ -- Return NULL if no user was found
26
+ IF user_data IS NULL THEN
27
+ RETURN NULL;
28
+ END IF;
29
+
30
+ RETURN user_data;
31
+ END;
32
+ $function$
@@ -0,0 +1,36 @@
1
+ CREATE OR REPLACE FUNCTION public.get_user_scores_lastday(userid integer, limit_param integer)
2
+ RETURNS jsonb
3
+ LANGUAGE sql
4
+ AS $function$
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)
12
+ )
13
+ select coalesce(
14
+ jsonb_agg(
15
+ jsonb_build_object(
16
+ 'created_at', s.created_at,
17
+ 'id', s.id,
18
+ 'passed', s.passed,
19
+ 'userId', s."userId",
20
+ 'awarded_sp', s.awarded_sp,
21
+ 'beatmapHash', s."beatmapHash",
22
+ 'misses', s.misses,
23
+ 'replay_url', s.replay_url,
24
+ 'songId', s."songId",
25
+ 'beatmapDifficulty', b.difficulty,
26
+ 'beatmapNotes', b."noteCount",
27
+ 'beatmapTitle', b.title,
28
+ 'speed', s.speed,
29
+ 'spin', s.spin
30
+ )
31
+ ),
32
+ '[]'::jsonb
33
+ )
34
+ from s
35
+ left join public.beatmaps b on b."beatmapHash" = s."beatmapHash";
36
+ $function$
@@ -0,0 +1,31 @@
1
+ CREATE OR REPLACE FUNCTION public.get_user_scores_reign(userid integer)
2
+ RETURNS jsonb
3
+ LANGUAGE sql
4
+ AS $function$
5
+ with r as (
6
+ select * from public.get_user_reigning_scores(userid, 10)
7
+ )
8
+ select coalesce(
9
+ jsonb_agg(
10
+ jsonb_build_object(
11
+ 'id', r.id,
12
+ 'awarded_sp', r.awarded_sp,
13
+ 'created_at', r.created_at,
14
+ 'misses', r.misses,
15
+ 'mods', r.mods,
16
+ 'passed', r.passed,
17
+ 'replay_url', s.replay_url,
18
+ 'songId', r.songid,
19
+ 'speed', r.speed,
20
+ 'spin', r.spin,
21
+ 'beatmapHash', r.beatmaphash,
22
+ 'beatmapTitle', r.beatmaptitle,
23
+ 'beatmapNotes', r.notes,
24
+ 'difficulty', r.difficulty
25
+ )
26
+ ),
27
+ '[]'::jsonb
28
+ )
29
+ from r
30
+ left join public.scores s on s.id = r.id;
31
+ $function$
@@ -0,0 +1,84 @@
1
+ CREATE OR REPLACE FUNCTION public.get_user_scores_top_and_stats(userid integer, limit_param integer)
2
+ RETURNS jsonb
3
+ LANGUAGE plpgsql
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 (
22
+ select
23
+ s.created_at,
24
+ s.id,
25
+ s.passed,
26
+ s."userId",
27
+ s.awarded_sp,
28
+ s."beatmapHash",
29
+ s.misses,
30
+ s.replay_url,
31
+ s."songId",
32
+ s.speed,
33
+ s.spin
34
+ from public.scores s
35
+ where s."userId" = userid
36
+ and s.passed = true
37
+ and s.awarded_sp is not null and s.awarded_sp <> 0
38
+ ),
39
+ best_per_hash as (
40
+ select distinct on ("beatmapHash") *
41
+ from base
42
+ order by "beatmapHash", awarded_sp desc
43
+ ),
44
+ top_rows as (
45
+ select *
46
+ from best_per_hash
47
+ order by awarded_sp desc
48
+ limit least(limit_param, 100)
49
+ )
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;
84
+ $function$
@@ -0,0 +1,69 @@
1
+ CREATE OR REPLACE FUNCTION public.grant_special_badges(p_user_id integer, p_beatmap_id integer, p_spin boolean DEFAULT false, p_passed boolean DEFAULT true)
2
+ RETURNS json
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ AS $function$
6
+ DECLARE
7
+ badge_granted JSON := '{"granted": false, "badge": null}';
8
+ current_badges JSONB;
9
+ new_badge_name TEXT := NULL;
10
+ BEGIN
11
+ -- Only proceed if the score was a pass
12
+ IF NOT p_passed THEN
13
+ RETURN badge_granted;
14
+ END IF;
15
+
16
+ -- Get current badges from user profile (cast to JSONB)
17
+ SELECT COALESCE(badges::jsonb, '[]'::jsonb) INTO current_badges
18
+ FROM profiles
19
+ WHERE id = p_user_id;
20
+
21
+ -- Check each special map and determine badge to grant
22
+ CASE p_beatmap_id
23
+ WHEN 5368 THEN
24
+ new_badge_name := 'The Start of an Era';
25
+ WHEN 6178 THEN
26
+ new_badge_name := 'New Farm';
27
+ WHEN 4701 THEN
28
+ -- Spinnin (only if using spin gamestyle)
29
+ IF p_spin THEN
30
+ new_badge_name := 'Spinnin';
31
+ END IF;
32
+ WHEN 5216 THEN
33
+ new_badge_name := 'Old Farm';
34
+ WHEN 4305 THEN
35
+ new_badge_name := 'Birb';
36
+ WHEN 5094 THEN
37
+ new_badge_name := 'Flamingos';
38
+ WHEN 4285 THEN
39
+ new_badge_name := 'Cats!';
40
+ ELSE
41
+ -- No special badge for this beatmap
42
+ RETURN badge_granted;
43
+ END CASE;
44
+
45
+ -- If no badge should be granted (e.g., spin condition not met), return
46
+ IF new_badge_name IS NULL THEN
47
+ RETURN badge_granted;
48
+ END IF;
49
+
50
+ -- Check if user already has this badge
51
+ IF current_badges ? new_badge_name THEN
52
+ -- Badge already exists (using JSONB containment operator)
53
+ RETURN badge_granted;
54
+ END IF;
55
+
56
+ -- Append new badge to the existing array (both operands are JSONB now)
57
+ UPDATE profiles
58
+ SET badges = (badges::jsonb || jsonb_build_array(new_badge_name))::json
59
+ WHERE id = p_user_id;
60
+
61
+ -- Return success response
62
+ badge_granted := json_build_object(
63
+ 'granted', true,
64
+ 'badge', new_badge_name
65
+ );
66
+
67
+ RETURN badge_granted;
68
+ END;
69
+ $function$