diff --git a/src/routers/web/associations.ts b/src/routers/web/associations.ts index 89a2a4c..e063802 100644 --- a/src/routers/web/associations.ts +++ b/src/routers/web/associations.ts @@ -1,10 +1,18 @@ import { asc, eq } from "drizzle-orm" import z from "zod" +import { uploadBlob } from "@/azure/blob" import { DB, SCHEMA } from "@/db" import { createTRPCRouter, publicProcedure } from "@/trpc" +import { getImageExtension } from "@/utils/web" const ASSOCIATIONS = SCHEMA.WEB.associations +const logoFileSchema = z + .file() + .mime(["image/png", "image/jpeg", "image/svg+xml"]) + .min(1) + .max(1024 * 1024) + const associationLinksSchema = z.object({ email: z.email().nullable(), website: z.url().nullable(), @@ -18,6 +26,13 @@ const associationLinksSchema = z.object({ spotify: z.url().nullable(), }) +const associationFormSchema = z.object({ + name: z.string(), + descriptionIt: z.string(), + descriptionEn: z.string(), + logo: logoFileSchema.optional(), +}) + const associationSchema = z.object({ id: z.number(), name: z.string(), @@ -58,23 +73,28 @@ export default createTRPCRouter({ addAssociation: publicProcedure .input( - z.object({ - name: z.string(), - descriptionIt: z.string(), - descriptionEn: z.string(), - logo: z.string().nullable(), - createdBy: z.number(), - }) + z + .instanceof(FormData) + .transform((fd): Record => Object.fromEntries(fd.entries())) + .pipe( + associationFormSchema.extend({ + createdBy: z.coerce.number(), + }) + ) ) .mutation(async ({ input }) => { const { name, descriptionIt, descriptionEn, logo, createdBy } = input + const uploadedLogo = logo + ? await uploadBlob(Buffer.from(await logo.arrayBuffer()), getImageExtension(logo), logo.type) + : null + const [res] = await DB.insert(ASSOCIATIONS) .values({ name, descriptionIt, descriptionEn, - logo, + logo: uploadedLogo?.url ?? null, createdBy, }) .returning() @@ -84,24 +104,29 @@ export default createTRPCRouter({ editAssociation: publicProcedure .input( - z.object({ - id: z.number(), - name: z.string(), - descriptionIt: z.string(), - descriptionEn: z.string(), - logo: z.string().nullable(), - modifiedBy: z.number(), - }) + z + .instanceof(FormData) + .transform((fd): Record => Object.fromEntries(fd.entries())) + .pipe( + associationFormSchema.extend({ + id: z.coerce.number(), + modifiedBy: z.coerce.number(), + }) + ) ) .mutation(async ({ input }) => { const { id, name, descriptionIt, descriptionEn, logo, modifiedBy } = input + const uploadedLogo = logo + ? await uploadBlob(Buffer.from(await logo.arrayBuffer()), getImageExtension(logo), logo.type) + : undefined + const [res] = await DB.update(ASSOCIATIONS) .set({ name, descriptionIt, descriptionEn, - logo, + ...(uploadedLogo ? { logo: uploadedLogo.url } : {}), modifiedBy, }) .where(eq(ASSOCIATIONS.id, id)) diff --git a/src/routers/web/projects.ts b/src/routers/web/projects.ts index f201860..b23a247 100644 --- a/src/routers/web/projects.ts +++ b/src/routers/web/projects.ts @@ -1,11 +1,28 @@ import { asc, eq } from "drizzle-orm" import z from "zod" +import { uploadBlob } from "@/azure/blob" import { DB, SCHEMA } from "@/db" import { projectsCategories } from "@/db/schema/web/projects" import { createTRPCRouter, publicProcedure } from "@/trpc" +import { getImageExtension } from "@/utils/web" const PROJECTS = SCHEMA.WEB.projects +const logoFileSchema = z + .file() + .mime(["image/png", "image/jpeg", "image/svg+xml"]) + .min(1) + .max(1024 * 1024) + +const projectFormSchema = z.object({ + title: z.string(), + descriptionIt: z.string(), + descriptionEn: z.string(), + logo: logoFileSchema.optional(), + link: z.string().transform((value) => value || null), + category: z.enum(projectsCategories), +}) + const projectSchema = z.object({ id: z.number(), title: z.string(), @@ -22,16 +39,29 @@ export default createTRPCRouter({ }), addProject: publicProcedure - .input(projectSchema.omit({ id: true }).extend({ createdBy: z.number() })) + .input( + z + .instanceof(FormData) + .transform((fd): Record => Object.fromEntries(fd.entries())) + .pipe( + projectFormSchema.extend({ + createdBy: z.coerce.number(), + }) + ) + ) .mutation(async ({ input }) => { const { title, descriptionIt, descriptionEn, logo, link, category, createdBy } = input + const uploadedLogo = logo + ? await uploadBlob(Buffer.from(await logo.arrayBuffer()), getImageExtension(logo), logo.type) + : null + const [res] = await DB.insert(PROJECTS) .values({ title, descriptionIt, descriptionEn, - logo, + logo: uploadedLogo?.url ?? null, link, category, createdBy, @@ -41,25 +71,40 @@ export default createTRPCRouter({ return res }), - editProject: publicProcedure.input(projectSchema.extend({ modifiedBy: z.number() })).mutation(async ({ input }) => { - const { id, title, descriptionIt, descriptionEn, logo, link, category, modifiedBy } = input - - const [res] = await DB.update(PROJECTS) - .set({ - title, - descriptionIt, - descriptionEn, - logo, - link, - category, - modifiedBy, - }) - .where(eq(PROJECTS.id, id)) - .returning() + editProject: publicProcedure + .input( + z + .instanceof(FormData) + .transform((fd): Record => Object.fromEntries(fd.entries())) + .pipe( + projectFormSchema.extend({ + id: z.coerce.number(), + modifiedBy: z.coerce.number(), + }) + ) + ) + .mutation(async ({ input }) => { + const { title, descriptionIt, descriptionEn, logo, link, category, modifiedBy } = input + const uploadedLogo = logo + ? await uploadBlob(Buffer.from(await logo.arrayBuffer()), getImageExtension(logo), logo.type) + : undefined - if (!res) return { error: "NOT_FOUND" } - return res - }), + const [res] = await DB.update(PROJECTS) + .set({ + title, + descriptionIt, + descriptionEn, + ...(uploadedLogo ? { logo: uploadedLogo.url } : {}), + link, + category, + modifiedBy, + }) + .where(eq(PROJECTS.id, input.id)) + .returning() + + if (!res) return { error: "NOT_FOUND" } + return res + }), reorderProjects: publicProcedure .input( diff --git a/src/utils/web.ts b/src/utils/web.ts index 49327f3..b489637 100644 --- a/src/utils/web.ts +++ b/src/utils/web.ts @@ -15,3 +15,16 @@ export async function verifyTgLink(link: string): Promise { root.querySelector(".tgme_page_action")?.querySelector("a")?.classList.contains("tgme_action_button_new") ?? false ) } + +export function getImageExtension(file: File) { + switch (file.type) { + case "image/png": + return "png" + case "image/jpeg": + return "jpeg" + case "image/svg+xml": + return "svg" + default: + throw new Error("Unsupported image type") + } +}