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.
- package/api/getUserScores.ts +18 -15
- package/handleApi.ts +3 -7
- package/index.ts +18 -15
- package/package.json +10 -2
- package/queries/admin_delete_user.sql +39 -0
- package/queries/admin_exclude_user.sql +21 -0
- package/queries/admin_invalidate_ranked_scores.sql +18 -0
- package/queries/admin_log_action.sql +10 -0
- package/queries/admin_profanity_clear.sql +29 -0
- package/queries/admin_remove_all_scores.sql +29 -0
- package/queries/admin_restrict_user.sql +21 -0
- package/queries/admin_search_users.sql +24 -0
- package/queries/admin_silence_user.sql +21 -0
- package/queries/admin_unban_user.sql +21 -0
- package/queries/get_badge_leaderboard.sql +50 -0
- package/queries/get_clan_leaderboard.sql +68 -0
- package/queries/get_collections_v4.sql +109 -0
- package/queries/get_top_scores_for_beatmap.sql +44 -0
- package/queries/get_user_by_email.sql +32 -0
- package/queries/get_user_scores_lastday.sql +36 -0
- package/queries/get_user_scores_reign.sql +31 -0
- package/queries/get_user_scores_top_and_stats.sql +84 -0
- package/queries/grant_special_badges.sql +69 -0
package/api/getUserScores.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
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": "
|
|
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$
|