diff --git a/backend/prisma/migrations/20260518070619_add_doctor_specialization/migration.sql b/backend/prisma/migrations/20260518070619_add_doctor_specialization/migration.sql new file mode 100644 index 0000000..8c12154 --- /dev/null +++ b/backend/prisma/migrations/20260518070619_add_doctor_specialization/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - You are about to drop the `HealthCheckCategory` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `HealthPackage` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `HealthPackageInquiry` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "HealthPackage" DROP CONSTRAINT "HealthPackage_categoryId_fkey"; + +-- DropForeignKey +ALTER TABLE "HealthPackageInquiry" DROP CONSTRAINT "HealthPackageInquiry_packageId_fkey"; + +-- DropTable +DROP TABLE "HealthCheckCategory"; + +-- DropTable +DROP TABLE "HealthPackage"; + +-- DropTable +DROP TABLE "HealthPackageInquiry"; + +-- 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") +); + +-- 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/20260518093623_add_professional_bio/migration.sql b/backend/prisma/migrations/20260518093623_add_professional_bio/migration.sql new file mode 100644 index 0000000..733006b --- /dev/null +++ b/backend/prisma/migrations/20260518093623_add_professional_bio/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Doctor" ADD COLUMN "professionalSummary" TEXT; diff --git a/backend/prisma/migrations/20260518100644_add_doctor_experience/migration.sql b/backend/prisma/migrations/20260518100644_add_doctor_experience/migration.sql new file mode 100644 index 0000000..0f8f004 --- /dev/null +++ b/backend/prisma/migrations/20260518100644_add_doctor_experience/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Doctor" ADD COLUMN "experience" INTEGER, +ADD COLUMN "focusKeyphrase" TEXT, +ADD COLUMN "metaDescription" TEXT, +ADD COLUMN "seoTitle" TEXT, +ADD COLUMN "slug" TEXT, +ADD COLUMN "tags" TEXT[]; diff --git a/backend/prisma/migrations/20260519042007_add_open_graph_fields_to_doctor/migration.sql b/backend/prisma/migrations/20260519042007_add_open_graph_fields_to_doctor/migration.sql new file mode 100644 index 0000000..def77dc --- /dev/null +++ b/backend/prisma/migrations/20260519042007_add_open_graph_fields_to_doctor/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Doctor" ADD COLUMN "ogDescription" TEXT, +ADD COLUMN "ogImage" TEXT, +ADD COLUMN "ogTitle" TEXT; diff --git a/backend/prisma/migrations/20260519062600_add_seo_table_relation/migration.sql b/backend/prisma/migrations/20260519062600_add_seo_table_relation/migration.sql new file mode 100644 index 0000000..103ca37 --- /dev/null +++ b/backend/prisma/migrations/20260519062600_add_seo_table_relation/migration.sql @@ -0,0 +1,19 @@ +-- 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"); diff --git a/backend/prisma/migrations/20260519064224_add_seo_relation_to_doctor/migration.sql b/backend/prisma/migrations/20260519064224_add_seo_relation_to_doctor/migration.sql new file mode 100644 index 0000000..1e5fdbc --- /dev/null +++ b/backend/prisma/migrations/20260519064224_add_seo_relation_to_doctor/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `focusKeyphrase` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `metaDescription` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `ogDescription` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `ogImage` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `ogTitle` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `seoTitle` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `slug` on the `Doctor` table. All the data in the column will be lost. + - You are about to drop the column `tags` on the `Doctor` table. All the data in the column will be lost. + - 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" DROP COLUMN "focusKeyphrase", +DROP COLUMN "metaDescription", +DROP COLUMN "ogDescription", +DROP COLUMN "ogImage", +DROP COLUMN "ogTitle", +DROP COLUMN "seoTitle", +DROP COLUMN "slug", +DROP COLUMN "tags", +ADD COLUMN "seoId" INTEGER; + +-- 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; diff --git a/backend/prisma/migrations/20260519115003_health_check/migration.sql b/backend/prisma/migrations/20260519115003_health_check/migration.sql new file mode 100644 index 0000000..5a8735c --- /dev/null +++ b/backend/prisma/migrations/20260519115003_health_check/migration.sql @@ -0,0 +1,66 @@ +-- CreateTable +CREATE TABLE "HealthCheckCategory" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 1000, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HealthCheckCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HealthPackage" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "price" DECIMAL(10,2), + "image" TEXT, + "discountedPrice" DECIMAL(10,2), + "inclusions" JSONB NOT NULL DEFAULT '{}', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 1000, + "categoryId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HealthPackage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HealthPackageInquiry" ( + "id" SERIAL NOT NULL, + "fullName" TEXT NOT NULL, + "mobileNumber" TEXT NOT NULL, + "email" TEXT, + "age" INTEGER, + "gender" TEXT, + "preferredDate" TIMESTAMP(3), + "message" TEXT, + "packageId" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HealthPackageInquiry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "HealthCheckCategory_name_key" ON "HealthCheckCategory"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "HealthCheckCategory_slug_key" ON "HealthCheckCategory"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "HealthPackage_slug_key" ON "HealthPackage"("slug"); + +-- AddForeignKey +ALTER TABLE "HealthPackage" ADD CONSTRAINT "HealthPackage_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "HealthCheckCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HealthPackageInquiry" ADD CONSTRAINT "HealthPackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "HealthPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f31c375..1fd0d9c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -23,14 +23,17 @@ model Doctor { name String image String? designation String? + experience Int? workingStatus String? qualification String? isActive Boolean @default(true) globalSortOrder Int @default(1000) - departments DoctorDepartment[] appointments Appointment[] - + specializations DoctorSpecialization[] + professionalSummary String? @db.Text + seoId Int? @unique + seo Seo? @relation(fields: [seoId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -220,7 +223,6 @@ model NewsImage { createdAt DateTime @default(now()) } - model HealthCheckCategory { id Int @id @default(autoincrement()) name String @unique @@ -276,3 +278,30 @@ 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..da676da 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,32 @@ export const createDoctor = async (req, res) => { isActive, globalSortOrder, departments, + experience, + professionalSummary, + seoTitle, + metaDescription, + focusKeyphrase, + slug, + tags, + specializations, + ogTitle, + ogDescription, + ogImage, } = req.body; + 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 +263,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 +296,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,6 +334,14 @@ export const updateDoctor = async (req, res) => { isActive, globalSortOrder, departments, + experience, + professionalSummary, + seoTitle, + metaDescription, + focusKeyphrase, + slug, + tags, + specializations, } = req.body; const doctor = await prisma.doctor.findUnique({where: {doctorId}}); @@ -268,15 +359,71 @@ export const updateDoctor = async (req, res) => { workingStatus, qualification, isActive, + experience: experience ? Number(experience) : null, + professionalSummary, globalSortOrder: globalSortOrder !== undefined ? Number(globalSortOrder) : undefined, }, }); + 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/components/BytescaleUploader/BytescaleUploader.tsx b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx index cc412f3..04b3f46 100644 --- a/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx +++ b/frontend/src/components/BytescaleUploader/BytescaleUploader.tsx @@ -11,7 +11,8 @@ interface BytescaleUploaderProps { | "/departments" | "/news" | "/blog" - | "/health-packages"; + | "/health-packages" + | "/doctor-og"; } export function BytescaleUploader({ diff --git a/frontend/src/pages/Doctor.tsx b/frontend/src/pages/Doctor.tsx index 74621c7..3c6c985 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 () => { @@ -221,9 +237,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 +275,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 +308,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 +649,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"} + +
+