User submitted images to Kysely (#2610)

This commit is contained in:
Kalle 2025-11-01 14:55:23 +02:00 committed by GitHub
parent a2b3e36c49
commit 7b28ce3877
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 840 additions and 194 deletions

View File

@ -7,7 +7,7 @@ let imageCounter = 0;
const createArt = async ({ authorId }: { authorId: number }) => {
imageCounter++;
return ArtRepository.insert({
const art = await ArtRepository.insert({
authorId,
url: `https://example.com/image-${authorId}-${imageCounter}.png`,
validatedAt: Date.now(),
@ -15,6 +15,8 @@ const createArt = async ({ authorId }: { authorId: number }) => {
linkedUsers: [],
tags: [],
});
return art.id;
};
describe("findShowcaseArts", () => {
@ -126,7 +128,7 @@ describe("unlinkUserFromArt", () => {
});
test("removes user link from art", async () => {
const artId = await ArtRepository.insert({
const art = await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
@ -135,7 +137,7 @@ describe("unlinkUserFromArt", () => {
tags: [],
});
await ArtRepository.unlinkUserFromArt({ userId: 2, artId });
await ArtRepository.unlinkUserFromArt({ userId: 2, artId: art.id });
const result = await ArtRepository.findArtsByUserId(2, {
includeAuthored: false,
@ -155,7 +157,7 @@ describe("findShowcaseArtsByTag", () => {
});
test("returns arts filtered by tag", async () => {
const art1Id = await ArtRepository.insert({
const art1 = await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
@ -181,7 +183,7 @@ describe("findShowcaseArtsByTag", () => {
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(art1Id);
expect(result[0].id).toBe(art1.id);
});
test("shows only one art per artist", async () => {
@ -254,7 +256,7 @@ describe("findArtsByUserId", () => {
});
test("returns tagged art", async () => {
const artId = await ArtRepository.insert({
const art = await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
@ -266,7 +268,7 @@ describe("findArtsByUserId", () => {
const result = await ArtRepository.findArtsByUserId(2);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(artId);
expect(result[0].id).toBe(art.id);
});
});
@ -312,7 +314,7 @@ describe("insert", () => {
});
test("inserts art with all metadata", async () => {
const artId = await ArtRepository.insert({
const art = await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
@ -324,7 +326,7 @@ describe("insert", () => {
const result = await ArtRepository.findArtsByUserId(1);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(artId);
expect(result[0].id).toBe(art.id);
expect(result[0].description).toBe("Test description");
expect(result[0].tags).toHaveLength(1);
expect(result[0].linkedUsers).toHaveLength(1);
@ -350,7 +352,7 @@ describe("update", () => {
});
test("updates art metadata", async () => {
const artId = await ArtRepository.insert({
const art = await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
@ -359,7 +361,7 @@ describe("update", () => {
tags: [{ name: "Character" }],
});
await ArtRepository.update(artId, {
await ArtRepository.update(art.id, {
description: "Updated",
linkedUsers: [3],
tags: [{ name: "Weapon" }],

View File

@ -294,7 +294,7 @@ type InsertArtArgs = Pick<Tables["Art"], "authorId" | "description"> &
tags: TagsToAdd;
};
export async function insert(args: InsertArtArgs): Promise<number> {
export async function insert(args: InsertArtArgs) {
return await db.transaction().execute(async (trx) => {
const img = await trx
.insertInto("UnvalidatedUserSubmittedImage")
@ -336,7 +336,7 @@ export async function insert(args: InsertArtArgs): Promise<number> {
artId: art.id,
});
return art.id;
return art;
});
}

View File

@ -8,7 +8,7 @@ import {
import { nanoid } from "nanoid";
import * as ArtRepository from "~/features/art/ArtRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { s3UploadHandler } from "~/features/img-upload";
import { s3UploadHandler } from "~/features/img-upload/s3.server";
import { notify } from "~/features/notifications/core/notify.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
@ -86,7 +86,7 @@ export const action: ActionFunction = async ({ request }) => {
schema: newArtSchema,
});
const addedArtId = await ArtRepository.insert({
const addedArt = await ArtRepository.insert({
authorId: user.id,
description: data.description,
url: fileName,
@ -102,7 +102,7 @@ export const action: ActionFunction = async ({ request }) => {
meta: {
adderUsername: user.username,
adderDiscordId: user.discordId,
artId: addedArtId,
artId: addedArt.id,
},
},
});

View File

@ -0,0 +1,654 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { databaseTimestampNow } from "~/utils/dates";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as ArtRepository from "../art/ArtRepository.server";
import * as CalendarRepository from "../calendar/CalendarRepository.server";
import * as TeamRepository from "../team/TeamRepository.server";
import * as TournamentOrganizationRepository from "../tournament-organization/TournamentOrganizationRepository.server";
import * as ImageRepository from "./ImageRepository.server";
let imageCounter = 0;
let teamCounter = 0;
let orgCounter = 0;
const createImage = async ({
submitterUserId,
validatedAt = null,
}: {
submitterUserId: number;
validatedAt?: number | null;
}) => {
imageCounter++;
const url = `https://example.com/image-${submitterUserId}-${imageCounter}.png`;
return ImageRepository.addNewImage({
submitterUserId,
url,
validatedAt,
type: "team-pfp",
});
};
const createTeam = async (ownerUserId: number) => {
teamCounter++;
const customUrl = `team-${teamCounter}`;
await TeamRepository.create({
name: `Team ${teamCounter}`,
customUrl,
ownerUserId,
isMainTeam: true,
});
const team = await TeamRepository.findByCustomUrl(customUrl);
if (!team) throw new Error("Team not found after creation");
return team;
};
const createOrganization = async (ownerId: number) => {
orgCounter++;
return TournamentOrganizationRepository.create({
name: `Org ${orgCounter}`,
ownerId,
});
};
const createCalendarEvent = async (authorId: number, avatarImgId?: number) => {
return CalendarRepository.create({
isFullTournament: false,
authorId,
badges: [],
bracketUrl: `https://example.com/bracket-${Date.now()}`,
description: null,
discordInviteCode: null,
name: `Event ${Date.now()}`,
organizationId: null,
startTimes: [databaseTimestampNow()],
tags: null,
mapPickingStyle: "AUTO_SZ",
bracketProgression: null,
deadlines: "DEFAULT",
rules: null,
avatarImgId,
});
};
describe("findById", () => {
beforeEach(async () => {
imageCounter = 0;
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("finds image by id", async () => {
const img = await createImage({ submitterUserId: 1 });
const result = await ImageRepository.findById(img.id);
expect(result).toBeDefined();
expect(result?.tournamentId).toBeNull();
});
test("finds image with calendar event data", async () => {
const img = await createImage({ submitterUserId: 1 });
await createCalendarEvent(1, img.id);
const result = await ImageRepository.findById(img.id);
expect(result).toBeDefined();
expect(result?.tournamentId).toBeDefined();
});
test("returns undefined for non-existent image", async () => {
const result = await ImageRepository.findById(999);
expect(result).toBeUndefined();
});
});
describe("deleteImageById", () => {
beforeEach(async () => {
imageCounter = 0;
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("deletes image by id", async () => {
const img = await createImage({ submitterUserId: 1 });
await ImageRepository.deleteImageById(img.id);
const result = await ImageRepository.findById(img.id);
expect(result).toBeUndefined();
});
test("deletes associated art when deleting image", async () => {
imageCounter++;
const art = await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: Date.now(),
description: null,
linkedUsers: [],
tags: [],
});
const artsBefore = await ArtRepository.findArtsByUserId(1);
expect(artsBefore).toHaveLength(1);
expect(artsBefore[0].id).toBe(art.id);
const imgId = art.imgId;
await ImageRepository.deleteImageById(imgId);
const result = await ImageRepository.findById(imgId);
expect(result).toBeUndefined();
const artsAfter = await ArtRepository.findArtsByUserId(1);
expect(artsAfter).toHaveLength(0);
});
});
describe("countUnvalidatedArt", () => {
beforeEach(async () => {
imageCounter = 0;
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("counts unvalidated art by author", async () => {
imageCounter++;
await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: null,
description: null,
linkedUsers: [],
tags: [],
});
imageCounter++;
await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: null,
description: null,
linkedUsers: [],
tags: [],
});
const count = await ImageRepository.countUnvalidatedArt(1);
expect(count).toBe(2);
});
test("does not count validated art", async () => {
imageCounter++;
await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: null,
description: null,
linkedUsers: [],
tags: [],
});
imageCounter++;
await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: Date.now(),
description: null,
linkedUsers: [],
tags: [],
});
const count = await ImageRepository.countUnvalidatedArt(1);
expect(count).toBe(1);
});
test("returns 0 when author has no unvalidated art", async () => {
const count = await ImageRepository.countUnvalidatedArt(1);
expect(count).toBe(0);
});
});
describe("countAllUnvalidated", () => {
beforeEach(async () => {
imageCounter = 0;
teamCounter = 0;
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("counts unvalidated images used in teams", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(1);
});
test("counts unvalidated images used in art", async () => {
imageCounter++;
await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: null,
description: null,
linkedUsers: [],
tags: [],
});
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(1);
});
test("counts unvalidated images used in calendar events", async () => {
const img = await createImage({ submitterUserId: 1 });
await createCalendarEvent(1, img.id);
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(1);
});
test("does not count validated images", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: Date.now(),
teamId: team.id,
type: "team-pfp",
});
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(0);
});
test("counts multiple unvalidated images across different types", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
imageCounter++;
await ArtRepository.insert({
authorId: 1,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: null,
description: null,
linkedUsers: [],
tags: [],
});
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(2);
});
test("returns 0 when no unvalidated images exist", async () => {
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(0);
});
});
describe("countUnvalidatedBySubmitterUserId", () => {
beforeEach(async () => {
imageCounter = 0;
teamCounter = 0;
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("counts unvalidated team images by submitter", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
const count = await ImageRepository.countUnvalidatedBySubmitterUserId(1);
expect(count).toBe(1);
});
test("does not count validated images", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: Date.now(),
teamId: team.id,
type: "team-pfp",
});
const count = await ImageRepository.countUnvalidatedBySubmitterUserId(1);
expect(count).toBe(0);
});
test("does not count images from other submitters", async () => {
const team1 = await createTeam(1);
const team2 = await createTeam(2);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team1-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team1.id,
type: "team-pfp",
});
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 2,
url: `https://example.com/team2-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team2.id,
type: "team-pfp",
});
const count = await ImageRepository.countUnvalidatedBySubmitterUserId(1);
expect(count).toBe(1);
});
test("returns 0 when user has no unvalidated team images", async () => {
const count = await ImageRepository.countUnvalidatedBySubmitterUserId(1);
expect(count).toBe(0);
});
});
describe("validateImage", () => {
beforeEach(async () => {
imageCounter = 0;
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("marks image as validated", async () => {
const img = await createImage({ submitterUserId: 1 });
await ImageRepository.validateImage(img.id);
const result = await ImageRepository.findById(img.id);
expect(result).toBeDefined();
});
test("validated image is not included in unvalidated count", async () => {
const team = await createTeam(1);
imageCounter++;
const img = await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
const countBefore = await ImageRepository.countAllUnvalidated();
expect(countBefore).toBe(1);
await ImageRepository.validateImage(img.id);
const countAfter = await ImageRepository.countAllUnvalidated();
expect(countAfter).toBe(0);
});
});
describe("unvalidatedImages", () => {
beforeEach(async () => {
imageCounter = 0;
teamCounter = 0;
await dbInsertUsers(10);
});
afterEach(() => {
dbReset();
});
test("fetches unvalidated images with submitter info", async () => {
const team = await createTeam(1);
imageCounter++;
const url = `https://example.com/team-avatar-${imageCounter}.png`;
await ImageRepository.addNewImage({
submitterUserId: 1,
url,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
const result = await ImageRepository.unvalidatedImages();
expect(result).toHaveLength(1);
expect(result[0].submitterUserId).toBe(1);
expect(result[0].username).toBe("user1");
expect(result[0].url).toBe(url);
});
test("does not fetch validated images", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: Date.now(),
teamId: team.id,
type: "team-pfp",
});
const result = await ImageRepository.unvalidatedImages();
expect(result).toHaveLength(0);
});
test("fetches images from teams, art, and calendar events", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
imageCounter++;
await ArtRepository.insert({
authorId: 2,
url: `https://example.com/art-${imageCounter}.png`,
validatedAt: null,
description: null,
linkedUsers: [],
tags: [],
});
const img2 = await createImage({ submitterUserId: 3 });
await createCalendarEvent(3, img2.id);
const result = await ImageRepository.unvalidatedImages();
expect(result).toHaveLength(3);
});
test("respects the max unvalidated images to show at once for approval limit constant", async () => {
for (let i = 0; i < 10; i++) {
const teamOwnerId = i + 1;
const team = await createTeam(teamOwnerId);
await ImageRepository.addNewImage({
submitterUserId: teamOwnerId,
url: `https://example.com/team-avatar-${i}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
}
const result = await ImageRepository.unvalidatedImages();
expect(result.length).toBe(5);
});
test("returns empty array when no unvalidated images exist", async () => {
const result = await ImageRepository.unvalidatedImages();
expect(result).toHaveLength(0);
});
});
describe("addNewImage", () => {
beforeEach(async () => {
imageCounter = 0;
teamCounter = 0;
orgCounter = 0;
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("creates image for team avatar", async () => {
const team = await createTeam(1);
imageCounter++;
const url = `https://example.com/team-avatar-${imageCounter}.png`;
const img = await ImageRepository.addNewImage({
submitterUserId: 1,
url,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
expect(img.url).toBe(url);
expect(img.submitterUserId).toBe(1);
expect(img.validatedAt).toBeNull();
const result = await ImageRepository.findById(img.id);
expect(result).toBeDefined();
});
test("creates image for team banner", async () => {
const team = await createTeam(1);
imageCounter++;
const url = `https://example.com/team-banner-${imageCounter}.png`;
const img = await ImageRepository.addNewImage({
submitterUserId: 1,
url,
validatedAt: null,
teamId: team.id,
type: "team-banner",
});
expect(img.url).toBe(url);
expect(img.submitterUserId).toBe(1);
expect(img.validatedAt).toBeNull();
const result = await ImageRepository.findById(img.id);
expect(result).toBeDefined();
});
test("creates image for organization avatar", async () => {
const org = await createOrganization(1);
imageCounter++;
const url = `https://example.com/org-avatar-${imageCounter}.png`;
const img = await ImageRepository.addNewImage({
submitterUserId: 1,
url,
validatedAt: null,
organizationId: org.id,
type: "org-pfp",
});
expect(img.url).toBe(url);
expect(img.submitterUserId).toBe(1);
expect(img.validatedAt).toBeNull();
const result = await ImageRepository.findById(img.id);
expect(result).toBeDefined();
});
test("creates validated image when validatedAt is provided", async () => {
const team = await createTeam(1);
const validatedAt = Date.now();
imageCounter++;
const img = await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt,
teamId: team.id,
type: "team-pfp",
});
expect(img.validatedAt).toBe(validatedAt);
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(0);
});
test("creates unvalidated image when validatedAt is null", async () => {
const team = await createTeam(1);
imageCounter++;
await ImageRepository.addNewImage({
submitterUserId: 1,
url: `https://example.com/team-avatar-${imageCounter}.png`,
validatedAt: null,
teamId: team.id,
type: "team-pfp",
});
const count = await ImageRepository.countAllUnvalidated();
expect(count).toBe(1);
});
});

View File

@ -1,5 +1,9 @@
import { db } from "~/db/sql";
import { databaseTimestampNow } from "~/utils/dates";
import { IMAGES_TO_VALIDATE_AT_ONCE } from "./upload-constants";
import type { ImageUploadType } from "./upload-types";
/** Finds an unvalidated image by ID with associated calendar event data */
export function findById(id: number) {
return db
.selectFrom("UnvalidatedUserSubmittedImage")
@ -13,6 +17,7 @@ export function findById(id: number) {
.executeTakeFirst();
}
/** Deletes an image and its associated art entry in a transaction */
export function deleteImageById(id: number) {
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("Art").where("Art.imgId", "=", id).execute();
@ -22,3 +27,149 @@ export function deleteImageById(id: number) {
.execute();
});
}
/** Counts unvalidated art images for a specific author */
export async function countUnvalidatedArt(authorId: number) {
const result = await db
.selectFrom("UnvalidatedUserSubmittedImage")
.innerJoin("Art", "Art.imgId", "UnvalidatedUserSubmittedImage.id")
.select(({ fn }) => fn.countAll<number>().as("count"))
.where("UnvalidatedUserSubmittedImage.validatedAt", "is", null)
.where("Art.authorId", "=", authorId)
.executeTakeFirstOrThrow();
return result.count;
}
const unvalidatedImagesBaseQuery = db
.selectFrom("UnvalidatedUserSubmittedImage")
.leftJoin("Team", (join) =>
join.on((eb) =>
eb.or([
eb("UnvalidatedUserSubmittedImage.id", "=", eb.ref("Team.avatarImgId")),
eb("UnvalidatedUserSubmittedImage.id", "=", eb.ref("Team.bannerImgId")),
]),
),
)
.leftJoin("Art", "UnvalidatedUserSubmittedImage.id", "Art.imgId")
.leftJoin(
"CalendarEvent",
"UnvalidatedUserSubmittedImage.id",
"CalendarEvent.avatarImgId",
)
.where("UnvalidatedUserSubmittedImage.validatedAt", "is", null)
.where((eb) =>
eb.or([
eb("Team.id", "is not", null),
eb("Art.id", "is not", null),
eb("CalendarEvent.id", "is not", null),
]),
);
/** Counts all unvalidated images used in teams, art, or calendar events */
export async function countAllUnvalidated() {
const result = await unvalidatedImagesBaseQuery
.select(({ fn }) => fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow();
return result.count;
}
/** Fetches unvalidated images for admin review with submitter info */
export function unvalidatedImages() {
return unvalidatedImagesBaseQuery
.leftJoin(
"User",
"UnvalidatedUserSubmittedImage.submitterUserId",
"User.id",
)
.select([
"UnvalidatedUserSubmittedImage.id",
"UnvalidatedUserSubmittedImage.url",
"UnvalidatedUserSubmittedImage.submitterUserId",
"User.username",
])
.limit(IMAGES_TO_VALIDATE_AT_ONCE)
.execute();
}
/** Counts unvalidated team images submitted by a specific user */
export async function countUnvalidatedBySubmitterUserId(userId: number) {
const result = await db
.selectFrom("UnvalidatedUserSubmittedImage")
.innerJoin("Team", (join) =>
join.on((eb) =>
eb.or([
eb(
"UnvalidatedUserSubmittedImage.id",
"=",
eb.ref("Team.avatarImgId"),
),
eb(
"UnvalidatedUserSubmittedImage.id",
"=",
eb.ref("Team.bannerImgId"),
),
]),
),
)
.select(({ fn }) => fn.countAll<number>().as("count"))
.where("UnvalidatedUserSubmittedImage.validatedAt", "is", null)
.where("UnvalidatedUserSubmittedImage.submitterUserId", "=", userId)
.executeTakeFirstOrThrow();
return result.count;
}
/** Marks an image as validated by setting the current timestamp */
export function validateImage(id: number) {
return db
.updateTable("UnvalidatedUserSubmittedImage")
.set({ validatedAt: databaseTimestampNow() })
.where("id", "=", id)
.execute();
}
/** Creates a new image and associates it with a team or organization */
export function addNewImage({
submitterUserId,
url,
validatedAt,
teamId,
organizationId,
type,
}: {
submitterUserId: number;
url: string;
validatedAt: number | null;
teamId?: number;
organizationId?: number;
type: ImageUploadType;
}) {
return db.transaction().execute(async (trx) => {
const img = await trx
.insertInto("UnvalidatedUserSubmittedImage")
.values({ submitterUserId, url, validatedAt })
.returningAll()
.executeTakeFirstOrThrow();
if (type === "team-pfp" && teamId) {
await trx
.updateTable("AllTeam")
.set({ avatarImgId: img.id })
.where("id", "=", teamId)
.execute();
} else if (type === "team-banner" && teamId) {
await trx
.updateTable("AllTeam")
.set({ bannerImgId: img.id })
.where("id", "=", teamId)
.execute();
} else if (type === "org-pfp" && organizationId) {
await trx
.updateTable("TournamentOrganization")
.set({ avatarImgId: img.id })
.where("id", "=", organizationId)
.execute();
}
return img;
});
}

View File

@ -9,7 +9,6 @@ import {
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as ImageRepository from "../ImageRepository.server";
import { validateImage } from "../queries/validateImage";
import { validateImageSchema } from "../upload-schemas.server";
export const action: ActionFunction = async ({ request }) => {
@ -28,7 +27,7 @@ export const action: ActionFunction = async ({ request }) => {
await ImageRepository.findById(imageId),
);
validateImage(imageId);
await ImageRepository.validateImage(imageId);
if (image.tournamentId) {
clearTournamentDataCache(imageId);

View File

@ -19,8 +19,7 @@ import {
parseSearchParams,
} from "~/utils/remix.server";
import { teamPage, tournamentOrganizationPage } from "~/utils/urls";
import { addNewImage } from "../queries/addNewImage";
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
import * as ImageRepository from "../ImageRepository.server";
import { s3UploadHandler } from "../s3.server";
import { MAX_UNVALIDATED_IMG_COUNT } from "../upload-constants";
import { requestToImgType } from "../upload-utils";
@ -41,7 +40,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
: undefined;
errorToastIfFalsy(
countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT,
(await ImageRepository.countUnvalidatedBySubmitterUserId(user.id)) <
MAX_UNVALIDATED_IMG_COUNT,
"Too many unvalidated images",
);
@ -60,7 +60,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const shouldAutoValidate =
user.roles.includes("SUPPORTER") || validatedType === "org-pfp";
addNewImage({
await ImageRepository.addNewImage({
submitterUserId: user.id,
teamId: team?.id,
organizationId: organization?.id,

View File

@ -1,3 +0,0 @@
export { countUnvalidatedArt } from "./queries/countUnvalidatedArt.server";
export { s3UploadHandler } from "./s3.server";
export type { ImageUploadType } from "./upload-types";

View File

@ -1,15 +1,14 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server";
import { unvalidatedImages } from "../queries/unvalidatedImages";
import * as ImageRepository from "../ImageRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
requireRole(user, "STAFF");
return {
images: unvalidatedImages(),
unvalidatedImgCount: countAllUnvalidatedImg(),
images: await ImageRepository.unvalidatedImages(),
unvalidatedImgCount: await ImageRepository.countAllUnvalidated(),
};
};

View File

@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { isTeamManager } from "~/features/team/team-utils";
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
import * as ImageRepository from "../ImageRepository.server";
import { requestToImgType } from "../upload-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
@ -25,6 +25,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
type: validatedType,
unvalidatedImages: countUnvalidatedImg(user.id),
unvalidatedImages: await ImageRepository.countUnvalidatedBySubmitterUserId(
user.id,
),
};
};

View File

@ -1,66 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { ImageUploadType } from "../upload-types";
const addImgStm = sql.prepare(/* sql */ `
insert into "UnvalidatedUserSubmittedImage"
("submitterUserId", "url", "validatedAt")
values
(@submitterUserId, @url, @validatedAt)
returning *
`);
const updateTeamAvatarStm = sql.prepare(/* sql */ `
update "AllTeam"
set "avatarImgId" = @avatarImgId
where "id" = @teamId
`);
const updateTeamBannerStm = sql.prepare(/* sql */ `
update "AllTeam"
set "bannerImgId" = @bannerImgId
where "id" = @teamId
`);
const updateOrganizationAvatarStm = sql.prepare(/* sql */ `
update "TournamentOrganization"
set "avatarImgId" = @avatarImgId
where "id" = @organizationId
`);
export const addNewImage = sql.transaction(
({
submitterUserId,
url,
validatedAt,
teamId,
organizationId,
type,
}: {
submitterUserId: number;
url: string;
validatedAt: number | null;
teamId?: number;
organizationId?: number;
type: ImageUploadType;
}) => {
const img = addImgStm.get({
submitterUserId,
url,
validatedAt,
}) as Tables["UserSubmittedImage"];
if (type === "team-pfp") {
updateTeamAvatarStm.run({ avatarImgId: img.id, teamId: teamId ?? null });
} else if (type === "team-banner") {
updateTeamBannerStm.run({ bannerImgId: img.id, teamId: teamId ?? null });
} else if (type === "org-pfp") {
updateOrganizationAvatarStm.run({
avatarImgId: img.id,
organizationId: organizationId ?? null,
});
}
return img;
},
);

View File

@ -1,18 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql*/ `
select count(*) as "count" from "UnvalidatedUserSubmittedImage"
left join "Team" on
"UnvalidatedUserSubmittedImage"."id" = "Team"."avatarImgId" or
"UnvalidatedUserSubmittedImage"."id" = "Team"."bannerImgId"
left join "Art" on
"UnvalidatedUserSubmittedImage"."id" = "Art"."imgId"
left join "CalendarEvent" on
"UnvalidatedUserSubmittedImage"."id" = "CalendarEvent"."avatarImgId"
where "UnvalidatedUserSubmittedImage"."validatedAt" is null
and ("Team"."id" is not null or "Art"."id" is not null or "CalendarEvent"."id" is not null)
`);
export function countAllUnvalidatedImg() {
return (stm.get() as any).count as number;
}

View File

@ -1,15 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql*/ `
select count(*) as "count"
from "UnvalidatedUserSubmittedImage"
inner join "Art" on "Art"."imgId" = "UnvalidatedUserSubmittedImage"."id"
where
"validatedAt" is null
and
"Art"."authorId" = @authorId
`);
export function countUnvalidatedArt(authorId: number) {
return (stm.get({ authorId }) as any).count as number;
}

View File

@ -1,14 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql*/ `
select count(*) as "count" from "UnvalidatedUserSubmittedImage"
inner join "Team" on
"UnvalidatedUserSubmittedImage"."id" = "Team"."avatarImgId" or
"UnvalidatedUserSubmittedImage"."id" = "Team"."bannerImgId"
where "validatedAt" is null
and "submitterUserId" = @userId
`);
export function countUnvalidatedImg(userId: number) {
return (stm.get({ userId }) as any).count as number;
}

View File

@ -1,35 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { IMAGES_TO_VALIDATE_AT_ONCE } from "../upload-constants";
const stm = sql.prepare(/* sql */ `
select
"UnvalidatedUserSubmittedImage"."id",
"UnvalidatedUserSubmittedImage"."url",
"UnvalidatedUserSubmittedImage"."submitterUserId",
"User"."username"
from "UnvalidatedUserSubmittedImage"
left join "User" on
"UnvalidatedUserSubmittedImage"."submitterUserId" = "User"."id"
left join "Team" on
"UnvalidatedUserSubmittedImage"."id" = "Team"."avatarImgId" or
"UnvalidatedUserSubmittedImage"."id" = "Team"."bannerImgId"
left join "Art" on
"UnvalidatedUserSubmittedImage"."id" = "Art"."imgId"
left join "CalendarEvent" on
"UnvalidatedUserSubmittedImage"."id" = "CalendarEvent"."avatarImgId"
where "UnvalidatedUserSubmittedImage"."validatedAt" is null
and ("Team"."id" is not null or "Art"."id" is not null or "CalendarEvent"."id" is not null)
limit ${IMAGES_TO_VALIDATE_AT_ONCE}
`);
type UnvalidatedImage = Pick<
Tables["UserSubmittedImage"],
"id" | "url" | "submitterUserId"
> & {
username: Tables["User"]["username"];
};
export function unvalidatedImages() {
return stm.all() as Array<UnvalidatedImage>;
}

View File

@ -1,12 +0,0 @@
import { sql } from "~/db/sql";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const stm = sql.prepare(/* sql */ `
update "UnvalidatedUserSubmittedImage"
set "validatedAt" = @validatedAt
where "id" = @id
`);
export function validateImage(id: number) {
stm.run({ validatedAt: dateToDatabaseTimestamp(new Date()), id });
}

View File

@ -1,7 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as ArtRepository from "~/features/art/ArtRepository.server";
import { getUserId } from "~/features/auth/core/user.server";
import { countUnvalidatedArt } from "~/features/img-upload";
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { userParamsSchema } from "../user-page-schemas";
@ -36,6 +36,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
arts,
tagCounts: tagCountsSortedArr.length > 0 ? tagCountsSortedArr : null,
unvalidatedArtCount:
user.id === loggedInUser?.id ? countUnvalidatedArt(user.id) : 0,
user.id === loggedInUser?.id
? await ImageRepository.countUnvalidatedArt(user.id)
: 0,
};
};

View File

@ -11,7 +11,7 @@ import { nanoid } from "nanoid";
import type { Ok, Result } from "neverthrow";
import type { z } from "zod/v4";
import type { navItems } from "~/components/layout/nav-items";
import { s3UploadHandler } from "~/features/img-upload";
import { s3UploadHandler } from "~/features/img-upload/s3.server";
import invariant from "./invariant";
import { logger } from "./logger";