Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
Comment on lines +15 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Seed the built-in SiteAction keys before shipping the update-only API.

The migration creates an empty SiteAction table, but the supplied service path first does findUnique({ where: { key } }) and throws 404 when the row is missing; the stack lists updateSiteAction but no create action route/service. On a fresh database, keys such as recruitment cannot be managed until seeded out-of-band. Seed every supported key here, or change the service to upsert by key.

Example migration direction
 CREATE UNIQUE INDEX "SiteAction_key_key" ON "SiteAction"("key");
+
+-- Seed every fixed action key supported by the site-content API.
+-- Keep this list in sync with the API/types.
+INSERT INTO "SiteAction" ("key", "label", "url", "isVisible", "updatedAt")
+VALUES
+    ('recruitment', NULL, NULL, false, CURRENT_TIMESTAMP)
+ON CONFLICT ("key") DO NOTHING;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prisma/migrations/20260614203131_add_dynamic_site_feature/migration.sql`
around lines 15 - 28, The SiteAction table migration creates an empty table, but
the updateSiteAction service expects rows to exist and uses findUnique with a
key field that will return 404 if missing. To fix this, add INSERT statements at
the end of the migration to seed all built-in SiteAction keys (such as
'recruitment') with their respective labels and default values, ensuring the
update-only API can function on a fresh database without requiring out-of-band
seeding.


-- 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;
27 changes: 27 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

16 changes: 6 additions & 10 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
Harish-Naruto marked this conversation as resolved.
import config from "./config";

const app = express();
class LoggerStream {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
2 changes: 1 addition & 1 deletion src/controllers/achievement.controller.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
220 changes: 220 additions & 0 deletions src/controllers/site-content.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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<string, unknown>;
}

function parseActionData(body: Record<string, unknown>) {
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<string, unknown>;
}

function parsePhotoData(body: Record<string, unknown>) {
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<string, unknown>;
}

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);
}
Comment thread
Harish-Naruto marked this conversation as resolved.

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,
);
Comment thread
Harish-Naruto marked this conversation as resolved.
}

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",
});
};
3 changes: 1 addition & 2 deletions src/routes/achievements.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();


Expand Down
11 changes: 7 additions & 4 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -25,6 +26,8 @@ export default function routes(upload: Multer, supabase: SupabaseClient) {

router.use("/progress", progressRouter());

router.use("/site-content", siteContentRouter(upload));

return router;
}

Loading
Loading