diff --git a/backend/prisma/migrations/20260520043835_add_seo/migration.sql b/backend/prisma/migrations/20260520043835_add_seo/migration.sql new file mode 100644 index 0000000..f2918a4 --- /dev/null +++ b/backend/prisma/migrations/20260520043835_add_seo/migration.sql @@ -0,0 +1,50 @@ +/* + Warnings: + + - A unique constraint covering the columns `[seoId]` on the table `Doctor` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT, +ADD COLUMN "seoId" INTEGER; + +-- CreateTable +CREATE TABLE "DoctorSpecialization" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "doctorId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DoctorSpecialization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Seo" ( + "id" SERIAL NOT NULL, + "seoTitle" TEXT, + "metaDescription" TEXT, + "focusKeyphrase" TEXT, + "slug" TEXT, + "tags" TEXT[], + "ogTitle" TEXT, + "ogDescription" TEXT, + "ogImage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Seo_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Seo_slug_key" ON "Seo"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Doctor_seoId_key" ON "Doctor"("seoId"); + +-- AddForeignKey +ALTER TABLE "Doctor" ADD CONSTRAINT "Doctor_seoId_fkey" FOREIGN KEY ("seoId") REFERENCES "Seo"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DoctorSpecialization" ADD CONSTRAINT "DoctorSpecialization_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "Doctor"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260520044321_add_exp/migration.sql b/backend/prisma/migrations/20260520044321_add_exp/migration.sql new file mode 100644 index 0000000..932dcd9 --- /dev/null +++ b/backend/prisma/migrations/20260520044321_add_exp/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f31c375..d8ca932 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -23,11 +23,15 @@ model Doctor { name String image String? designation String? + experience Int? workingStatus String? qualification String? isActive Boolean @default(true) globalSortOrder Int @default(1000) - + specializations DoctorSpecialization[] + professionalSummary String? @db.Text + seoId Int? @unique + seo Seo? @relation(fields: [seoId], references: [id]) departments DoctorDepartment[] appointments Appointment[] @@ -276,3 +280,32 @@ model HealthPackageInquiry { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + + +model DoctorSpecialization { + id Int @id @default(autoincrement()) + name String + description String? @db.Text + doctorId Int + doctor Doctor @relation(fields: [doctorId],references: [id],onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Seo { + id Int @id @default(autoincrement()) + doctor Doctor? + + seoTitle String? + metaDescription String? @db.Text + focusKeyphrase String? + slug String? @unique + tags String[] + + ogTitle String? + ogDescription String? @db.Text + ogImage String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/backend/src/controllers/doctor.controller.js b/backend/src/controllers/doctor.controller.js index 750d3d7..99e89ad 100644 --- a/backend/src/controllers/doctor.controller.js +++ b/backend/src/controllers/doctor.controller.js @@ -9,12 +9,18 @@ export const getAllDoctors = async (req, res) => { const doctors = await prisma.doctor.findMany({ where: admin === "true" ? {} : {isActive: true}, include: { + seo: true, departments: { include: { department: true, timing: true, }, }, + specializations: { + orderBy: { + createdAt: "asc", + }, + }, }, orderBy: [{globalSortOrder: "asc"}, {name: "asc"}], }); @@ -28,7 +34,25 @@ export const getAllDoctors = async (req, res) => { workingStatus: doc.workingStatus, qualification: doc.qualification, isActive: doc.isActive, + experience: doc.experience, + professionalSummary: doc.professionalSummary, globalSortOrder: doc.globalSortOrder, + specializations: doc.specializations.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + })), + seo: { + seoTitle: doc.seo?.seoTitle ?? "", + metaDescription: doc.seo?.metaDescription ?? "", + focusKeyphrase: doc.seo?.focusKeyphrase ?? "", + slug: doc.seo?.slug ?? "", + tags: doc.seo?.tags ?? [], + + ogTitle: doc.seo?.ogTitle ?? "", + ogDescription: doc.seo?.ogDescription ?? "", + ogImage: doc.seo?.ogImage ?? "", + }, departments: doc.departments.map((d) => { const t = d.timing || {}; const timingArray = [ @@ -73,6 +97,8 @@ export const getDoctorByDoctorId = async (req, res) => { const doctor = await prisma.doctor.findUnique({ where: {doctorId}, include: { + seo: true, + specializations: true, departments: { include: { department: true, @@ -96,6 +122,24 @@ export const getDoctorByDoctorId = async (req, res) => { designation: doctor.designation, workingStatus: doctor.workingStatus, qualification: doctor.qualification, + experience: doctor.experience, + professionalSummary: doctor.professionalSummary, + seo: { + seoTitle: doctor.seo?.seoTitle ?? "", + metaDescription: doctor.seo?.metaDescription ?? "", + focusKeyphrase: doctor.seo?.focusKeyphrase ?? "", + slug: doctor.seo?.slug ?? "", + tags: doctor.seo?.tags ?? [], + ogTitle: doctor.seo?.ogTitle ?? "", + ogDescription: doctor.seo?.ogDescription ?? "", + ogImage: doctor.seo?.ogImage ?? "", + }, + specializations: + doctor.specializations?.map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + })) ?? [], departments: doctor.departments.map((d) => ({ departmentId: d.department.departmentId, departmentName: d.department.name, @@ -184,7 +228,48 @@ export const createDoctor = async (req, res) => { isActive, globalSortOrder, departments, + experience, + professionalSummary, + seoTitle, + metaDescription, + focusKeyphrase, + slug, + tags, + specializations, + ogTitle, + ogDescription, + ogImage, } = req.body; + const messages = []; + + if (!doctorId) messages.push("Doctor ID is required"); + if (!name?.trim()) messages.push("Doctor name is required"); + if (!designation?.trim()) messages.push("Designation is required"); + if (!qualification?.trim()) messages.push("Qualification is required"); + if (!departments || departments.length === 0) { + messages.push("At least one department is required"); + } + + if (messages.length > 0) { + return res.status(400).json({ + success: false, + message: messages.join(", "), + }); + } + const seo = await prisma.seo.create({ + data: { + seoTitle, + metaDescription, + focusKeyphrase, + slug: slug ? slug : null, + tags: tags || [], + + // Open Graph + ogTitle, + ogDescription, + ogImage, + }, + }); const doctor = await prisma.doctor.create({ data: { @@ -194,6 +279,9 @@ export const createDoctor = async (req, res) => { designation, workingStatus, qualification, + experience: experience ? Number(experience) : null, + professionalSummary, + seoId: seo.id, isActive: isActive !== undefined ? isActive : true, globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : 0, @@ -224,6 +312,17 @@ export const createDoctor = async (req, res) => { }); } } + if (specializations?.length) { + await prisma.doctorSpecialization.createMany({ + data: specializations + .filter((item) => item.name?.trim()) + .map((item) => ({ + name: item.name.trim(), + description: item.description?.trim() || null, + doctorId: doctor.id, + })), + }); + } res.status(201).json({ success: true, @@ -251,8 +350,31 @@ export const updateDoctor = async (req, res) => { isActive, globalSortOrder, departments, + experience, + professionalSummary, + seoTitle, + metaDescription, + focusKeyphrase, + slug, + tags, + specializations, } = req.body; + const messages = []; + if (!doctorId) messages.push("Doctor ID is required"); + if (!name?.trim()) messages.push("Doctor name is required"); + if (!qualification?.trim()) messages.push("Qualification is required"); + if (!designation?.trim()) messages.push("Designation is required"); + if (!departments || departments.length === 0) { + messages.push("At least one department is required"); + } + + if (messages.length > 0) { + return res.status(400).json({ + success: false, + message: messages.join(", "), + }); + } const doctor = await prisma.doctor.findUnique({where: {doctorId}}); if (!doctor) return res @@ -268,15 +390,139 @@ export const updateDoctor = async (req, res) => { workingStatus, qualification, isActive, + experience: experience ? Number(experience) : null, + professionalSummary, globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined, }, }); + const existingDepartments = await prisma.doctorDepartment.findMany({ + where: { + doctorId: doctor.id, + }, + include: { + timing: true, + }, + }); + + for (const dep of departments) { + const department = await prisma.department.findUnique({ + where: {departmentId: dep.departmentId}, + }); + + if (!department) continue; + + const existing = existingDepartments.find( + (d) => d.departmentId === department.id, + ); + + const newSortOrder = + dep.sortOrder !== undefined ? Number(dep.sortOrder) : 0; + + const isSameDepartment = existing && existing.sortOrder === newSortOrder; + + const isSameTiming = + JSON.stringify(existing?.timing || {}) === + JSON.stringify(dep.timing || {}); + + if (isSameDepartment && isSameTiming) { + continue; + } + + let doctorDepartment = existing; + + if (!existing) { + doctorDepartment = await prisma.doctorDepartment.create({ + data: { + doctorId: doctor.id, + departmentId: department.id, + sortOrder: newSortOrder, + }, + }); + } else if (existing.sortOrder !== newSortOrder) { + doctorDepartment = await prisma.doctorDepartment.update({ + where: {id: existing.id}, + data: { + sortOrder: newSortOrder, + }, + }); + } + + if (dep.timing) { + if (existing?.timing) { + await prisma.doctorTiming.update({ + where: {id: existing.timing.id}, + data: dep.timing, + }); + } else { + await prisma.doctorTiming.create({ + data: { + doctorDepartmentId: doctorDepartment.id, + ...dep.timing, + }, + }); + } + } + } + if (doctor.seoId) { + await prisma.seo.update({ + where: { + id: doctor.seoId, + }, + data: { + seoTitle, + metaDescription, + focusKeyphrase, + slug: slug ? slug : null, + tags: tags || [], + }, + }); + } else { + const seo = await prisma.seo.create({ + data: { + seoTitle, + metaDescription, + focusKeyphrase, + slug: slug ? slug : null, + tags: tags || [], + }, + }); + + await prisma.doctor.update({ + where: { + id: doctor.id, + }, + data: { + seoId: seo.id, + }, + }); + } const hasTimingData = departments?.some( (dep) => dep.timing && Object.keys(dep.timing).length > 0, ); + // Update Specializations + if (Array.isArray(specializations)) { + await prisma.doctorSpecialization.deleteMany({ + where: { + doctorId: doctor.id, + }, + }); + + if (specializations.length) { + await prisma.doctorSpecialization.createMany({ + data: specializations + .filter((item) => item.name?.trim()) + .map((item) => ({ + name: item.name.trim(), + description: item.description?.trim() || null, + doctorId: doctor.id, + })), + }); + } + } + if (departments && Array.isArray(departments) && hasTimingData) { const oldRelations = await prisma.doctorDepartment.findMany({ where: {doctorId: doctor.id}, diff --git a/frontend/src/pages/Doctor.tsx b/frontend/src/pages/Doctor.tsx index 74621c7..df7a7ab 100644 --- a/frontend/src/pages/Doctor.tsx +++ b/frontend/src/pages/Doctor.tsx @@ -41,6 +41,7 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; interface Department { departmentId: string; @@ -83,6 +84,21 @@ export default function DoctorPage() { isActive: true, globalSortOrder: 0, departments: [], + professionalSummary: "", + seoTitle: "", + metaDescription: "", + ogTitle: "", + ogDescription: "", + ogImage: "", + specializations: [ + { + name: "", + description: "", + }, + ], + focusKeyphrase: "", + slug: "", + tags: [], }); const fetchAll = useCallback(async () => { @@ -151,8 +167,16 @@ export default function DoctorPage() { const currentItems = filteredDoctors.slice(indexOfFirstItem, indexOfLastItem); function handleChange(e: any) { - const value = + let value = e.target.type === "number" ? Number(e.target.value) : e.target.value; + if (e.target.name === "slug") { + value = value + .toLowerCase() + .replace(/\s+/g, "-") // replace spaces with - + .replace(/[^\w-]+/g, "") // remove special chars + .replace(/--+/g, "-"); // remove duplicate - + } + setForm({ ...form, [e.target.name]: value }); } @@ -221,9 +245,25 @@ export default function DoctorPage() { designation: "", workingStatus: "", qualification: "", + experience: "", + professionalSummary: "", isActive: true, globalSortOrder: 0, + specializations: [ + { + name: "", + description: "", + }, + ], departments: [], + seoTitle: "", + metaDescription: "", + focusKeyphrase: "", + slug: "", + tags: [], + ogTitle: "", + ogDescription: "", + ogImage: "", }); setOpenModal(true); } @@ -243,6 +283,27 @@ export default function DoctorPage() { qualification: doc.qualification, isActive: doc.isActive ?? true, globalSortOrder: doc.globalSortOrder ?? 0, + experience: doc.experience || "", + professionalSummary: doc.professionalSummary || "", + seoTitle: doc.seo?.seoTitle || "", + metaDescription: doc.seo?.metaDescription || "", + focusKeyphrase: doc.seo?.focusKeyphrase || "", + slug: doc.seo?.slug || "", + tags: doc.seo?.tags || [], + ogTitle: doc.seo?.ogTitle || "", + ogDescription: doc.seo?.ogDescription || "", + ogImage: doc.seo?.ogImage || "", + specializations: doc.specializations?.length + ? doc.specializations.map((item: any) => ({ + name: item.name || "", + description: item.description || "", + })) + : [ + { + name: "", + description: "", + }, + ], departments: timingData.map((d: any) => ({ departmentId: d.departmentId, sortOrder: d.deptSortOrder ?? 0, @@ -255,6 +316,8 @@ export default function DoctorPage() { } } + console.log("Current form state:", form); // Debug log to check form state + async function handleSubmit() { try { if (editing) { @@ -594,6 +657,24 @@ export default function DoctorPage() { className="text-base" /> +
+ Enter total years of professional experience +
+Specializations
+ + ++ No specializations added +
+ ) : ( ++ Specialization {index + 1} +
+ + +SEO Settings
+ ++ Title shown in Google search results +
++ Open Graph (Social Preview) +
+ ++ If empty, SEO title will be used +
++ Main keyword people may search in Google +
++ URL: + + {" "} + /doctors/ + {form.slug || "doctor-slug"} + +
+