Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 22 additions & 4 deletions backend/__tests__/gitBookApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions backend/__tests__/maintenanceRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/// <reference types="mocha" />
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'
});
});
});
2 changes: 2 additions & 0 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
15 changes: 15 additions & 0 deletions backend/src/cronJobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
};
38 changes: 38 additions & 0 deletions backend/src/routes/maintenanceRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions backend/src/shared/maintenanceLock.ts
Original file line number Diff line number Diff line change
@@ -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;
};
13 changes: 13 additions & 0 deletions frontend/components/header/Header.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 36 additions & 7 deletions frontend/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -72,22 +91,32 @@ const Header = () => {
<div className="action-buttons">
<div className="header-login">
{isLoggedIn ? (
<button onClick={handleLogout} className="header-menu-button">
<BiLogOut className="header-icon"/>
{format('app.logout.label')}
</button>
<>
<button onClick={handleLogout} className="header-menu-button">
<BiLogOut className="header-icon" />
{format('app.logout.label')}
</button>
<button
onClick={syncGitBookBBRequirements}
className="header-menu-button"
disabled={isSyncing}
>
<BiSync className={`header-icon ${isSyncing ? 'syncing' : ''}`} />
{format('app.sync.label')}
</button>
</>
) : (
<>
<button onClick={handleLogin} className="header-menu-button">
<BiLogIn className="header-icon"/>
<BiLogIn className="header-icon" />
{format('app.login.label')}
</button>
</>
)}
</div>
<div className="header-help">
<button onClick={handleHelpClick} className="header-menu-button">
<RiQuestionLine className="header-icon"/>
<RiQuestionLine className="header-icon" />
{format('app.help.label')}
</button>
</div>
Expand Down
22 changes: 22 additions & 0 deletions frontend/service/serviceAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Loading