Compare commits
15 Commits
a1abde36e6
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f372390b
|
||
|
|
4028cebb63
|
||
|
|
c1a74d712b
|
||
|
|
a4a259f119
|
||
|
|
aff21cb7ff
|
||
|
|
e5121c4e7a
|
||
|
|
fd783681ba
|
||
|
|
93acd7e452
|
||
|
|
2a47417b47
|
||
|
|
b5c0e2e98d
|
||
|
|
3fe47795d9
|
||
|
|
1308e9c599
|
||
|
|
b7d899e66e
|
||
|
|
818a92f18c
|
||
|
|
ea6684b7fa
|
@@ -38,6 +38,8 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"csurf": "^1.11.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-orm": "^0.30.4",
|
"drizzle-orm": "^0.30.4",
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
|
import * as csurf from 'csurf';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -17,8 +19,34 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configuration CORS selon l'environnement
|
// Configure cookie parser
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Get environment configuration
|
||||||
const environment = configService.get<string>('NODE_ENV', 'development');
|
const environment = configService.get<string>('NODE_ENV', 'development');
|
||||||
|
|
||||||
|
// Configure CSRF protection
|
||||||
|
if (environment !== 'test') { // Skip CSRF in test environment
|
||||||
|
app.use(csurf({
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: environment === 'production'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add CSRF token to response
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.cookie('XSRF-TOKEN', req.csrfToken?.() || '', {
|
||||||
|
httpOnly: false, // Client-side JavaScript needs to read this
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: environment === 'production'
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration CORS selon l'environnement
|
||||||
const frontendUrl = configService.get<string>('FRONTEND_URL', 'http://localhost:3001');
|
const frontendUrl = configService.get<string>('FRONTEND_URL', 'http://localhost:3001');
|
||||||
|
|
||||||
if (environment === 'development') {
|
if (environment === 'development') {
|
||||||
|
|||||||
@@ -161,7 +161,16 @@ describe('GroupsService', () => {
|
|||||||
const id = 'nonexistent';
|
const id = 'nonexistent';
|
||||||
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => [undefined]);
|
mockDbOperations.where.mockImplementationOnce(() => []);
|
||||||
|
|
||||||
|
await expect(service.findById(id)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException if there is a database error', async () => {
|
||||||
|
const id = 'invalid-id';
|
||||||
|
mockDb.select.mockImplementationOnce(() => {
|
||||||
|
throw new Error('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
await expect(service.findById(id)).rejects.toThrow(NotFoundException);
|
await expect(service.findById(id)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
@@ -174,8 +183,12 @@ describe('GroupsService', () => {
|
|||||||
name: 'Updated Group',
|
name: 'Updated Group',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mock findById to return the group
|
||||||
|
jest.spyOn(service, 'findById').mockResolvedValueOnce(mockGroup);
|
||||||
|
|
||||||
const result = await service.update(id, updateGroupDto);
|
const result = await service.update(id, updateGroupDto);
|
||||||
|
|
||||||
|
expect(service.findById).toHaveBeenCalledWith(id);
|
||||||
expect(mockDb.update).toHaveBeenCalled();
|
expect(mockDb.update).toHaveBeenCalled();
|
||||||
expect(mockDb.set).toHaveBeenCalled();
|
expect(mockDb.set).toHaveBeenCalled();
|
||||||
expect(mockDb.where).toHaveBeenCalled();
|
expect(mockDb.where).toHaveBeenCalled();
|
||||||
@@ -197,10 +210,8 @@ describe('GroupsService', () => {
|
|||||||
name: 'Updated Group',
|
name: 'Updated Group',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockDb.update.mockImplementationOnce(() => mockDbOperations);
|
// Mock findById to throw NotFoundException
|
||||||
mockDbOperations.set.mockImplementationOnce(() => mockDbOperations);
|
jest.spyOn(service, 'findById').mockRejectedValueOnce(new NotFoundException(`Group with ID ${id} not found`));
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => [undefined]);
|
|
||||||
|
|
||||||
await expect(service.update(id, updateGroupDto)).rejects.toThrow(NotFoundException);
|
await expect(service.update(id, updateGroupDto)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
@@ -251,19 +262,22 @@ describe('GroupsService', () => {
|
|||||||
// Mock person lookup
|
// Mock person lookup
|
||||||
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => [[mockPerson]]);
|
mockDbOperations.where.mockImplementationOnce(() => [mockPerson]);
|
||||||
|
|
||||||
// Mock relation lookup
|
// Mock relation lookup
|
||||||
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => [undefined]);
|
mockDbOperations.where.mockImplementationOnce(() => []);
|
||||||
|
|
||||||
// Mock relation creation
|
// Mock relation creation
|
||||||
mockDb.insert.mockImplementationOnce(() => mockDbOperations);
|
mockDb.insert.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.values.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.values.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
||||||
|
|
||||||
|
// Mock getPersonsInGroup
|
||||||
|
jest.spyOn(service, 'getPersonsInGroup').mockResolvedValueOnce([mockPerson]);
|
||||||
|
|
||||||
const result = await service.addPersonToGroup(groupId, personId);
|
const result = await service.addPersonToGroup(groupId, personId);
|
||||||
|
|
||||||
expect(service.findById).toHaveBeenCalledWith(groupId);
|
expect(service.findById).toHaveBeenCalledWith(groupId);
|
||||||
@@ -274,14 +288,14 @@ describe('GroupsService', () => {
|
|||||||
personId,
|
personId,
|
||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockPersonToGroup);
|
expect(result).toEqual({ ...mockGroup, persons: [mockPerson] });
|
||||||
|
|
||||||
// Check if WebSocketsService.emitPersonAddedToGroup was called with correct parameters
|
// Check if WebSocketsService.emitPersonAddedToGroup was called with correct parameters
|
||||||
expect(mockWebSocketsService.emitPersonAddedToGroup).toHaveBeenCalledWith(
|
expect(mockWebSocketsService.emitPersonAddedToGroup).toHaveBeenCalledWith(
|
||||||
mockGroup.projectId,
|
mockGroup.projectId,
|
||||||
{
|
{
|
||||||
group: mockGroup,
|
group: mockGroup,
|
||||||
person: [mockPerson],
|
person: mockPerson,
|
||||||
relation: mockPersonToGroup,
|
relation: mockPersonToGroup,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -300,7 +314,12 @@ describe('GroupsService', () => {
|
|||||||
// Mock person lookup to return no person
|
// Mock person lookup to return no person
|
||||||
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => [undefined]);
|
mockDbOperations.where.mockImplementationOnce(() => []);
|
||||||
|
|
||||||
|
// Mock user lookup to return no user
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => []);
|
||||||
|
|
||||||
await expect(service.addPersonToGroup(groupId, personId)).rejects.toThrow(NotFoundException);
|
await expect(service.addPersonToGroup(groupId, personId)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
@@ -328,6 +347,9 @@ describe('GroupsService', () => {
|
|||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
||||||
|
|
||||||
|
// Mock getPersonsInGroup
|
||||||
|
jest.spyOn(service, 'getPersonsInGroup').mockResolvedValueOnce([mockPerson]);
|
||||||
|
|
||||||
const result = await service.removePersonFromGroup(groupId, personId);
|
const result = await service.removePersonFromGroup(groupId, personId);
|
||||||
|
|
||||||
expect(service.findById).toHaveBeenCalledWith(groupId);
|
expect(service.findById).toHaveBeenCalledWith(groupId);
|
||||||
@@ -335,7 +357,7 @@ describe('GroupsService', () => {
|
|||||||
expect(mockDb.from).toHaveBeenCalled();
|
expect(mockDb.from).toHaveBeenCalled();
|
||||||
expect(mockDb.delete).toHaveBeenCalled();
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
expect(mockDb.where).toHaveBeenCalled();
|
expect(mockDb.where).toHaveBeenCalled();
|
||||||
expect(result).toEqual(mockPersonToGroup);
|
expect(result).toEqual({ ...mockGroup, persons: [mockPerson] });
|
||||||
|
|
||||||
// Check if WebSocketsService.emitPersonRemovedFromGroup was called with correct parameters
|
// Check if WebSocketsService.emitPersonRemovedFromGroup was called with correct parameters
|
||||||
expect(mockWebSocketsService.emitPersonRemovedFromGroup).toHaveBeenCalledWith(
|
expect(mockWebSocketsService.emitPersonRemovedFromGroup).toHaveBeenCalledWith(
|
||||||
@@ -376,7 +398,7 @@ describe('GroupsService', () => {
|
|||||||
describe('getPersonsInGroup', () => {
|
describe('getPersonsInGroup', () => {
|
||||||
it('should get all persons in a group', async () => {
|
it('should get all persons in a group', async () => {
|
||||||
const groupId = 'group1';
|
const groupId = 'group1';
|
||||||
const mockPersons = [{ person: mockPerson }];
|
const personIds = [{ id: 'person1' }];
|
||||||
|
|
||||||
// Mock findById to return the group
|
// Mock findById to return the group
|
||||||
jest.spyOn(service, 'findById').mockResolvedValueOnce(mockGroup);
|
jest.spyOn(service, 'findById').mockResolvedValueOnce(mockGroup);
|
||||||
@@ -384,22 +406,25 @@ describe('GroupsService', () => {
|
|||||||
// Reset and setup mocks for this test
|
// Reset and setup mocks for this test
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Mock the select chain to return the expected result
|
// Mock the select chain to return person IDs
|
||||||
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.innerJoin.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.where.mockImplementationOnce(() => personIds);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockPersons);
|
|
||||||
|
// Mock the person lookup
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => [mockPerson]);
|
||||||
|
|
||||||
const result = await service.getPersonsInGroup(groupId);
|
const result = await service.getPersonsInGroup(groupId);
|
||||||
|
|
||||||
expect(service.findById).toHaveBeenCalledWith(groupId);
|
expect(service.findById).toHaveBeenCalledWith(groupId);
|
||||||
expect(mockDb.select).toHaveBeenCalled();
|
expect(mockDb.select).toHaveBeenCalled();
|
||||||
expect(mockDb.from).toHaveBeenCalled();
|
expect(mockDb.from).toHaveBeenCalled();
|
||||||
expect(mockDb.innerJoin).toHaveBeenCalled();
|
|
||||||
expect(mockDb.where).toHaveBeenCalled();
|
expect(mockDb.where).toHaveBeenCalled();
|
||||||
|
|
||||||
// Just verify the result is defined, since the mock implementation is complex
|
// Verify the result is the expected array of persons
|
||||||
expect(result).toBeDefined();
|
expect(result).toEqual([mockPerson]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { PersonsController } from './persons.controller';
|
import { PersonsController } from './persons.controller';
|
||||||
import { PersonsService } from '../services/persons.service';
|
import { PersonsService } from '../services/persons.service';
|
||||||
import { CreatePersonDto, Gender, OralEaseLevel } from '../dto/create-person.dto';
|
import { CreatePersonDto } from '../dto/create-person.dto';
|
||||||
import { UpdatePersonDto } from '../dto/update-person.dto';
|
import { UpdatePersonDto } from '../dto/update-person.dto';
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@@ -12,16 +12,10 @@ describe('PersonsController', () => {
|
|||||||
// Mock data
|
// Mock data
|
||||||
const mockPerson = {
|
const mockPerson = {
|
||||||
id: 'person1',
|
id: 'person1',
|
||||||
firstName: 'John',
|
name: 'John Doe',
|
||||||
lastName: 'Doe',
|
|
||||||
gender: Gender.MALE,
|
|
||||||
technicalLevel: 3,
|
|
||||||
hasTechnicalTraining: true,
|
|
||||||
frenchSpeakingLevel: 4,
|
|
||||||
oralEaseLevel: OralEaseLevel.COMFORTABLE,
|
|
||||||
age: 30,
|
|
||||||
projectId: 'project1',
|
projectId: 'project1',
|
||||||
attributes: {},
|
skills: ['JavaScript', 'TypeScript'],
|
||||||
|
metadata: {},
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -66,14 +60,10 @@ describe('PersonsController', () => {
|
|||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
const createPersonDto: CreatePersonDto = {
|
const createPersonDto: CreatePersonDto = {
|
||||||
firstName: 'John',
|
name: 'John Doe',
|
||||||
lastName: 'Doe',
|
|
||||||
gender: Gender.MALE,
|
|
||||||
technicalLevel: 3,
|
|
||||||
hasTechnicalTraining: true,
|
|
||||||
frenchSpeakingLevel: 4,
|
|
||||||
oralEaseLevel: OralEaseLevel.COMFORTABLE,
|
|
||||||
projectId: 'project1',
|
projectId: 'project1',
|
||||||
|
skills: ['JavaScript', 'TypeScript'],
|
||||||
|
metadata: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(await controller.create(createPersonDto)).toBe(mockPerson);
|
expect(await controller.create(createPersonDto)).toBe(mockPerson);
|
||||||
@@ -106,7 +96,7 @@ describe('PersonsController', () => {
|
|||||||
it('should update a person', async () => {
|
it('should update a person', async () => {
|
||||||
const id = 'person1';
|
const id = 'person1';
|
||||||
const updatePersonDto: UpdatePersonDto = {
|
const updatePersonDto: UpdatePersonDto = {
|
||||||
firstName: 'Jane',
|
name: 'Jane Doe',
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(await controller.update(id, updatePersonDto)).toBe(mockPerson);
|
expect(await controller.update(id, updatePersonDto)).toBe(mockPerson);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { PersonsService } from './persons.service';
|
import { PersonsService } from './persons.service';
|
||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { DRIZZLE } from '../../../database/database.module';
|
import { DRIZZLE } from '../../../database/database.module';
|
||||||
import { Gender, OralEaseLevel } from '../dto/create-person.dto';
|
|
||||||
|
|
||||||
describe('PersonsService', () => {
|
describe('PersonsService', () => {
|
||||||
let service: PersonsService;
|
let service: PersonsService;
|
||||||
@@ -11,16 +10,21 @@ describe('PersonsService', () => {
|
|||||||
// Mock data
|
// Mock data
|
||||||
const mockPerson = {
|
const mockPerson = {
|
||||||
id: 'person1',
|
id: 'person1',
|
||||||
firstName: 'John',
|
name: 'John Doe',
|
||||||
lastName: 'Doe',
|
|
||||||
gender: Gender.MALE,
|
|
||||||
technicalLevel: 3,
|
|
||||||
hasTechnicalTraining: true,
|
|
||||||
frenchSpeakingLevel: 4,
|
|
||||||
oralEaseLevel: OralEaseLevel.COMFORTABLE,
|
|
||||||
age: 30,
|
|
||||||
projectId: 'project1',
|
projectId: 'project1',
|
||||||
attributes: {},
|
skills: ['JavaScript', 'TypeScript'],
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Updated mock person for update test
|
||||||
|
const updatedMockPerson = {
|
||||||
|
id: 'person1',
|
||||||
|
name: 'Jane Doe',
|
||||||
|
projectId: 'project1',
|
||||||
|
skills: [],
|
||||||
|
metadata: {},
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -83,20 +87,29 @@ describe('PersonsService', () => {
|
|||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
const createPersonDto = {
|
const createPersonDto = {
|
||||||
|
name: 'John Doe',
|
||||||
|
projectId: 'project1',
|
||||||
|
skills: ['JavaScript', 'TypeScript'],
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expected values that will be passed to the database
|
||||||
|
const expectedPersonData = {
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
gender: Gender.MALE,
|
gender: 'MALE',
|
||||||
technicalLevel: 3,
|
technicalLevel: 3,
|
||||||
hasTechnicalTraining: true,
|
hasTechnicalTraining: true,
|
||||||
frenchSpeakingLevel: 4,
|
frenchSpeakingLevel: 5,
|
||||||
oralEaseLevel: OralEaseLevel.COMFORTABLE,
|
oralEaseLevel: 'COMFORTABLE',
|
||||||
projectId: 'project1',
|
projectId: 'project1',
|
||||||
|
attributes: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await service.create(createPersonDto);
|
const result = await service.create(createPersonDto);
|
||||||
|
|
||||||
expect(mockDb.insert).toHaveBeenCalled();
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
expect(mockDb.values).toHaveBeenCalledWith(createPersonDto);
|
expect(mockDb.values).toHaveBeenCalledWith(expectedPersonData);
|
||||||
expect(result).toEqual(mockPerson);
|
expect(result).toEqual(mockPerson);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -159,27 +172,45 @@ describe('PersonsService', () => {
|
|||||||
it('should update a person', async () => {
|
it('should update a person', async () => {
|
||||||
const id = 'person1';
|
const id = 'person1';
|
||||||
const updatePersonDto = {
|
const updatePersonDto = {
|
||||||
|
name: 'Jane Doe',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the findById method to return a person
|
||||||
|
const existingPerson = {
|
||||||
|
id: 'person1',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
projectId: 'project1',
|
||||||
|
attributes: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
jest.spyOn(service, 'findById').mockResolvedValueOnce(existingPerson);
|
||||||
|
|
||||||
|
// Expected values that will be passed to the database
|
||||||
|
const expectedUpdateData = {
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await service.update(id, updatePersonDto);
|
const result = await service.update(id, updatePersonDto);
|
||||||
|
|
||||||
|
expect(service.findById).toHaveBeenCalledWith(id);
|
||||||
expect(mockDb.update).toHaveBeenCalled();
|
expect(mockDb.update).toHaveBeenCalled();
|
||||||
expect(mockDb.set).toHaveBeenCalled();
|
expect(mockDb.set).toHaveBeenCalledWith(expectedUpdateData);
|
||||||
expect(mockDb.where).toHaveBeenCalled();
|
expect(mockDb.where).toHaveBeenCalled();
|
||||||
expect(result).toEqual(mockPerson);
|
expect(result).toEqual(updatedMockPerson);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if person not found', async () => {
|
it('should throw NotFoundException if person not found', async () => {
|
||||||
const id = 'nonexistent';
|
const id = 'nonexistent';
|
||||||
const updatePersonDto = {
|
const updatePersonDto = {
|
||||||
firstName: 'Jane',
|
name: 'Jane Doe',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockDb.update.mockImplementationOnce(() => mockDbOperations);
|
// Mock the findById method to throw a NotFoundException
|
||||||
mockDbOperations.set.mockImplementationOnce(() => mockDbOperations);
|
jest.spyOn(service, 'findById').mockRejectedValueOnce(new NotFoundException(`Person with ID ${id} not found`));
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => []);
|
|
||||||
|
|
||||||
await expect(service.update(id, updatePersonDto)).rejects.toThrow(NotFoundException);
|
await expect(service.update(id, updatePersonDto)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
@@ -189,6 +220,11 @@ describe('PersonsService', () => {
|
|||||||
it('should delete a person', async () => {
|
it('should delete a person', async () => {
|
||||||
const id = 'person1';
|
const id = 'person1';
|
||||||
|
|
||||||
|
// Mock the database to return a person
|
||||||
|
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.returning.mockImplementationOnce(() => [mockPerson]);
|
||||||
|
|
||||||
const result = await service.remove(id);
|
const result = await service.remove(id);
|
||||||
|
|
||||||
expect(mockDb.delete).toHaveBeenCalled();
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
@@ -199,6 +235,7 @@ describe('PersonsService', () => {
|
|||||||
it('should throw NotFoundException if person not found', async () => {
|
it('should throw NotFoundException if person not found', async () => {
|
||||||
const id = 'nonexistent';
|
const id = 'nonexistent';
|
||||||
|
|
||||||
|
// Mock the database to return no person
|
||||||
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => []);
|
mockDbOperations.returning.mockImplementationOnce(() => []);
|
||||||
@@ -212,6 +249,17 @@ describe('PersonsService', () => {
|
|||||||
const projectId = 'project1';
|
const projectId = 'project1';
|
||||||
const groupId = 'group1';
|
const groupId = 'group1';
|
||||||
|
|
||||||
|
// Mock project check
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => [{ id: projectId }]);
|
||||||
|
|
||||||
|
// Mock group check
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => [{ id: groupId }]);
|
||||||
|
|
||||||
|
// Mock persons query
|
||||||
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.innerJoin.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.innerJoin.mockImplementationOnce(() => mockDbOperations);
|
||||||
@@ -219,11 +267,11 @@ describe('PersonsService', () => {
|
|||||||
|
|
||||||
const result = await service.findByProjectIdAndGroupId(projectId, groupId);
|
const result = await service.findByProjectIdAndGroupId(projectId, groupId);
|
||||||
|
|
||||||
expect(mockDb.select).toHaveBeenCalled();
|
expect(mockDb.select).toHaveBeenCalledTimes(3);
|
||||||
expect(mockDb.from).toHaveBeenCalled();
|
expect(mockDb.from).toHaveBeenCalledTimes(3);
|
||||||
expect(mockDb.innerJoin).toHaveBeenCalled();
|
expect(mockDb.innerJoin).toHaveBeenCalled();
|
||||||
expect(mockDb.where).toHaveBeenCalled();
|
expect(mockDb.where).toHaveBeenCalledTimes(3);
|
||||||
expect(result).toEqual([{ person: mockPerson }]);
|
expect(result).toEqual([mockPerson]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,12 +280,31 @@ describe('PersonsService', () => {
|
|||||||
const personId = 'person1';
|
const personId = 'person1';
|
||||||
const groupId = 'group1';
|
const groupId = 'group1';
|
||||||
|
|
||||||
|
// Mock person check
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => [mockPerson]);
|
||||||
|
|
||||||
|
// Mock group check
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => [mockGroup]);
|
||||||
|
|
||||||
|
// Mock relation check
|
||||||
|
mockDb.select.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.from.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => []);
|
||||||
|
|
||||||
|
// Mock relation creation
|
||||||
mockDb.insert.mockImplementationOnce(() => mockDbOperations);
|
mockDb.insert.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.values.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.values.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
||||||
|
|
||||||
const result = await service.addToGroup(personId, groupId);
|
const result = await service.addToGroup(personId, groupId);
|
||||||
|
|
||||||
|
expect(mockDb.select).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockDb.from).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockDb.where).toHaveBeenCalledTimes(3);
|
||||||
expect(mockDb.insert).toHaveBeenCalled();
|
expect(mockDb.insert).toHaveBeenCalled();
|
||||||
expect(mockDb.values).toHaveBeenCalledWith({
|
expect(mockDb.values).toHaveBeenCalledWith({
|
||||||
personId,
|
personId,
|
||||||
@@ -252,14 +319,16 @@ describe('PersonsService', () => {
|
|||||||
const personId = 'person1';
|
const personId = 'person1';
|
||||||
const groupId = 'group1';
|
const groupId = 'group1';
|
||||||
|
|
||||||
|
// Mock delete operation
|
||||||
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
// The where call with the and() condition
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
mockDbOperations.returning.mockImplementationOnce(() => [mockPersonToGroup]);
|
||||||
|
|
||||||
const result = await service.removeFromGroup(personId, groupId);
|
const result = await service.removeFromGroup(personId, groupId);
|
||||||
|
|
||||||
expect(mockDb.delete).toHaveBeenCalled();
|
expect(mockDb.delete).toHaveBeenCalled();
|
||||||
expect(mockDb.where).toHaveBeenCalled();
|
expect(mockDb.where).toHaveBeenCalledTimes(1);
|
||||||
expect(result).toEqual(mockPersonToGroup);
|
expect(result).toEqual(mockPersonToGroup);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,8 +336,10 @@ describe('PersonsService', () => {
|
|||||||
const personId = 'nonexistent';
|
const personId = 'nonexistent';
|
||||||
const groupId = 'group1';
|
const groupId = 'group1';
|
||||||
|
|
||||||
|
// Mock delete operation to return no relation
|
||||||
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
mockDb.delete.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
|
mockDbOperations.where.mockImplementationOnce(() => mockDbOperations);
|
||||||
mockDbOperations.returning.mockImplementationOnce(() => []);
|
mockDbOperations.returning.mockImplementationOnce(() => []);
|
||||||
|
|
||||||
await expect(service.removeFromGroup(personId, groupId)).rejects.toThrow(NotFoundException);
|
await expect(service.removeFromGroup(personId, groupId)).rejects.toThrow(NotFoundException);
|
||||||
|
|||||||
@@ -126,9 +126,13 @@ describe('ProjectsController', () => {
|
|||||||
it('should check if a user has access to a project', async () => {
|
it('should check if a user has access to a project', async () => {
|
||||||
const projectId = 'project1';
|
const projectId = 'project1';
|
||||||
const userId = 'user1';
|
const userId = 'user1';
|
||||||
|
const mockRes = {
|
||||||
|
json: jest.fn().mockReturnValue(true)
|
||||||
|
};
|
||||||
|
|
||||||
expect(await controller.checkUserAccess(projectId, userId)).toBe(true);
|
await controller.checkUserAccess(projectId, userId, mockRes);
|
||||||
expect(service.checkUserAccess).toHaveBeenCalledWith(projectId, userId);
|
expect(service.checkUserAccess).toHaveBeenCalledWith(projectId, userId);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { ProjectsService } from '../services/projects.service';
|
import { ProjectsService } from '../services/projects.service';
|
||||||
import { CreateProjectDto } from '../dto/create-project.dto';
|
import { CreateProjectDto } from '../dto/create-project.dto';
|
||||||
import { UpdateProjectDto } from '../dto/update-project.dto';
|
import { UpdateProjectDto } from '../dto/update-project.dto';
|
||||||
|
|
||||||
|
@ApiTags('projects')
|
||||||
@Controller('projects')
|
@Controller('projects')
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
constructor(private readonly projectsService: ProjectsService) {}
|
constructor(private readonly projectsService: ProjectsService) {}
|
||||||
@@ -22,6 +24,9 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Create a new project
|
* Create a new project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Create a new project' })
|
||||||
|
@ApiResponse({ status: 201, description: 'The project has been successfully created.' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request.' })
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
create(@Body() createProjectDto: CreateProjectDto) {
|
create(@Body() createProjectDto: CreateProjectDto) {
|
||||||
@@ -31,6 +36,9 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Get all projects or filter by owner ID
|
* Get all projects or filter by owner ID
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Get all projects or filter by owner ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return all projects or projects for a specific owner.' })
|
||||||
|
@ApiQuery({ name: 'ownerId', required: false, description: 'Filter projects by owner ID' })
|
||||||
@Get()
|
@Get()
|
||||||
findAll(@Query('ownerId') ownerId?: string) {
|
findAll(@Query('ownerId') ownerId?: string) {
|
||||||
if (ownerId) {
|
if (ownerId) {
|
||||||
@@ -42,6 +50,10 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Get a project by ID
|
* Get a project by ID
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Get a project by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return the project.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.projectsService.findById(id);
|
return this.projectsService.findById(id);
|
||||||
@@ -50,6 +62,11 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Update a project
|
* Update a project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Update a project' })
|
||||||
|
@ApiResponse({ status: 200, description: 'The project has been successfully updated.' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() updateProjectDto: UpdateProjectDto) {
|
update(@Param('id') id: string, @Body() updateProjectDto: UpdateProjectDto) {
|
||||||
return this.projectsService.update(id, updateProjectDto);
|
return this.projectsService.update(id, updateProjectDto);
|
||||||
@@ -58,6 +75,10 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Delete a project
|
* Delete a project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Delete a project' })
|
||||||
|
@ApiResponse({ status: 204, description: 'The project has been successfully deleted.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
@@ -67,6 +88,11 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Check if a user has access to a project
|
* Check if a user has access to a project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Check if a user has access to a project' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Returns true if the user has access, false otherwise.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'The ID of the user' })
|
||||||
@Get(':id/check-access/:userId')
|
@Get(':id/check-access/:userId')
|
||||||
async checkUserAccess(
|
async checkUserAccess(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -81,6 +107,11 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Add a collaborator to a project
|
* Add a collaborator to a project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Add a collaborator to a project' })
|
||||||
|
@ApiResponse({ status: 201, description: 'The collaborator has been successfully added to the project.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project or user not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'The ID of the user to add as a collaborator' })
|
||||||
@Post(':id/collaborators/:userId')
|
@Post(':id/collaborators/:userId')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
addCollaborator(@Param('id') id: string, @Param('userId') userId: string) {
|
addCollaborator(@Param('id') id: string, @Param('userId') userId: string) {
|
||||||
@@ -90,6 +121,11 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Remove a collaborator from a project
|
* Remove a collaborator from a project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Remove a collaborator from a project' })
|
||||||
|
@ApiResponse({ status: 204, description: 'The collaborator has been successfully removed from the project.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project or collaborator not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'The ID of the user to remove as a collaborator' })
|
||||||
@Delete(':id/collaborators/:userId')
|
@Delete(':id/collaborators/:userId')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
removeCollaborator(@Param('id') id: string, @Param('userId') userId: string) {
|
removeCollaborator(@Param('id') id: string, @Param('userId') userId: string) {
|
||||||
@@ -99,6 +135,10 @@ export class ProjectsController {
|
|||||||
/**
|
/**
|
||||||
* Get all collaborators for a project
|
* Get all collaborators for a project
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Get all collaborators for a project' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return all collaborators for the project.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Project not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the project' })
|
||||||
@Get(':id/collaborators')
|
@Get(':id/collaborators')
|
||||||
getCollaborators(@Param('id') id: string) {
|
getCollaborators(@Param('id') id: string) {
|
||||||
return this.projectsService.getCollaborators(id);
|
return this.projectsService.getCollaborators(id);
|
||||||
|
|||||||
@@ -247,6 +247,11 @@ export class ProjectsService {
|
|||||||
.innerJoin(schema.users, eq(schema.projectCollaborators.userId, schema.users.id))
|
.innerJoin(schema.users, eq(schema.projectCollaborators.userId, schema.users.id))
|
||||||
.where(eq(schema.projectCollaborators.projectId, projectId));
|
.where(eq(schema.projectCollaborators.projectId, projectId));
|
||||||
|
|
||||||
|
// Ensure collaborators is an array before mapping
|
||||||
|
if (!Array.isArray(collaborators)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Map the results to extract just the user objects
|
// Map the results to extract just the user objects
|
||||||
return collaborators.map(collaborator => collaborator.user);
|
return collaborators.map(collaborator => collaborator.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||||
import { UsersService } from '../services/users.service';
|
import { UsersService } from '../services/users.service';
|
||||||
import { CreateUserDto } from '../dto/create-user.dto';
|
import { CreateUserDto } from '../dto/create-user.dto';
|
||||||
import { UpdateUserDto } from '../dto/update-user.dto';
|
import { UpdateUserDto } from '../dto/update-user.dto';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
@@ -20,6 +22,9 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Create a new user
|
* Create a new user
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Create a new user' })
|
||||||
|
@ApiResponse({ status: 201, description: 'The user has been successfully created.' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request.' })
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
create(@Body() createUserDto: CreateUserDto) {
|
create(@Body() createUserDto: CreateUserDto) {
|
||||||
@@ -29,6 +34,8 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Get all users
|
* Get all users
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Get all users' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return all users.' })
|
||||||
@Get()
|
@Get()
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.usersService.findAll();
|
return this.usersService.findAll();
|
||||||
@@ -37,6 +44,10 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Get a user by ID
|
* Get a user by ID
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Get a user by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return the user.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the user' })
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.usersService.findById(id);
|
return this.usersService.findById(id);
|
||||||
@@ -45,6 +56,11 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Update a user
|
* Update a user
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Update a user' })
|
||||||
|
@ApiResponse({ status: 200, description: 'The user has been successfully updated.' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the user' })
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||||
return this.usersService.update(id, updateUserDto);
|
return this.usersService.update(id, updateUserDto);
|
||||||
@@ -53,6 +69,10 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Delete a user
|
* Delete a user
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Delete a user' })
|
||||||
|
@ApiResponse({ status: 204, description: 'The user has been successfully deleted.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the user' })
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
@@ -62,6 +82,10 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Update GDPR consent timestamp
|
* Update GDPR consent timestamp
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Update GDPR consent timestamp' })
|
||||||
|
@ApiResponse({ status: 200, description: 'The GDPR consent timestamp has been successfully updated.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the user' })
|
||||||
@Post(':id/gdpr-consent')
|
@Post(':id/gdpr-consent')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
updateGdprConsent(@Param('id') id: string) {
|
updateGdprConsent(@Param('id') id: string) {
|
||||||
@@ -71,6 +95,10 @@ export class UsersController {
|
|||||||
/**
|
/**
|
||||||
* Export user data (for GDPR compliance)
|
* Export user data (for GDPR compliance)
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Export user data (for GDPR compliance)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Return the user data.' })
|
||||||
|
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||||
|
@ApiParam({ name: 'id', description: 'The ID of the user' })
|
||||||
@Get(':id/export-data')
|
@Get(':id/export-data')
|
||||||
exportUserData(@Param('id') id: string) {
|
exportUserData(@Param('id') id: string) {
|
||||||
return this.usersService.exportUserData(id);
|
return this.usersService.exportUserData(id);
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ describe('UsersService', () => {
|
|||||||
const result = await service.updateGdprConsent(id);
|
const result = await service.updateGdprConsent(id);
|
||||||
|
|
||||||
expect(service.update).toHaveBeenCalledWith(id, { gdprTimestamp: expect.any(Date) });
|
expect(service.update).toHaveBeenCalledWith(id, { gdprTimestamp: expect.any(Date) });
|
||||||
expect(result).toEqual(mockUser);
|
expect(result).toEqual({
|
||||||
|
...mockUser,
|
||||||
|
gdprConsentDate: mockUser.gdprTimestamp
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,6 +247,8 @@ describe('UsersService', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
projects: [mockProject],
|
projects: [mockProject],
|
||||||
|
groups: [],
|
||||||
|
persons: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
|
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { DRIZZLE } from '../../../database/database.module';
|
import { DRIZZLE } from '../../../database/database.module';
|
||||||
import * as schema from '../../../database/schema';
|
import * as schema from '../../../database/schema';
|
||||||
import { CreateUserDto } from '../dto/create-user.dto';
|
import { CreateUserDto } from '../dto/create-user.dto';
|
||||||
@@ -111,17 +111,59 @@ export class UsersService {
|
|||||||
*/
|
*/
|
||||||
async exportUserData(id: string) {
|
async exportUserData(id: string) {
|
||||||
const user = await this.findById(id);
|
const user = await this.findById(id);
|
||||||
|
|
||||||
|
// Get all projects owned by the user
|
||||||
const projects = await this.db
|
const projects = await this.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.projects)
|
.from(schema.projects)
|
||||||
.where(eq(schema.projects.ownerId, id));
|
.where(eq(schema.projects.ownerId, id));
|
||||||
|
|
||||||
// Add empty groups and persons arrays for compatibility with tests
|
// Get all project IDs
|
||||||
|
const projectIds = projects.map(project => project.id);
|
||||||
|
|
||||||
|
// Get all persons in user's projects
|
||||||
|
const persons = projectIds.length > 0
|
||||||
|
? await this.db
|
||||||
|
.select()
|
||||||
|
.from(schema.persons)
|
||||||
|
.where(inArray(schema.persons.projectId, projectIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Get all groups in user's projects
|
||||||
|
const groups = projectIds.length > 0
|
||||||
|
? await this.db
|
||||||
|
.select()
|
||||||
|
.from(schema.groups)
|
||||||
|
.where(inArray(schema.groups.projectId, projectIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Get all project collaborations where the user is a collaborator
|
||||||
|
const collaborations = await this.db
|
||||||
|
.select({
|
||||||
|
collaboration: schema.projectCollaborators,
|
||||||
|
project: schema.projects
|
||||||
|
})
|
||||||
|
.from(schema.projectCollaborators)
|
||||||
|
.innerJoin(
|
||||||
|
schema.projects,
|
||||||
|
eq(schema.projectCollaborators.projectId, schema.projects.id)
|
||||||
|
)
|
||||||
|
.where(eq(schema.projectCollaborators.userId, id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
projects,
|
projects,
|
||||||
groups: [],
|
groups,
|
||||||
persons: []
|
persons,
|
||||||
|
collaborations: collaborations.map(c => ({
|
||||||
|
id: c.collaboration.id,
|
||||||
|
projectId: c.collaboration.projectId,
|
||||||
|
project: {
|
||||||
|
id: c.project.id,
|
||||||
|
name: c.project.name,
|
||||||
|
description: c.project.description
|
||||||
|
}
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
|
|||||||
- ✅ Communication en temps réel avec Socket.IO
|
- ✅ Communication en temps réel avec Socket.IO
|
||||||
- ⏳ Fonctionnalités de conformité RGPD (partiellement implémentées)
|
- ⏳ Fonctionnalités de conformité RGPD (partiellement implémentées)
|
||||||
- ✅ Tests unitaires pour les services et contrôleurs
|
- ✅ Tests unitaires pour les services et contrôleurs
|
||||||
- ⏳ Tests e2e (en cours d'implémentation)
|
- ✅ Tests e2e
|
||||||
- ❌ Documentation API avec Swagger
|
- ✅ Documentation API avec Swagger
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -92,9 +92,9 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
|
|||||||
- [x] Mettre en place le service WebSocket pour la gestion des connexions
|
- [x] Mettre en place le service WebSocket pour la gestion des connexions
|
||||||
|
|
||||||
##### Sécurité et Conformité RGPD
|
##### Sécurité et Conformité RGPD
|
||||||
- [ ] Implémenter la validation des entrées avec class-validator
|
- [x] Implémenter la validation des entrées avec class-validator
|
||||||
- [x] Configurer CORS pour sécuriser les API
|
- [x] Configurer CORS pour sécuriser les API
|
||||||
- [ ] Mettre en place la protection contre les attaques CSRF
|
- [x] Mettre en place la protection contre les attaques CSRF
|
||||||
- [x] Implémenter les fonctionnalités d'export de données utilisateur (RGPD) dans le backend
|
- [x] Implémenter les fonctionnalités d'export de données utilisateur (RGPD) dans le backend
|
||||||
- [ ] Implémenter l'interface frontend pour l'export de données utilisateur
|
- [ ] Implémenter l'interface frontend pour l'export de données utilisateur
|
||||||
- [x] Implémenter le renouvellement du consentement utilisateur dans le backend
|
- [x] Implémenter le renouvellement du consentement utilisateur dans le backend
|
||||||
@@ -107,9 +107,9 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
|
|||||||
- [x] Écrire des tests unitaires pour les fonctionnalités WebSocket
|
- [x] Écrire des tests unitaires pour les fonctionnalités WebSocket
|
||||||
- [x] Écrire des tests unitaires pour les autres services
|
- [x] Écrire des tests unitaires pour les autres services
|
||||||
- [x] Écrire des tests unitaires pour les contrôleurs
|
- [x] Écrire des tests unitaires pour les contrôleurs
|
||||||
- [ ] Développer des tests e2e pour les API
|
- [x] Développer des tests e2e pour les API
|
||||||
- [ ] Configurer Swagger pour la documentation API
|
- [x] Configurer Swagger pour la documentation API
|
||||||
- [ ] Documenter les endpoints API
|
- [x] Documenter les endpoints API
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -174,19 +174,19 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
|
|||||||
## Prochaines Étapes Prioritaires
|
## Prochaines Étapes Prioritaires
|
||||||
|
|
||||||
### Backend (Priorité Haute)
|
### Backend (Priorité Haute)
|
||||||
1. **Tests e2e**
|
1. **Tests e2e** ✅
|
||||||
- Développer des tests e2e pour les API principales
|
- Développer des tests e2e pour les API principales ✅
|
||||||
- Configurer l'environnement de test e2e
|
- Configurer l'environnement de test e2e ✅
|
||||||
- Intégrer les tests e2e dans le pipeline CI/CD
|
- Intégrer les tests e2e dans le pipeline CI/CD ✅
|
||||||
|
|
||||||
2. **Documentation API**
|
2. **Documentation API** ✅
|
||||||
- Configurer Swagger pour la documentation API
|
- Configurer Swagger pour la documentation API ✅
|
||||||
- Documenter tous les endpoints API
|
- Documenter tous les endpoints API ✅
|
||||||
- Générer une documentation interactive
|
- Générer une documentation interactive ✅
|
||||||
|
|
||||||
3. **Sécurité**
|
3. **Sécurité** ✅
|
||||||
- Implémenter la validation des entrées avec class-validator
|
- Implémenter la validation des entrées avec class-validator ✅
|
||||||
- Mettre en place la protection contre les attaques CSRF
|
- Mettre en place la protection contre les attaques CSRF ✅
|
||||||
|
|
||||||
### Frontend (Priorité Haute)
|
### Frontend (Priorité Haute)
|
||||||
1. **Conformité RGPD**
|
1. **Conformité RGPD**
|
||||||
@@ -206,35 +206,35 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
|
|||||||
|
|
||||||
## Progression Globale
|
## Progression Globale
|
||||||
|
|
||||||
| Composant | Progression |
|
| Composant | Progression |
|
||||||
|-----------|-------------|
|
|----------------------------------------|-------------|
|
||||||
| Backend - Structure de Base | 100% |
|
| Backend - Structure de Base | 100% |
|
||||||
| Backend - Base de Données | 100% |
|
| Backend - Base de Données | 100% |
|
||||||
| Backend - Modules Fonctionnels | 100% |
|
| Backend - Modules Fonctionnels | 100% |
|
||||||
| Backend - Authentification | 100% |
|
| Backend - Authentification | 100% |
|
||||||
| Backend - WebSockets | 100% |
|
| Backend - WebSockets | 100% |
|
||||||
| Backend - Tests Unitaires | 100% |
|
| Backend - Tests Unitaires | 100% |
|
||||||
| Backend - Tests e2e | 20% |
|
| Backend - Tests e2e | 100% |
|
||||||
| Backend - Documentation API | 0% |
|
| Backend - Documentation API | 100% |
|
||||||
| Backend - Sécurité et RGPD | 67% |
|
| Backend - Sécurité et RGPD | 100% |
|
||||||
| Frontend - Structure de Base | 100% |
|
| Frontend - Structure de Base | 100% |
|
||||||
| Frontend - Pages et Composants | 100% |
|
| Frontend - Pages et Composants | 100% |
|
||||||
| Frontend - Authentification | 100% |
|
| Frontend - Authentification | 100% |
|
||||||
| Frontend - Intégration API | 90% |
|
| Frontend - Intégration API | 90% |
|
||||||
| Frontend - Communication en Temps Réel | 100% |
|
| Frontend - Communication en Temps Réel | 100% |
|
||||||
| Frontend - Fonctionnalités RGPD | 10% |
|
| Frontend - Fonctionnalités RGPD | 10% |
|
||||||
| Frontend - Tests | 30% |
|
| Frontend - Tests | 30% |
|
||||||
| Frontend - Optimisations | 40% |
|
| Frontend - Optimisations | 40% |
|
||||||
| Déploiement | 70% |
|
| Déploiement | 70% |
|
||||||
|
|
||||||
## Estimation du Temps Restant
|
## Estimation du Temps Restant
|
||||||
|
|
||||||
Basé sur l'état d'avancement actuel et les tâches restantes, l'estimation du temps nécessaire pour compléter le projet est la suivante:
|
Basé sur l'état d'avancement actuel et les tâches restantes, l'estimation du temps nécessaire pour compléter le projet est la suivante:
|
||||||
|
|
||||||
- **Backend**: ~2 semaines
|
- **Backend**: ~1-2 jours
|
||||||
- Tests e2e: 3-4 jours
|
- Tests e2e: ✅ Terminé
|
||||||
- Documentation API avec Swagger: 3-4 jours
|
- Documentation API avec Swagger: ✅ Terminé
|
||||||
- Sécurité (validation des entrées, CSRF): 1-2 jours
|
- Sécurité (validation des entrées, CSRF): ✅ Terminé
|
||||||
- Finalisation des fonctionnalités RGPD: 1-2 jours
|
- Finalisation des fonctionnalités RGPD: 1-2 jours
|
||||||
|
|
||||||
- **Frontend**: ~3 semaines
|
- **Frontend**: ~3 semaines
|
||||||
@@ -247,7 +247,7 @@ Basé sur l'état d'avancement actuel et les tâches restantes, l'estimation du
|
|||||||
- Tests d'intégration complets: 3-4 jours
|
- Tests d'intégration complets: 3-4 jours
|
||||||
- Correction des bugs: 2-3 jours
|
- Correction des bugs: 2-3 jours
|
||||||
|
|
||||||
**Temps total estimé**: 5-6 semaines
|
**Temps total estimé**: 3-4 semaines
|
||||||
|
|
||||||
## Recommandations
|
## Recommandations
|
||||||
|
|
||||||
@@ -279,16 +279,12 @@ Cependant, plusieurs aspects importants restent à finaliser:
|
|||||||
|
|
||||||
1. **Conformité RGPD**: Bien que les fonctionnalités backend pour l'export de données et le renouvellement du consentement soient implémentées, les interfaces frontend correspondantes sont manquantes.
|
1. **Conformité RGPD**: Bien que les fonctionnalités backend pour l'export de données et le renouvellement du consentement soient implémentées, les interfaces frontend correspondantes sont manquantes.
|
||||||
|
|
||||||
2. **Tests e2e et documentation**: Les tests end-to-end et la documentation API avec Swagger sont nécessaires pour assurer la qualité et la maintenabilité du projet.
|
2. **Sécurité**: Les améliorations de sécurité comme la validation des entrées et la protection CSRF ont été implémentées. La configuration CORS a été mise en place avec des paramètres différents pour les environnements de développement et de production.
|
||||||
|
|
||||||
3. **Sécurité**: Des améliorations de sécurité comme la validation des entrées et la protection CSRF sont encore à implémenter. La configuration CORS a été mise en place avec des paramètres différents pour les environnements de développement et de production.
|
3. **Optimisations frontend**: Des optimisations de performance, une meilleure expérience mobile et des tests frontend sont nécessaires pour offrir une expérience utilisateur optimale.
|
||||||
|
|
||||||
4. **Optimisations frontend**: Des optimisations de performance, une meilleure expérience mobile et des tests frontend sont nécessaires pour offrir une expérience utilisateur optimale.
|
|
||||||
|
|
||||||
Les prochaines étapes prioritaires devraient se concentrer sur:
|
Les prochaines étapes prioritaires devraient se concentrer sur:
|
||||||
1. Implémenter les interfaces frontend pour la conformité RGPD
|
1. Implémenter les interfaces frontend pour la conformité RGPD
|
||||||
2. Développer des tests e2e pour valider l'intégration complète
|
2. Optimiser les performances du frontend
|
||||||
3. Ajouter la documentation API avec Swagger
|
|
||||||
4. Renforcer la sécurité du backend
|
|
||||||
|
|
||||||
En suivant ces recommandations, le projet pourra atteindre un niveau de qualité production dans les 5-6 semaines à venir, offrant une application complète, sécurisée et conforme aux normes actuelles.
|
En suivant ces recommandations, le projet pourra atteindre un niveau de qualité production dans les 3-4 semaines à venir, offrant une application complète, sécurisée et conforme aux normes actuelles.
|
||||||
|
|||||||
108
pnpm-lock.yaml
generated
108
pnpm-lock.yaml
generated
@@ -46,6 +46,12 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.2
|
specifier: ^0.14.2
|
||||||
version: 0.14.2
|
version: 0.14.2
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
|
csurf:
|
||||||
|
specifier: ^1.11.0
|
||||||
|
version: 1.11.0
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.5.0
|
specifier: ^16.5.0
|
||||||
version: 16.5.0
|
version: 16.5.0
|
||||||
@@ -3223,10 +3229,21 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
cookie-signature@1.0.6:
|
||||||
|
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||||
|
|
||||||
cookie-signature@1.2.2:
|
cookie-signature@1.2.2:
|
||||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||||
engines: {node: '>=6.6.0'}
|
engines: {node: '>=6.6.0'}
|
||||||
|
|
||||||
|
cookie@0.4.0:
|
||||||
|
resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
cookie@0.7.2:
|
cookie@0.7.2:
|
||||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3262,9 +3279,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
csrf@3.1.0:
|
||||||
|
resolution: {integrity: sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
csurf@1.11.0:
|
||||||
|
resolution: {integrity: sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
deprecated: This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions
|
||||||
|
|
||||||
d3-array@3.2.4:
|
d3-array@3.2.4:
|
||||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3371,6 +3397,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
depd@1.1.2:
|
||||||
|
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4010,6 +4040,10 @@ packages:
|
|||||||
http-cache-semantics@4.2.0:
|
http-cache-semantics@4.2.0:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
|
http-errors@1.7.3:
|
||||||
|
resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5041,6 +5075,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
random-bytes@1.0.0:
|
||||||
|
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||||
|
|
||||||
@@ -5202,6 +5240,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rndm@1.2.0:
|
||||||
|
resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==}
|
||||||
|
|
||||||
router@2.2.0:
|
router@2.2.0:
|
||||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -5267,6 +5308,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
setprototypeof@1.1.1:
|
||||||
|
resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
@@ -5373,6 +5417,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
statuses@1.5.0:
|
||||||
|
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
statuses@2.0.1:
|
statuses@2.0.1:
|
||||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5554,6 +5602,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
||||||
|
toidentifier@1.0.0:
|
||||||
|
resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
toidentifier@1.0.1:
|
toidentifier@1.0.1:
|
||||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -5628,6 +5680,10 @@ packages:
|
|||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
tsscmp@1.0.6:
|
||||||
|
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||||
|
engines: {node: '>=0.6.x'}
|
||||||
|
|
||||||
tw-animate-css@1.2.9:
|
tw-animate-css@1.2.9:
|
||||||
resolution: {integrity: sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg==}
|
resolution: {integrity: sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg==}
|
||||||
|
|
||||||
@@ -5673,6 +5729,10 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uid-safe@2.1.5:
|
||||||
|
resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
uid2@0.0.4:
|
uid2@0.0.4:
|
||||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||||
|
|
||||||
@@ -8875,8 +8935,17 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
dependencies:
|
||||||
|
cookie: 0.7.2
|
||||||
|
cookie-signature: 1.0.6
|
||||||
|
|
||||||
|
cookie-signature@1.0.6: {}
|
||||||
|
|
||||||
cookie-signature@1.2.2: {}
|
cookie-signature@1.2.2: {}
|
||||||
|
|
||||||
|
cookie@0.4.0: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
cookiejar@2.1.4: {}
|
cookiejar@2.1.4: {}
|
||||||
@@ -8920,8 +8989,21 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
csrf@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
rndm: 1.2.0
|
||||||
|
tsscmp: 1.0.6
|
||||||
|
uid-safe: 2.1.5
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
|
csurf@1.11.0:
|
||||||
|
dependencies:
|
||||||
|
cookie: 0.4.0
|
||||||
|
cookie-signature: 1.0.6
|
||||||
|
csrf: 3.1.0
|
||||||
|
http-errors: 1.7.3
|
||||||
|
|
||||||
d3-array@3.2.4:
|
d3-array@3.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
internmap: 2.0.3
|
internmap: 2.0.3
|
||||||
@@ -8997,6 +9079,8 @@ snapshots:
|
|||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
|
depd@1.1.2: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
@@ -9705,6 +9789,14 @@ snapshots:
|
|||||||
|
|
||||||
http-cache-semantics@4.2.0: {}
|
http-cache-semantics@4.2.0: {}
|
||||||
|
|
||||||
|
http-errors@1.7.3:
|
||||||
|
dependencies:
|
||||||
|
depd: 1.1.2
|
||||||
|
inherits: 2.0.4
|
||||||
|
setprototypeof: 1.1.1
|
||||||
|
statuses: 1.5.0
|
||||||
|
toidentifier: 1.0.0
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -10807,6 +10899,8 @@ snapshots:
|
|||||||
|
|
||||||
quick-lru@5.1.1: {}
|
quick-lru@5.1.1: {}
|
||||||
|
|
||||||
|
random-bytes@1.0.0: {}
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -10963,6 +11057,8 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rndm@1.2.0: {}
|
||||||
|
|
||||||
router@2.2.0:
|
router@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
@@ -11049,6 +11145,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
setprototypeof@1.1.1: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
sharp@0.34.1:
|
sharp@0.34.1:
|
||||||
@@ -11204,6 +11302,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 2.0.0
|
escape-string-regexp: 2.0.0
|
||||||
|
|
||||||
|
statuses@1.5.0: {}
|
||||||
|
|
||||||
statuses@2.0.1: {}
|
statuses@2.0.1: {}
|
||||||
|
|
||||||
streamsearch@1.1.0: {}
|
streamsearch@1.1.0: {}
|
||||||
@@ -11393,6 +11493,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|
||||||
|
toidentifier@1.0.0: {}
|
||||||
|
|
||||||
toidentifier@1.0.1: {}
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
token-types@6.0.0:
|
token-types@6.0.0:
|
||||||
@@ -11472,6 +11574,8 @@ snapshots:
|
|||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tsscmp@1.0.6: {}
|
||||||
|
|
||||||
tw-animate-css@1.2.9: {}
|
tw-animate-css@1.2.9: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
@@ -11511,6 +11615,10 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.8.3: {}
|
typescript@5.8.3: {}
|
||||||
|
|
||||||
|
uid-safe@2.1.5:
|
||||||
|
dependencies:
|
||||||
|
random-bytes: 1.0.0
|
||||||
|
|
||||||
uid2@0.0.4: {}
|
uid2@0.0.4: {}
|
||||||
|
|
||||||
uid@2.0.2:
|
uid@2.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user