diff --git a/prisma/migrations/20240101035724_add_referential_actions/migration.sql b/prisma/migrations/20240101035724_add_referential_actions/migration.sql new file mode 100644 index 0000000..475d733 --- /dev/null +++ b/prisma/migrations/20240101035724_add_referential_actions/migration.sql @@ -0,0 +1,41 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_user_group_membership" ( + "id" TEXT NOT NULL PRIMARY KEY, + "active" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "roleId" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "user_group_membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "user_group_membership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "group" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "user_group_membership_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_user_group_membership" ("active", "groupId", "id", "roleId", "userId") SELECT "active", "groupId", "id", "roleId", "userId" FROM "user_group_membership"; +DROP TABLE "user_group_membership"; +ALTER TABLE "new_user_group_membership" RENAME TO "user_group_membership"; +CREATE UNIQUE INDEX "user_group_membership_id_key" ON "user_group_membership"("id"); +CREATE TABLE "new_items" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "price" TEXT, + "url" TEXT, + "note" TEXT, + "image_url" TEXT, + "userId" TEXT NOT NULL, + "addedById" TEXT NOT NULL, + "pledgedById" TEXT, + "approved" BOOLEAN NOT NULL DEFAULT true, + "purchased" BOOLEAN NOT NULL DEFAULT false, + "groupId" TEXT, + CONSTRAINT "items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "items_addedById_fkey" FOREIGN KEY ("addedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "items_pledgedById_fkey" FOREIGN KEY ("pledgedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "items_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "group" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_items" ("addedById", "approved", "groupId", "id", "image_url", "name", "note", "pledgedById", "price", "purchased", "url", "userId") SELECT "addedById", "approved", "groupId", "id", "image_url", "name", "note", "pledgedById", "price", "purchased", "url", "userId" FROM "items"; +DROP TABLE "items"; +ALTER TABLE "new_items" RENAME TO "items"; +CREATE UNIQUE INDEX "items_id_key" ON "items"("id"); +CREATE INDEX "items_userId_idx" ON "items"("userId"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0afa4dd..4bc82a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,8 +66,8 @@ model Group { model UserGroupMembership { id String @id @unique @default(uuid()) active Boolean @default(false) - user User @relation(fields: [userId], references: [id]) - group Group @relation(fields: [groupId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) role Role @relation(fields: [roleId], references: [id]) userId String groupId String @@ -83,15 +83,15 @@ model Item { url String? note String? image_url String? - user User @relation(name: "MyItems", fields: [userId], references: [id]) + user User @relation(name: "MyItems", fields: [userId], references: [id], onDelete: Cascade) userId String - addedBy User @relation(name: "AddedItems", fields: [addedById], references: [id]) + addedBy User @relation(name: "AddedItems", fields: [addedById], references: [id], onDelete: Cascade) addedById String - pledgedBy User? @relation(name: "PledgedItems", fields: [pledgedById], references: [id]) + pledgedBy User? @relation(name: "PledgedItems", fields: [pledgedById], references: [id], onDelete: SetNull) pledgedById String? approved Boolean @default(true) purchased Boolean @default(false) - group Group? @relation(fields: [groupId], references: [id]) + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) groupId String? @@index([userId]) diff --git a/src/lib/server/image-util.ts b/src/lib/server/image-util.ts index 2d176f8..56c624f 100644 --- a/src/lib/server/image-util.ts +++ b/src/lib/server/image-util.ts @@ -1,4 +1,5 @@ import sharp from "sharp"; +import { unlink } from "fs/promises"; export const createImage = async (username: string, image: File): Promise => { let filename = null; @@ -13,3 +14,19 @@ export const createImage = async (username: string, image: File): Promise => { + try { + await unlink(`uploads/${filename}`); + } catch (e) { + console.warn("Unable to delete file: ", filename); + } +}; + +export const tryDeleteImage = async (imageUrl: string): Promise => { + try { + new URL(imageUrl); + } catch { + await deleteImage(imageUrl); + } +}; diff --git a/src/lib/server/invite-user.ts b/src/lib/server/invite-user.ts index f603758..695f1b4 100644 --- a/src/lib/server/invite-user.ts +++ b/src/lib/server/invite-user.ts @@ -8,29 +8,26 @@ import generateToken, { hashToken } from "./token"; export const inviteUser = async ({ url, request }: RequestEvent) => { const token = await generateToken(); const tokenUrl = new URL(`/signup?token=${token}`, url); + const formData = Object.fromEntries(await request.formData()); + let schema; const config = await getConfig(); - - if (!config.smtp.enable) { - await client.signupToken.create({ - data: { - hashedToken: hashToken(token) - } + if (config.smtp.enable) { + schema = z.object({ + "invite-email": z.string().email(), + "invite-group": z.string().optional() + }); + } else { + schema = z.object({ + "invite-email": z.string().optional(), + "invite-group": z.string().optional() }); - - return { action: "invite-email", success: true, url: tokenUrl.href }; } - const formData = Object.fromEntries(await request.formData()); - const schema = z.object({ - "invite-email": z.string().email(), - "invite-group": z.string().min(1) - }); + const data = schema.safeParse(formData); - const emailData = schema.safeParse(formData); - - if (!emailData.success) { - const errors = emailData.error.errors.map((error) => { + if (!data.success) { + const errors = data.error.errors.map((error) => { return { field: error.path[0], message: error.message @@ -39,13 +36,24 @@ export const inviteUser = async ({ url, request }: RequestEvent) => { return fail(400, { action: "invite-email", error: true, errors }); } + if (!config.smtp.enable) { + await client.signupToken.create({ + data: { + hashedToken: hashToken(token), + groupId: data.data["invite-group"] + } + }); + + return { action: "invite-email", success: true, url: tokenUrl.href }; + } + await client.signupToken.create({ data: { hashedToken: hashToken(token), - groupId: emailData.data["invite-group"] + groupId: data.data["invite-group"] } }); - await sendSignupLink(emailData.data["invite-email"], tokenUrl.href); + await sendSignupLink(data.data["invite-email"]!, tokenUrl.href); return { action: "invite-email", success: true, url: null }; }; diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index 70389b8..40b0ac3 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -5,7 +5,7 @@ import { fail, redirect } from "@sveltejs/kit"; import { z } from "zod"; import type { Actions, PageServerLoad } from "./$types"; import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; -import { createImage } from "$lib/server/image-util"; +import { createImage, tryDeleteImage } from "$lib/server/image-util"; export const load: PageServerLoad = async ({ locals }) => { const session = await locals.validate(); @@ -73,6 +73,14 @@ export const actions: Actions = { const filename = await createImage(session.user.username, image); if (filename) { + const user = await client.user.findUniqueOrThrow({ + select: { + picture: true + }, + where: { + id: session.user.userId + } + }); await client.user.update({ where: { id: session.user.userId @@ -81,6 +89,9 @@ export const actions: Actions = { picture: filename } }); + if (user.picture) { + await tryDeleteImage(user.picture); + } } }, diff --git a/src/routes/admin/users/[username]/+page@.svelte b/src/routes/admin/users/[username]/+page@.svelte index 2f7bd38..7f111b0 100644 --- a/src/routes/admin/users/[username]/+page@.svelte +++ b/src/routes/admin/users/[username]/+page@.svelte @@ -32,7 +32,7 @@ invalidateAll(); toastStore.trigger({ - message: `${userId} was deleted`, + message: `${username} was deleted`, autohide: true, timeout: 5000 }); diff --git a/src/routes/api/items/+server.ts b/src/routes/api/items/+server.ts index f2c1d5a..776ccf8 100644 --- a/src/routes/api/items/+server.ts +++ b/src/routes/api/items/+server.ts @@ -3,6 +3,7 @@ import { client } from "$lib/server/prisma"; import { error } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; import { _authCheck } from "../groups/[groupId]/auth"; +import { tryDeleteImage } from "$lib/server/image-util"; export const DELETE: RequestHandler = async ({ locals, request }) => { const groupId = new URL(request.url).searchParams.get("groupId"); @@ -23,13 +24,32 @@ export const DELETE: RequestHandler = async ({ locals, request }) => { } try { - const items = await client.item.deleteMany({ + const items = await client.item.findMany({ + select: { + id: true, + image_url: true + }, where: { groupId: groupId ? groupId : undefined, pledgedById: claimed && Boolean(claimed) ? { not: null } : undefined } }); - return new Response(JSON.stringify(items), { status: 200 }); + + for (const item of items) { + if (item.image_url) { + await tryDeleteImage(item.image_url); + } + } + + const deletedItems = await client.item.deleteMany({ + where: { + id: { + in: items.map((item) => item.id) + } + } + }); + + return new Response(JSON.stringify(deletedItems), { status: 200 }); } catch (e) { error(500, "Unable to delete items"); } diff --git a/src/routes/api/items/[itemId]/+server.ts b/src/routes/api/items/[itemId]/+server.ts index 4ff99b5..fcbec4e 100644 --- a/src/routes/api/items/[itemId]/+server.ts +++ b/src/routes/api/items/[itemId]/+server.ts @@ -1,5 +1,6 @@ import { getConfig } from "$lib/server/config"; import { getActiveMembership } from "$lib/server/group-membership"; +import { tryDeleteImage } from "$lib/server/image-util"; import { client } from "$lib/server/prisma"; import { error, type RequestHandler } from "@sveltejs/kit"; import assert from "assert"; @@ -28,7 +29,8 @@ const validateItem = async (itemId: string | undefined, session: Session | null) select: { username: true } - } + }, + image_url: true } }); @@ -68,10 +70,15 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { select: { username: true } - } + }, + image_url: true } }); + if (item.image_url) { + await tryDeleteImage(item.image_url); + } + return new Response(JSON.stringify(item), { status: 200 }); } catch (e) { error(404, "item id not found"); @@ -81,7 +88,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { export const PATCH: RequestHandler = async ({ params, locals, request }) => { const session = await locals.validate(); - await validateItem(params?.itemId, session); + const item = await validateItem(params?.itemId, session); const body = (await request.json()) as Record; const data: { @@ -97,12 +104,16 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => { approved?: boolean; purchased?: boolean; } = {}; + let deleteOldImage = false; if (body.name && typeof body.name === "string") data.name = body.name; if (body.price && typeof body.price === "string") data.price = body.price; if (body.url && typeof body.url === "string") data.url = body.url; if (body.note && typeof body.note === "string") data.note = body.note; - if (body.image_url && typeof body.image_url === "string") data.image_url = body.image_url; + if (body.image_url && typeof body.image_url === "string") { + data.image_url = body.image_url; + deleteOldImage = true; + } if (body.pledgedById && typeof body.pledgedById === "string") { if (body.pledgedById === "0") { data.pledgedBy = { @@ -120,7 +131,7 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => { if (Object.keys(body).includes("purchased") && typeof body.purchased === "boolean") data.purchased = body.purchased; try { - const item = await client.item.update({ + const updatedItem = await client.item.update({ where: { // @ts-expect-error params.itemId is checked in a previous function id: parseInt(params.itemId) @@ -128,7 +139,11 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => { data }); - return new Response(JSON.stringify(item), { status: 200 }); + if (deleteOldImage && item.image_url) { + await tryDeleteImage(item.image_url); + } + + return new Response(JSON.stringify(updatedItem), { status: 200 }); } catch (e) { error(404, "item id not found"); } diff --git a/src/routes/api/users/[userId]/+server.ts b/src/routes/api/users/[userId]/+server.ts index 90d366c..8b0591d 100644 --- a/src/routes/api/users/[userId]/+server.ts +++ b/src/routes/api/users/[userId]/+server.ts @@ -1,4 +1,5 @@ import { Role } from "$lib/schema"; +import { tryDeleteImage } from "$lib/server/image-util"; import { client } from "$lib/server/prisma"; import { type RequestHandler, error } from "@sveltejs/kit"; @@ -30,16 +31,14 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { } try { - await client.userGroupMembership.deleteMany({ - where: { - userId: user.id - } - }); const deletedUser = await client.user.delete({ where: { id: user.id } }); + if (deletedUser && deletedUser.picture) { + await tryDeleteImage(deletedUser.picture); + } return new Response(JSON.stringify(deletedUser), { status: 200 }); } catch (e) { diff --git a/src/routes/wishlists/[username]/edit/[itemId]/+page.server.ts b/src/routes/wishlists/[username]/edit/[itemId]/+page.server.ts index 9a9b487..a5714a6 100644 --- a/src/routes/wishlists/[username]/edit/[itemId]/+page.server.ts +++ b/src/routes/wishlists/[username]/edit/[itemId]/+page.server.ts @@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from "./$types"; import { client } from "$lib/server/prisma"; import { getConfig } from "$lib/server/config"; import { getActiveMembership } from "$lib/server/group-membership"; -import { createImage } from "$lib/server/image-util"; +import { createImage, tryDeleteImage } from "$lib/server/image-util"; export const load: PageServerLoad = async ({ locals, params }) => { const session = await locals.validate(); @@ -74,6 +74,12 @@ export const actions: Actions = { const filename = await createImage(session.user.username, image); + const item = await client.item.findUniqueOrThrow({ + where: { + id: parseInt(params.itemId) + } + }); + await client.item.update({ where: { id: parseInt(params.itemId) @@ -87,6 +93,10 @@ export const actions: Actions = { } }); + if (filename && item.image_url && item.image_url !== filename) { + await tryDeleteImage(item.image_url); + } + const ref = new URL(request.url).searchParams.get("ref"); redirect(302, ref ?? `/wishlists/${params.username}`); }