This commit is contained in:
2026-01-14 01:10:01 +03:00
parent 24c5581d7b
commit 2ce092aa59
40 changed files with 2001 additions and 297 deletions

View File

@ -7,3 +7,7 @@ DB_DATABASE=teamplanner
# App
PORT=4001
# Keycloak
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
KEYCLOAK_CLIENT_ID=team-planner-frontend

View File

@ -30,11 +30,15 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^16.4.7",
"jwks-rsa": "^3.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@ -49,6 +53,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",

View File

@ -1,16 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Public } from './auth';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@Public()
getHello(): string {
return this.appService.getHello();
}
@Get('health')
@Public()
health(): { status: string } {
return { status: 'ok' };
}

View File

@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { IdeasModule } from './ideas/ideas.module';
import { AuthModule, JwtAuthGuard } from './auth';
@Module({
imports: [
@ -26,9 +28,16 @@ import { IdeasModule } from './ideas/ideas.module';
synchronize: false,
}),
}),
AuthModule,
IdeasModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
providers: [JwtStrategy, JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule {}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,4 @@
export * from './auth.module';
export * from './jwt-auth.guard';
export * from './jwt.strategy';
export * from './decorators/public.decorator';

View File

@ -0,0 +1,24 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: string;
preferred_username: string;
email: string;
given_name?: string;
family_name?: string;
}
export interface AuthUser {
userId: string;
username: string;
email: string;
firstName?: string;
lastName?: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
const realmUrl = configService.get<string>('KEYCLOAK_REALM_URL');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
issuer: realmUrl,
algorithms: ['RS256'],
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${realmUrl}/protocol/openid-connect/certs`,
}),
});
}
validate(payload: JwtPayload): AuthUser {
return {
userId: payload.sub,
username: payload.preferred_username,
email: payload.email,
firstName: payload.given_name,
lastName: payload.family_name,
};
}
}

View File

@ -1,3 +1,4 @@
export * from './create-idea.dto';
export * from './update-idea.dto';
export * from './query-ideas.dto';
export * from './reorder-ideas.dto';

View File

@ -0,0 +1,26 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsUUID,
Min,
ValidateNested,
ArrayMinSize,
} from 'class-validator';
export class ReorderItemDto {
@IsUUID()
id: string;
@IsInt()
@Min(0)
order: number;
}
export class ReorderIdeasDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ReorderItemDto)
items: ReorderItemDto[];
}

View File

@ -10,7 +10,12 @@ import {
ParseUUIDPipe,
} from '@nestjs/common';
import { IdeasService } from './ideas.service';
import { CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto } from './dto';
import {
CreateIdeaDto,
UpdateIdeaDto,
QueryIdeasDto,
ReorderIdeasDto,
} from './dto';
@Controller('ideas')
export class IdeasController {
@ -31,6 +36,11 @@ export class IdeasController {
return this.ideasService.getModules();
}
@Patch('reorder')
reorder(@Body() reorderIdeasDto: ReorderIdeasDto) {
return this.ideasService.reorder(reorderIdeasDto.items);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ideasService.findOne(id);

View File

@ -123,4 +123,12 @@ export class IdeasService {
return result.map((r) => r.module).filter(Boolean);
}
async reorder(items: { id: string; order: number }[]): Promise<void> {
await this.ideasRepository.manager.transaction(async (manager) => {
for (const item of items) {
await manager.update(Idea, item.id, { order: item.order });
}
});
}
}