From 74b2a45b8e655df76b9a9815a41ff1f0e8ac3023 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:43:00 +0200 Subject: [PATCH] Refactor art queries to Kysely --- app/features/admin/AdminRepository.server.ts | 8 + app/features/admin/actions/admin.server.ts | 3 +- app/features/art/ArtRepository.server.test.ts | 412 ++++++++++++++---- app/features/art/ArtRepository.server.ts | 271 +++++++++++- app/features/art/actions/art.new.server.ts | 23 +- app/features/art/art-types.ts | 7 +- app/features/art/components/ArtGrid.tsx | 8 +- app/features/art/loaders/art.new.server.ts | 8 +- app/features/art/queries/addNewArt.server.ts | 180 -------- .../art/queries/artsByUserId.server.ts | 90 ---- app/features/art/queries/deleteArt.server.ts | 10 - .../art/queries/findArtById.server.ts | 46 -- app/features/art/queries/makeArtist.server.ts | 11 - app/features/art/routes/art.new.tsx | 7 +- .../actions/u.$identifier.art.server.ts | 14 +- .../loaders/u.$identifier.art.server.ts | 6 +- .../user-page/routes/u.$identifier.art.tsx | 2 +- 17 files changed, 643 insertions(+), 463 deletions(-) delete mode 100644 app/features/art/queries/addNewArt.server.ts delete mode 100644 app/features/art/queries/artsByUserId.server.ts delete mode 100644 app/features/art/queries/deleteArt.server.ts delete mode 100644 app/features/art/queries/findArtById.server.ts delete mode 100644 app/features/art/queries/makeArtist.server.ts diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index a3e9016ef..6fec3f68c 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -191,6 +191,14 @@ export function makeVideoAdderByUserId(userId: number) { .execute(); } +export function makeArtistByUserId(userId: number) { + return db + .updateTable("User") + .set({ isArtist: 1 }) + .where("User.id", "=", userId) + .execute(); +} + export function makeTournamentOrganizerByUserId(userId: number) { return db .updateTable("User") diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 4bc4887ad..91aa16823 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -1,7 +1,6 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod/v4"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; -import { makeArtist } from "~/features/art/queries/makeArtist.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; @@ -88,7 +87,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { case "ARTIST": { requireRole(user, "STAFF"); - makeArtist(data.user); + await AdminRepository.makeArtistByUserId(data.user); message = "Artist permissions given"; break; diff --git a/app/features/art/ArtRepository.server.test.ts b/app/features/art/ArtRepository.server.test.ts index aa1a33153..40d5a5d95 100644 --- a/app/features/art/ArtRepository.server.test.ts +++ b/app/features/art/ArtRepository.server.test.ts @@ -1,48 +1,20 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { db } from "~/db/sql"; import { dbInsertUsers, dbReset } from "~/utils/Test"; import * as ArtRepository from "./ArtRepository.server"; let imageCounter = 0; -const createUserSubmittedImage = async (userId: number) => { +const createArt = async ({ authorId }: { authorId: number }) => { imageCounter++; - // TODO: instead of using db, use ArtRepository's insert when implemented - const result = await db - .insertInto("UnvalidatedUserSubmittedImage") - .values({ - url: `https://example.com/image-${userId}-${imageCounter}.png`, - submitterUserId: userId, - validatedAt: Date.now(), - }) - .returning("id") - .executeTakeFirstOrThrow(); - - return result.id; -}; - -const createArt = async ({ - authorId, - isShowcase = 0, - createdAt, -}: { - authorId: number; - isShowcase?: 0 | 1; - createdAt?: number; -}) => { - const imgId = await createUserSubmittedImage(authorId); - - await db - .insertInto("Art") - .values({ - authorId, - imgId, - isShowcase, - description: null, - createdAt: createdAt ?? Date.now(), - }) - .execute(); + return ArtRepository.insert({ + authorId, + url: `https://example.com/image-${authorId}-${imageCounter}.png`, + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [], + }); }; describe("findShowcaseArts", () => { @@ -55,10 +27,10 @@ describe("findShowcaseArts", () => { dbReset(); }); - test("shows one art per artist when all have showcase art", async () => { - await createArt({ authorId: 1, isShowcase: 1 }); - await createArt({ authorId: 2, isShowcase: 1 }); - await createArt({ authorId: 3, isShowcase: 1 }); + test("shows one art per artist", async () => { + await createArt({ authorId: 1 }); + await createArt({ authorId: 2 }); + await createArt({ authorId: 3 }); const result = await ArtRepository.findShowcaseArts(); @@ -68,59 +40,34 @@ describe("findShowcaseArts", () => { }); test("prioritizes showcase art over regular art for same artist", async () => { - const now = Date.now(); + // first create art should be showcase + const id = await createArt({ authorId: 1 }); + await createArt({ authorId: 1 }); - await createArt({ authorId: 1, isShowcase: 0, createdAt: now + 1000 }); - await createArt({ authorId: 1, isShowcase: 1, createdAt: now }); + const result = await ArtRepository.findShowcaseArts(); + + expect(result[0].id).toBe(id); + }); + + test("shows only one art per artist even with multiple pieces", async () => { + await createArt({ authorId: 1 }); + await createArt({ authorId: 1 }); + await createArt({ authorId: 1 }); const result = await ArtRepository.findShowcaseArts(); expect(result).toHaveLength(1); - expect(result[0].createdAt).toBe(now); }); - test("shows most recent art for artist without showcase art", async () => { - const now = Date.now(); + test("shows artist even if no showcase art exists", async () => { + const showcaseArtId = await createArt({ authorId: 1 }); + const nonShowcaseArtId = await createArt({ authorId: 1 }); - await createArt({ authorId: 1, isShowcase: 0, createdAt: now }); - await createArt({ authorId: 1, isShowcase: 0, createdAt: now + 1000 }); + await ArtRepository.deleteById(showcaseArtId); const result = await ArtRepository.findShowcaseArts(); - expect(result).toHaveLength(1); - expect(result[0].createdAt).toBe(now + 1000); - }); - - test("shows only one art per artist even with multiple showcase pieces", async () => { - const now = Date.now(); - - await createArt({ authorId: 1, isShowcase: 1, createdAt: now }); - await createArt({ authorId: 1, isShowcase: 1, createdAt: now + 1000 }); - await createArt({ authorId: 1, isShowcase: 1, createdAt: now + 2000 }); - - const result = await ArtRepository.findShowcaseArts(); - - expect(result).toHaveLength(1); - expect(result[0].createdAt).toBe(now + 2000); - }); - - test("handles mix of artists with and without showcase art", async () => { - const now = Date.now(); - - await createArt({ authorId: 1, isShowcase: 1, createdAt: now }); - await createArt({ authorId: 2, isShowcase: 0, createdAt: now + 1000 }); - await createArt({ authorId: 2, isShowcase: 0, createdAt: now + 2000 }); - await createArt({ authorId: 3, isShowcase: 1, createdAt: now + 3000 }); - - const result = await ArtRepository.findShowcaseArts(); - - expect(result).toHaveLength(3); - - const author1Art = result.find((art) => art.author?.discordId === "0"); - const author2Art = result.find((art) => art.author?.discordId === "1"); - - expect(author1Art?.createdAt).toBe(now); - expect(author2Art?.createdAt).toBe(now + 2000); + expect(result[0].id).toBe(nonShowcaseArtId); }); test("returns empty array when no art exists", async () => { @@ -132,6 +79,7 @@ describe("findShowcaseArts", () => { describe("findAllTags", () => { beforeEach(async () => { + imageCounter = 0; await dbInsertUsers(1); }); @@ -140,14 +88,15 @@ describe("findAllTags", () => { }); test("returns all art tags", async () => { - await db - .insertInto("ArtTag") - .values([ - { authorId: 1, name: "Character" }, - { authorId: 1, name: "Weapon" }, - { authorId: 1, name: "Landscape" }, - ]) - .execute(); + imageCounter++; + await ArtRepository.insert({ + authorId: 1, + url: `https://example.com/image-1-${imageCounter}.png`, + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Character" }, { name: "Weapon" }, { name: "Landscape" }], + }); const result = await ArtRepository.findAllTags(); @@ -165,3 +114,284 @@ describe("findAllTags", () => { expect(result).toHaveLength(0); }); }); + +describe("unlinkUserFromArt", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("removes user link from art", async () => { + const artId = await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [2], + tags: [], + }); + + await ArtRepository.unlinkUserFromArt({ userId: 2, artId }); + + const result = await ArtRepository.findArtsByUserId(2, { + includeAuthored: false, + }); + expect(result).toHaveLength(0); + }); +}); + +describe("findShowcaseArtsByTag", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns arts filtered by tag", async () => { + const art1Id = await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Character" }], + }); + + await ArtRepository.insert({ + authorId: 2, + url: "https://example.com/image-2.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Weapon" }], + }); + + const tags = await ArtRepository.findAllTags(); + const characterTag = tags.find((t) => t.name === "Character"); + + const result = await ArtRepository.findShowcaseArtsByTag( + characterTag?.id ?? 0, + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(art1Id); + }); + + test("shows only one art per artist", async () => { + await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Character" }], + }); + + const tags = await ArtRepository.findAllTags(); + const characterTag = tags.find((t) => t.name === "Character"); + + await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-2.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ id: characterTag?.id }], + }); + + const result = await ArtRepository.findShowcaseArtsByTag( + characterTag?.id ?? 0, + ); + + expect(result).toHaveLength(1); + }); +}); + +describe("findRecentlyUploadedArts", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns recently uploaded arts", async () => { + const artId = await createArt({ authorId: 1 }); + + const result = await ArtRepository.findRecentlyUploadedArts(); + + expect(result.length).toBeGreaterThan(0); + expect(result.some((art) => art.id === artId)).toBe(true); + }); +}); + +describe("findArtsByUserId", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns authored art", async () => { + const artId = await createArt({ authorId: 1 }); + + const result = await ArtRepository.findArtsByUserId(1); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(artId); + }); + + test("returns tagged art", async () => { + const artId = await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [2], + tags: [], + }); + + const result = await ArtRepository.findArtsByUserId(2); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(artId); + }); +}); + +describe("deleteById", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(1); + }); + + afterEach(() => { + dbReset(); + }); + + test("deletes art by id", async () => { + const artId = await createArt({ authorId: 1 }); + + await ArtRepository.deleteById(artId); + + const result = await ArtRepository.findArtsByUserId(1); + expect(result).toHaveLength(0); + }); + + test("deletes only the specified art", async () => { + const firstArtId = await createArt({ authorId: 1 }); + const secondArtId = await createArt({ authorId: 1 }); + + await ArtRepository.deleteById(firstArtId); + + const result = await ArtRepository.findArtsByUserId(1); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(secondArtId); + }); +}); + +describe("insert", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("inserts art with all metadata", async () => { + const artId = await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: "Test description", + linkedUsers: [2], + tags: [{ name: "Character" }], + }); + + const result = await ArtRepository.findArtsByUserId(1); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(artId); + expect(result[0].description).toBe("Test description"); + expect(result[0].tags).toHaveLength(1); + expect(result[0].linkedUsers).toHaveLength(1); + }); + + test("sets first art as showcase", async () => { + await createArt({ authorId: 1 }); + + const result = await ArtRepository.findArtsByUserId(1); + + expect(result[0].isShowcase).toBe(true); + }); +}); + +describe("update", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("updates art metadata", async () => { + const artId = await ArtRepository.insert({ + authorId: 1, + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: "Original", + linkedUsers: [2], + tags: [{ name: "Character" }], + }); + + await ArtRepository.update(artId, { + description: "Updated", + linkedUsers: [3], + tags: [{ name: "Weapon" }], + isShowcase: 1, + }); + + const result = await ArtRepository.findArtsByUserId(1); + + expect(result[0].description).toBe("Updated"); + expect(result[0].linkedUsers).toHaveLength(1); + expect(result[0].linkedUsers?.[0].id).toBe(3); + expect(result[0].tags).toHaveLength(1); + expect(result[0].tags?.[0].name).toBe("Weapon"); + }); + + test("unsets other showcase art when setting new showcase", async () => { + const firstArtId = await createArt({ authorId: 1 }); + const secondArtId = await createArt({ authorId: 1 }); + + await ArtRepository.update(secondArtId, { + description: null, + linkedUsers: [], + tags: [], + isShowcase: 1, + }); + + const result = await ArtRepository.findArtsByUserId(1); + + expect(result).toHaveLength(2); + const showcaseArt = result.find((art) => art.id === secondArtId); + const nonShowcaseArt = result.find((art) => art.id === firstArtId); + expect(showcaseArt?.isShowcase).toBe(true); + expect(nonShowcaseArt?.isShowcase).toBe(false); + }); +}); diff --git a/app/features/art/ArtRepository.server.ts b/app/features/art/ArtRepository.server.ts index a08cae009..f8d9a49d9 100644 --- a/app/features/art/ArtRepository.server.ts +++ b/app/features/art/ArtRepository.server.ts @@ -1,5 +1,7 @@ +import type { Transaction } from "kysely"; +import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; -import type { Tables } from "~/db/tables"; +import type { DB, Tables } from "~/db/tables"; import { seededRandom } from "~/utils/random"; import type { ListedArt } from "./art-types"; @@ -33,6 +35,7 @@ export async function findShowcaseArts(): Promise { .select([ "Art.id", "Art.createdAt", + "Art.isShowcase", "User.id as userId", "User.discordId", "User.username", @@ -61,6 +64,7 @@ export async function findShowcaseArts(): Promise { id: a.id, createdAt: a.createdAt, url: a.url, + isShowcase: Boolean(a.isShowcase), author: { commissionsOpen: a.commissionsOpen, discordAvatar: a.discordAvatar, @@ -84,6 +88,7 @@ export async function findShowcaseArtsByTag( .select([ "Art.id", "Art.createdAt", + "Art.isShowcase", "User.id as userId", "User.discordId", "User.username", @@ -112,6 +117,7 @@ export async function findShowcaseArtsByTag( id: a.id, createdAt: a.createdAt, url: a.url, + isShowcase: Boolean(a.isShowcase), author: { commissionsOpen: a.commissionsOpen, discordAvatar: a.discordAvatar, @@ -129,6 +135,7 @@ export async function findRecentlyUploadedArts(): Promise { .select([ "Art.id", "Art.createdAt", + "Art.isShowcase", "User.discordId", "User.username", "User.discordAvatar", @@ -143,6 +150,7 @@ export async function findRecentlyUploadedArts(): Promise { id: a.id, createdAt: a.createdAt, url: a.url, + isShowcase: Boolean(a.isShowcase), author: { commissionsOpen: a.commissionsOpen, discordAvatar: a.discordAvatar, @@ -155,3 +163,264 @@ export async function findRecentlyUploadedArts(): Promise { export async function findAllTags() { return db.selectFrom("ArtTag").select(["id", "name"]).execute(); } + +export async function findArtsByUserId( + userId: number, + { includeAuthored = true, includeTagged = true } = {}, +): Promise { + const taggedButNotAuthored = includeTagged + ? await db + .selectFrom("Art") + .innerJoin("ArtUserMetadata", "ArtUserMetadata.artId", "Art.id") + .innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId") + .innerJoin("User", "User.id", "Art.authorId") + .select(({ eb }) => [ + "Art.id", + "Art.description", + "Art.createdAt", + "Art.isShowcase", + "UserSubmittedImage.url", + "User.discordId", + "User.username", + "User.discordAvatar", + "User.commissionsOpen", + jsonArrayFrom( + eb + .selectFrom("TaggedArt") + .innerJoin("ArtTag", "ArtTag.id", "TaggedArt.tagId") + .select(["ArtTag.id", "ArtTag.name"]) + .whereRef("TaggedArt.artId", "=", "Art.id"), + ).as("tags"), + jsonArrayFrom( + eb + .selectFrom("ArtUserMetadata") + .innerJoin( + "User as LinkedUser", + "LinkedUser.id", + "ArtUserMetadata.userId", + ) + .select([ + "LinkedUser.id", + "LinkedUser.discordId", + "LinkedUser.username", + "LinkedUser.customUrl", + ]) + .whereRef("ArtUserMetadata.artId", "=", "Art.id"), + ).as("linkedUsers"), + ]) + .where("ArtUserMetadata.userId", "=", userId) + .where("Art.authorId", "!=", userId) + .execute() + : []; + + const authored = includeAuthored + ? await db + .selectFrom("Art") + .innerJoin("UserSubmittedImage", "UserSubmittedImage.id", "Art.imgId") + .select(({ eb }) => [ + "Art.id", + "Art.description", + "Art.createdAt", + "Art.isShowcase", + "UserSubmittedImage.url", + jsonArrayFrom( + eb + .selectFrom("TaggedArt") + .innerJoin("ArtTag", "ArtTag.id", "TaggedArt.tagId") + .select(["ArtTag.id", "ArtTag.name"]) + .whereRef("TaggedArt.artId", "=", "Art.id"), + ).as("tags"), + jsonArrayFrom( + eb + .selectFrom("ArtUserMetadata") + .innerJoin( + "User as LinkedUser", + "LinkedUser.id", + "ArtUserMetadata.userId", + ) + .select([ + "LinkedUser.id", + "LinkedUser.discordId", + "LinkedUser.username", + "LinkedUser.customUrl", + ]) + .whereRef("ArtUserMetadata.artId", "=", "Art.id"), + ).as("linkedUsers"), + ]) + .where("Art.authorId", "=", userId) + .execute() + : []; + + const combined = [ + ...taggedButNotAuthored.map((row) => ({ + id: row.id, + url: row.url, + description: row.description ?? undefined, + createdAt: row.createdAt, + isShowcase: Boolean(row.isShowcase), + tags: row.tags.length > 0 ? row.tags : undefined, + linkedUsers: row.linkedUsers.length > 0 ? row.linkedUsers : undefined, + author: { + discordId: row.discordId, + username: row.username, + discordAvatar: row.discordAvatar, + commissionsOpen: row.commissionsOpen ?? undefined, + }, + })), + ...authored.map((row) => ({ + id: row.id, + url: row.url, + description: row.description ?? undefined, + createdAt: row.createdAt, + isShowcase: Boolean(row.isShowcase), + tags: row.tags.length > 0 ? row.tags : undefined, + linkedUsers: row.linkedUsers.length > 0 ? row.linkedUsers : undefined, + author: undefined, + })), + ]; + + return combined.sort((a, b) => b.createdAt - a.createdAt); +} + +export async function deleteById(id: number) { + return db.deleteFrom("Art").where("id", "=", id).execute(); +} + +type TagsToAdd = Array>>; + +type InsertArtArgs = Pick & + Pick & { + linkedUsers: number[]; + tags: TagsToAdd; + }; + +export async function insert(args: InsertArtArgs): Promise { + return await db.transaction().execute(async (trx) => { + const img = await trx + .insertInto("UnvalidatedUserSubmittedImage") + .values({ + submitterUserId: args.authorId, + url: args.url, + validatedAt: args.validatedAt, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + const hasExistingArt = await trx + .selectFrom("Art") + .select("id") + .where("authorId", "=", args.authorId) + .executeTakeFirst(); + + const art = await trx + .insertInto("Art") + .values({ + authorId: args.authorId, + description: args.description, + imgId: img.id, + isShowcase: hasExistingArt ? 0 : 1, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + if (args.linkedUsers.length > 0) { + await trx + .insertInto("ArtUserMetadata") + .values(args.linkedUsers.map((userId) => ({ artId: art.id, userId }))) + .execute(); + } + + await insertTags(trx, { + tags: args.tags, + authorId: args.authorId, + artId: art.id, + }); + + return art.id; + }); +} + +type UpdateArtArgs = Pick & { + linkedUsers: number[]; + tags: TagsToAdd; +}; + +export async function update(id: number, args: UpdateArtArgs) { + return await db.transaction().execute(async (trx) => { + const { authorId } = await trx + .selectFrom("Art") + .select("authorId") + .where("id", "=", id) + .executeTakeFirstOrThrow(); + + if (args.isShowcase) { + await trx + .updateTable("Art") + .set({ isShowcase: 0 }) + .where("authorId", "=", authorId) + .execute(); + } + + await trx + .updateTable("Art") + .set({ + description: args.description, + isShowcase: args.isShowcase ? 1 : 0, + }) + .where("id", "=", id) + .execute(); + + await trx.deleteFrom("ArtUserMetadata").where("artId", "=", id).execute(); + + if (args.linkedUsers.length > 0) { + await trx + .insertInto("ArtUserMetadata") + .values(args.linkedUsers.map((userId) => ({ artId: id, userId }))) + .execute(); + } + + await trx.deleteFrom("TaggedArt").where("artId", "=", id).execute(); + + await insertTags(trx, { + tags: args.tags, + authorId, + artId: id, + }); + + return id; + }); +} + +async function insertTags( + trx: Transaction, + { + tags, + authorId, + artId, + }: { + tags: TagsToAdd; + authorId: number; + artId: number; + }, +) { + for (const tag of tags) { + let tagId = tag.id; + if (!tagId) { + if (!tag.name) { + throw new Error("tag name must be provided if no id"); + } + + const newTag = await trx + .insertInto("ArtTag") + .values({ + name: tag.name, + authorId, + }) + .returningAll() + .executeTakeFirstOrThrow(); + tagId = newTag.id; + } + + await trx.insertInto("TaggedArt").values({ artId, tagId }).execute(); + } +} diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts index 9ce477b9d..884c63902 100644 --- a/app/features/art/actions/art.new.server.ts +++ b/app/features/art/actions/art.new.server.ts @@ -6,6 +6,7 @@ import { redirect, } from "@remix-run/node"; 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 { notify } from "~/features/notifications/core/notify.server"; @@ -20,8 +21,6 @@ import { import { userArtPage } from "~/utils/urls"; import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; import { editArtSchema, newArtSchema } from "../art-schemas.server"; -import { addNewArt, editArt } from "../queries/addNewArt.server"; -import { findArtById } from "../queries/findArtById.server"; export const action: ActionFunction = async ({ request }) => { const user = await requireUser(request); @@ -34,28 +33,28 @@ export const action: ActionFunction = async ({ request }) => { if (artIdRaw) { const artId = Number(artIdRaw); - const existingArt = findArtById(artId); - errorToastIfFalsy( - existingArt?.authorId === user.id, - "Art author is someone else", - ); + const userArts = await ArtRepository.findArtsByUserId(user.id, { + includeTagged: false, + }); + const existingArt = userArts.find((art) => art.id === artId); + errorToastIfFalsy(existingArt, "Art author is someone else"); const data = await parseRequestPayload({ request, schema: editArtSchema, }); - const editedArtId = editArt({ - authorId: user.id, - artId, + const editedArtId = await ArtRepository.update(artId, { description: data.description, isShowcase: data.isShowcase, linkedUsers: data.linkedUsers, tags: data.tags, }); + const existingLinkedUserIds = + existingArt.linkedUsers?.map((u) => u.id) ?? []; const newLinkedUsers = data.linkedUsers.filter( - (userId) => !existingArt.linkedUsers.includes(userId), + (userId) => !existingLinkedUserIds.includes(userId), ); notify({ @@ -87,7 +86,7 @@ export const action: ActionFunction = async ({ request }) => { schema: newArtSchema, }); - const addedArtId = addNewArt({ + const addedArtId = await ArtRepository.insert({ authorId: user.id, description: data.description, url: fileName, diff --git a/app/features/art/art-types.ts b/app/features/art/art-types.ts index def8e528a..fa34936cc 100644 --- a/app/features/art/art-types.ts +++ b/app/features/art/art-types.ts @@ -5,8 +5,13 @@ export interface ListedArt { createdAt: Tables["Art"]["createdAt"]; url: Tables["UserSubmittedImage"]["url"]; description?: Tables["Art"]["description"]; - tags?: string[]; + isShowcase: boolean; + tags?: Array<{ + id: Tables["ArtTag"]["id"]; + name: Tables["ArtTag"]["name"]; + }>; linkedUsers?: Array<{ + id: Tables["User"]["id"]; discordId: Tables["User"]["discordId"]; username: Tables["User"]["username"]; customUrl: Tables["User"]["customUrl"]; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 514547d6f..55f8848b3 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -128,8 +128,12 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) { ))} {art.tags?.map((tag) => ( - - #{tag} + + #{tag.name} ))} diff --git a/app/features/art/loaders/art.new.server.ts b/app/features/art/loaders/art.new.server.ts index 48630ee90..386d5856d 100644 --- a/app/features/art/loaders/art.new.server.ts +++ b/app/features/art/loaders/art.new.server.ts @@ -2,7 +2,6 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { requireUser } from "~/features/auth/core/user.server"; import * as ArtRepository from "../ArtRepository.server"; import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; -import { findArtById } from "../queries/findArtById.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -13,8 +12,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { if (!artIdRaw) return { art: null, tags: await ArtRepository.findAllTags() }; const artId = Number(artIdRaw); - const art = findArtById(artId); - if (!art || art.authorId !== user.id) { + const userArts = await ArtRepository.findArtsByUserId(user.id, { + includeTagged: false, + }); + const art = userArts.find((a) => a.id === artId); + if (!art) { return { art: null, tags: await ArtRepository.findAllTags() }; } diff --git a/app/features/art/queries/addNewArt.server.ts b/app/features/art/queries/addNewArt.server.ts deleted file mode 100644 index 686a47b14..000000000 --- a/app/features/art/queries/addNewArt.server.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import invariant from "~/utils/invariant"; - -const addImgStm = sql.prepare(/* sql */ ` - insert into "UnvalidatedUserSubmittedImage" - ("submitterUserId", "url", "validatedAt") - values - (@authorId, @url, @validatedAt) - returning * -`); - -const addArtStm = sql.prepare(/* sql */ ` - insert into "Art" - ( - "authorId", - "description", - "imgId", - "isShowcase" - ) - values - ( - @authorId, - @description, - @imgId, - -- ensures first art is always showcase - not exists ( - select - 1 - from - "Art" - where - "authorId" = @authorId - ) - ) - returning * -`); - -const addArtTagStm = sql.prepare(/* sql */ ` - insert into "ArtTag" - ("name", "authorId") - values - (@name, @authorId) - returning * -`); - -const addTaggedArtStm = sql.prepare(/* sql */ ` - insert into "TaggedArt" - ("artId", "tagId") - values - (@artId, @tagId) -`); - -const deleteAllTaggedArtStm = sql.prepare(/* sql */ ` - delete from - "TaggedArt" - where - "artId" = @artId -`); - -const updateArtStm = sql.prepare(/* sql */ ` - update - "Art" - set - "description" = @description, - "isShowcase" = @isShowcase - where - "id" = @artId - returning * -`); - -const removeIsShowcaseFromAllStm = sql.prepare(/* sql */ ` - update - "Art" - set - "isShowcase" = 0 - where - "authorId" = @authorId -`); - -const addArtUserMetadataStm = sql.prepare(/* sql */ ` - insert into "ArtUserMetadata" - ("artId", "userId") - values - (@artId, @userId) -`); - -const removeUserMetadataStm = sql.prepare(/* sql */ ` - delete from - "ArtUserMetadata" - where - "artId" = @artId -`); - -type TagsToAdd = Array>>; -type AddNewArtArgs = Pick & - Pick & { - linkedUsers: number[]; - tags: TagsToAdd; - }; - -export const addNewArt = sql.transaction((args: AddNewArtArgs) => { - const img = addImgStm.get({ - authorId: args.authorId, - url: args.url, - validatedAt: args.validatedAt, - }) as Tables["UserSubmittedImage"]; - const art = addArtStm.get({ - authorId: args.authorId, - description: args.description, - imgId: img.id, - }) as Tables["Art"]; - - for (const userId of args.linkedUsers) { - addArtUserMetadataStm.run({ artId: art.id, userId }); - } - - for (const tag of args.tags) { - let tagId = tag.id; - if (!tagId) { - invariant(tag.name, "tag name must be provided if no id"); - - const newTag = addArtTagStm.get({ - name: tag.name, - authorId: args.authorId, - }) as Tables["ArtTag"]; - tagId = newTag.id; - } - - addTaggedArtStm.run({ artId: art.id, tagId }); - } - - return art.id; -}); - -type EditArtArgs = Pick< - Tables["Art"], - "authorId" | "description" | "isShowcase" -> & { - linkedUsers: number[]; - artId: number; - tags: TagsToAdd; -}; - -export const editArt = sql.transaction((args: EditArtArgs) => { - if (args.isShowcase) { - removeIsShowcaseFromAllStm.run({ - authorId: args.authorId, - }); - } - - const updatedArt = updateArtStm.get({ - description: args.description, - isShowcase: args.isShowcase, - artId: args.artId, - }); - - removeUserMetadataStm.run({ artId: args.artId }); - for (const userId of args.linkedUsers) { - addArtUserMetadataStm.run({ artId: args.artId, userId }); - } - - deleteAllTaggedArtStm.run({ artId: args.artId }); - for (const tag of args.tags) { - let tagId = tag.id; - if (!tagId) { - invariant(tag.name, "tag name must be provided if no id"); - - const newTag = addArtTagStm.get({ - name: tag.name, - authorId: args.authorId, - }) as Tables["ArtTag"]; - tagId = newTag.id; - } - - addTaggedArtStm.run({ artId: args.artId, tagId }); - } - - return (updatedArt as { id: number }).id; -}); diff --git a/app/features/art/queries/artsByUserId.server.ts b/app/features/art/queries/artsByUserId.server.ts deleted file mode 100644 index 059a8e3f8..000000000 --- a/app/features/art/queries/artsByUserId.server.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { sql } from "~/db/sql"; -import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; -import type { ListedArt } from "../art-types"; - -const stm = sql.prepare(/* sql */ ` - with "q1" as ( - select - "Art"."id", - "Art"."description", - "Art"."createdAt", - "User"."discordId", - "User"."username", - "User"."discordAvatar", - "UserSubmittedImage"."url" - from - "Art" - left join "User" on "User"."id" = "Art"."authorId" - left join "ArtUserMetadata" on "ArtUserMetadata"."artId" = "Art"."id" - inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId" - where "ArtUserMetadata"."userId" = @userId - and "Art"."authorId" != @userId - - union all - - select - "Art"."id", - "Art"."description", - "Art"."createdAt", - null, -- discordId - null, -- username - null, -- discordAvatar - "UserSubmittedImage"."url" - from - "Art" - inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId" - where - "Art"."authorId" = @userId - - order by "Art"."createdAt" desc - ), - "q2" as ( - select - "q1".*, - json_group_array("ArtTag"."name") as "tags" - from - "q1" - left join "TaggedArt" on "TaggedArt"."artId" = "q1"."id" - left join "ArtTag" on "ArtTag"."id" = "TaggedArt"."tagId" - group by "q1"."id" - ) - select - "q2".*, - json_group_array( - json_object( - 'discordId', "LinkedUser"."discordId", - 'username', "LinkedUser"."username", - 'customUrl', "LinkedUser"."customUrl" - ) - ) as "linkedUsers" - from - "q2" - left join "ArtUserMetadata" on "ArtUserMetadata"."artId" = "q2"."id" - left join "User" as "LinkedUser" on "LinkedUser"."id" = "ArtUserMetadata"."userId" - group by "q2"."id" - order by "q2"."createdAt" desc -`); - -export function artsByUserId(userId: number): ListedArt[] { - return stm.all({ userId }).map((a: any) => { - const tags = parseDBArray(a.tags) as any[]; - const linkedUsers = parseDBJsonArray(a.linkedUsers) as any[]; - - return { - id: a.id, - url: a.url, - description: a.description, - createdAt: a.createdAt, - tags: tags.length > 0 ? tags : undefined, - linkedUsers: linkedUsers.length > 0 ? linkedUsers : undefined, - author: a.discordId - ? { - commissionsOpen: a.commissionsOpen, - discordAvatar: a.discordAvatar, - discordId: a.discordId, - username: a.username, - } - : undefined, - }; - }); -} diff --git a/app/features/art/queries/deleteArt.server.ts b/app/features/art/queries/deleteArt.server.ts deleted file mode 100644 index a4834f71e..000000000 --- a/app/features/art/queries/deleteArt.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - delete from "Art" - where "id" = @id -`); - -export function deleteArt(id: number) { - stm.run({ id }); -} diff --git a/app/features/art/queries/findArtById.server.ts b/app/features/art/queries/findArtById.server.ts deleted file mode 100644 index 03a930e79..000000000 --- a/app/features/art/queries/findArtById.server.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import { parseDBArray } from "~/utils/sql"; - -const findArtStm = sql.prepare(/* sql */ ` - select - "Art"."isShowcase", - "Art"."description", - "Art"."authorId", - "UserSubmittedImage"."url", - json_group_array("ArtUserMetadata"."userId") as "linkedUsers" - from "Art" - left join "ArtUserMetadata" on "Art"."id" = "ArtUserMetadata"."artId" - inner join "UserSubmittedImage" on "Art"."imgId" = "UserSubmittedImage"."id" - where "Art"."id" = @artId - group by "Art"."id" -`); - -const findTagsStm = sql.prepare(/* sql */ ` - select - "ArtTag"."id", - "ArtTag"."name" - from "ArtTag" - inner join "TaggedArt" on "ArtTag"."id" = "TaggedArt"."tagId" - where "TaggedArt"."artId" = @artId -`); - -interface FindArtById { - isShowcase: Tables["Art"]["isShowcase"]; - description: Tables["Art"]["description"]; - url: Tables["UserSubmittedImage"]["url"]; - authorId: Tables["Art"]["authorId"]; - linkedUsers: Tables["User"]["id"][]; - tags: Array>; -} - -export function findArtById(artId: number): FindArtById | null { - const art = findArtStm.get({ artId }) as any; - if (!art) return null; - - return { - ...art, - linkedUsers: parseDBArray(art.linkedUsers), - tags: findTagsStm.all({ artId }), - }; -} diff --git a/app/features/art/queries/makeArtist.server.ts b/app/features/art/queries/makeArtist.server.ts deleted file mode 100644 index e46cf7c70..000000000 --- a/app/features/art/queries/makeArtist.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sql } from "~/db/sql"; - -const stm = sql.prepare(/* sql */ ` - update "User" - set "isArtist" = 1 - where "id" = @userId -`); - -export function makeArtist(userId: number) { - stm.run({ userId }); -} diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 75614fd8a..79942d9e3 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -319,8 +319,11 @@ function LinkedUsers() { const [users, setUsers] = React.useState< { inputId: string; userId?: number }[] >( - (data.art?.linkedUsers ?? []).length > 0 - ? data.art!.linkedUsers.map((userId) => ({ userId, inputId: nanoid() })) + data.art?.linkedUsers && data.art.linkedUsers.length > 0 + ? data.art.linkedUsers.map((user) => ({ + userId: user.id, + inputId: nanoid(), + })) : [{ inputId: nanoid() }], ); diff --git a/app/features/user-page/actions/u.$identifier.art.server.ts b/app/features/user-page/actions/u.$identifier.art.server.ts index 2c5ed7901..6cbf68976 100644 --- a/app/features/user-page/actions/u.$identifier.art.server.ts +++ b/app/features/user-page/actions/u.$identifier.art.server.ts @@ -1,8 +1,6 @@ import type { ActionFunction } from "@remix-run/node"; import * as ArtRepository from "~/features/art/ArtRepository.server"; import { userArtPageActionSchema } from "~/features/art/art-schemas.server"; -import { deleteArt } from "~/features/art/queries/deleteArt.server"; -import { findArtById } from "~/features/art/queries/findArtById.server"; import { requireUserId } from "~/features/auth/core/user.server"; import { logger } from "~/utils/logger"; import { @@ -24,13 +22,13 @@ export const action: ActionFunction = async ({ request }) => { // this actually doesn't delete the image itself from the static hosting // but the idea is that storage is cheap anyway and if needed later // then we can have a routine that checks all the images still current and nukes the rest - const artToDelete = findArtById(data.id); - errorToastIfFalsy( - artToDelete?.authorId === user.id, - "Insufficient permissions", - ); + const userArts = await ArtRepository.findArtsByUserId(user.id, { + includeTagged: false, + }); + const artToDelete = userArts.find((art) => art.id === data.id); + errorToastIfFalsy(artToDelete, "Insufficient permissions"); - deleteArt(data.id); + await ArtRepository.deleteById(data.id); return successToast("Deleting art successful"); } diff --git a/app/features/user-page/loaders/u.$identifier.art.server.ts b/app/features/user-page/loaders/u.$identifier.art.server.ts index 2f65c0aa3..8d192c305 100644 --- a/app/features/user-page/loaders/u.$identifier.art.server.ts +++ b/app/features/user-page/loaders/u.$identifier.art.server.ts @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { artsByUserId } from "~/features/art/queries/artsByUserId.server"; +import * as ArtRepository from "~/features/art/ArtRepository.server"; import { getUserId } from "~/features/auth/core/user.server"; import { countUnvalidatedArt } from "~/features/img-upload"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -14,14 +14,14 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { await UserRepository.identifierToUserId(identifier), ); - const arts = artsByUserId(user.id); + const arts = await ArtRepository.findArtsByUserId(user.id); const tagCounts = arts.reduce( (acc, art) => { if (!art.tags) return acc; for (const tag of art.tags) { - acc[tag] = (acc[tag] ?? 0) + 1; + acc[tag.name] = (acc[tag.name] ?? 0) + 1; } return acc; }, diff --git a/app/features/user-page/routes/u.$identifier.art.tsx b/app/features/user-page/routes/u.$identifier.art.tsx index 7dbe4fb05..320c94038 100644 --- a/app/features/user-page/routes/u.$identifier.art.tsx +++ b/app/features/user-page/routes/u.$identifier.art.tsx @@ -47,7 +47,7 @@ export default function UserArtPage() { : data.arts.filter((a) => a.author); if (filteredTag) { - arts = arts.filter((a) => a.tags?.includes(filteredTag)); + arts = arts.filter((a) => a.tags?.some((tag) => tag.name === filteredTag)); } return (