diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 59db7519e..853f06829 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -42,3 +42,5 @@ export const ISOMER_ADMIN_REPOS = [ "infra", "markdown-helper", ] + +export const INACTIVE_USER_THRESHOLD_DAYS = 60 diff --git a/src/routes/v2/authenticated/__tests__/collaborators.spec.ts b/src/routes/v2/authenticated/__tests__/collaborators.spec.ts index d2d16cea3..ed53caf79 100644 --- a/src/routes/v2/authenticated/__tests__/collaborators.spec.ts +++ b/src/routes/v2/authenticated/__tests__/collaborators.spec.ts @@ -20,6 +20,7 @@ describe("Collaborator Router", () => { delete: jest.fn(), list: jest.fn(), getRole: jest.fn(), + getStatistics: jest.fn(), } const mockAuthorizationMiddleware = { verifySiteAdmin: jest.fn(), @@ -50,6 +51,12 @@ describe("Collaborator Router", () => { `/:siteName/collaborators/:userId`, attachReadRouteHandlerWrapper(collaboratorsRouter.deleteCollaborator) ) + subrouter.get( + `/:siteName/collaborators/statistics`, + attachReadRouteHandlerWrapper( + collaboratorsRouter.getCollaboratorsStatistics + ) + ) const app = generateRouter(subrouter) @@ -176,4 +183,47 @@ describe("Collaborator Router", () => { ) }) }) + + describe("get collaborators statistics", () => { + it("should get collaborators statistics", async () => { + // Arrange + const MOCK_COLLABORATORS_STATISTICS = { + total: 1, + inactive: 1, + } + mockCollaboratorsService.getStatistics.mockResolvedValue( + MOCK_COLLABORATORS_STATISTICS + ) + + // Act + const resp = await request(app) + .get(`/${mockSiteName}/collaborators/statistics`) + .expect(200) + + // Assert + expect(resp.body).toStrictEqual(MOCK_COLLABORATORS_STATISTICS) + expect(mockCollaboratorsService.getStatistics).toHaveBeenCalledWith( + mockSiteName + ) + }) + + it("should return 404 if a NotFoundError occurred", async () => { + // Arrange + const mockErrorMessage = "error" + mockCollaboratorsService.getStatistics.mockResolvedValue( + new NotFoundError(mockErrorMessage) + ) + + // Act + const resp = await request(app) + .get(`/${mockSiteName}/collaborators/statistics`) + .expect(404) + + // Assert + expect(resp.body).toStrictEqual({ message: mockErrorMessage }) + expect(mockCollaboratorsService.getStatistics).toHaveBeenCalledWith( + mockSiteName + ) + }) + }) }) diff --git a/src/routes/v2/authenticated/collaborators.ts b/src/routes/v2/authenticated/collaborators.ts index 25b97317f..f19df674a 100644 --- a/src/routes/v2/authenticated/collaborators.ts +++ b/src/routes/v2/authenticated/collaborators.ts @@ -103,6 +103,23 @@ export class CollaboratorsRouter { return res.status(200).json({ role }) } + getCollaboratorsStatistics: RequestHandler< + { siteName: string }, + unknown, + never, + never, + { userWithSiteSessionData: UserWithSiteSessionData } + > = async (req, res) => { + const { siteName } = req.params + const statistics = await this.collaboratorsService.getStatistics(siteName) + + // Check for error and throw + if (statistics instanceof BaseIsomerError) { + return res.status(404).json({ message: statistics.message }) + } + return res.status(200).json(statistics) + } + getRouter() { const router = express.Router({ mergeParams: true }) router.get( @@ -129,6 +146,12 @@ export class CollaboratorsRouter { this.authorizationMiddleware.verifySiteAdmin, attachReadRouteHandlerWrapper(this.deleteCollaborator) ) + router.get( + "/statistics", + attachSiteHandler, + this.authorizationMiddleware.verifySiteMember, + attachReadRouteHandlerWrapper(this.getCollaboratorsStatistics) + ) return router } diff --git a/src/services/identity/CollaboratorsService.ts b/src/services/identity/CollaboratorsService.ts index 6035695e9..00ca5b0ae 100644 --- a/src/services/identity/CollaboratorsService.ts +++ b/src/services/identity/CollaboratorsService.ts @@ -6,7 +6,10 @@ import { ForbiddenError } from "@errors/ForbiddenError" import { NotFoundError } from "@errors/NotFoundError" import { UnprocessableError } from "@errors/UnprocessableError" -import { CollaboratorRoles } from "@constants/constants" +import { + CollaboratorRoles, + INACTIVE_USER_THRESHOLD_DAYS, +} from "@constants/constants" import { Whitelist, User, Site, SiteMember } from "@database/models" import { BadRequestError } from "@root/errors/BadRequestError" @@ -235,6 +238,39 @@ class CollaboratorsService { return (site?.site_members?.[0]?.SiteMember?.role as string | null) ?? null } + + getStatistics = async (siteName: string) => { + const inactiveLimit = new Date() + inactiveLimit.setDate( + inactiveLimit.getDate() - INACTIVE_USER_THRESHOLD_DAYS + ) + const site = await this.siteRepository.findOne({ + where: { name: siteName }, + include: [ + { + model: User, + as: "site_members", + }, + ], + }) + + const collaborators = site?.site_members ?? [] + const totalCount = collaborators.length + + if (totalCount === 0) { + // Every site must have at least one collaborator + return new NotFoundError(`Site does not exist`) + } + + const inactiveCount = collaborators.filter( + (collaborator) => collaborator.lastLoggedIn < inactiveLimit + ).length + + return { + total: totalCount, + inactive: inactiveCount, + } + } } export default CollaboratorsService diff --git a/src/services/identity/__tests__/CollaboratorsService.spec.ts b/src/services/identity/__tests__/CollaboratorsService.spec.ts index b90208ff8..925aee7ff 100644 --- a/src/services/identity/__tests__/CollaboratorsService.spec.ts +++ b/src/services/identity/__tests__/CollaboratorsService.spec.ts @@ -4,7 +4,7 @@ import { ForbiddenError } from "@errors/ForbiddenError" import { NotFoundError } from "@errors/NotFoundError" import { UnprocessableError } from "@errors/UnprocessableError" -import { Site, SiteMember, Whitelist } from "@database/models" +import { Site, SiteMember, User, Whitelist } from "@database/models" import { expectedSortedMockCollaboratorsList, mockSiteOrmResponseWithAllCollaborators, @@ -12,7 +12,10 @@ import { mockSiteOrmResponseWithOneContributorCollaborator, mockSiteOrmResponseWithNoCollaborators, } from "@fixtures/identity" -import { CollaboratorRoles } from "@root/constants" +import { + CollaboratorRoles, + INACTIVE_USER_THRESHOLD_DAYS, +} from "@root/constants" import { BadRequestError } from "@root/errors/BadRequestError" import { ConflictError } from "@root/errors/ConflictError" import CollaboratorsService from "@services/identity/CollaboratorsService" @@ -473,4 +476,70 @@ describe("CollaboratorsService", () => { expect(resp instanceof UnprocessableError).toBe(true) }) }) + + describe("getStatistics", () => { + const inactiveDate = new Date() + inactiveDate.setDate( + inactiveDate.getDate() - INACTIVE_USER_THRESHOLD_DAYS - 1 + ) + const mockActiveCollaborator: Partial = { + lastLoggedIn: new Date(), + } + const mockInactiveCollaborator: Partial = { + lastLoggedIn: inactiveDate, + } + + it("should return non-zero collaborators statistics", async () => { + // Arrange + const expected = { + total: 2, + inactive: 1, + } + mockSiteRepo.findOne.mockResolvedValue({ + site_members: [mockActiveCollaborator, mockInactiveCollaborator], + }) + + // Act + const actual = await collaboratorsService.getStatistics(mockSiteName) + + // Assert + expect(actual).toEqual(expected) + expect(mockSiteRepo.findOne).toBeCalled() + }) + + it("should return zero inactive collaborators statistics if there is none", async () => { + // Arrange + const expected = { + total: 1, + inactive: 0, + } + mockSiteRepo.findOne.mockResolvedValue({ + site_members: [mockActiveCollaborator], + }) + + // Act + const actual = await collaboratorsService.getStatistics(mockSiteName) + + // Assert + expect(actual).toEqual(expected) + expect(mockSiteRepo.findOne).toBeCalled() + }) + + it("should return NotFoundError if site is not found", async () => { + // Arrange + const expected = { + total: 0, + inactive: 0, + } + mockSiteRepo.findOne.mockResolvedValue(null) + + // Act + await expect( + collaboratorsService.getStatistics(mockSiteName) + ).resolves.toBeInstanceOf(NotFoundError) + + // Assert + expect(mockSiteRepo.findOne).toBeCalled() + }) + }) })