From f4c757248bbf5dc2c6a6d9541f6f441cdd9338ac Mon Sep 17 00:00:00 2001 From: athulrajtflycatchtech Date: Fri, 28 Aug 2026 16:22:50 +0530 Subject: [PATCH 1/2] feat(services: devops & application services): add DevOps Consult, Infrastructure Management, Application Development, Application Modernization, Mobile Application Development, User Centered Design, and Overview sections; implement forms, lists, and API integration for enhanced content administration --- .../scripts/check-contract-drift.mjs | 7 + .../scripts/generate-client.mjs | 50 +++ .../src/components/AdminShell.tsx | 69 +++ .../src/components/LandingForm.tsx | 422 ++++++++++++++++++ .../src/components/LandingList.tsx | 211 +++++++++ apps/Administration-FE/src/i18n/en.json | 366 ++++++++++++++- apps/Administration-FE/src/lib/admin-api.ts | 93 ++++ .../Administration-FE/src/lib/admin-routes.ts | 28 ++ .../src/lib/landing-sections.ts | 141 ++++++ .../pages/admin/application-development.astro | 4 + .../admin/application-modernization.astro | 4 + .../src/pages/admin/devops-consult.astro | 4 + .../admin/infrastructure-management.astro | 4 + .../mobile-application-development.astro | 4 + .../src/pages/admin/overview.astro | 4 + .../pages/admin/user-centered-design.astro | 4 + .../tests/unit/admin-routes.test.ts | 24 + .../versions/024_service_landing_pages.py | 114 +++++ .../src/flycatch_api/api/landing_pages.py | 132 ++++++ .../src/flycatch_api/api/landing_routers.py | 124 +++++ apps/Backend/src/flycatch_api/main.py | 15 + .../src/flycatch_api/models/__init__.py | 24 +- .../src/flycatch_api/models/landing_pages.py | 106 +++++ .../flycatch_api/schemas/admin_landings.py | 253 +++++++++++ .../flycatch_api/schemas/public_landings.py | 95 ++++ .../flycatch_api/services/landing_catalog.py | 291 ++++++++++++ .../flycatch_api/services/landing_sections.py | 103 +++++ .../src/flycatch_api/services/role_service.py | 11 + .../tests/contract/test_openapi_parity.py | 14 + .../tests/integration/test_admin_landings.py | 110 +++++ .../contracts/admin-devops-consult.v1.yaml | 242 ++++++++++ .../contracts/public-devops-consult.v1.yaml | 106 +++++ specs/016-admin-devops-consult/data-model.md | 31 ++ specs/016-admin-devops-consult/quickstart.md | 15 + specs/016-admin-devops-consult/spec.md | 51 +++ .../admin-infrastructure-management.v1.yaml | 230 ++++++++++ .../public-infrastructure-management.v1.yaml | 100 +++++ .../data-model.md | 27 ++ .../quickstart.md | 15 + .../spec.md | 51 +++ .../admin-application-development.v1.yaml | 244 ++++++++++ .../public-application-development.v1.yaml | 107 +++++ .../data-model.md | 34 ++ .../quickstart.md | 15 + .../018-admin-application-development/spec.md | 51 +++ .../admin-application-modernization.v1.yaml | 242 ++++++++++ .../public-application-modernization.v1.yaml | 106 +++++ .../data-model.md | 31 ++ .../quickstart.md | 15 + .../spec.md | 51 +++ ...min-mobile-application-development.v1.yaml | 244 ++++++++++ ...lic-mobile-application-development.v1.yaml | 107 +++++ .../data-model.md | 32 ++ .../quickstart.md | 15 + .../spec.md | 51 +++ .../admin-user-centered-design.v1.yaml | 242 ++++++++++ .../public-user-centered-design.v1.yaml | 106 +++++ .../data-model.md | 31 ++ .../quickstart.md | 15 + specs/021-admin-user-centered-design/spec.md | 51 +++ .../contracts/admin-overview.v1.yaml | 222 +++++++++ .../contracts/public-overview.v1.yaml | 96 ++++ specs/022-admin-overview/data-model.md | 25 ++ specs/022-admin-overview/quickstart.md | 15 + specs/022-admin-overview/spec.md | 51 +++ 65 files changed, 5893 insertions(+), 5 deletions(-) create mode 100644 apps/Administration-FE/src/components/LandingForm.tsx create mode 100644 apps/Administration-FE/src/components/LandingList.tsx create mode 100644 apps/Administration-FE/src/lib/landing-sections.ts create mode 100644 apps/Administration-FE/src/pages/admin/application-development.astro create mode 100644 apps/Administration-FE/src/pages/admin/application-modernization.astro create mode 100644 apps/Administration-FE/src/pages/admin/devops-consult.astro create mode 100644 apps/Administration-FE/src/pages/admin/infrastructure-management.astro create mode 100644 apps/Administration-FE/src/pages/admin/mobile-application-development.astro create mode 100644 apps/Administration-FE/src/pages/admin/overview.astro create mode 100644 apps/Administration-FE/src/pages/admin/user-centered-design.astro create mode 100644 apps/Backend/alembic/versions/024_service_landing_pages.py create mode 100644 apps/Backend/src/flycatch_api/api/landing_pages.py create mode 100644 apps/Backend/src/flycatch_api/api/landing_routers.py create mode 100644 apps/Backend/src/flycatch_api/models/landing_pages.py create mode 100644 apps/Backend/src/flycatch_api/schemas/admin_landings.py create mode 100644 apps/Backend/src/flycatch_api/schemas/public_landings.py create mode 100644 apps/Backend/src/flycatch_api/services/landing_catalog.py create mode 100644 apps/Backend/src/flycatch_api/services/landing_sections.py create mode 100644 apps/Backend/tests/integration/test_admin_landings.py create mode 100644 specs/016-admin-devops-consult/contracts/admin-devops-consult.v1.yaml create mode 100644 specs/016-admin-devops-consult/contracts/public-devops-consult.v1.yaml create mode 100644 specs/016-admin-devops-consult/data-model.md create mode 100644 specs/016-admin-devops-consult/quickstart.md create mode 100644 specs/016-admin-devops-consult/spec.md create mode 100644 specs/017-admin-infrastructure-management/contracts/admin-infrastructure-management.v1.yaml create mode 100644 specs/017-admin-infrastructure-management/contracts/public-infrastructure-management.v1.yaml create mode 100644 specs/017-admin-infrastructure-management/data-model.md create mode 100644 specs/017-admin-infrastructure-management/quickstart.md create mode 100644 specs/017-admin-infrastructure-management/spec.md create mode 100644 specs/018-admin-application-development/contracts/admin-application-development.v1.yaml create mode 100644 specs/018-admin-application-development/contracts/public-application-development.v1.yaml create mode 100644 specs/018-admin-application-development/data-model.md create mode 100644 specs/018-admin-application-development/quickstart.md create mode 100644 specs/018-admin-application-development/spec.md create mode 100644 specs/019-admin-application-modernization/contracts/admin-application-modernization.v1.yaml create mode 100644 specs/019-admin-application-modernization/contracts/public-application-modernization.v1.yaml create mode 100644 specs/019-admin-application-modernization/data-model.md create mode 100644 specs/019-admin-application-modernization/quickstart.md create mode 100644 specs/019-admin-application-modernization/spec.md create mode 100644 specs/020-admin-mobile-application-development/contracts/admin-mobile-application-development.v1.yaml create mode 100644 specs/020-admin-mobile-application-development/contracts/public-mobile-application-development.v1.yaml create mode 100644 specs/020-admin-mobile-application-development/data-model.md create mode 100644 specs/020-admin-mobile-application-development/quickstart.md create mode 100644 specs/020-admin-mobile-application-development/spec.md create mode 100644 specs/021-admin-user-centered-design/contracts/admin-user-centered-design.v1.yaml create mode 100644 specs/021-admin-user-centered-design/contracts/public-user-centered-design.v1.yaml create mode 100644 specs/021-admin-user-centered-design/data-model.md create mode 100644 specs/021-admin-user-centered-design/quickstart.md create mode 100644 specs/021-admin-user-centered-design/spec.md create mode 100644 specs/022-admin-overview/contracts/admin-overview.v1.yaml create mode 100644 specs/022-admin-overview/contracts/public-overview.v1.yaml create mode 100644 specs/022-admin-overview/data-model.md create mode 100644 specs/022-admin-overview/quickstart.md create mode 100644 specs/022-admin-overview/spec.md diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index b14fde0..61c1e4d 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -23,6 +23,13 @@ const required = [ 'admin-cloud-services.v1.ts', 'admin-data-analytics.v1.ts', 'admin-digital-transformation.v1.ts', + 'admin-devops-consult.v1.ts', + 'admin-infrastructure-management.v1.ts', + 'admin-application-development.v1.ts', + 'admin-application-modernization.v1.ts', + 'admin-mobile-application-development.v1.ts', + 'admin-user-centered-design.v1.ts', + 'admin-overview.v1.ts', ]; let failed = false; diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index 89dd09a..2302f96 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -30,6 +30,28 @@ const digitalTransformationContractsDir = join( repoRoot, 'specs/015-admin-digital-transformation/contracts', ); +const devopsConsultContractsDir = join(repoRoot, 'specs/016-admin-devops-consult/contracts'); +const infrastructureManagementContractsDir = join( + repoRoot, + 'specs/017-admin-infrastructure-management/contracts', +); +const applicationDevelopmentContractsDir = join( + repoRoot, + 'specs/018-admin-application-development/contracts', +); +const applicationModernizationContractsDir = join( + repoRoot, + 'specs/019-admin-application-modernization/contracts', +); +const mobileApplicationDevelopmentContractsDir = join( + repoRoot, + 'specs/020-admin-mobile-application-development/contracts', +); +const userCenteredDesignContractsDir = join( + repoRoot, + 'specs/021-admin-user-centered-design/contracts', +); +const overviewContractsDir = join(repoRoot, 'specs/022-admin-overview/contracts'); const contractSets = [ { @@ -90,6 +112,34 @@ const contractSets = [ dir: digitalTransformationContractsDir, files: ['admin-digital-transformation.v1.yaml'], }, + { + dir: devopsConsultContractsDir, + files: ['admin-devops-consult.v1.yaml'], + }, + { + dir: infrastructureManagementContractsDir, + files: ['admin-infrastructure-management.v1.yaml'], + }, + { + dir: applicationDevelopmentContractsDir, + files: ['admin-application-development.v1.yaml'], + }, + { + dir: applicationModernizationContractsDir, + files: ['admin-application-modernization.v1.yaml'], + }, + { + dir: mobileApplicationDevelopmentContractsDir, + files: ['admin-mobile-application-development.v1.yaml'], + }, + { + dir: userCenteredDesignContractsDir, + files: ['admin-user-centered-design.v1.yaml'], + }, + { + dir: overviewContractsDir, + files: ['admin-overview.v1.yaml'], + }, ]; mkdirSync(outDir, { recursive: true }); diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index 8675018..6b62367 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -55,6 +55,9 @@ import DataAnalyticsList from './DataAnalyticsList'; import DataAnalyticsForm from './DataAnalyticsForm'; import DigitalTransformationList from './DigitalTransformationList'; import DigitalTransformationForm from './DigitalTransformationForm'; +import LandingList from './LandingList'; +import LandingForm from './LandingForm'; +import { LANDING_SECTIONS, isLandingFormView, landingByView } from '../lib/landing-sections'; type View = AdminView; @@ -92,6 +95,7 @@ function applyRoute( setEditingCloudServiceId: (id: string | null) => void; setEditingDataAnalyticId: (id: string | null) => void; setEditingDigitalTransformationId: (id: string | null) => void; + setEditingLandingId: (id: string | null) => void; }, ) { setters.setView(route.view); @@ -121,6 +125,7 @@ function applyRoute( setters.setEditingDigitalTransformationId( route.view === 'digital_transformation_form' ? route.editingId : null, ); + setters.setEditingLandingId(isLandingFormView(route.view) ? route.editingId : null); } export default function AdminShell() { @@ -205,6 +210,10 @@ export default function AdminShell() { const route = readAdminLocation(); return route.view === 'digital_transformation_form' ? route.editingId : null; }); + const [editingLandingId, setEditingLandingId] = useState(() => { + const route = readAdminLocation(); + return isLandingFormView(route.view) ? route.editingId : null; + }); const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [message, setMessage] = useState(null); @@ -261,6 +270,7 @@ export default function AdminShell() { setEditingCloudServiceId, setEditingDataAnalyticId, setEditingDigitalTransformationId, + setEditingLandingId, }); }, []); @@ -358,6 +368,11 @@ export default function AdminShell() { document.title = t('admin.workspace.digital_transformation'); return; } + const landing = landingByView(view); + if (landing) { + document.title = t(`admin.workspace.${landing.resource}`); + return; + } if (view === 'blogs' || view === 'blog_form') { document.title = t('admin.workspace.blogs'); return; @@ -574,6 +589,15 @@ export default function AdminShell() { const canPublishCloudServices = hasPermission(session, 'cloud_services.publish'); const canPublishDataAnalytics = hasPermission(session, 'data_analytics.publish'); const canPublishDigitalTransformation = hasPermission(session, 'digital_transformation.publish'); + const canReadLandings = Object.fromEntries( + LANDING_SECTIONS.map((section) => [section.resource, canReadResource(session, section.resource)]), + ); + const canPublishLandings = Object.fromEntries( + LANDING_SECTIONS.map((section) => [ + section.resource, + hasPermission(session, `${section.resource}.publish`), + ]), + ); const canManageRoles = canManageRolesFrom(session); const settingsCurrent = view === 'roles' || view === 'role_form'; const initial = session.email.slice(0, 1).toUpperCase(); @@ -607,6 +631,7 @@ export default function AdminShell() { canReadCloudServices || canReadDataAnalytics || canReadDigitalTransformation || + LANDING_SECTIONS.some((section) => canReadLandings[section.resource]) || canReadBlogs || canReadCaseStudies || canReadIndustries || @@ -787,6 +812,25 @@ export default function AdminShell() { )} + {LANDING_SECTIONS.map( + (section) => + canReadLandings[section.resource] && ( +
  • + + onNavClick(event, adminListHref(section.listView as AdminView)) + } + > + {t(`admin.workspace.${section.resource}`)} + +
  • + ), + )} {canReadBlogs && (
  • )} + {LANDING_SECTIONS.map((section) => + view === section.listView ? ( + openForm(section.listView as AdminView, null)} + onEdit={(id) => openForm(section.listView as AdminView, id)} + /> + ) : view === section.formView ? ( + openList(section.listView as AdminView)} + onSaved={() => + afterListSave( + () => openList(section.listView as AdminView), + t(`${section.ns}.saved`), + ) + } + /> + ) : null, + )} {view === 'blogs' && ( void; + onSaved: () => void; +} + +type AccordionDraft = { key: string; title: string; contents: string; order: number }; + +function nextKey(): string { + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function emptyAccordion(): AccordionDraft { + return { key: nextKey(), title: '', contents: '', order: 0 }; +} + +async function hydrateAccordion(rows: { title: string; contents: string; order: number }[]) { + if (!rows.length) return [emptyAccordion()]; + return Promise.all( + rows.map(async (row) => ({ + key: nextKey(), + title: row.title, + contents: await hydrateRichText(row.contents), + order: row.order, + })), + ); +} + +export default function LandingForm({ section, entryId, canPublish, onCancel, onSaved }: Props) { + const ns = section.ns; + const [bannerTitle, setBannerTitle] = useState(''); + const [bannerKey, setBannerKey] = useState(null); + const [bannerFile, setBannerFile] = useState(null); + const [introductionTitle, setIntroductionTitle] = useState(''); + const [introductionFirst, setIntroductionFirst] = useState(''); + const [introductionSecond, setIntroductionSecond] = useState(''); + const [introductionThird, setIntroductionThird] = useState(''); + const [accordion, setAccordion] = useState([emptyAccordion()]); + const [experienceTitle, setExperienceTitle] = useState(''); + const [experienceAccordion, setExperienceAccordion] = useState([emptyAccordion()]); + const [experienceKey, setExperienceKey] = useState(null); + const [experienceFile, setExperienceFile] = useState(null); + const [experienceDescription, setExperienceDescription] = useState(''); + const [offeringKey, setOfferingKey] = useState(null); + const [offeringFile, setOfferingFile] = useState(null); + const [offeringTitle, setOfferingTitle] = useState(''); + const [offeringDescription, setOfferingDescription] = useState(''); + const [faqTitle, setFaqTitle] = useState(''); + const [faqDescription, setFaqDescription] = useState(''); + const [faqAccordion, setFaqAccordion] = useState([emptyAccordion()]); + const [seo, setSeo] = useState(emptySeo); + const [seoImageFile, setSeoImageFile] = useState(null); + const [status, setStatus] = useState<'draft' | 'publish'>('draft'); + const [error, setError] = useState(null); + const [ready, setReady] = useState(!entryId); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!entryId) return; + getLanding(section.path, entryId) + .then(async (item) => { + setBannerTitle(item.banner_title); + setBannerKey(item.banner_image_key); + setIntroductionTitle(item.introduction_title); + setIntroductionFirst(item.introduction_first_paragraph); + setIntroductionSecond(item.introduction_second_paragraph); + setIntroductionThird(item.introduction_third_paragraph || ''); + setAccordion(await hydrateAccordion(item.accordion || [])); + setExperienceTitle(item.experience_title || ''); + setExperienceAccordion(await hydrateAccordion(item.experience_accordion || [])); + setExperienceKey(item.experience_image_key || null); + setExperienceDescription(await hydrateRichText(item.experience_description || '')); + setOfferingKey(item.offering_image_key || null); + setOfferingTitle(item.offering_title || ''); + setOfferingDescription(await hydrateRichText(item.offering_description || '')); + setFaqTitle(item.faq_title || ''); + setFaqDescription(item.faq_description || ''); + setFaqAccordion(await hydrateAccordion(item.faq_accordion || [])); + setSeo(seoValue(item.seo)); + setStatus(item.status); + setReady(true); + }) + .catch(() => { + setError(t('admin.workspace.request_failed')); + setReady(true); + }); + }, [entryId, section.path]); + + async function persist(nextStatus: 'draft' | 'publish') { + setError(null); + setSaving(true); + try { + const nextBanner = bannerFile ? (await uploadMedia(bannerFile)).key : bannerKey; + const nextExperience = experienceFile ? (await uploadMedia(experienceFile)).key : experienceKey; + const nextOffering = offeringFile ? (await uploadMedia(offeringFile)).key : offeringKey; + const nextSeoImage = seoImageFile ? (await uploadMedia(seoImageFile)).key : seo.image_key; + const payload: LandingWritePayload = { + banner_title: bannerTitle.trim(), + banner_image_key: nextBanner, + introduction_title: introductionTitle.trim(), + introduction_first_paragraph: introductionFirst.trim(), + introduction_second_paragraph: introductionSecond.trim(), + seo: { ...seo, image_key: nextSeoImage }, + status: nextStatus, + }; + if (section.hasThirdIntro) { + payload.introduction_third_paragraph = introductionThird.trim(); + } + if (section.hasAccordion) { + payload.accordion = accordion.map((item, index) => ({ + title: item.title.trim(), + contents: persistRichText(item.contents), + order: index, + })); + } + if (section.hasExperience) { + payload.experience_title = experienceTitle.trim(); + payload.experience_accordion = experienceAccordion.map((item, index) => ({ + title: item.title.trim(), + contents: persistRichText(item.contents), + order: index, + })); + payload.experience_image_key = nextExperience; + payload.experience_description = persistRichText(experienceDescription); + } + if (section.hasOffering) { + payload.offering_image_key = nextOffering; + payload.offering_title = offeringTitle.trim(); + payload.offering_description = persistRichText(offeringDescription); + } + if (section.hasFaq) { + payload.faq_title = faqTitle.trim(); + payload.faq_description = faqDescription.trim(); + payload.faq_accordion = faqAccordion.map((item, index) => ({ + title: item.title.trim(), + contents: persistRichText(item.contents), + order: index, + })); + } + if (entryId) await updateLanding(section.path, entryId, payload); + else await createLanding(section.path, payload); + onSaved(); + } catch (caught) { + setError(apiErrorMessage(caught)); + } finally { + setSaving(false); + } + } + + function accordionEditor( + items: AccordionDraft[], + setItems: (updater: (rows: AccordionDraft[]) => AccordionDraft[]) => void, + titleKey: string, + idPrefix: string, + ) { + return ( + item.title.trim() || `${t(`${ns}.item`)} ${index + 1}`} + onAdd={() => setItems((rows) => [...rows, emptyAccordion()])} + onRemove={(key) => setItems((rows) => rows.filter((row) => row.key !== key))} + > + {(item, index) => ( + <> + + + setItems((rows) => + rows.map((row, rowIndex) => + rowIndex === index ? { ...row, contents: value } : row, + ), + ) + } + /> + + )} + + ); + } + + if (!ready) { + return ( +
    +

    +

    +
    + ); + } + + return ( +
    +
    +

    {entryId ? t(`${ns}.edit`) : t(`${ns}.add`)}

    +
    +
    { + event.preventDefault(); + persist(status).catch(() => undefined); + }} + > + + + + +