diff --git a/prisma/migrations/20260614203131_add_dynamic_site_feature/migration.sql b/prisma/migrations/20260614203131_add_dynamic_site_feature/migration.sql new file mode 100644 index 0000000..6070d35 --- /dev/null +++ b/prisma/migrations/20260614203131_add_dynamic_site_feature/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "SitePageContent" ( + "id" INTEGER NOT NULL DEFAULT 1, + "heroImageUrl" TEXT, + "heroCaption" TEXT, + "heroAltText" TEXT, + "galleryPhotos" JSONB NOT NULL DEFAULT '[]', + "updatedById" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SitePageContent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SiteAction" ( + "id" SERIAL NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT, + "url" TEXT, + "isVisible" BOOLEAN NOT NULL DEFAULT false, + "updatedById" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SiteAction_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SiteAction_key_key" ON "SiteAction"("key"); + +-- AddForeignKey +ALTER TABLE "SitePageContent" ADD CONSTRAINT "SitePageContent_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SiteAction" ADD CONSTRAINT "SiteAction_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 538e9a5..7e1cf95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,8 @@ model Member { updatedAchievements Achievement[] @relation("AchievementUpdatedBy") createdProjects Project[] @relation("ProjectCreatedBy") updatedProjects Project[] @relation("ProjectUpdatedBy") + updatedSitePageContent SitePageContent[] @relation("SitePageContentUpdatedBy") + updatedSiteActions SiteAction[] @relation("SiteActionUpdatedBy") } model Account { @@ -192,3 +194,28 @@ model CompletedQuestion { @@id([memberId, questionId]) } + +model SitePageContent { + id Int @id @default(1) + heroImageUrl String? + heroCaption String? + heroAltText String? + galleryPhotos Json @default("[]") + + updatedBy Member? @relation("SitePageContentUpdatedBy", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById String? + updatedAt DateTime @updatedAt +} + +model SiteAction { + id Int @id @default(autoincrement()) + key String @unique + label String? + url String? + isVisible Boolean @default(false) + + updatedBy Member? @relation("SiteActionUpdatedBy", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById String? + updatedAt DateTime @updatedAt +} + diff --git a/src/app.ts b/src/app.ts index 2a4470d..8f535ad 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,16 +4,11 @@ import multer from "multer"; import { json, urlencoded } from "body-parser"; import routes from "./routes"; import { errorHandler } from "./utils/apiError"; -import { createClient } from "@supabase/supabase-js"; -import config from "./config"; import path from "path"; import { logger } from "./utils/logger"; import morgan from "morgan"; -// Initialize Supabase client for storage operations -export const supabase = createClient( - config.SUPABASE_URL, - config.SUPABASE_SERVICE_ROLE_KEY, -); +import { supabase } from "./utils/supabaseClient"; +import config from "./config"; const app = express(); class LoggerStream { @@ -44,7 +39,10 @@ app.use("/health",(req,res)=>{ res.status(200).json({ message: "OK" }); }) -app.use("/api/v1", routes(upload, supabase)); +app.use("/api/v1", routes(upload)); + +// Serve API documentation +app.use("/docs", express.static(path.join(__dirname, "..", "doc"))); // 404 handler app.use((req, res) => { @@ -55,6 +53,4 @@ app.use((req, res) => { // Global error handler app.use(errorHandler); -// Serve API documentation -app.use("/docs", express.static(path.join(__dirname, "..", "docs/apidoc"))); export default app; diff --git a/src/controllers/achievement.controller.ts b/src/controllers/achievement.controller.ts index b7e176d..6edc1b4 100644 --- a/src/controllers/achievement.controller.ts +++ b/src/controllers/achievement.controller.ts @@ -1,8 +1,8 @@ import { Request, Response } from "express"; import * as achievementService from "../services/achievement.service"; import { uploadImage, deleteImage } from "../utils/imageUtils"; -import { supabase } from "../app"; import { ApiError } from "../utils/apiError"; +import { supabase } from "../utils/supabaseClient"; export const getAchievements = async (req: Request, res: Response) => { const achievements = await achievementService.getAchievements(); diff --git a/src/controllers/project.controller.ts b/src/controllers/project.controller.ts index 1821604..c8be4b9 100644 --- a/src/controllers/project.controller.ts +++ b/src/controllers/project.controller.ts @@ -2,7 +2,7 @@ import * as projectService from "../services/project.service"; import { Request, Response } from "express"; import { ApiError } from "../utils/apiError"; import { deleteImage, uploadImage } from "../utils/imageUtils"; -import { supabase } from "../app"; +import { supabase } from "../utils/supabaseClient"; export const getProjects = async (req: Request, res: Response) => { diff --git a/src/controllers/site-content.controller.ts b/src/controllers/site-content.controller.ts new file mode 100644 index 0000000..184768d --- /dev/null +++ b/src/controllers/site-content.controller.ts @@ -0,0 +1,220 @@ +import { Request, Response } from "express"; +import * as siteContentService from "../services/site-content.service"; +import { uploadImage, deleteImage } from "../utils/imageUtils"; +import { supabase } from "../utils/supabaseClient"; +import { ApiError } from "../utils/apiError"; + +function parseSiteContentData(body: Record) { + let siteContentData = body.siteContentData ?? body; + + if (typeof siteContentData === "string") { + try { + siteContentData = JSON.parse(siteContentData); + } catch { + throw new ApiError("Invalid JSON in siteContentData field", 400); + } + } + + return siteContentData as Record; +} + +function parseActionData(body: Record) { + let actionData = body.actionData ?? body; + + if (typeof actionData === "string") { + try { + actionData = JSON.parse(actionData); + } catch { + throw new ApiError("Invalid JSON in actionData field", 400); + } + } + + return actionData as Record; +} + +function parsePhotoData(body: Record) { + let photoData = body.photoData ?? body; + + if (typeof photoData === "string") { + try { + photoData = JSON.parse(photoData); + } catch { + throw new ApiError("Invalid JSON in photoData field", 400); + } + } + + return photoData as Record; +} + +export const getSiteContent = async (_req: Request, res: Response) => { + const content = await siteContentService.getSiteContent(); + + res.status(200).json({ + success: true, + data: content, + }); +}; + +export const updateSiteContent = async (req: Request, res: Response) => { + const siteContentData = parseSiteContentData(req.body); + const adminId = siteContentData.adminId as string | undefined; + + if (!adminId) { + throw new ApiError("adminId is required", 400); + } + + const file = req.file; + let heroImageUrl: string | undefined; + + if (file) { + const current = await siteContentService.getSiteContent(); + heroImageUrl = await uploadImage( + supabase, + file, + "group-photos", + current.hero.imageUrl ?? undefined, + ); + } + + const content = await siteContentService.updateSitePageContent(adminId, { + heroCaption: siteContentData.heroCaption as string | null | undefined, + heroAltText: siteContentData.heroAltText as string | null | undefined, + heroImageUrl, + }); + + res.status(200).json({ + success: true, + data: content, + }); +}; + +export const updateSiteAction = async (req: Request, res: Response) => { + const key = req.params.key; + if (!key) { + throw new ApiError("Action key is required", 400); + } + + const actionData = parseActionData(req.body); + const adminId = actionData.adminId as string | undefined; + + if (!adminId) { + throw new ApiError("adminId is required", 400); + } + + const content = await siteContentService.updateSiteAction(adminId, key, { + label: actionData.label as string | null | undefined, + url: actionData.url as string | null | undefined, + isVisible: actionData.isVisible as boolean | undefined, + }); + + res.status(200).json({ + success: true, + data: content, + }); +}; + +export const addGalleryPhoto = async (req: Request, res: Response) => { + const file = req.file; + if (!file) { + throw new ApiError("Image file is required", 400); + } + + const photoData = parsePhotoData(req.body); + const adminId = photoData.adminId as string | undefined; + + if (!adminId) { + throw new ApiError("adminId is required", 400); + } + + const imageUrl = await uploadImage(supabase, file, "group-photos"); + if (!imageUrl) { + throw new ApiError("Image URL is missing", 400); + } + + const content = await siteContentService.addGalleryPhoto(adminId, { + imageUrl, + caption: photoData.caption as string | undefined, + altText: photoData.altText as string | undefined, + sortOrder: photoData.sortOrder as number | undefined, + }); + + res.status(201).json({ + success: true, + data: content, + }); +}; + +export const updateGalleryPhoto = async (req: Request, res: Response) => { + const photoId = req.params.photoId; + if (!photoId) { + throw new ApiError("Photo ID is required", 400); + } + + const photoData = parsePhotoData(req.body); + const adminId = photoData.adminId as string | undefined; + + if (!adminId) { + throw new ApiError("adminId is required", 400); + } + + const file = req.file; + let imageUrl: string | undefined; + + if (file) { + const existing = await siteContentService.getGalleryPhoto(adminId, photoId); + imageUrl = await uploadImage( + supabase, + file, + "group-photos", + existing.imageUrl, + ); + } + + const hasUpdate = + imageUrl !== undefined || + photoData.caption !== undefined || + photoData.altText !== undefined || + photoData.sortOrder !== undefined; + + if (!hasUpdate) { + throw new ApiError( + "At least one field (image, caption, altText, or sortOrder) must be provided", + 400, + ); + } + + const { content} = + await siteContentService.updateGalleryPhoto(adminId, photoId, { + imageUrl, + caption: photoData.caption as string | null | undefined, + altText: photoData.altText as string | null | undefined, + sortOrder: photoData.sortOrder as number | undefined, + }); + + + res.status(200).json({ + success: true, + data: content, + }); +}; + +export const deleteGalleryPhoto = async (req: Request, res: Response) => { + const photoId = req.params.photoId; + const adminId = req.body.adminId as string | undefined; + + if (!photoId) { + throw new ApiError("Photo ID is required", 400); + } + + if (!adminId) { + throw new ApiError("adminId is required", 400); + } + + const imageUrl = await siteContentService.deleteGalleryPhoto(adminId, photoId); + await deleteImage(supabase, imageUrl); + + res.status(200).json({ + success: true, + message: "Gallery photo deleted successfully", + }); +}; diff --git a/src/routes/achievements.ts b/src/routes/achievements.ts index 6e892e5..0f76d8a 100644 --- a/src/routes/achievements.ts +++ b/src/routes/achievements.ts @@ -1,7 +1,6 @@ import express from 'express'; import * as acheivementsCtrl from '../controllers/achievement.controller'; import { Multer } from 'multer'; -import { SupabaseClient } from '@supabase/supabase-js'; import { Request, Response,NextFunction } from 'express'; @@ -18,7 +17,7 @@ export function parseCreateAchievementData(req: Request, res: Response, next: Ne -export default function acheivementsRouter(upload: Multer, supabase: SupabaseClient) { +export default function acheivementsRouter(upload: Multer) { const router = express.Router(); diff --git a/src/routes/index.ts b/src/routes/index.ts index 74757ee..80a3e14 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,14 +8,15 @@ import topicRouter from './topics' import quetionsRouter from './questions' import progressRouter from './progress' import membersRouter from './members' +import siteContentRouter from './site-content' -export default function routes(upload: Multer, supabase: SupabaseClient) { +export default function routes(upload: Multer) { const router = Router(); - router.use('/members', membersRouter(upload, supabase)) + router.use('/members', membersRouter(upload)) - router.use('/projects', projectsRouter(upload, supabase)) + router.use('/projects', projectsRouter(upload)) - router.use('/achievements' ,acheivementsRouter(upload, supabase)); + router.use('/achievements' ,acheivementsRouter(upload)); router.use('/interviews', interviewRouter()); @@ -25,6 +26,8 @@ export default function routes(upload: Multer, supabase: SupabaseClient) { router.use("/progress", progressRouter()); + router.use("/site-content", siteContentRouter(upload)); + return router; } diff --git a/src/routes/members.ts b/src/routes/members.ts index 1a7addc..0b4b331 100644 --- a/src/routes/members.ts +++ b/src/routes/members.ts @@ -1,11 +1,11 @@ import express from "express"; import * as memberCtrl from "../controllers/member.controller"; import { Multer } from "multer"; -import { SupabaseClient } from "@supabase/supabase-js"; +import { supabase } from "../utils/supabaseClient"; + export default function membersRouter( upload: Multer, - supabase: SupabaseClient, ) { const router = express.Router(); diff --git a/src/routes/projects.ts b/src/routes/projects.ts index 8463e62..002086b 100644 --- a/src/routes/projects.ts +++ b/src/routes/projects.ts @@ -1,6 +1,5 @@ import { NextFunction, Request, Response, Router } from 'express' import { Multer } from 'multer' -import { SupabaseClient } from '@supabase/supabase-js' import { addMembers, createProject, @@ -26,8 +25,7 @@ function parseProjectData(req: Request, res: Response, next: NextFunction) { } export default function projectsRouter( - upload: Multer, - supabase: SupabaseClient, + upload: Multer, ) { const router = Router(); diff --git a/src/routes/site-content.ts b/src/routes/site-content.ts new file mode 100644 index 0000000..4d7e5d6 --- /dev/null +++ b/src/routes/site-content.ts @@ -0,0 +1,173 @@ +import express from "express"; +import { Multer } from "multer"; +import { Request, Response, NextFunction } from "express"; +import * as siteContentCtrl from "../controllers/site-content.controller"; + +function parseSiteContentData(req: Request, res: Response, next: NextFunction) { + if (req.body.siteContentData) { + try { + req.body.siteContentData = JSON.parse(req.body.siteContentData); + } catch { + return res + .status(400) + .json({ message: "Invalid JSON in siteContentData field" }); + } + } + next(); +} + +function parsePhotoData(req: Request, res: Response, next: NextFunction) { + if (req.body.photoData) { + try { + req.body.photoData = JSON.parse(req.body.photoData); + } catch { + return res.status(400).json({ message: "Invalid JSON in photoData field" }); + } + } + next(); +} + +function parseActionData(req: Request, res: Response, next: NextFunction) { + if (req.body.actionData) { + try { + req.body.actionData = JSON.parse(req.body.actionData); + } catch { + return res.status(400).json({ message: "Invalid JSON in actionData field" }); + } + } + next(); +} + +export default function siteContentRouter(upload: Multer) { + const router = express.Router(); + + /** + * @api {get} /site-content Get published site content + * @apiName getSiteContent + * @apiGroup SiteContent + * + * @apiSuccess {Boolean} success Request status + * @apiSuccess {Object} data Site content including actions, hero, and gallery + * @apiError (500) InternalServerError Failed to fetch site content + */ + router.get("/", siteContentCtrl.getSiteContent); + + /** + * @api {patch} /site-content Update site content + * @apiName updateSiteContent + * @apiGroup SiteContent + * + * @apiBody (FormData) {File} [image] Optional hero image file + * @apiBody (FormData) {String} siteContentData JSON string of fields: + * - adminId: string (required) + * - heroCaption?: string + * - heroAltText?: string + * + * @apiSuccess {Boolean} success Request status + * @apiSuccess {Object} data Updated site content + * @apiError (400) BadRequest Missing or invalid data + * @apiError (403) Forbidden Manager access required + * @apiError (500) InternalServerError Server error + */ + router.patch( + "/", + upload.single("image"), + parseSiteContentData, + siteContentCtrl.updateSiteContent, + ); + + /** + * @api {patch} /site-content/actions/:key Update a site action + * @apiName updateSiteAction + * @apiGroup SiteContent + * + * @apiParam (Path Params) {String} key Action key (e.g. recruitment) + * @apiBody {String} adminId ID of the manager (required) + * @apiBody {String} [label] Button label + * @apiBody {String} [url] Action URL + * @apiBody {Boolean} [isVisible] Whether the action is visible + * + * @apiSuccess {Boolean} success Request status + * @apiSuccess {Object} data Updated site content + * @apiError (400) BadRequest Missing or invalid data + * @apiError (403) Forbidden Manager access required + * @apiError (404) NotFound Site action not found + * @apiError (500) InternalServerError Server error + */ + router.patch( + "/actions/:key", + parseActionData, + siteContentCtrl.updateSiteAction, + ); + + /** + * @api {post} /site-content/gallery Add a gallery photo + * @apiName addGalleryPhoto + * @apiGroup SiteContent + * + * @apiBody (FormData) {File} image Image file + * @apiBody (FormData) {String} photoData JSON string of fields: + * - adminId: string (required) + * - caption?: string + * - altText?: string + * - sortOrder?: number + * + * @apiSuccess {Boolean} success Request status + * @apiSuccess {Object} data Updated site content + * @apiError (400) BadRequest Missing or invalid data + * @apiError (403) Forbidden Manager access required + * @apiError (500) InternalServerError Server error + */ + router.post( + "/gallery", + upload.single("image"), + parsePhotoData, + siteContentCtrl.addGalleryPhoto, + ); + + /** + * @api {patch} /site-content/gallery/:photoId Update a gallery photo + * @apiName updateGalleryPhoto + * @apiGroup SiteContent + * + * @apiParam (Path Params) {String} photoId Gallery photo ID + * @apiBody (FormData) {File} [image] Optional new image + * @apiBody (FormData) {String} photoData JSON string of fields: + * - adminId: string (required) + * - caption?: string + * - altText?: string + * - sortOrder?: number + * + * @apiSuccess {Boolean} success Request status + * @apiSuccess {Object} data Updated site content + * @apiError (400) BadRequest Missing or invalid data + * @apiError (403) Forbidden Manager access required + * @apiError (404) NotFound Gallery photo not found + * @apiError (500) InternalServerError Server error + */ + router.patch( + "/gallery/:photoId", + upload.single("image"), + parsePhotoData, + siteContentCtrl.updateGalleryPhoto, + ); + + /** + * @api {delete} /site-content/gallery/:photoId Delete a gallery photo + * @apiName deleteGalleryPhoto + * @apiGroup SiteContent + * + * @apiParam (Path Params) {String} photoId Gallery photo ID + * @apiBody {String} adminId ID of the manager performing the delete + * + * @apiSuccess {Boolean} success Request status + * @apiSuccess {String} message Deletion confirmation + * @apiError (400) BadRequest Missing or invalid data + * @apiError (403) Forbidden Manager access required + * @apiError (404) NotFound Gallery photo not found + * @apiError (500) InternalServerError Server error + */ + router.delete("/gallery/:photoId", siteContentCtrl.deleteGalleryPhoto); + + return router; +} diff --git a/src/services/site-content.service.ts b/src/services/site-content.service.ts new file mode 100644 index 0000000..f704525 --- /dev/null +++ b/src/services/site-content.service.ts @@ -0,0 +1,254 @@ +import { v4 as uuidv4 } from "uuid"; +import prisma from "../db/client"; +import { ApiError } from "../utils/apiError"; +import { Prisma } from "../generated/prisma/client"; + +const PAGE_CONTENT_ID = 1; + +function toGalleryJson(gallery: GalleryPhoto[]): Prisma.InputJsonValue { + return gallery as unknown as Prisma.InputJsonValue; +} + +async function assertManager(adminId: string) { + const member = await prisma.member.findUnique({ + where: { id: adminId }, + select: { isManager: true }, + }); + + if (!member?.isManager) { + throw new ApiError("Forbidden: manager access required", 403); + } +} + +function parseGalleryPhotos(value: unknown): GalleryPhoto[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter( + (item): item is GalleryPhoto => + typeof item === "object" && + item !== null && + typeof (item as GalleryPhoto).id === "string" && + typeof (item as GalleryPhoto).imageUrl === "string" && + typeof (item as GalleryPhoto).sortOrder === "number", + ) + .sort((a, b) => a.sortOrder - b.sortOrder); +} + +function toSiteContentResponse( + page: { + heroImageUrl: string | null; + heroCaption: string | null; + heroAltText: string | null; + galleryPhotos: unknown; + }, + actions: { + key: string; + label: string | null; + url: string | null; + isVisible: boolean; + }[], +): SiteContentResponse { + return { + actions: actions.map(({ key, label, url, isVisible }) => ({ + key, + label, + url, + isVisible, + })), + hero: { + imageUrl: page.heroImageUrl, + caption: page.heroCaption, + altText: page.heroAltText, + }, + gallery: parseGalleryPhotos(page.galleryPhotos), + }; +} + +async function getPageContent() { + return prisma.sitePageContent.findUniqueOrThrow({ + where: { id: PAGE_CONTENT_ID }, + }); +} + +async function getActions() { + return prisma.siteAction.findMany({ + orderBy: { key: "asc" }, + }); +} + +async function buildSiteContentResponse(): Promise { + const [page, actions] = await Promise.all([getPageContent(), getActions()]); + return toSiteContentResponse(page, actions); +} + +export async function getSiteContent(): Promise { + return buildSiteContentResponse(); +} + +export async function updateSitePageContent( + adminId: string, + data: Omit, +) { + await assertManager(adminId); + + const page = await prisma.sitePageContent.update({ + where: { id: PAGE_CONTENT_ID }, + data: { + heroCaption: data.heroCaption, + heroAltText: data.heroAltText, + heroImageUrl: data.heroImageUrl, + updatedById: adminId, + }, + }); + + const actions = await getActions(); + return toSiteContentResponse(page, actions); +} + +export async function updateSiteAction( + adminId: string, + key: string, + data: Omit, +) { + await assertManager(adminId); + + const current = await prisma.siteAction.findUnique({ where: { key } }); + if (!current) { + throw new ApiError("Site action not found", 404); + } + + const isVisible = data.isVisible ?? current.isVisible; + const url = data.url !== undefined ? data.url : current.url; + + if (isVisible && !url) { + throw new ApiError("url is required when action is visible", 400); + } + console.log(key) + await prisma.siteAction.update({ + where: { key }, + data: { + label: data.label, + url: data.url, + isVisible: data.isVisible, + updatedById: adminId, + }, + }); + + return buildSiteContentResponse(); +} + +export async function addGalleryPhoto( + adminId: string, + data: Omit, +) { + await assertManager(adminId); + + const current = await getPageContent(); + const gallery = parseGalleryPhotos(current.galleryPhotos); + + const photo: GalleryPhoto = { + id: uuidv4(), + imageUrl: data.imageUrl, + caption: data.caption, + altText: data.altText, + sortOrder: + data.sortOrder ?? + (gallery.length > 0 + ? Math.max(...gallery.map((p) => p.sortOrder)) + 1 + : 0), + }; + + await prisma.sitePageContent.update({ + where: { id: PAGE_CONTENT_ID }, + data: { + galleryPhotos: toGalleryJson([...gallery, photo]), + updatedById: adminId, + }, + }); + + return buildSiteContentResponse(); +} + +export async function updateGalleryPhoto( + adminId: string, + photoId: string, + data: Omit, +) { + await assertManager(adminId); + + const current = await getPageContent(); + const gallery = parseGalleryPhotos(current.galleryPhotos); + const index = gallery.findIndex((photo) => photo.id === photoId); + + if (index === -1) { + throw new ApiError("Gallery photo not found", 404); + } + + const existing = gallery[index]; + gallery[index] = { + ...existing, + imageUrl: data.imageUrl ?? existing.imageUrl, + caption: + data.caption !== undefined ? (data.caption ?? undefined) : existing.caption, + altText: + data.altText !== undefined ? (data.altText ?? undefined) : existing.altText, + sortOrder: data.sortOrder ?? existing.sortOrder, + }; + + await prisma.sitePageContent.update({ + where: { id: PAGE_CONTENT_ID }, + data: { + galleryPhotos: toGalleryJson( + gallery.sort((a, b) => a.sortOrder - b.sortOrder), + ), + updatedById: adminId, + }, + }); + + return { + content: await buildSiteContentResponse(), + previousImageUrl: + data.imageUrl && data.imageUrl !== existing.imageUrl + ? existing.imageUrl + : undefined, + }; +} + +export async function deleteGalleryPhoto(adminId: string, photoId: string) { + await assertManager(adminId); + + const current = await getPageContent(); + const gallery = parseGalleryPhotos(current.galleryPhotos); + const photo = gallery.find((item) => item.id === photoId); + + if (!photo) { + throw new ApiError("Gallery photo not found", 404); + } + + await prisma.sitePageContent.update({ + where: { id: PAGE_CONTENT_ID }, + data: { + galleryPhotos: toGalleryJson(gallery.filter((item) => item.id !== photoId)), + updatedById: adminId, + }, + }); + + return photo.imageUrl; +} + +export async function getGalleryPhoto(adminId: string, photoId: string) { + await assertManager(adminId); + + const current = await getPageContent(); + const gallery = parseGalleryPhotos(current.galleryPhotos); + const photo = gallery.find((item) => item.id === photoId); + + if (!photo) { + throw new ApiError("Gallery photo not found", 404); + } + + return photo; +} diff --git a/src/types/site-content.d.ts b/src/types/site-content.d.ts new file mode 100644 index 0000000..99754bb --- /dev/null +++ b/src/types/site-content.d.ts @@ -0,0 +1,58 @@ +export {}; + +declare global { + interface GalleryPhoto { + id: string; + imageUrl: string; + caption?: string; + altText?: string; + sortOrder: number; + } + + interface SiteActionItem { + key: string; + label: string | null; + url: string | null; + isVisible: boolean; + } + + interface SiteContentResponse { + actions: SiteActionItem[]; + hero: { + imageUrl: string | null; + caption: string | null; + altText: string | null; + }; + gallery: GalleryPhoto[]; + } + + interface UpdateSitePageContentInput { + heroCaption?: string | null; + heroAltText?: string | null; + heroImageUrl?: string | null; + updatedById: string; + } + + interface UpdateSiteActionInput { + label?: string | null; + url?: string | null; + isVisible?: boolean; + updatedById: string; + } + + interface CreateGalleryPhotoInput { + imageUrl: string; + caption?: string; + altText?: string; + sortOrder?: number; + createdById: string; + } + + interface UpdateGalleryPhotoInput { + imageUrl?: string; + caption?: string | null; + altText?: string | null; + sortOrder?: number; + updatedById: string; + } +} diff --git a/src/utils/supabaseClient.ts b/src/utils/supabaseClient.ts new file mode 100644 index 0000000..0d9bbc4 --- /dev/null +++ b/src/utils/supabaseClient.ts @@ -0,0 +1,7 @@ +import { createClient } from "@supabase/supabase-js"; +import config from "../config"; + +export const supabase = createClient( + config.SUPABASE_URL, + config.SUPABASE_SERVICE_ROLE_KEY, +); diff --git a/tests/Achievement.test.ts b/tests/Achievement.test.ts index 620a32e..8c796be 100644 --- a/tests/Achievement.test.ts +++ b/tests/Achievement.test.ts @@ -10,7 +10,7 @@ import * as achievementService from "../src/services/achievement.service"; import { uploadImage, deleteImage } from "../src/utils/imageUtils"; import { ApiError } from "../src/utils/apiError"; -jest.mock("../src/app", () => ({ +jest.mock("../src/utils/supabaseClient", () => ({ supabase: { storage: { from: jest.fn(() => ({ diff --git a/tests/Project.test.ts b/tests/Project.test.ts index 6678b06..2570f4c 100644 --- a/tests/Project.test.ts +++ b/tests/Project.test.ts @@ -7,7 +7,7 @@ import { Response , Request} from 'express'; import * as imageUtils from '../src/utils/imageUtils'; -jest.mock('../src/app', () => ({ +jest.mock('../src/utils/supabaseClient', () => ({ supabase: { storage: { from: jest.fn(() => ({ diff --git a/tests/SiteContent.test.ts b/tests/SiteContent.test.ts new file mode 100644 index 0000000..fc26ebe --- /dev/null +++ b/tests/SiteContent.test.ts @@ -0,0 +1,301 @@ +import { + getSiteContent, + updateSiteContent, + updateSiteAction, + addGalleryPhoto, + deleteGalleryPhoto, +} from "../src/controllers/site-content.controller"; +import * as siteContentService from "../src/services/site-content.service"; +import { uploadImage, deleteImage } from "../src/utils/imageUtils"; +import { ApiError } from "../src/utils/apiError"; + +jest.mock("../src/utils/supabaseClient", () => ({ + supabase: { + storage: { + from: jest.fn(() => ({ + upload: jest + .fn() + .mockResolvedValue({ data: { path: "fake-path" }, error: null }), + remove: jest.fn().mockResolvedValue({ data: null, error: null }), + })), + }, + }, +})); + +jest.mock("../src/db/client", () => ({ + __esModule: true, + default: { + member: { + findUnique: jest.fn(), + }, + sitePageContent: { + findUniqueOrThrow: jest.fn(), + update: jest.fn(), + }, + siteAction: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }, +})); + +jest.mock("../src/utils/imageUtils", () => ({ + uploadImage: jest.fn(), + deleteImage: jest.fn(), +})); + +const mockedUploadImage = uploadImage as jest.Mock; +const mockedDeleteImage = deleteImage as jest.Mock; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const mockContent: SiteContentResponse = { + actions: [ + { + key: "recruitment", + isVisible: true, + url: "https://forms.example.com/recruit", + label: "Join Us", + }, + ], + hero: { + imageUrl: "https://example.com/hero.jpg", + caption: "Our team", + altText: "Group photo", + }, + gallery: [ + { + id: "photo-1", + imageUrl: "https://example.com/gallery-1.jpg", + caption: "Event 2025", + sortOrder: 0, + }, + ], +}; + +describe("getSiteContent", () => { + it("should return site content", async () => { + const req: any = {}; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + jest + .spyOn(siteContentService, "getSiteContent") + .mockResolvedValue(mockContent); + + await getSiteContent(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockContent, + }); + }); +}); + +describe("updateSiteContent", () => { + it("should update hero content for a manager", async () => { + const req: any = { + body: { + siteContentData: { + adminId: "manager-1", + heroCaption: "Our team", + heroAltText: "Group photo", + }, + }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + jest + .spyOn(siteContentService, "updateSitePageContent") + .mockResolvedValue(mockContent); + + await updateSiteContent(req, res); + + expect(siteContentService.updateSitePageContent).toHaveBeenCalledWith( + "manager-1", + { + heroCaption: "Our team", + heroAltText: "Group photo", + heroImageUrl: undefined, + }, + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("should throw when adminId is missing", async () => { + const req: any = { + body: { + siteContentData: { + heroCaption: "Our team", + }, + }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + await expect(updateSiteContent(req, res)).rejects.toThrow( + new ApiError("adminId is required", 400), + ); + }); +}); + +describe("updateSiteAction", () => { + it("should update a site action", async () => { + const req: any = { + params: { key: "recruitment" }, + body: { + actionData: { + adminId: "manager-1", + isVisible: true, + url: "https://forms.example.com/recruit", + label: "Join Us", + }, + }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + jest + .spyOn(siteContentService, "updateSiteAction") + .mockResolvedValue(mockContent); + + await updateSiteAction(req, res); + + expect(siteContentService.updateSiteAction).toHaveBeenCalledWith( + "manager-1", + "recruitment", + { + isVisible: true, + url: "https://forms.example.com/recruit", + label: "Join Us", + }, + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +describe("addGalleryPhoto", () => { + it("should add a gallery photo and return updated content", async () => { + const req: any = { + file: { + originalname: "event.png", + buffer: Buffer.from("test"), + }, + body: { + photoData: { + adminId: "manager-1", + caption: "Hackathon", + sortOrder: 1, + }, + }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockedUploadImage.mockResolvedValue("https://example.com/event.jpg"); + jest + .spyOn(siteContentService, "addGalleryPhoto") + .mockResolvedValue(mockContent); + + await addGalleryPhoto(req, res); + + expect(siteContentService.addGalleryPhoto).toHaveBeenCalledWith( + "manager-1", + { + imageUrl: "https://example.com/event.jpg", + caption: "Hackathon", + altText: undefined, + sortOrder: 1, + }, + ); + expect(res.status).toHaveBeenCalledWith(201); + }); +}); + +describe("deleteGalleryPhoto", () => { + it("should delete a gallery photo and its image", async () => { + const req: any = { + params: { photoId: "photo-1" }, + body: { adminId: "manager-1" }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + jest + .spyOn(siteContentService, "deleteGalleryPhoto") + .mockResolvedValue("https://example.com/gallery-1.jpg"); + mockedDeleteImage.mockResolvedValue(undefined); + + await deleteGalleryPhoto(req, res); + + expect(siteContentService.deleteGalleryPhoto).toHaveBeenCalledWith( + "manager-1", + "photo-1", + ); + expect(mockedDeleteImage).toHaveBeenCalledWith( + expect.anything(), + "https://example.com/gallery-1.jpg", + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +describe("site-content service guards", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should reject non-manager adminId", async () => { + const prisma = (await import("../src/db/client")).default; + + (prisma.member.findUnique as jest.Mock).mockResolvedValue({ + isManager: false, + }); + + await expect( + siteContentService.updateSiteAction("user-1", "recruitment", { + isVisible: true, + url: "https://example.com", + }), + ).rejects.toThrow(new ApiError("Forbidden: manager access required", 403)); + }); + + it("should reject visible action without URL", async () => { + const prisma = (await import("../src/db/client")).default; + + (prisma.member.findUnique as jest.Mock).mockResolvedValue({ + isManager: true, + }); + (prisma.siteAction.findUnique as jest.Mock).mockResolvedValue({ + key: "recruitment", + label: null, + url: null, + isVisible: false, + }); + + await expect( + siteContentService.updateSiteAction("manager-1", "recruitment", { + isVisible: true, + }), + ).rejects.toThrow( + new ApiError("url is required when action is visible", 400), + ); + }); +});