Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • web-et-numerique/factory/pamn_plateforme-des-acteurs-de-la-mediation-numerique/pamn_server
1 result
Show changes
Commits on Source (5)
Showing
with 9815 additions and 13511 deletions
......@@ -53,7 +53,7 @@ test:
- dev
- merge_requests
needs: []
image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:18.17.0
image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/node:20.18.0
services:
- name: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/elasticsearch:7.16.2
alias: elasticsearch
......
18
20
ARG DEPENDENCY_PROXY=
FROM ${DEPENDENCY_PROXY}node:18-bullseye
FROM ${DEPENDENCY_PROXY}node:20.18-bullseye
# Create app directory
WORKDIR /app
......
This diff is collapsed.
......@@ -30,20 +30,19 @@
"@elastic/elasticsearch": "~8.5.0",
"@gouvfr-anct/timetable-to-osm-opening-hours": "^1.1.0",
"@mailchimp/mailchimp_marketing": "^3.0.80",
"@nestjs/axios": "^1.0.0",
"@nestjs/common": "^9.0.11",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.11",
"@nestjs/elasticsearch": "^9.0.0",
"@nestjs/jwt": "^9.0.0",
"@nestjs/mongoose": "^9.2.1",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.0",
"@nestjs/schedule": "^2.2.3",
"@nestjs/swagger": "^6.1.3",
"@nestjs/axios": "^3.1.0",
"@nestjs/common": "^10.4.6",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.6",
"@nestjs/elasticsearch": "^10.0.2",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mongoose": "^10.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.6",
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^7.4.2",
"@tryghost/admin-api": "^1.13.1",
"@types/bcrypt": "^5.0.0",
"axios": "1.1.3",
"bcrypt": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
......@@ -58,6 +57,7 @@
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"puppeteer": "^23.4.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.5",
......@@ -68,9 +68,9 @@
"devDependencies": {
"@compodoc/compodoc": "^1.1.24",
"@golevelup/ts-jest": "^0.3.2",
"@nestjs/cli": "^9.1.3",
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.0.11",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.2",
"@nestjs/testing": "^10.4.6",
"@types/express": "^4.17.8",
"@types/jest": "^26.0.15",
"@types/node": "^18.0.0",
......
......@@ -33,9 +33,9 @@ import { SetUserJobDto } from './dto/set-user-job.dto';
type PendingStructure = {
userEmail: string;
structureId: string;
createdAt: string;
createdAt: Date;
structureName: string;
updatedAt: string;
updatedAt: Date;
permalink: string;
lastUpdateMail: Date;
comment: string;
......
......@@ -3,7 +3,7 @@ import { DateTime, Interval } from 'luxon';
@Injectable()
export class AdminService {
public isDateOutdated(date: DateTime, nbMonths: number): boolean {
public isDateOutdated(date: Date, nbMonths: number): boolean {
const today = DateTime.local().setZone('utc', { keepLocalTime: true });
return Interval.fromDateTimes(date, today).length('months') > nbMonths;
}
......
import { Test } from '@nestjs/testing';
import { version } from '../package.json';
import { AppController } from './app.controller';
import { ConfigurationModule } from './configuration/configuration.module';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [ConfigurationModule],
controllers: [AppController],
}).compile();
appController = module.get<AppController>(AppController);
});
describe('healthcheck', () => {
it('should return healthcheck', async () => {
const healthcheck = appController.healthcheck();
expect(healthcheck.status).toBe('API Online');
expect(healthcheck.uptime).not.toBeNull();
expect(healthcheck.version).toBe(version);
});
it('should return healthcheck', async () => {
const healthcheck = appController.healthcheck();
expect(healthcheck.status).toBe('API Online');
expect(healthcheck.uptime).not.toBeNull();
expect(healthcheck.version).toBe(version);
});
});
import { Controller, Get } from '@nestjs/common';
import { Body, Controller, Get, Header, Logger, Post, Res } from '@nestjs/common';
import { version } from '../package.json';
import { Response } from 'express';
import { generatePDF } from './shared/utils';
import { GetPdfDto } from './users/dto/get-pdf.dto';
import { ConfigurationService } from './configuration/configuration.service';
@Controller()
export class AppController {
private start = Date.now();
private readonly logger = new Logger(AppController.name);
constructor(private configurationService: ConfigurationService) {}
@Get('healthcheck')
healthcheck() {
......@@ -25,4 +32,21 @@ export class AppController {
matomoSiteId: process.env.MATOMO_SITEID,
};
}
@Post('/pdf')
@Header('Content-Type', 'application/pdf')
@Header('Cache-Control', 'no-cache, no-store, must-revalidate')
@Header('Pragma', 'no-cache')
@Header('Expires', '0')
async getPdf(@Body() body: GetPdfDto, @Res() res: Response): Promise<void> {
this.logger.log('getPdf: ' + JSON.stringify(body));
const buffer = await generatePDF(`${this.configurationService.appUrl}/${body.urlPath}`);
res.set({
'Content-Disposition': `attachment; filename=${body.fileName}`,
'Content-Length': buffer.length,
});
res.end(buffer);
}
}
import { HttpModule, HttpService } from '@nestjs/axios';
import { Test } from '@nestjs/testing';
import { AxiosResponse } from 'axios';
import { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { of, throwError } from 'rxjs';
......@@ -47,7 +47,7 @@ describe('MailerService', () => {
status: 200,
statusText: 'OK',
headers: {},
config: {},
config: {} as InternalAxiosRequestConfig,
};
httpServiceMock.post.mockImplementationOnce(() => of(result));
expect(await mailerService.send('a@a.com', 'test', '<p>This is a test</p>')).toBe(result.data);
......@@ -65,7 +65,7 @@ describe('MailerService', () => {
status: 400,
statusText: 'KO',
headers: {},
config: {},
config: {} as InternalAxiosRequestConfig,
};
httpServiceMock.post.mockImplementationOnce(() => throwError(result));
try {
......
......@@ -9,7 +9,7 @@ export const up = async () => {
while ((document = await cursor.next())) {
let value: Date;
if (!document.structuresLink || document.structuresLink.length == 0) {
value = DateTime.local();
value = DateTime.local().toJSDate();
} else {
value = null;
}
......
import { Db } from 'mongodb';
import { getDb } from '../migrations-utils/db';
export const up = async () => {
const db: Db = await getDb();
const usersCollection = db.collection('users');
const usersWithPendingLinks = await usersCollection
.find({
pendingStructuresLink: { $exists: true, $ne: [] },
})
.toArray();
for (const user of usersWithPendingLinks) {
const updatedLinks = user.pendingStructuresLink.map((link) => {
if (typeof link.createdAt === 'string') {
link.createdAt = new Date(link.createdAt);
}
return link;
});
await usersCollection.updateOne({ _id: user._id }, { $set: { pendingStructuresLink: updatedLinks } });
}
console.log('Update done: pending structures links createdAt field converted to ISODate');
};
export const down = async () => {
const db: Db = await getDb();
const usersCollection = db.collection('users');
const usersWithPendingLinks = await usersCollection
.find({
pendingStructuresLink: { $exists: true, $ne: [] },
})
.toArray();
for (const user of usersWithPendingLinks) {
const revertedLinks = user.pendingStructuresLink.map((link) => {
if (link.createdAt instanceof Date) {
link.createdAt = link.createdAt.toISOString();
}
return link;
});
await usersCollection.updateOne({ _id: user._id }, { $set: { pendingStructuresLink: revertedLinks } });
}
console.log('Downgrade done: pending structures links createdAt field converted to string');
};
import { HttpModule, HttpService } from '@nestjs/axios';
import { Test } from '@nestjs/testing';
import { AxiosResponse } from 'axios';
import { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { of } from 'rxjs';
import { ConfigurationModule } from '../configuration/configuration.module';
import { PagesController } from './pages.controller';
......@@ -81,7 +81,7 @@ describe('PagesController', () => {
status: 200,
statusText: 'OK',
headers: {},
config: {},
config: {} as InternalAxiosRequestConfig,
};
httpServiceMock.get.mockImplementationOnce(() => of(axiosResult));
const result = await (await pagesController.getPagebySlug('hello')).toPromise();
......
import { HttpModule, HttpService } from '@nestjs/axios';
import { Test } from '@nestjs/testing';
import { AxiosResponse } from 'axios';
import { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { of } from 'rxjs';
import { ConfigurationModule } from '../configuration/configuration.module';
import { PostsController } from './posts.controller';
......@@ -193,7 +193,7 @@ describe('PostsController', () => {
status: 200,
statusText: 'OK',
headers: {},
config: {},
config: {} as InternalAxiosRequestConfig,
};
jest.spyOn(httpServiceMock, 'get').mockImplementationOnce(() => of(result));
const response = await postsController.findAll(query);
......
......@@ -4,6 +4,7 @@ import { Page } from '../pages/schemas/page.schema';
import { Post } from '../posts/schemas/post.schema';
import { UserRole } from '../users/enum/user-role.enum';
import { User } from '../users/schemas/user.schema';
import puppeteer from 'puppeteer';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto');
export const md5 = (data: string): string => crypto.createHash('md5').update(data).digest('hex');
......@@ -80,3 +81,19 @@ export const sanitize = (str: string) => {
.replace(/\s/g, '-') // replace spaces by "-"
.replace(/[^\w\s-]/g, ''); // remove all non alphanumeric characters except spaces and dashes
};
export async function generatePDF(url: string): Promise<Uint8Array> {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle0' }); // wait until all is loaded
const buffer = await page.pdf({
format: 'A4',
printBackground: true, // Include background colors and images
margin: { top: '20mm', right: '10mm', bottom: '20mm', left: '10mm' },
});
await browser.close();
return buffer;
}
......@@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
import { of } from 'rxjs';
import { DataGouvStructure } from '../interfaces/data-gouv-structure.interface';
import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios';
import { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { mockGouvStructure, mockGouvStructureToResinFormat } from '../../../test/mock/data/gouvStructures.mock.data';
import { StructuresSearchService } from './structures-search.service';
import { StructuresService } from './structures.service';
......@@ -79,7 +79,7 @@ describe('StructuresImportService', () => {
status: 200,
statusText: 'OK',
headers: {},
config: {},
config: {} as InternalAxiosRequestConfig,
};
const saveSpy = jest
......
......@@ -57,7 +57,8 @@ export class StructuresService {
filters?: Array<any>,
fields?: string[],
onlyOffersWithAppointment?: boolean,
limit?: number
limit?: number,
inseeCodes?: string[]
): Promise<StructureDocument[]> {
this.logger.debug(
`searchForStructures : ${text} | filters: ${JSON.stringify(filters)} | fields : ${JSON.stringify(fields)} | `
......@@ -77,59 +78,80 @@ export class StructuresService {
return 'onlineProcedures' in filter || 'baseSkills' in filter || 'advancedSkills' in filter;
});
// we match ids from Elasticsearch with ids from mongoDB (and filters) and sort the result according to ElasticSearch order.
if (andFiltersNotOnOffers?.length > 0 && orFilters?.length == 0) {
structures = await this.structureModel
.find({
_id: { $in: ids },
$and: [...this.parseFilter(andFiltersNotOnOffers), { deletedAt: { $exists: false }, accountVerified: true }],
})
.populate('personalOffers')
.populate('structureType')
.limit(limit)
.exec();
} else if (andFiltersNotOnOffers?.length > 0 && orFilters?.length > 0) {
structures = await this.structureModel
.find({
_id: { $in: ids },
$or: [...this.parseFilter(orFilters)],
$and: [...this.parseFilter(andFiltersNotOnOffers), { deletedAt: { $exists: false }, accountVerified: true }],
})
.populate('personalOffers')
.populate('structureType')
.limit(limit)
.exec();
} else if (andFiltersNotOnOffers?.length == 0 && orFilters?.length > 0) {
structures = await this.structureModel
.find({
_id: { $in: ids },
$or: [...this.parseFilter(orFilters)],
$and: [{ deletedAt: { $exists: false }, accountVerified: true }],
})
.populate('personalOffers')
.populate('structureType')
.limit(limit)
.exec();
} else {
structures = await this.structureModel
.find({
_id: { $in: ids },
$and: [{ deletedAt: { $exists: false }, accountVerified: true }],
})
.populate('personalOffers')
.populate('structureType')
.limit(limit)
.exec();
// Construct a global $and condition that can encompass multiple $and and $or clauses
let queryConditions: FilterQuery<StructureDocument>[] = [
{ deletedAt: { $exists: false } },
{ accountVerified: true },
];
if (andFiltersNotOnOffers?.length) {
queryConditions.push({
$and: [...this.parseFilter(andFiltersNotOnOffers)],
});
}
if (orFilters?.length) {
queryConditions = this.addOrConditions(orFilters, queryConditions);
}
if (inseeCodes?.length > 0) {
queryConditions.push({
$or: [{ 'address.inseeCode': { $in: inseeCodes } }],
});
}
// Match ids from Elasticsearch text search with mongoDB query with filters
structures = await this.structureModel
.find({
_id: { $in: ids },
$and: queryConditions,
})
.populate('personalOffers')
.populate('structureType')
.limit(limit)
.exec();
// Filter offers using structure offers and structure members personalOffers
if (andFiltersOnOffers.length) {
structures = await this.filterOnOffers(structures, andFiltersOnOffers, onlyOffersWithAppointment);
}
// Sort the result according to ElasticSearch order
return structures.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
}
/**
* For "or" filters of different types, add to queryConditions a different "or" filter for each type
* (For example for "or" filters "ctm" and "freeWorkShop" : cf. issue #620 )
*/
private addOrConditions(
orFilters,
queryConditions: FilterQuery<StructureDocument>[]
): FilterQuery<StructureDocument>[] {
// Object to hold arrays for each key (type of filter)
const grouped = {};
orFilters.forEach((item) => {
// Get the key (assumes each item has only one key)
const key = Object.keys(item)[0];
const value = item[key];
// Initialize the array for the key if it doesn't exist
if (!grouped[key]) {
grouped[key] = [];
}
// Push the key-value pair into the array
grouped[key].push({ [key]: value });
});
// Create $or conditions for each group (type of filter)
for (const key in grouped) {
queryConditions.push({
$or: this.parseFilter(grouped[key]),
});
}
return queryConditions;
}
/**
* set structure offers and structure social workers personalOffers in non-persistant property structure.categoriesWithPersonalOffers
*/
......@@ -154,7 +176,8 @@ export class StructuresService {
await Promise.all(
(structure.personalOffers || []).map(async (personalOffer) => {
if (!personalOffer.categories) {
throw new Error(`personalOffer not populated for structure ${structure.structureName} : ${personalOffer}`);
Logger.log(`personalOffer not populated for structure ${structure.structureName} : ${personalOffer}`);
return;
}
// If we only want personalOffers from user with appointment
......@@ -627,7 +650,7 @@ export class StructuresService {
if (!structure) {
throw new HttpException('Invalid structure id', HttpStatus.BAD_REQUEST);
}
structure.toBeDeletedAt = DateTime.local().plus({ weeks: 5 }).setZone('Europe/Paris').toString();
structure.toBeDeletedAt = DateTime.local().plus({ weeks: 5 }).setZone('Europe/Paris').toJSDate();
structure.save({ timestamps: !hasAdminRole(user) });
this.sendToBeDeletedNotification(user, structure);
......@@ -703,7 +726,7 @@ export class StructuresService {
}
this.structuresSearchService.deleteIndexStructure(structure);
if (structure.toBeDeletedAt) structure.toBeDeletedAt = null;
structure.deletedAt = DateTime.local().setZone('Europe/Paris').toString();
structure.deletedAt = DateTime.local().setZone('Europe/Paris').toJSDate();
structure.save({ timestamps: !hasAdminRole(user) });
this.logger.debug(`delete structure : ${structure.structureName} (${structure._id})`);
......@@ -1285,7 +1308,7 @@ export class StructuresService {
const structures = await this.structureModel
.find()
.where('toBeDeletedAt')
.lte(DateTime.local())
.lte(DateTime.local().toMillis())
.where('deletedAt')
.exists(false)
.exec();
......
......@@ -114,7 +114,8 @@ export class StructuresController {
body ? body.filters : null,
null,
body?.onlyOffersWithAppointment || false,
body?.limit || null
body?.limit || null,
body?.inseeCodes || null
);
}
......
......@@ -48,6 +48,7 @@ describe('UsersController', () => {
deleteOne: jest.fn(),
findById: jest.fn(),
findOne: jest.fn(),
usersFilteredByJobAndLocation: jest.fn(),
isStructureClaimed: jest.fn(),
sendResetPasswordEmail: jest.fn(),
updateDescription: jest.fn(),
......@@ -147,6 +148,13 @@ describe('UsersController', () => {
});
});
describe('findUsersWithJobWithPersonalOffer', () => {
it('should find users with job and with personal offer', async () => {
await usersController.findUsersWithJobWithPersonalOffer();
expect(userServiceMock.usersFilteredByJobAndLocation).toHaveBeenCalledTimes(1);
});
});
describe('setProfile', () => {
const setProfileData: ProfileDto = {
employerName: 'Metro',
......@@ -393,7 +401,7 @@ describe('UsersController', () => {
});
it('should have expired token', async () => {
mockJwtService.decode.mockReturnValue({ expiresAt: '', idStructure: '', userId: '' });
mockJwtService.decode.mockReturnValue({ expiresAt: '2000-01-01T00:00:00.000Z', idStructure: '', userId: '' });
try {
await usersController.joinValidation('token', 'true');
expect(true).toBe(false);
......
......@@ -60,6 +60,24 @@ export class UsersController {
return req.user;
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT')
@ApiOperation({ description: 'Find users with specific job criteria and location' })
@ApiResponse({ status: 200, description: 'Returns filtered list of users' })
@Get('filteredUsers')
public async findUsersWithJobWithPersonalOffer(
@Query('hasPersonalOffer') hasPersonalOffer?: boolean,
@Query('job') jobs?: string,
@Query('ctms') ctms?: string,
@Query('inseeCodes') inseeCodes?: string
): Promise<IUser[]> {
this.logger.log(`findUsersWithJobWithPersonalOffer: ${hasPersonalOffer}; ${jobs}; ${ctms}; ${inseeCodes}`);
const jobArray = jobs ? jobs.split(',') : undefined;
const ctmsArray = ctms ? ctms.split(',') : undefined;
const inseeCodesArray = inseeCodes ? inseeCodes.split(',') : undefined;
return this.usersService.usersFilteredByJobAndLocation(hasPersonalOffer, jobArray, ctmsArray, inseeCodesArray);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT')
@ApiOperation({ description: 'Set user profile with employer and job' })
......@@ -269,7 +287,7 @@ export class UsersController {
if (!token || !status) {
throw new HttpException('Wrong parameters', HttpStatus.NOT_FOUND);
}
if (decoded.expiresAt < today) {
if (DateTime.fromISO(decoded.expiresAt) < today) {
throw new HttpException('Expired or invalid token', HttpStatus.FORBIDDEN);
}
// Get structure name
......