mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Plus Voting to 60% criteria + leaderboard access (#1537)
* Update PlusVotingResult * Logic * Replace plus tiers * Testing initial * Test progress * Working one test * Tests * Schema different file * Dynamic test * Fix test util
This commit is contained in:
parent
0ee7c2569f
commit
9a126f543d
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,6 +9,7 @@ translation-progress.md
|
|||
notes.md
|
||||
|
||||
db*.sqlite3*
|
||||
!db-test.sqlite3
|
||||
dump
|
||||
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ import invariant from "tiny-invariant";
|
|||
import { Kysely, ParseJSONResultsPlugin, SqliteDialect } from "kysely";
|
||||
import type { DB } from "./tables";
|
||||
|
||||
const migratedEmptyDb = new Database("db-test.sqlite3").serialize();
|
||||
|
||||
invariant(process.env["DB_PATH"], "DB_PATH env variable must be set");
|
||||
export const sql = new Database(process.env["DB_PATH"]);
|
||||
const isInMemoryDB = process.env["DB_PATH"] === ":memory:";
|
||||
|
||||
export const sql = new Database(
|
||||
isInMemoryDB ? migratedEmptyDb : process.env["DB_PATH"],
|
||||
);
|
||||
|
||||
sql.pragma("journal_mode = WAL");
|
||||
sql.pragma("foreign_keys = ON");
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ export interface CalendarEventResultTeam {
|
|||
}
|
||||
|
||||
export interface FreshPlusTier {
|
||||
tier: string | null;
|
||||
userId: number | null;
|
||||
tier: number | null;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { db, sql } from "~/db/sql";
|
|||
import { syncXPBadges } from "../badges";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
const removeOldLikesStm = sql.prepare(/*sql*/ `
|
||||
delete from
|
||||
|
|
@ -48,23 +49,25 @@ export function migrate(args: { newUserId: number; oldUserId: number }) {
|
|||
});
|
||||
}
|
||||
|
||||
export function refreshPlusTiers() {
|
||||
export function replacePlusTiers(
|
||||
plusTiers: Array<{ userId: number; tier: number }>,
|
||||
) {
|
||||
invariant(plusTiers.length > 0, "plusTiers must not be empty");
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("PlusTier").execute();
|
||||
|
||||
await trx
|
||||
.insertInto("PlusTier")
|
||||
.columns(["userId", "tier"])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom("FreshPlusTier")
|
||||
.select(["FreshPlusTier.userId", "FreshPlusTier.tier"])
|
||||
.where("FreshPlusTier.tier", "is not", null),
|
||||
)
|
||||
.execute();
|
||||
await trx.insertInto("PlusTier").values(plusTiers).execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function allPlusTiersFromLatestVoting() {
|
||||
return db
|
||||
.selectFrom("FreshPlusTier")
|
||||
.select(["FreshPlusTier.userId", "FreshPlusTier.tier"])
|
||||
.where("FreshPlusTier.tier", "is not", null)
|
||||
.execute() as Promise<{ userId: number; tier: number }[]>;
|
||||
}
|
||||
|
||||
export function makeVideoAdderByUserId(userId: number) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
|
|
|
|||
35
app/features/admin/admin-schemas.server.ts
Normal file
35
app/features/admin/admin-schemas.server.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { _action, actualNumber } from "~/utils/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const adminActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("MIGRATE"),
|
||||
"old-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
"new-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REFRESH"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("CLEAN_UP"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("FORCE_PATRON"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
patronTier: z.preprocess(actualNumber, z.number()),
|
||||
patronTill: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("VIDEO_ADDER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ARTIST"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("LINK_PLAYER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
playerId: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
]);
|
||||
44
app/features/admin/core/plus-tier.server.ts
Normal file
44
app/features/admin/core/plus-tier.server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import invariant from "tiny-invariant";
|
||||
import { addPendingPlusTiers } from "~/features/leaderboards/core/leaderboards.server";
|
||||
import { userSPLeaderboard } from "~/features/leaderboards/queries/userSPLeaderboard.server";
|
||||
import { previousSeason } from "~/features/mmr/season";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
|
||||
export async function plusTiersFromVotingAndLeaderboard() {
|
||||
const newMembersFromLeaderboard = fromLeaderboard();
|
||||
const newMembersFromVoting =
|
||||
await AdminRepository.allPlusTiersFromLatestVoting();
|
||||
|
||||
return [
|
||||
...newMembersFromLeaderboard,
|
||||
// filter to ensure that user gets their highest tier
|
||||
...newMembersFromVoting.filter(
|
||||
(member) =>
|
||||
!newMembersFromLeaderboard.some(
|
||||
(leaderboardMember) => leaderboardMember.userId === member.userId,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function fromLeaderboard() {
|
||||
const now = new Date();
|
||||
const lastCompletedSeason = previousSeason(now);
|
||||
invariant(lastCompletedSeason, "No previous season found");
|
||||
|
||||
// there has been voting after this season ended, the results no longer apply
|
||||
if (now.getMonth() !== lastCompletedSeason.ends.getMonth()) return [];
|
||||
|
||||
const leaderboard = addPendingPlusTiers(
|
||||
userSPLeaderboard(lastCompletedSeason.nth),
|
||||
);
|
||||
|
||||
return leaderboard.flatMap((entry) => {
|
||||
if (!entry.pendingPlusTier) return [];
|
||||
|
||||
return {
|
||||
userId: entry.id,
|
||||
tier: entry.pendingPlusTier,
|
||||
};
|
||||
});
|
||||
}
|
||||
221
app/features/admin/routes/admin.test.ts
Normal file
221
app/features/admin/routes/admin.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { suite } from "uvu";
|
||||
import * as assert from "uvu/assert";
|
||||
import * as Test from "~/utils/Test";
|
||||
import { action } from "./admin";
|
||||
import { db } from "~/db/sql";
|
||||
import MockDate from "mockdate";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import type { adminActionSchema } from "../admin-schemas.server";
|
||||
|
||||
const PlusVoting = suite("Plus voting");
|
||||
|
||||
const adminAction = Test.wrappedAction<typeof adminActionSchema>({ action });
|
||||
|
||||
const voteArgs = ({
|
||||
score,
|
||||
votedId,
|
||||
authorId = 1,
|
||||
month = 6,
|
||||
year = 2021,
|
||||
}: {
|
||||
score: number;
|
||||
votedId: number;
|
||||
authorId?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}) => ({
|
||||
score,
|
||||
votedId,
|
||||
authorId,
|
||||
month,
|
||||
tier: 1,
|
||||
validAfter: dateToDatabaseTimestamp(new Date("2021-12-11T00:00:00.000Z")),
|
||||
year,
|
||||
});
|
||||
|
||||
const countPlusTierMembers = (tier = 1) =>
|
||||
db
|
||||
.selectFrom("PlusTier")
|
||||
.where("PlusTier.tier", "=", tier)
|
||||
.select(({ fn }) => fn.count<number>("PlusTier.tier").as("count"))
|
||||
.executeTakeFirstOrThrow()
|
||||
.then((row) => row.count);
|
||||
|
||||
const createLeaderboard = (userIds: number[]) =>
|
||||
db
|
||||
.insertInto("Skill")
|
||||
.values(
|
||||
userIds.map((userId, i) => ({
|
||||
matchesCount: 10,
|
||||
mu: 25,
|
||||
sigma: 8.333333333333334,
|
||||
ordinal: 0.5 - i * 0.001,
|
||||
userId,
|
||||
season: 1,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
PlusVoting.after.each(() => {
|
||||
MockDate.reset();
|
||||
Test.database.reset();
|
||||
});
|
||||
|
||||
PlusVoting("gives correct amount of plus tiers", async () => {
|
||||
MockDate.set(new Date("2023-12-12T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(10);
|
||||
await PlusVotingRepository.upsertMany(
|
||||
Array.from({ length: 10 }).map((_, i) => {
|
||||
const id = i + 1;
|
||||
|
||||
return voteArgs({
|
||||
score: id <= 5 ? -1 : 1,
|
||||
votedId: id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
assert.equal(await countPlusTierMembers(), 5);
|
||||
});
|
||||
|
||||
PlusVoting("60% is the criteria to pass voting", async () => {
|
||||
MockDate.set(new Date("2023-12-12T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(10);
|
||||
|
||||
// 50%
|
||||
await PlusVotingRepository.upsertMany(
|
||||
Array.from({ length: 10 }).map((_, i) => {
|
||||
return voteArgs({
|
||||
authorId: i + 1,
|
||||
score: i < 5 ? -1 : 1,
|
||||
votedId: 1,
|
||||
});
|
||||
}),
|
||||
);
|
||||
// 60%
|
||||
await PlusVotingRepository.upsertMany(
|
||||
Array.from({ length: 10 }).map((_, i) => {
|
||||
return voteArgs({
|
||||
authorId: i + 1,
|
||||
score: i < 4 ? -1 : 1,
|
||||
votedId: 2,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("PlusTier")
|
||||
.select(["PlusTier.tier", "PlusTier.userId"])
|
||||
.where("PlusTier.tier", "=", 1)
|
||||
.execute();
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].userId, 2);
|
||||
});
|
||||
|
||||
PlusVoting(
|
||||
"combines leaderboard and voting results (after season over)",
|
||||
async () => {
|
||||
MockDate.set(new Date("2023-11-29T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(2);
|
||||
await PlusVotingRepository.upsertMany([
|
||||
voteArgs({
|
||||
score: 1,
|
||||
votedId: 1,
|
||||
}),
|
||||
]);
|
||||
await createLeaderboard([2]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
assert.equal(await countPlusTierMembers(), 2);
|
||||
},
|
||||
);
|
||||
|
||||
PlusVoting("ignores leaderboard while season is ongoing", async () => {
|
||||
MockDate.set(new Date("2024-02-15T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(2);
|
||||
await PlusVotingRepository.upsertMany([
|
||||
voteArgs({
|
||||
score: 1,
|
||||
votedId: 1,
|
||||
}),
|
||||
]);
|
||||
await createLeaderboard([2]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
assert.equal(await countPlusTierMembers(), 1);
|
||||
assert.equal(await countPlusTierMembers(2), 0);
|
||||
});
|
||||
|
||||
PlusVoting("leaderboard gives members to all tiers", async () => {
|
||||
MockDate.set(new Date("2023-11-20T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(60);
|
||||
await createLeaderboard(Array.from({ length: 60 }, (_, i) => i + 1));
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
assert.ok((await countPlusTierMembers()) > 0);
|
||||
assert.ok((await countPlusTierMembers(2)) > 0);
|
||||
assert.ok((await countPlusTierMembers(3)) > 0);
|
||||
});
|
||||
|
||||
PlusVoting(
|
||||
"gives membership if failed voting and is on the leaderboard",
|
||||
async () => {
|
||||
MockDate.set(new Date("2023-11-29T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(1);
|
||||
await PlusVotingRepository.upsertMany([
|
||||
voteArgs({
|
||||
score: -1,
|
||||
votedId: 1,
|
||||
}),
|
||||
]);
|
||||
await createLeaderboard([1]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
assert.equal(await countPlusTierMembers(1), 1);
|
||||
},
|
||||
);
|
||||
|
||||
PlusVoting("members who fails voting drops one tier", async () => {
|
||||
MockDate.set(new Date("2024-02-15T00:00:00.000Z"));
|
||||
|
||||
await Test.database.insertUsers(1);
|
||||
await PlusVotingRepository.upsertMany([
|
||||
voteArgs({
|
||||
score: 1,
|
||||
votedId: 1,
|
||||
month: 11,
|
||||
year: 2023,
|
||||
}),
|
||||
]);
|
||||
|
||||
await PlusVotingRepository.upsertMany([
|
||||
voteArgs({
|
||||
score: -1,
|
||||
votedId: 1,
|
||||
month: 2,
|
||||
year: 2024,
|
||||
}),
|
||||
]);
|
||||
|
||||
await adminAction({ _action: "REFRESH" }, { user: "admin" });
|
||||
|
||||
assert.equal(await countPlusTierMembers(2), 1);
|
||||
});
|
||||
|
||||
PlusVoting.run();
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
ActionArgs,
|
||||
LoaderFunction,
|
||||
V2_MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData, useNavigation } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Catcher } from "~/components/Catcher";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { makeArtist } from "~/features/art";
|
||||
import { useUser } from "~/features/auth/core";
|
||||
import {
|
||||
|
|
@ -27,48 +27,15 @@ import {
|
|||
} from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls";
|
||||
import { _action, actualNumber } from "~/utils/zod";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
|
||||
import { adminActionSchema } from "../admin-schemas.server";
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{ title: makeTitle("Admin page") }];
|
||||
};
|
||||
|
||||
const adminActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("MIGRATE"),
|
||||
"old-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
"new-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REFRESH"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("CLEAN_UP"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("FORCE_PATRON"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
patronTier: z.preprocess(actualNumber, z.number()),
|
||||
patronTill: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("VIDEO_ADDER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ARTIST"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("LINK_PLAYER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
playerId: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
export const action = async ({ request }: ActionArgs) => {
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: adminActionSchema,
|
||||
|
|
@ -88,7 +55,9 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
case "REFRESH": {
|
||||
validate(isAdmin(user));
|
||||
|
||||
await AdminRepository.refreshPlusTiers();
|
||||
await AdminRepository.replacePlusTiers(
|
||||
await plusTiersFromVotingAndLeaderboard(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "FORCE_PATRON": {
|
||||
|
|
@ -136,7 +105,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
interface AdminPageLoaderData {
|
||||
|
|
|
|||
|
|
@ -31,9 +31,13 @@ export function currentOrPreviousSeason(date: Date) {
|
|||
const _currentSeason = currentSeason(date);
|
||||
if (_currentSeason) return _currentSeason;
|
||||
|
||||
return previousSeason(date);
|
||||
}
|
||||
|
||||
export function previousSeason(date: Date) {
|
||||
let latestPreviousSeason;
|
||||
for (const season of SEASONS) {
|
||||
if (date >= season.ends) latestPreviousSeason = season;
|
||||
if (date > season.ends) latestPreviousSeason = season;
|
||||
}
|
||||
|
||||
return latestPreviousSeason;
|
||||
|
|
|
|||
70
app/utils/Test.ts
Normal file
70
app/utils/Test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { ActionArgs } from "@remix-run/node";
|
||||
import type { z } from "zod";
|
||||
import { ADMIN_ID } from "~/constants";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { db, sql } from "~/db/sql";
|
||||
import { SESSION_KEY } from "~/features/auth/core/authenticator.server";
|
||||
import { authSessionStorage } from "~/features/auth/core/session.server";
|
||||
|
||||
export function wrappedAction<T extends z.ZodTypeAny>({
|
||||
action,
|
||||
}: {
|
||||
// TODO: strongly type this
|
||||
action: (args: ActionArgs) => any;
|
||||
}) {
|
||||
return async (
|
||||
args: z.infer<T>,
|
||||
{ user }: { user?: "admin" | "regular" } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams(args);
|
||||
const request = new Request("http://app.com/path", {
|
||||
method: "POST",
|
||||
body: params,
|
||||
headers: await authHeader(user),
|
||||
});
|
||||
|
||||
return action({
|
||||
request,
|
||||
context: {},
|
||||
params: {},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function authHeader(user?: "admin" | "regular"): Promise<HeadersInit> {
|
||||
if (!user) return [];
|
||||
|
||||
const session = await authSessionStorage.getSession();
|
||||
|
||||
session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : NZAP_TEST_ID);
|
||||
|
||||
return [["Cookie", await authSessionStorage.commitSession(session)]];
|
||||
}
|
||||
|
||||
export const database = {
|
||||
reset: () => {
|
||||
const tables = sql
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'migrations';",
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
|
||||
sql.prepare("PRAGMA foreign_keys = OFF").run();
|
||||
for (const table of tables) {
|
||||
sql.prepare(`DELETE FROM "${table.name}"`).run();
|
||||
}
|
||||
sql.prepare("PRAGMA foreign_keys = ON").run();
|
||||
},
|
||||
insertUsers: (count: number) =>
|
||||
db
|
||||
.insertInto("User")
|
||||
.values(
|
||||
Array.from({ length: count }).map((_, i) => ({
|
||||
id: i + 1,
|
||||
discordName: `user${i + 1}`,
|
||||
discordDiscriminator: "0",
|
||||
discordId: String(i),
|
||||
})),
|
||||
)
|
||||
.execute(),
|
||||
};
|
||||
BIN
db-test.sqlite3
Normal file
BIN
db-test.sqlite3
Normal file
Binary file not shown.
37
migrations/041-plus-sixty.js
Normal file
37
migrations/041-plus-sixty.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(`drop view "PlusVotingResult"`).run();
|
||||
|
||||
// same as 000-initial.js except 60% criteria
|
||||
db.prepare(
|
||||
/* sql */ `
|
||||
create view "PlusVotingResult" as
|
||||
select
|
||||
"votedId",
|
||||
"tier",
|
||||
avg("score") as "score",
|
||||
avg("score") >= 0.2 as "passedVoting",
|
||||
"month",
|
||||
"year",
|
||||
exists (
|
||||
select
|
||||
1
|
||||
from
|
||||
"PlusSuggestion"
|
||||
where
|
||||
"PlusSuggestion"."month" = "PlusVote"."month"
|
||||
and "PlusSuggestion"."year" = "PlusVote"."year"
|
||||
and "PlusSuggestion"."suggestedId" = "PlusVote"."votedId"
|
||||
and "PlusSuggestion"."tier" = "PlusVote"."tier"
|
||||
) as "wasSuggested"
|
||||
from
|
||||
"PlusVote"
|
||||
group by
|
||||
"votedId",
|
||||
"tier",
|
||||
"month",
|
||||
"year";
|
||||
`,
|
||||
).run();
|
||||
})();
|
||||
};
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -82,6 +82,7 @@
|
|||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ley": "^0.8.1",
|
||||
"mockdate": "^3.0.5",
|
||||
"prettier": "3.0.2",
|
||||
"stylelint": "^15.10.3",
|
||||
"stylelint-config-standard": "^34.0.0",
|
||||
|
|
@ -12554,6 +12555,12 @@
|
|||
"ufo": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mockdate": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
|
||||
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
|
||||
|
|
@ -25941,6 +25948,12 @@
|
|||
"ufo": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"mockdate": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
|
||||
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
|
||||
"dev": true
|
||||
},
|
||||
"morgan": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
"prettier:check": "prettier --check . --log-level warn",
|
||||
"prettier:write": "prettier --write . --log-level warn",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "uvu -r tsm -r tsconfig-paths/register -i e2e",
|
||||
"test:unit": "cross-env DB_PATH=:memory: NODE_ENV=test BASE_URL=https://example.com uvu -r tsm -r tsconfig-paths/register -i e2e",
|
||||
"test:e2e": "npx playwright test",
|
||||
"checks": "npm run test:unit && npm run lint:css && npm run lint:ts && npm run prettier:check && npm run typecheck",
|
||||
"cf": "npm run test:unit && npm run check-translation-jsons && npm run lint:css -- --fix && npm run lint:ts -- --fix && npm run prettier:write && npm run typecheck && npm run test:e2e",
|
||||
|
|
@ -116,6 +116,7 @@
|
|||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ley": "^0.8.1",
|
||||
"mockdate": "^3.0.5",
|
||||
"prettier": "3.0.2",
|
||||
"stylelint": "^15.10.3",
|
||||
"stylelint-config-standard": "^34.0.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user