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
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;
+};
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',