rhythia-api 217.0.0 → 226.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/.env +12 -0
- package/.yarnrc +1 -0
- package/README.md +2 -0
- package/api/createBeatmap.ts +1 -1
- package/api/deleteBeatmapPage.ts +20 -20
- package/api/executeAdminOperation.ts +113 -113
- package/api/getBadgeLeaders.ts +25 -3
- package/api/getBadgedUsers.ts +2 -1
- package/api/getBeatmapComments.ts +32 -32
- package/api/getBeatmapPage.ts +67 -67
- package/api/getBeatmapPageById.ts +72 -63
- package/api/getLeaderboard.ts +173 -44
- package/api/getOnlinePlayers.ts +78 -78
- package/api/getProfile.ts +21 -7
- package/api/getPublicStats.ts +5 -2
- package/api/getUserScores.ts +48 -48
- package/api/postBeatmapComment.ts +23 -23
- package/api/submitScore.ts +26 -26
- package/api/submitScoreInternal.ts +426 -0
- package/handleApi.ts +7 -3
- package/index.ts +64 -32
- package/package.json +3 -3
- package/types/database.ts +1179 -1145
- package/utils/activityStatus.ts +36 -0
- package/utils/cache.ts +60 -60
- package/utils/leaderboardCache.ts +8 -0
- package/utils/requestUtils.ts +2 -1
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
2
|
-
import z from "zod";
|
|
3
|
-
import { protectedApi } from "../utils/requestUtils";
|
|
4
|
-
import { getCacheValue, setCacheValue } from "../utils/cache";
|
|
5
|
-
import { supabase } from "../utils/supabase";
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { protectedApi } from "../utils/requestUtils";
|
|
4
|
+
import { getCacheValue, setCacheValue } from "../utils/cache";
|
|
5
|
+
import { supabase } from "../utils/supabase";
|
|
6
|
+
import { getActiveProfileIdSet } from "../utils/activityStatus";
|
|
6
7
|
|
|
7
|
-
export const Schema = {
|
|
8
|
-
input: z.strictObject({
|
|
9
|
-
session: z.string(),
|
|
10
|
-
mapId: z.string(),
|
|
11
|
-
limit: z.number().min(1).max(200).default(50),
|
|
12
|
-
}),
|
|
13
|
-
output: z.object({
|
|
14
|
-
error: z.string().optional(),
|
|
15
|
-
scores: z
|
|
16
|
-
.array(
|
|
8
|
+
export const Schema = {
|
|
9
|
+
input: z.strictObject({
|
|
10
|
+
session: z.string(),
|
|
11
|
+
mapId: z.string(),
|
|
12
|
+
limit: z.number().min(1).max(200).default(50),
|
|
13
|
+
}),
|
|
14
|
+
output: z.object({
|
|
15
|
+
error: z.string().optional(),
|
|
16
|
+
scores: z
|
|
17
|
+
.array(
|
|
17
18
|
z.object({
|
|
18
19
|
id: z.number(),
|
|
19
20
|
awarded_sp: z.number().nullable(),
|
|
@@ -64,14 +65,14 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
export async function handler(
|
|
68
|
-
data: (typeof Schema)["input"]["_type"],
|
|
69
|
-
req: Request
|
|
70
|
-
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
71
|
-
const limit = data.limit ?? 50;
|
|
72
|
-
|
|
73
|
-
let { data: beatmapPage, error: errorlast } = await supabase
|
|
74
|
-
.from("beatmapPages")
|
|
68
|
+
export async function handler(
|
|
69
|
+
data: (typeof Schema)["input"]["_type"],
|
|
70
|
+
req: Request
|
|
71
|
+
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
72
|
+
const limit = data.limit ?? 50;
|
|
73
|
+
|
|
74
|
+
let { data: beatmapPage, error: errorlast } = await supabase
|
|
75
|
+
.from("beatmapPages")
|
|
75
76
|
.select(
|
|
76
77
|
`
|
|
77
78
|
*,
|
|
@@ -93,46 +94,54 @@ export async function handler(
|
|
|
93
94
|
)
|
|
94
95
|
`
|
|
95
96
|
)
|
|
96
|
-
.eq("latestBeatmapHash", data.mapId)
|
|
97
|
-
.single();
|
|
98
|
-
|
|
99
|
-
if (!beatmapPage) return NextResponse.json({});
|
|
100
|
-
|
|
101
|
-
const beatmapHash = beatmapPage?.latestBeatmapHash || "";
|
|
102
|
-
const isCacheable =
|
|
103
|
-
beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
|
|
104
|
-
const cacheKey = `beatmap-scores:${beatmapHash}
|
|
105
|
-
|
|
106
|
-
let scoreData: any[] | null = null;
|
|
107
|
-
|
|
108
|
-
if (isCacheable && beatmapHash) {
|
|
109
|
-
scoreData = await getCacheValue<any[]>(cacheKey);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (!scoreData) {
|
|
113
|
-
const { data: rpcScores, error } = await supabase.rpc(
|
|
114
|
-
"get_top_scores_for_beatmap",
|
|
115
|
-
{ beatmap_hash: beatmapHash }
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
if (error) {
|
|
119
|
-
return NextResponse.json({ error: JSON.stringify(error) });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
scoreData = (rpcScores || []).slice(0,
|
|
123
|
-
|
|
124
|
-
if (isCacheable && beatmapHash) {
|
|
125
|
-
await setCacheValue(cacheKey, scoreData);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
97
|
+
.eq("latestBeatmapHash", data.mapId)
|
|
98
|
+
.single();
|
|
99
|
+
|
|
100
|
+
if (!beatmapPage) return NextResponse.json({});
|
|
101
|
+
|
|
102
|
+
const beatmapHash = beatmapPage?.latestBeatmapHash || "";
|
|
103
|
+
const isCacheable =
|
|
104
|
+
beatmapPage?.status === "RANKED" || beatmapPage?.status === "APPROVED";
|
|
105
|
+
const cacheKey = `beatmap-scores:${beatmapHash}`;
|
|
106
|
+
|
|
107
|
+
let scoreData: any[] | null = null;
|
|
108
|
+
|
|
109
|
+
if (isCacheable && beatmapHash) {
|
|
110
|
+
scoreData = await getCacheValue<any[]>(cacheKey);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!scoreData) {
|
|
114
|
+
const { data: rpcScores, error } = await supabase.rpc(
|
|
115
|
+
"get_top_scores_for_beatmap",
|
|
116
|
+
{ beatmap_hash: beatmapHash }
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (error) {
|
|
120
|
+
return NextResponse.json({ error: JSON.stringify(error) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scoreData = (rpcScores || []).slice(0, 200);
|
|
124
|
+
|
|
125
|
+
if (isCacheable && beatmapHash) {
|
|
126
|
+
await setCacheValue(cacheKey, scoreData);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const userIds = Array.from(
|
|
131
|
+
new Set((scoreData || []).map((score) => score.userid).filter(Boolean))
|
|
132
|
+
);
|
|
133
|
+
const activeUserIds = await getActiveProfileIdSet(userIds);
|
|
134
|
+
const visibleScores = (scoreData || [])
|
|
135
|
+
.filter((score) => activeUserIds.has(score.userid))
|
|
136
|
+
.slice(0, limit);
|
|
137
|
+
|
|
138
|
+
return NextResponse.json({
|
|
139
|
+
scores: visibleScores.map((score: any) => ({
|
|
140
|
+
id: score.id,
|
|
141
|
+
awarded_sp: score.awarded_sp,
|
|
142
|
+
created_at: score.created_at,
|
|
143
|
+
misses: score.misses,
|
|
144
|
+
mods: score.mods,
|
|
136
145
|
passed: score.passed,
|
|
137
146
|
songId: score.songid,
|
|
138
147
|
speed: score.speed,
|
package/api/getLeaderboard.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { protectedApi } 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 { getScoreActivityCutoffIso } from "../utils/activityStatus";
|
|
8
|
+
import { getCacheValue, setCacheValue } from "../utils/cache";
|
|
9
|
+
import { LEADERBOARD_CACHE_INVALIDATE_KEY } from "../utils/leaderboardCache";
|
|
7
10
|
|
|
8
11
|
export const Schema = {
|
|
9
12
|
input: z.strictObject({
|
|
@@ -11,6 +14,7 @@ export const Schema = {
|
|
|
11
14
|
page: z.number().default(1),
|
|
12
15
|
flag: z.string().optional(),
|
|
13
16
|
spin: z.boolean().default(false),
|
|
17
|
+
include_inactive: z.boolean().optional().default(false),
|
|
14
18
|
}),
|
|
15
19
|
output: z.object({
|
|
16
20
|
error: z.string().optional(),
|
|
@@ -23,6 +27,7 @@ export const Schema = {
|
|
|
23
27
|
z.object({
|
|
24
28
|
flag: z.string().nullable(),
|
|
25
29
|
id: z.number(),
|
|
30
|
+
avatar_url: z.string().nullable(),
|
|
26
31
|
username: z.string().nullable(),
|
|
27
32
|
play_count: z.number().nullable(),
|
|
28
33
|
skill_points: z.number().nullable(),
|
|
@@ -58,74 +63,112 @@ export async function handler(
|
|
|
58
63
|
data.page,
|
|
59
64
|
data.session,
|
|
60
65
|
data.spin,
|
|
61
|
-
data.flag
|
|
66
|
+
data.flag,
|
|
67
|
+
data.include_inactive
|
|
62
68
|
);
|
|
63
69
|
return NextResponse.json(result);
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
const VIEW_PER_PAGE = 50;
|
|
73
|
+
const CACHE_TTL_MS = 4 * 60 * 1000;
|
|
67
74
|
|
|
68
75
|
export async function getLeaderboard(
|
|
69
76
|
page = 1,
|
|
70
77
|
session: string,
|
|
71
78
|
spin: boolean,
|
|
72
|
-
flag?: string
|
|
79
|
+
flag?: string,
|
|
80
|
+
includeInactive = false
|
|
73
81
|
) {
|
|
74
|
-
const
|
|
82
|
+
const cutoffIso = getScoreActivityCutoffIso();
|
|
83
|
+
const userPromise = getUserBySession(session) as Promise<User | null>;
|
|
84
|
+
const invalidateAtPromise = getCacheValue<number>(
|
|
85
|
+
LEADERBOARD_CACHE_INVALIDATE_KEY
|
|
86
|
+
);
|
|
75
87
|
|
|
76
|
-
|
|
88
|
+
const startPage = (page - 1) * VIEW_PER_PAGE;
|
|
89
|
+
const endPage = startPage + VIEW_PER_PAGE - 1;
|
|
90
|
+
const cacheKey = `leaderboard:page=${page}:spin=${spin ? 1 : 0}:flag=${
|
|
91
|
+
flag || "all"
|
|
92
|
+
}:include_inactive=${includeInactive ? 1 : 0}`;
|
|
93
|
+
const cachedPagePromise = getCacheValue<{
|
|
94
|
+
cachedAt: number;
|
|
95
|
+
total: number;
|
|
96
|
+
leaderboard: (typeof Schema)["output"]["_type"]["leaderboard"];
|
|
97
|
+
}>(cacheKey);
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.single();
|
|
99
|
+
const [user, invalidateAt, cachedPage] = await Promise.all([
|
|
100
|
+
userPromise,
|
|
101
|
+
invalidateAtPromise,
|
|
102
|
+
cachedPagePromise,
|
|
103
|
+
]);
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
const cutoffInvalidation = invalidateAt || 0;
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const cacheFresh = Boolean(
|
|
108
|
+
cachedPage &&
|
|
109
|
+
cachedPage.cachedAt >= cutoffInvalidation &&
|
|
110
|
+
now - cachedPage.cachedAt < CACHE_TTL_MS
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const pageDataPromise = (async () => {
|
|
114
|
+
if (cachedPage && cacheFresh) {
|
|
115
|
+
return cachedPage;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let countQuery;
|
|
119
|
+
let query;
|
|
120
|
+
|
|
121
|
+
if (includeInactive) {
|
|
122
|
+
countQuery = supabase
|
|
123
|
+
.from("profiles")
|
|
124
|
+
.select("id", { count: "exact", head: true })
|
|
125
|
+
.neq("ban", "excluded");
|
|
126
|
+
|
|
127
|
+
query = supabase
|
|
128
|
+
.from("profiles")
|
|
129
|
+
.select(
|
|
130
|
+
"flag,id,avatar_url,username,play_count,skill_points,spin_skill_points,total_score,verified,clans:clan(id, acronym)"
|
|
131
|
+
)
|
|
132
|
+
.neq("ban", "excluded");
|
|
133
|
+
} else {
|
|
134
|
+
countQuery = supabase
|
|
87
135
|
.from("profiles")
|
|
88
|
-
.select("
|
|
136
|
+
.select("id,scores!inner(id)", { count: "exact", head: true })
|
|
89
137
|
.neq("ban", "excluded")
|
|
90
|
-
.
|
|
138
|
+
.gte("scores.created_at", cutoffIso);
|
|
91
139
|
|
|
92
|
-
|
|
140
|
+
query = supabase
|
|
141
|
+
.from("profiles")
|
|
142
|
+
.select(
|
|
143
|
+
"flag,id,avatar_url,username,play_count,skill_points,spin_skill_points,total_score,verified,clans:clan(id, acronym),scores!inner(id)"
|
|
144
|
+
)
|
|
145
|
+
.neq("ban", "excluded")
|
|
146
|
+
.gte("scores.created_at", cutoffIso)
|
|
147
|
+
.limit(1, { foreignTable: "scores" });
|
|
93
148
|
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const startPage = (page - 1) * VIEW_PER_PAGE;
|
|
97
|
-
const endPage = startPage + VIEW_PER_PAGE - 1;
|
|
98
|
-
const countQuery = await supabase
|
|
99
|
-
.from("profiles")
|
|
100
|
-
.select("ban", { count: "exact", head: true })
|
|
101
|
-
.neq("ban", "excluded");
|
|
102
149
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
if (flag) {
|
|
151
|
+
countQuery.eq("flag", flag);
|
|
152
|
+
query.eq("flag", flag);
|
|
153
|
+
}
|
|
107
154
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
155
|
+
if (spin) {
|
|
156
|
+
query.order("spin_skill_points", { ascending: false });
|
|
157
|
+
} else {
|
|
158
|
+
query.order("skill_points", { ascending: false });
|
|
159
|
+
}
|
|
111
160
|
|
|
112
|
-
|
|
113
|
-
query.order("spin_skill_points", { ascending: false });
|
|
114
|
-
} else {
|
|
115
|
-
query.order("skill_points", { ascending: false });
|
|
116
|
-
}
|
|
161
|
+
query.range(startPage, endPage);
|
|
117
162
|
|
|
118
|
-
|
|
163
|
+
const [countQueryResult, { data: queryData }] = await Promise.all([
|
|
164
|
+
countQuery,
|
|
165
|
+
query,
|
|
166
|
+
]);
|
|
119
167
|
|
|
120
|
-
|
|
121
|
-
return {
|
|
122
|
-
total: countQuery.count || 0,
|
|
123
|
-
viewPerPage: VIEW_PER_PAGE,
|
|
124
|
-
currentPage: page,
|
|
125
|
-
userPosition: leaderPosition,
|
|
126
|
-
leaderboard: queryData?.map((user) => ({
|
|
168
|
+
const leaderboard = queryData?.map((user) => ({
|
|
127
169
|
flag: user.flag,
|
|
128
170
|
id: user.id,
|
|
171
|
+
avatar_url: user.avatar_url,
|
|
129
172
|
play_count: user.play_count,
|
|
130
173
|
skill_points: user.skill_points,
|
|
131
174
|
spin_skill_points: user.spin_skill_points,
|
|
@@ -133,6 +176,92 @@ export async function getLeaderboard(
|
|
|
133
176
|
username: user.username,
|
|
134
177
|
clans: user.clans as any,
|
|
135
178
|
verified: user.verified,
|
|
136
|
-
}))
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
const data = {
|
|
182
|
+
cachedAt: Date.now(),
|
|
183
|
+
total: countQueryResult.count || 0,
|
|
184
|
+
leaderboard,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await setCacheValue(cacheKey, data);
|
|
188
|
+
|
|
189
|
+
return data;
|
|
190
|
+
})();
|
|
191
|
+
|
|
192
|
+
const leaderPositionPromise = (async () => {
|
|
193
|
+
if (!user) return 0;
|
|
194
|
+
|
|
195
|
+
const posCacheKey = `leaderboard:userPosition:uid=${user.id}:include_inactive=${
|
|
196
|
+
includeInactive ? 1 : 0
|
|
197
|
+
}`;
|
|
198
|
+
|
|
199
|
+
const cachedPosition = await getCacheValue<{
|
|
200
|
+
cachedAt: number;
|
|
201
|
+
position: number;
|
|
202
|
+
}>(posCacheKey);
|
|
203
|
+
|
|
204
|
+
const positionCacheFresh =
|
|
205
|
+
cachedPosition &&
|
|
206
|
+
cachedPosition.cachedAt >= cutoffInvalidation &&
|
|
207
|
+
now - cachedPosition.cachedAt < CACHE_TTL_MS;
|
|
208
|
+
|
|
209
|
+
if (positionCacheFresh) {
|
|
210
|
+
return cachedPosition.position;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (includeInactive) {
|
|
214
|
+
const { data: profile } = await supabase
|
|
215
|
+
.from("profiles")
|
|
216
|
+
.select("id,skill_points")
|
|
217
|
+
.eq("uid", user.id)
|
|
218
|
+
.maybeSingle();
|
|
219
|
+
|
|
220
|
+
if (!profile) return 0;
|
|
221
|
+
|
|
222
|
+
const { count: playersWithMorePoints } = await supabase
|
|
223
|
+
.from("profiles")
|
|
224
|
+
.select("id", { count: "exact", head: true })
|
|
225
|
+
.neq("ban", "excluded")
|
|
226
|
+
.gt("skill_points", profile.skill_points);
|
|
227
|
+
|
|
228
|
+
const position = (playersWithMorePoints || 0) + 1;
|
|
229
|
+
await setCacheValue(posCacheKey, { cachedAt: Date.now(), position });
|
|
230
|
+
return position;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { data: profile } = await supabase
|
|
234
|
+
.from("profiles")
|
|
235
|
+
.select("id,skill_points,scores!inner(id)")
|
|
236
|
+
.eq("uid", user.id)
|
|
237
|
+
.gte("scores.created_at", cutoffIso)
|
|
238
|
+
.limit(1, { foreignTable: "scores" })
|
|
239
|
+
.maybeSingle();
|
|
240
|
+
|
|
241
|
+
if (!profile) return 0;
|
|
242
|
+
|
|
243
|
+
const { count: playersWithMorePoints } = await supabase
|
|
244
|
+
.from("profiles")
|
|
245
|
+
.select("id,scores!inner(id)", { count: "exact", head: true })
|
|
246
|
+
.neq("ban", "excluded")
|
|
247
|
+
.gte("scores.created_at", cutoffIso)
|
|
248
|
+
.gt("skill_points", profile.skill_points);
|
|
249
|
+
|
|
250
|
+
const position = (playersWithMorePoints || 0) + 1;
|
|
251
|
+
await setCacheValue(posCacheKey, { cachedAt: Date.now(), position });
|
|
252
|
+
return position;
|
|
253
|
+
})();
|
|
254
|
+
|
|
255
|
+
const [pageData, leaderPosition] = await Promise.all([
|
|
256
|
+
pageDataPromise,
|
|
257
|
+
leaderPositionPromise,
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
total: pageData.total,
|
|
262
|
+
viewPerPage: VIEW_PER_PAGE,
|
|
263
|
+
currentPage: page,
|
|
264
|
+
userPosition: leaderPosition,
|
|
265
|
+
leaderboard: pageData.leaderboard,
|
|
137
266
|
};
|
|
138
267
|
}
|
package/api/getOnlinePlayers.ts
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
2
|
-
import z from "zod";
|
|
3
|
-
import { protectedApi } from "../utils/requestUtils";
|
|
4
|
-
import { supabase } from "../utils/supabase";
|
|
5
|
-
|
|
6
|
-
const ONLINE_WINDOW_MS = 30 * 60 * 1000;
|
|
7
|
-
|
|
8
|
-
export const Schema = {
|
|
9
|
-
input: z.strictObject({}),
|
|
10
|
-
output: z.object({
|
|
11
|
-
error: z.string().optional(),
|
|
12
|
-
players: z.array(
|
|
13
|
-
z.object({
|
|
14
|
-
id: z.number(),
|
|
15
|
-
name: z.string().nullable(),
|
|
16
|
-
profilePictureUrl: z.string().nullable(),
|
|
17
|
-
})
|
|
18
|
-
),
|
|
19
|
-
}),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export async function POST(request: Request): Promise<NextResponse> {
|
|
23
|
-
return protectedApi({
|
|
24
|
-
request,
|
|
25
|
-
schema: Schema,
|
|
26
|
-
authorization: () => {},
|
|
27
|
-
activity: handler,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function handler(
|
|
32
|
-
_data: (typeof Schema)["input"]["_type"]
|
|
33
|
-
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
34
|
-
const cutoff = Date.now() - ONLINE_WINDOW_MS;
|
|
35
|
-
|
|
36
|
-
const { data: activityRows, error: activityError } = await supabase
|
|
37
|
-
.from("profileActivities")
|
|
38
|
-
.select("uid")
|
|
39
|
-
.gt("last_activity", cutoff);
|
|
40
|
-
|
|
41
|
-
if (activityError) {
|
|
42
|
-
return NextResponse.json(
|
|
43
|
-
{ players: [], error: "Failed to load online players" },
|
|
44
|
-
{ status: 500 }
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const onlineUids = Array.from(
|
|
49
|
-
new Set((activityRows || []).map((row) => row.uid).filter(Boolean))
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
if (!onlineUids.length) {
|
|
53
|
-
return NextResponse.json({ players: [] });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const { data: profiles, error: profilesError } = await supabase
|
|
57
|
-
.from("profiles")
|
|
58
|
-
.select("id,username,avatar_url,profile_image,skill_points")
|
|
59
|
-
.in("uid", onlineUids)
|
|
60
|
-
.neq("ban", "excluded")
|
|
61
|
-
.order("skill_points", { ascending: false });
|
|
62
|
-
|
|
63
|
-
if (profilesError) {
|
|
64
|
-
return NextResponse.json(
|
|
65
|
-
{ players: [], error: "Failed to load online players" },
|
|
66
|
-
{ status: 500 }
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const players =
|
|
71
|
-
profiles?.map((profile) => ({
|
|
72
|
-
id: profile.id,
|
|
73
|
-
name: profile.username,
|
|
74
|
-
profilePictureUrl: profile.
|
|
75
|
-
})) || [];
|
|
76
|
-
|
|
77
|
-
return NextResponse.json({ players });
|
|
78
|
-
}
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
import { protectedApi } from "../utils/requestUtils";
|
|
4
|
+
import { supabase } from "../utils/supabase";
|
|
5
|
+
|
|
6
|
+
const ONLINE_WINDOW_MS = 30 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
export const Schema = {
|
|
9
|
+
input: z.strictObject({}),
|
|
10
|
+
output: z.object({
|
|
11
|
+
error: z.string().optional(),
|
|
12
|
+
players: z.array(
|
|
13
|
+
z.object({
|
|
14
|
+
id: z.number(),
|
|
15
|
+
name: z.string().nullable(),
|
|
16
|
+
profilePictureUrl: z.string().nullable(),
|
|
17
|
+
})
|
|
18
|
+
),
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function POST(request: Request): Promise<NextResponse> {
|
|
23
|
+
return protectedApi({
|
|
24
|
+
request,
|
|
25
|
+
schema: Schema,
|
|
26
|
+
authorization: () => {},
|
|
27
|
+
activity: handler,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function handler(
|
|
32
|
+
_data: (typeof Schema)["input"]["_type"]
|
|
33
|
+
): Promise<NextResponse<(typeof Schema)["output"]["_type"]>> {
|
|
34
|
+
const cutoff = Date.now() - ONLINE_WINDOW_MS;
|
|
35
|
+
|
|
36
|
+
const { data: activityRows, error: activityError } = await supabase
|
|
37
|
+
.from("profileActivities")
|
|
38
|
+
.select("uid")
|
|
39
|
+
.gt("last_activity", cutoff);
|
|
40
|
+
|
|
41
|
+
if (activityError) {
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ players: [], error: "Failed to load online players" },
|
|
44
|
+
{ status: 500 }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const onlineUids = Array.from(
|
|
49
|
+
new Set((activityRows || []).map((row) => row.uid).filter(Boolean))
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (!onlineUids.length) {
|
|
53
|
+
return NextResponse.json({ players: [] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { data: profiles, error: profilesError } = await supabase
|
|
57
|
+
.from("profiles")
|
|
58
|
+
.select("id,username,avatar_url,profile_image,skill_points")
|
|
59
|
+
.in("uid", onlineUids)
|
|
60
|
+
.neq("ban", "excluded")
|
|
61
|
+
.order("skill_points", { ascending: false });
|
|
62
|
+
|
|
63
|
+
if (profilesError) {
|
|
64
|
+
return NextResponse.json(
|
|
65
|
+
{ players: [], error: "Failed to load online players" },
|
|
66
|
+
{ status: 500 }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const players =
|
|
71
|
+
profiles?.map((profile) => ({
|
|
72
|
+
id: profile.id,
|
|
73
|
+
name: profile.username,
|
|
74
|
+
profilePictureUrl: profile.avatar_url,
|
|
75
|
+
})) || [];
|
|
76
|
+
|
|
77
|
+
return NextResponse.json({ players });
|
|
78
|
+
}
|
package/api/getProfile.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { protectedApi } from "../utils/requestUtils";
|
|
|
6
6
|
import { supabase } from "../utils/supabase";
|
|
7
7
|
import { getUserBySession } from "../utils/getUserBySession";
|
|
8
8
|
import { User } from "@supabase/supabase-js";
|
|
9
|
+
import {
|
|
10
|
+
getActivityStatusForUserId,
|
|
11
|
+
getScoreActivityCutoffIso,
|
|
12
|
+
} from "../utils/activityStatus";
|
|
9
13
|
|
|
10
14
|
export const Schema = {
|
|
11
15
|
input: z.strictObject({
|
|
@@ -33,6 +37,7 @@ export const Schema = {
|
|
|
33
37
|
squares_hit: z.number().nullable(),
|
|
34
38
|
total_score: z.number().nullable(),
|
|
35
39
|
position: z.number().nullable(),
|
|
40
|
+
activity_status: z.enum(["active", "inactive"]),
|
|
36
41
|
is_online: z.boolean(),
|
|
37
42
|
clans: z
|
|
38
43
|
.object({
|
|
@@ -114,6 +119,8 @@ export async function handler(
|
|
|
114
119
|
|
|
115
120
|
const user = profiles[0];
|
|
116
121
|
|
|
122
|
+
const activityStatus = await getActivityStatusForUserId(user.id);
|
|
123
|
+
|
|
117
124
|
const { data: activityData } = await supabase
|
|
118
125
|
.from("profileActivities")
|
|
119
126
|
.select("*")
|
|
@@ -125,12 +132,18 @@ export async function handler(
|
|
|
125
132
|
isOnline = Date.now() - activityData.last_activity < 1800000;
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
let position: number | null = null;
|
|
136
|
+
if (activityStatus === "active") {
|
|
137
|
+
const cutoffIso = getScoreActivityCutoffIso();
|
|
138
|
+
const { count: playersWithMorePoints } = await supabase
|
|
139
|
+
.from("profiles")
|
|
140
|
+
.select("id,scores!inner(id)", { count: "exact", head: true })
|
|
141
|
+
.neq("ban", "excluded")
|
|
142
|
+
.gte("scores.created_at", cutoffIso)
|
|
143
|
+
.gt("skill_points", user.skill_points);
|
|
144
|
+
|
|
145
|
+
position = (playersWithMorePoints || 0) + 1;
|
|
146
|
+
}
|
|
134
147
|
|
|
135
148
|
if (user.verificationDeadline < Date.now()) {
|
|
136
149
|
await supabase
|
|
@@ -145,7 +158,8 @@ export async function handler(
|
|
|
145
158
|
return NextResponse.json({
|
|
146
159
|
user: {
|
|
147
160
|
...user,
|
|
148
|
-
position
|
|
161
|
+
position,
|
|
162
|
+
activity_status: activityStatus,
|
|
149
163
|
is_online: isOnline,
|
|
150
164
|
},
|
|
151
165
|
});
|
package/api/getPublicStats.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import z from "zod";
|
|
3
3
|
import { protectedApi } from "../utils/requestUtils";
|
|
4
4
|
import { supabase } from "../utils/supabase";
|
|
5
|
+
import { getScoreActivityCutoffIso } from "../utils/activityStatus";
|
|
5
6
|
|
|
6
7
|
export const Schema = {
|
|
7
8
|
input: z.strictObject({}),
|
|
@@ -103,10 +104,12 @@ export async function handler(data: (typeof Schema)["input"]["_type"]) {
|
|
|
103
104
|
|
|
104
105
|
let { data: topUsers } = await supabase
|
|
105
106
|
.from("profiles")
|
|
106
|
-
.select("
|
|
107
|
+
.select("id,username,avatar_url,skill_points,scores!inner(id)")
|
|
107
108
|
.neq("ban", "excluded")
|
|
109
|
+
.gte("scores.created_at", getScoreActivityCutoffIso())
|
|
108
110
|
.order("skill_points", { ascending: false })
|
|
109
|
-
.limit(3)
|
|
111
|
+
.limit(3)
|
|
112
|
+
.limit(1, { foreignTable: "scores" });
|
|
110
113
|
|
|
111
114
|
let { data: comments } = await supabase
|
|
112
115
|
.from("beatmapPageComments")
|