From faefd9909e9eea894e4ecf3959a46128a4ed13a0 Mon Sep 17 00:00:00 2001 From: Manuel Marcigliano Date: Mon, 28 Jul 2025 20:19:32 +0200 Subject: [PATCH 1/3] Added an .env Variable for the CRON Job execution feat(maintenance): add cron schedules, maintenance lock and /maintenance/sync API - Introduce REMOVE_EXPIRED_DRAFTS_SCHEDULE and SYNC_GITBOOK_REQUIREMENTS_SCHEDULE env vars - Wire up cron jobs to use the configured schedules and guard with a shared lock - Implement acquireLock/releaseLock in shared/maintenanceLock.ts - Add new Express route POST /maintenance/sync with GitHub-authenticated middleware - Compose maintenance tasks (removeExpiredDrafts + syncGitBookBBRequirements) and proper success/error responses - Add tests for maintenanceRoutes (unauthorized, happy path, concurrent lock) - Update server.ts and config/index.ts accordingly --- backend/.env.example | 6 +++ backend/__tests__/gitBookApi.test.ts | 26 +++++++-- backend/__tests__/maintenanceRoutes.test.ts | 60 +++++++++++++++++++++ backend/server.ts | 2 + backend/src/config/index.ts | 4 +- backend/src/cronJobs/index.ts | 15 ++++++ backend/src/routes/maintenanceRoutes.ts | 38 +++++++++++++ backend/src/shared/maintenanceLock.ts | 14 +++++ 8 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 backend/__tests__/maintenanceRoutes.test.ts create mode 100644 backend/src/routes/maintenanceRoutes.ts create mode 100644 backend/src/shared/maintenanceLock.ts diff --git a/backend/.env.example b/backend/.env.example index e8f8d2bb..388c2faa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -48,3 +48,9 @@ SMTP_USER=user SMTP_PASSWORD=password EMAIL_SENDER=no-reply@example.com + +# Cron Schedules (uses standard cron syntax) +# Default for both is '0 3 * * 0' (every Sunday at 3:00 AM) +# Every day at midnight would be '0 0 * * *' +REMOVE_EXPIRED_DRAFTS_SCHEDULE=0 3 * * 0 +SYNC_GITBOOK_REQUIREMENTS_SCHEDULE=0 3 * * 0 diff --git a/backend/__tests__/gitBookApi.test.ts b/backend/__tests__/gitBookApi.test.ts index 6ed6fde8..42d2b4b0 100644 --- a/backend/__tests__/gitBookApi.test.ts +++ b/backend/__tests__/gitBookApi.test.ts @@ -37,7 +37,11 @@ describe('GitBook API', () => { // Process the data const contentManager = new GitBookPageContentManager(); - const result = contentManager.extractFunctionalRequirements(testData); + const result = contentManager.extractFunctionalRequirements( + testData, + 'functional-requirements', + 'test-bb-key' + ); if ('error' in result) { throw result.error; } @@ -62,7 +66,11 @@ describe('GitBook API', () => { // Process the data const contentManager = new GitBookPageContentManager(); - const result = contentManager.extractFunctionalRequirements(testData); + const result = contentManager.extractFunctionalRequirements( + testData, + 'functional-requirements', + 'some_key' + ); if ('error' in result) { throw result.error; } @@ -105,7 +113,12 @@ describe('GitBook API', () => { // Process the data const contentManager = new GitBookPageContentManager(); - const result = contentManager.extractCrossCuttingRequirements(testData, null); + const result = contentManager.extractCrossCuttingRequirements( + testData, + null, + 'cross-cutting-requirements', + 'test-bb-key' + ); if ('error' in result) { throw result.error; } @@ -130,7 +143,12 @@ describe('GitBook API', () => { // Process the data const contentManager = new GitBookPageContentManager(); - const result = contentManager.extractCrossCuttingRequirements(testData, null); + const result = contentManager.extractCrossCuttingRequirements( + testData, + null, + 'cross-cutting-requirements', + 'test-bb-key' + ); if ('error' in result) { throw result.error; } diff --git a/backend/__tests__/maintenanceRoutes.test.ts b/backend/__tests__/maintenanceRoutes.test.ts new file mode 100644 index 00000000..b96ac062 --- /dev/null +++ b/backend/__tests__/maintenanceRoutes.test.ts @@ -0,0 +1,60 @@ +/// +import chai, { expect } from 'chai'; +import request from 'supertest'; +import sinon from 'sinon'; +import jwt from 'jsonwebtoken'; +import app from '../server'; +import * as removeExpired from '../src/cronJobs/removeExpiredDrafts'; +import * as syncGitBook from '../src/cronJobs/syncGitBookBBRequirements'; +import * as lock from '../src/shared/maintenanceLock'; +import { appConfig } from '../src/config'; + +describe('Maintenance API', () => { + let token: string; + + before(() => { + // Create a valid GitHub token + token = jwt.sign({ user: 'test' }, appConfig.gitHub.jwtSecret); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should reject unauthorized requests', async () => { + const res = await request(app).post('/maintenance/sync'); + expect(res.status).to.equal(401); + expect(res.body).to.have.property('message', 'User not authorized'); + }); + + it('should execute maintenance tasks when authenticated', async () => { + const removeStub = sinon.stub(removeExpired, 'removeSensitiveDataFromExpiredDrafts').resolves(); + const syncStub = sinon.stub(syncGitBook, 'default').resolves(); + + const res = await request(app) + .post('/maintenance/sync') + .set('Authorization', `Bearer ${token}`); + + expect(removeStub.calledOnce).to.be.true; + expect(syncStub.calledOnce).to.be.true; + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ + success: true, + message: 'Maintenance tasks completed successfully' + }); + }); + + it('should prevent concurrent executions', async () => { + sinon.stub(lock, 'acquireLock').returns(false); + + const res = await request(app) + .post('/maintenance/sync') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).to.equal(409); + expect(res.body).to.deep.equal({ + success: false, + message: 'Maintenance is already in progress' + }); + }); +}); diff --git a/backend/server.ts b/backend/server.ts index b37a312d..f9b48ba3 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -20,6 +20,7 @@ import buildReportRoutes from './src/routes/record'; import buildComplianceRoutes from "./src/routes/compliance"; import { startCronJobs } from "./src/cronJobs"; import buildAuthRoutes from "./src/routes/auth"; +import maintenanceRoutes from "./src/routes/maintenanceRoutes"; import multer from 'multer'; const port: number = parseInt(process.env.PORT as string, 10) || 5000; @@ -35,6 +36,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use(buildReportRoutes(reportController(reportRepository, mongoReportRepository))); app.use(buildComplianceRoutes(complianceController(complianceRepository, mongoComplianceRepository))); app.use(buildAuthRoutes()); +app.use('/maintenance', maintenanceRoutes); app.use((err: any, _: express.Request, res: express.Response, next: express.NextFunction) => { if (err instanceof multer.MulterError) { diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 7c22edd5..d31f500f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -66,8 +66,8 @@ const appConfig: AppConfig = { : 30 * 24 * 60 * 60 * 1000, frontendHost: process.env.FE_HOST, cron: { - removeExpiredDraftsSchedule: '0 3 * * 0', // Run every Sunday at 3:00 AM - syncGitBookRequirementsSchedule: '0 3 * * 0', // Run every Sunday at 3:00 AM + removeExpiredDraftsSchedule: process.env.REMOVE_EXPIRED_DRAFTS_SCHEDULE || '0 3 * * 0', // Run every Sunday at 3:00 AM + syncGitBookRequirementsSchedule: process.env.SYNC_GITBOOK_REQUIREMENTS_SCHEDULE || '0 3 * * 0', // Run every Sunday at 3:00 AM }, enableJiraIntegration: process.env.ENABLE_JIRA_INTEGRATION ? process.env.ENABLE_JIRA_INTEGRATION === 'true' : false, gitBook: { diff --git a/backend/src/cronJobs/index.ts b/backend/src/cronJobs/index.ts index 8e130ebe..6ae3c003 100644 --- a/backend/src/cronJobs/index.ts +++ b/backend/src/cronJobs/index.ts @@ -2,23 +2,38 @@ import { schedule } from 'node-cron'; import { removeSensitiveDataFromExpiredDrafts } from './removeExpiredDrafts'; import { appConfig } from '../config'; import syncGitBookBBRequirements from './syncGitBookBBRequirements'; +import { acquireLock, releaseLock } from '../shared/maintenanceLock'; export const startCronJobs = (): void => { schedule(appConfig.cron.removeExpiredDraftsSchedule, async () => { + if (!acquireLock()) { + console.log('Maintenance is already running, skipping remove expired drafts job'); + return; + } + console.log('Running a job to remove sensitive data from expired drafts'); try { await removeSensitiveDataFromExpiredDrafts(); } catch (error) { console.error('An error occurred', error); + } finally { + releaseLock(); } }); schedule(appConfig.cron.syncGitBookRequirementsSchedule, async () => { + if (!acquireLock()) { + console.log('Maintenance is already running, skipping sync GitBook requirements job'); + return; + } + console.log('Running job to sync GitBook requirements'); try { await syncGitBookBBRequirements(); } catch (error) { console.error('An error occurred in syncGitBookRequirements', error); + } finally { + releaseLock(); } }); }; diff --git a/backend/src/routes/maintenanceRoutes.ts b/backend/src/routes/maintenanceRoutes.ts new file mode 100644 index 00000000..d40f9802 --- /dev/null +++ b/backend/src/routes/maintenanceRoutes.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { removeSensitiveDataFromExpiredDrafts } from '../cronJobs/removeExpiredDrafts'; +import syncGitBookBBRequirements from '../cronJobs/syncGitBookBBRequirements'; +import verifyGithubToken from '../middlewares/requiredAuthMiddleware'; +import { acquireLock, releaseLock } from '../shared/maintenanceLock'; + +const router = Router(); + +router.post('/sync', verifyGithubToken, async (_req, res) => { + if (!acquireLock()) { + return res.status(409).json({ + success: false, + message: 'Maintenance is already in progress' + }); + } + + try { + // Run both maintenance tasks sequentially + await removeSensitiveDataFromExpiredDrafts(); + await syncGitBookBBRequirements(); + + return res.json({ + success: true, + message: 'Maintenance tasks completed successfully' + }); + } catch (error) { + console.error('Error during maintenance:', error); + return res.status(500).json({ + success: false, + message: 'Error during maintenance tasks', + error: error instanceof Error ? error.message : String(error) + }); + } finally { + releaseLock(); + } +}); + +export default router; diff --git a/backend/src/shared/maintenanceLock.ts b/backend/src/shared/maintenanceLock.ts new file mode 100644 index 00000000..7be9e686 --- /dev/null +++ b/backend/src/shared/maintenanceLock.ts @@ -0,0 +1,14 @@ +// Shared state for maintenance tasks +export let isMaintenanceRunning = false; + +export const acquireLock = (): boolean => { + if (isMaintenanceRunning) { + return false; + } + isMaintenanceRunning = true; + return true; +}; + +export const releaseLock = (): void => { + isMaintenanceRunning = false; +}; From 20fe6d850f2fb605acc47427b4d8e802d8d6faa9 Mon Sep 17 00:00:00 2001 From: Manuel Marcigliano Date: Mon, 28 Jul 2025 20:21:13 +0200 Subject: [PATCH 2/3] Changed the README description as docker-compose is deprecated and replaced by docker compose --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe636036..2d546e09 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,13 @@ yarn lint Application can be deployed in docker container by running ```shell -docker-compose up --build +docker compose up --build ``` or ```shell - docker-compose up + docker compose up ``` #### Less From 4dbf36bc01fb7d427f8b851a5d308e0cb56e6ad7 Mon Sep 17 00:00:00 2001 From: Manuel Marcigliano Date: Mon, 11 Aug 2025 20:07:44 +0200 Subject: [PATCH 3/3] TECH-1131: implement authenticated sync for GitBook BB requirements Added sync functionality with: - Sync button in header for authenticated users - API endpoint integration for GitBook BB requirements - Loading animation and state management - Translation support for sync button --- frontend/components/header/Header.less | 13 ++++++++ frontend/components/header/Header.tsx | 43 +++++++++++++++++++++----- frontend/service/serviceAPI.ts | 22 +++++++++++++ frontend/translations/en.ts | 1 + 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/frontend/components/header/Header.less b/frontend/components/header/Header.less index 567900b6..4b22955e 100644 --- a/frontend/components/header/Header.less +++ b/frontend/components/header/Header.less @@ -53,6 +53,19 @@ color: inherit; } +.header-icon.syncing { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .action-buttons { display: flex; margin-left: auto; diff --git a/frontend/components/header/Header.tsx b/frontend/components/header/Header.tsx index 6f606086..cdea39a0 100644 --- a/frontend/components/header/Header.tsx +++ b/frontend/components/header/Header.tsx @@ -2,7 +2,8 @@ import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import '../../public/images/logo.png'; import { RiQuestionLine } from 'react-icons/ri'; -import { BiLogIn, BiLogOut } from 'react-icons/bi'; +import { BiLogIn, BiLogOut, BiSync } from 'react-icons/bi'; +import './Header.less'; import { COMPLIANCE_TESTING_RESULT_PAGE, API_TESTING_RESULT_PAGE, @@ -11,11 +12,13 @@ import { } from '../../service/constants'; import useTranslations from '../../hooks/useTranslation'; import HeaderMenuButton from './HeaderMenuButton'; +import { syncGitBookBBRequirements as syncGitBookBBRequirementsAPI } from '../../service/serviceAPI'; const Header = () => { const router = useRouter(); const { format } = useTranslations(); const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { const token = sessionStorage.getItem('accessToken'); @@ -40,6 +43,22 @@ const Header = () => { router.push('/'); }; + const syncGitBookBBRequirements = async () => { + if (isSyncing) return; // Prevent multiple simultaneous requests + + setIsSyncing(true); + try { + await syncGitBookBBRequirementsAPI(); + console.log('Building block requirements synced successfully'); + // Optionally refresh the page or show a success notification + } catch (error) { + console.error('Failed to sync building block requirements:', error); + // You could add a toast notification here for better UX + } finally { + setIsSyncing(false); + } + }; + const handleHelpClick = () => { const slackChannelUrl = process.env.SLACK_CHANNEL_URL; window.open(slackChannelUrl, '_blank'); @@ -72,14 +91,24 @@ const Header = () => {
{isLoggedIn ? ( - + <> + + + ) : ( <> @@ -87,7 +116,7 @@ const Header = () => {
diff --git a/frontend/service/serviceAPI.ts b/frontend/service/serviceAPI.ts index 47fba0b8..180773e7 100644 --- a/frontend/service/serviceAPI.ts +++ b/frontend/service/serviceAPI.ts @@ -693,3 +693,25 @@ export const fetchFileDetails = async (file: string) => { return undefined; } }; + +export const syncGitBookBBRequirements = async () => { + const accessToken = sessionStorage.getItem('accessToken'); + + return await fetch(`${baseUrl}/maintenance/sync`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + }) + .then((response) => { + if (!response.ok) { + const errorMessage = response.statusText || 'An error occurred while syncing building block requirements.'; + + const error: FormErrorResponseType = { status: response.status, name: 'CustomError', message: errorMessage }; + throw error; + } + + return response.json(); + }); +} diff --git a/frontend/translations/en.ts b/frontend/translations/en.ts index 8a27b174..085656fe 100644 --- a/frontend/translations/en.ts +++ b/frontend/translations/en.ts @@ -38,6 +38,7 @@ export const en = { 'API Compliance & Requirement Specification Compliance', 'app.login.label': 'Log In', 'app.logout.label': 'Log Out', + 'app.sync.label': 'Sync', 'app.tests_passed.label': 'Tests Passed', 'app.tests_failed.label': 'Tests Failed', 'app.save.label': 'Save',