Files
sendou.ink/app/features/plus-voting/PlusVotingRepository.server.ts
2026-09-06 16:57:29 +03:00

274 lines
7.3 KiB
TypeScript

import { type InferResult, sql } from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import {
type MonthYear,
nextNonCompletedVoting,
rangeToMonthYear,
} from "~/features/plus-voting/core";
import { invariant } from "~/utils/invariant";
import { commonUserSelect } from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
import * as PlusVoting from "./core/PlusVoting";
const resultsByMonthYearQuery = (args: MonthYear) =>
db
.selectFrom("PlusVotingResult")
.innerJoin("User", "PlusVotingResult.votedId", "User.id")
.select((eb) => [
...commonUserSelect(eb),
"PlusVotingResult.wasSuggested",
"PlusVotingResult.tier",
"PlusVotingResult.score",
"PlusVotingResult.votedId",
])
.where("PlusVotingResult.month", "=", args.month)
.where("PlusVotingResult.year", "=", args.year)
.orderBy(sql`"User"."username" collate nocase`, "asc");
type ResultsByMonthYearQueryReturnType = InferResult<
ReturnType<typeof resultsByMonthYearQuery>
>;
export async function findAllPlusTiersFromLatestVoting() {
// resolving month & year first lets SQLite push the filter into the PlusVotingResult view
const latestVoting = await db
.selectFrom("PlusVote")
.select(["PlusVote.year", "PlusVote.month"])
.where("PlusVote.becomesValidAt", "<", sql<number>`strftime('%s', 'now')`)
.orderBy("PlusVote.year", "desc")
.orderBy("PlusVote.month", "desc")
.limit(1)
.executeTakeFirst();
const rows = latestVoting
? await db
.selectFrom("PlusVotingResult")
.select([
"PlusVotingResult.votedId",
"PlusVotingResult.tier",
"PlusVotingResult.score",
"PlusVotingResult.wasSuggested",
])
.where("PlusVotingResult.year", "=", latestVoting.year)
.where("PlusVotingResult.month", "=", latestVoting.month)
.execute()
: [];
const withPassed = PlusVoting.computePassedVoting(rows);
return PlusVoting.computeFreshPlusTiers(withPassed);
}
export type ResultsByMonthYearItem = Unwrapped<typeof findResultsByMonthYear>;
export async function findResultsByMonthYear(args: MonthYear) {
const rows = await resultsByMonthYearQuery(args).execute();
const passedMap = new Map<
string,
{ passedVoting: number; wasSuggested: number }
>();
const rawForVoting = rows.map((row) => ({
votedId: row.votedId,
tier: row.tier,
score: row.score,
wasSuggested: row.wasSuggested,
}));
for (const r of PlusVoting.computePassedVoting(rawForVoting)) {
passedMap.set(`${r.votedId}-${r.tier}`, {
passedVoting: r.passedVoting,
wasSuggested: r.wasSuggested,
});
}
const enrichedRows = rows.map((row) => {
const computed = passedMap.get(`${row.votedId}-${row.tier}`);
return {
...row,
passedVoting: computed?.passedVoting ?? 0,
};
});
return groupPlusVotingResults(enrichedRows);
}
type EnrichedRow = ResultsByMonthYearQueryReturnType[number] & {
passedVoting: number;
};
function groupPlusVotingResults(rows: EnrichedRow[]) {
const grouped: Record<
number,
{
passed: EnrichedRow[];
failed: EnrichedRow[];
}
> = {};
for (const row of rows) {
const playersOfTier = grouped[row.tier] ?? {
passed: [],
failed: [],
};
grouped[row.tier] = playersOfTier;
playersOfTier[row.passedVoting ? "passed" : "failed"].push(row);
}
return Object.entries(grouped)
.map(([tier, { passed, failed }]) => ({
tier: Number(tier),
passed,
failed,
}))
.sort((a, b) => a.tier - b.tier);
}
type Bio = { text: string; markdown: boolean };
export type UsersForVoting = {
user: Pick<
Tables["User"],
"id" | "discordId" | "username" | "discordAvatar"
> & { customAvatarUrl: string | null; bio: Bio | null };
suggestion?: PlusSuggestionRepository.FindAllByMonthItem;
}[];
export async function findAllUsersForVoting(loggedInUser: {
id: number;
plusTier: number;
}) {
const members = await db
.selectFrom("User")
.innerJoin("PlusTier", "PlusTier.userId", "User.id")
.select((eb) => commonUserSelect(eb))
.where("PlusTier.tier", "=", loggedInUser.plusTier)
.execute();
const votingRange = nextNonCompletedVoting(new Date());
invariant(votingRange, "No next voting found");
const suggestedUsers = await PlusSuggestionRepository.findAllByMonth({
...rangeToMonthYear(votingRange),
tier: loggedInUser.plusTier,
});
// bios are not part of a suggestion (the suggestions page does not render them)
const bios = await findBiosByUserIds([
...members.map((member) => member.id),
...suggestedUsers.map((suggestion) => suggestion.suggested.id),
]);
const result: UsersForVoting = [];
for (const member of members) {
result.push({
user: {
id: member.id,
discordId: member.discordId,
username: member.username,
discordAvatar: member.discordAvatar,
customAvatarUrl: member.customAvatarUrl,
bio: bios.get(member.id) ?? null,
},
});
}
for (const suggestion of suggestedUsers) {
result.push({
user: {
id: suggestion.suggested.id,
discordId: suggestion.suggested.discordId,
username: suggestion.suggested.username,
discordAvatar: suggestion.suggested.discordAvatar,
customAvatarUrl: suggestion.suggested.customAvatarUrl,
bio: bios.get(suggestion.suggested.id) ?? null,
},
suggestion,
});
}
return R.shuffle(result.filter(({ user }) => user.id !== loggedInUser.id));
}
export async function hasVoted(args: {
authorId: number;
month: number;
year: number;
}) {
const rows = await db
.selectFrom("PlusVote")
.select(({ eb }) => eb.lit(1).as("one"))
.where("PlusVote.authorId", "=", args.authorId)
.where("PlusVote.month", "=", args.month)
.where("PlusVote.year", "=", args.year)
.execute();
return rows.length > 0;
}
export type UpsertManyPlusVotesArgs = Pick<
TablesInsertable["PlusVote"],
| "month"
| "year"
| "tier"
| "authorId"
| "votedId"
| "score"
| "becomesValidAt"
>[];
export function upsertMany(votes: UpsertManyPlusVotesArgs) {
const firstVote = votes[0];
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("PlusVote")
.where("PlusVote.authorId", "=", firstVote.authorId)
.where("PlusVote.month", "=", firstVote.month)
.where("PlusVote.year", "=", firstVote.year)
.execute();
await trx.insertInto("PlusVote").values(votes).execute();
});
}
/** Bios as the profile page's bio widget stores them, keyed by user id. */
async function findBiosByUserIds(userIds: number[]) {
const bios = new Map<number, Bio>();
if (userIds.length === 0) return bios;
const rows = await db
.selectFrom("UserWidget")
.select([
"UserWidget.userId",
// cast keeps a bio that happens to look like JSON a string, the dialect
// parses raw selections starting with `json` as documents
sql<
string | null
>`cast(json_extract("UserWidget"."widget", '$.settings.bio') as text)`.as(
"bio",
),
sql<string>`json_extract("UserWidget"."widget", '$.id')`.as("widgetId"),
])
.where("UserWidget.userId", "in", userIds)
.where(sql`json_extract("UserWidget"."widget", '$.id')`, "in", [
"bio",
"bio-md",
])
.orderBy("UserWidget.index", "asc")
.execute();
for (const row of rows) {
// a user can have both bio widgets, the one higher up their profile wins
if (row.bio && !bios.has(row.userId)) {
bios.set(row.userId, {
text: row.bio,
markdown: row.widgetId === "bio-md",
});
}
}
return bios;
}