🚀 release: v3.0.0
1
apps/server/.env.example
Normal file
@ -0,0 +1 @@
|
||||
PORT=3100
|
||||
18
apps/server/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
15
apps/server/jest.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
displayName: 'server',
|
||||
preset: '../../jest.preset.js',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
},
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': 'ts-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/server',
|
||||
};
|
||||
52
apps/server/project.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"root": "apps/server",
|
||||
"sourceRoot": "apps/server/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/node:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/server",
|
||||
"main": "apps/server/src/main.ts",
|
||||
"tsConfig": "apps/server/tsconfig.app.json",
|
||||
"assets": ["apps/server/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/server/src/environments/environment.ts",
|
||||
"with": "apps/server/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nrwl/node:execute",
|
||||
"options": {
|
||||
"buildTarget": "server:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/server/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/apps/server"],
|
||||
"options": {
|
||||
"jestConfig": "apps/server/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
46
apps/server/src/app.module.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||
import { FontsModule } from './fonts/fonts.module';
|
||||
import { IntegrationsModule } from './integrations/integrations.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { PrinterModule } from './printer/printer.module';
|
||||
import { ResumeModule } from './resume/resume.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, 'assets'),
|
||||
}),
|
||||
ConfigModule,
|
||||
DatabaseModule,
|
||||
ScheduleModule.forRoot(),
|
||||
AppModule,
|
||||
AuthModule,
|
||||
MailModule.register(),
|
||||
UsersModule,
|
||||
ResumeModule,
|
||||
FontsModule,
|
||||
IntegrationsModule,
|
||||
PrinterModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ClassSerializerInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
BIN
apps/server/src/assets/covers/cover-0ee139.jpeg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/server/src/assets/covers/cover-1ab08.jpeg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/server/src/assets/covers/cover-1f8c9.jpeg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/server/src/assets/covers/cover-1fe54f.jpeg
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
apps/server/src/assets/covers/cover-253f4a.jpeg
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
apps/server/src/assets/covers/cover-33aec.jpeg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/server/src/assets/covers/cover-3sc.jpeg
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
apps/server/src/assets/covers/cover-466cb.jpeg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/server/src/assets/covers/cover-478b3.jpeg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/server/src/assets/covers/cover-4d9.jpeg
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
apps/server/src/assets/covers/cover-4ed.jpeg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
apps/server/src/assets/covers/cover-4fd88.jpeg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
apps/server/src/assets/covers/cover-50f3f3.jpeg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/server/src/assets/covers/cover-6b8ae.jpeg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
apps/server/src/assets/covers/cover-6fa09.jpeg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/server/src/assets/covers/cover-713b2f.jpeg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
apps/server/src/assets/covers/cover-737f2.jpeg
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
apps/server/src/assets/covers/cover-73dab8.jpeg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
apps/server/src/assets/covers/cover-79df42.jpeg
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
apps/server/src/assets/covers/cover-7b601.jpeg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
apps/server/src/assets/covers/cover-7dh.jpeg
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
apps/server/src/assets/covers/cover-7e6ae.jpeg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/server/src/assets/covers/cover-94b.jpeg
Normal file
|
After Width: | Height: | Size: 329 KiB |
BIN
apps/server/src/assets/covers/cover-96bdd.jpeg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/server/src/assets/covers/cover-98afd.jpeg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
apps/server/src/assets/covers/cover-9hk.jpeg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
apps/server/src/assets/covers/cover-b26e75.jpeg
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
apps/server/src/assets/covers/cover-b6ea6.jpeg
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
apps/server/src/assets/covers/cover-c219f2.jpeg
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
apps/server/src/assets/covers/cover-c3642.jpeg
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
apps/server/src/assets/covers/cover-c584b.jpeg
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
apps/server/src/assets/covers/cover-c682cb.jpeg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/server/src/assets/covers/cover-c82a8.jpeg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
apps/server/src/assets/covers/cover-d312a7.jpeg
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
apps/server/src/assets/covers/cover-dcbd8.jpeg
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
apps/server/src/assets/covers/cover-df274.jpeg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
apps/server/src/assets/covers/cover-e26ee.jpeg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/server/src/assets/covers/cover-f3034.jpeg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
apps/server/src/assets/covers/cover-fec87.jpeg
Normal file
|
After Width: | Height: | Size: 205 KiB |
13
apps/server/src/assets/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Server | Reactive Resume</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
There's nothing here but a bunch of APIs.
|
||||
</body>
|
||||
</html>
|
||||
66
apps/server/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { User } from '@/decorators/user.decorator';
|
||||
import { User as UserEntity } from '@/users/entities/user.entity';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt.guard';
|
||||
import { LocalAuthGuard } from './guards/local.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
authenticate(@User() user: UserEntity) {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Post('google')
|
||||
async loginWithGoogle(@Body('accessToken') googleAccessToken: string) {
|
||||
const user = await this.authService.authenticateWithGoogle(googleAccessToken);
|
||||
const accessToken = this.authService.getAccessToken(user.id);
|
||||
|
||||
return { user, accessToken };
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
const user = await this.authService.register(registerDto);
|
||||
const accessToken = this.authService.getAccessToken(user.id);
|
||||
|
||||
return { user, accessToken };
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
async login(@User() user: UserEntity) {
|
||||
const accessToken = this.authService.getAccessToken(user.id);
|
||||
|
||||
return { user, accessToken };
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Post('forgot-password')
|
||||
forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
return this.authService.forgotPassword(forgotPasswordDto.email);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Post('reset-password')
|
||||
resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
||||
return this.authService.resetPassword(resetPasswordDto);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete()
|
||||
async remove(@User('id') id: number) {
|
||||
await this.authService.removeUser(id);
|
||||
}
|
||||
}
|
||||
33
apps/server/src/auth/auth.module.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { UsersModule } from '@/users/users.module';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategy/jwt.strategy';
|
||||
import { LocalStrategy } from './strategy/local.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('auth.jwtSecret'),
|
||||
signOptions: {
|
||||
expiresIn: `${configService.get<number>('auth.jwtExpiryTime')}s`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
141
apps/server/src/auth/auth.service.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { google } from 'googleapis';
|
||||
|
||||
import { PostgresErrorCode } from '@/database/errorCodes.enum';
|
||||
import { CreateGoogleUserDto } from '@/users/dto/create-google-user.dto';
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
import { UsersService } from '@/users/users.service';
|
||||
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private schedulerRegistry: SchedulerRegistry,
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async register(registerDto: RegisterDto) {
|
||||
const hashedPassword = await bcrypt.hash(registerDto.password, 10);
|
||||
|
||||
try {
|
||||
const createdUser = await this.usersService.create({
|
||||
...registerDto,
|
||||
password: hashedPassword,
|
||||
provider: 'email',
|
||||
});
|
||||
|
||||
return createdUser;
|
||||
} catch (error: any) {
|
||||
if (error?.code === PostgresErrorCode.UniqueViolation) {
|
||||
throw new HttpException('A user with that username and/or email already exists.', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
'Something went wrong. Please try again later, or raise an issue on GitHub if the problem persists.',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(identifier: string, password: string) {
|
||||
try {
|
||||
const user = await this.usersService.findByIdentifier(identifier);
|
||||
|
||||
await this.verifyPassword(password, user.password);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
'The username/email and password combination provided was incorrect.',
|
||||
HttpStatus.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPassword(password: string, hashedPassword: string) {
|
||||
const isPasswordMatching = await bcrypt.compare(password, hashedPassword);
|
||||
|
||||
if (!isPasswordMatching) {
|
||||
throw new HttpException(
|
||||
'The username/email and password combination provided was incorrect.',
|
||||
HttpStatus.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
forgotPassword(email: string) {
|
||||
this.usersService.generateResetToken(email);
|
||||
}
|
||||
|
||||
async resetPassword(resetPasswordDto: ResetPasswordDto) {
|
||||
const user = await this.usersService.findByResetToken(resetPasswordDto.resetToken);
|
||||
const hashedPassword = await bcrypt.hash(resetPasswordDto.password, 10);
|
||||
|
||||
await this.usersService.update(user.id, { password: hashedPassword, resetToken: null });
|
||||
|
||||
try {
|
||||
this.schedulerRegistry.deleteTimeout(`clear-resetToken-${user.id}`);
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
}
|
||||
|
||||
removeUser(id: number) {
|
||||
return this.usersService.remove(id);
|
||||
}
|
||||
|
||||
getAccessToken(id: number) {
|
||||
const expiresIn = this.configService.get<number>('auth.jwtExpiryTime');
|
||||
|
||||
return this.jwtService.sign({ id }, { expiresIn });
|
||||
}
|
||||
|
||||
getUserFromAccessToken(accessToken: string) {
|
||||
const payload: User = this.jwtService.verify(accessToken, {
|
||||
secret: this.configService.get<string>('auth.jwtSecret'),
|
||||
});
|
||||
|
||||
return this.usersService.findById(payload.id);
|
||||
}
|
||||
|
||||
async authenticateWithGoogle(googleAccessToken: string) {
|
||||
const clientID = this.configService.get<string>('google.clientID');
|
||||
const clientSecret = this.configService.get<string>('google.clientSecret');
|
||||
|
||||
const OAuthClient = new google.auth.OAuth2(clientID, clientSecret);
|
||||
OAuthClient.setCredentials({ access_token: googleAccessToken });
|
||||
|
||||
const { email } = await OAuthClient.getTokenInfo(googleAccessToken);
|
||||
|
||||
try {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error.status !== HttpStatus.NOT_FOUND) {
|
||||
throw new Error('Something went wrong, please try again later.');
|
||||
}
|
||||
|
||||
const UserInfoClient = google.oauth2('v2').userinfo;
|
||||
const { data } = await UserInfoClient.get({ auth: OAuthClient });
|
||||
const username = data.email.split('@').at(0);
|
||||
|
||||
const createUserDto: CreateGoogleUserDto = {
|
||||
name: `${data.given_name} ${data.family_name}`,
|
||||
username,
|
||||
email: data.email,
|
||||
provider: 'google',
|
||||
};
|
||||
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/server/src/auth/dto/forgot-password.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
}
|
||||
10
apps/server/src/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
18
apps/server/src/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
11
apps/server/src/auth/dto/reset-password.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
resetToken: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
5
apps/server/src/auth/guards/jwt.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
5
apps/server/src/auth/guards/local.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
11
apps/server/src/auth/guards/optional-jwt.guard.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
|
||||
handleRequest<TUser = User>(err: Error, user: TUser): TUser {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
22
apps/server/src/auth/strategy/jwt.strategy.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
import { UsersService } from '@/users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService, private readonly usersService: UsersService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: configService.get<string>('auth.jwtSecret'),
|
||||
ignoreExpiration: false,
|
||||
});
|
||||
}
|
||||
|
||||
validate({ id }: User): Promise<User> {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
}
|
||||
18
apps/server/src/auth/strategy/local.strategy.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({ usernameField: 'identifier' });
|
||||
}
|
||||
|
||||
async validate(identifier: string, password: string): Promise<User> {
|
||||
return this.authService.getUser(identifier, password);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/config/app.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
timezone: process.env.TZ,
|
||||
environment: process.env.NODE_ENV,
|
||||
secretKey: process.env.SECRET_KEY,
|
||||
port: parseInt(process.env.PORT, 10) || 3100,
|
||||
url: process.env.APP_URL || 'http://localhost:3000',
|
||||
serverUrl: process.env.SERVER_URL || 'http://localhost:3100',
|
||||
}));
|
||||
6
apps/server/src/config/auth.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('auth', () => ({
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
jwtExpiryTime: parseInt(process.env.JWT_EXPIRY_TIME, 10),
|
||||
}));
|
||||
49
apps/server/src/config/config.module.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule as NestConfigModule } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import appConfig from './app.config';
|
||||
import authConfig from './auth.config';
|
||||
import databaseConfig from './database.config';
|
||||
import googleConfig from './google.config';
|
||||
import mailConfig from './mail.config';
|
||||
|
||||
const validationSchema = Joi.object({
|
||||
// App
|
||||
TZ: Joi.string().default('UTC'),
|
||||
PORT: Joi.number().default(3100),
|
||||
SECRET_KEY: Joi.string().required(),
|
||||
APP_URL: Joi.string().default('http://localhost:3000'),
|
||||
SERVER_URL: Joi.string().default('http://localhost:3100'),
|
||||
NODE_ENV: Joi.string().valid('development', 'production').default('development'),
|
||||
|
||||
// Database
|
||||
POSTGRES_HOST: Joi.string().required(),
|
||||
POSTGRES_PORT: Joi.string().required(),
|
||||
POSTGRES_USERNAME: Joi.string().required(),
|
||||
POSTGRES_PASSWORD: Joi.string().required(),
|
||||
POSTGRES_DATABASE: Joi.string().required(),
|
||||
|
||||
// Auth
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
JWT_EXPIRY_TIME: Joi.number().required(),
|
||||
|
||||
// Google
|
||||
GOOGLE_API_KEY: Joi.string().allow(''),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: Joi.string().allow(''),
|
||||
MAIL_PORT: Joi.string().allow(''),
|
||||
MAIL_USERNAME: Joi.string().allow(''),
|
||||
MAIL_PASSWORD: Joi.string().allow(''),
|
||||
});
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestConfigModule.forRoot({
|
||||
load: [appConfig, authConfig, databaseConfig, googleConfig, mailConfig],
|
||||
validationSchema: validationSchema,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
9
apps/server/src/config/database.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('postgres', () => ({
|
||||
host: process.env.POSTGRES_HOST,
|
||||
port: parseInt(process.env.POSTGRES_PORT, 10) || 5432,
|
||||
username: process.env.POSTGRES_USERNAME,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
database: process.env.POSTGRES_DATABASE,
|
||||
}));
|
||||
7
apps/server/src/config/google.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('google', () => ({
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
}));
|
||||
9
apps/server/src/config/mail.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('mail', () => ({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: parseInt(process.env.MAIL_PORT, 10),
|
||||
username: process.env.MAIL_USERNAME,
|
||||
password: process.env.MAIL_PASSWORD,
|
||||
from: process.env.MAIL_FROM,
|
||||
}));
|
||||
2
apps/server/src/constants/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Date Formats
|
||||
export const FILENAME_TIMESTAMP = 'DDMMYYYYHHmmss';
|
||||
23
apps/server/src/database/database.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get<string>('postgres.host'),
|
||||
port: configService.get<number>('postgres.port'),
|
||||
username: configService.get<string>('postgres.username'),
|
||||
password: configService.get<string>('postgres.password'),
|
||||
database: configService.get<string>('postgres.database'),
|
||||
synchronize: configService.get<string>('app.environment') === 'development',
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
3
apps/server/src/database/errorCodes.enum.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export enum PostgresErrorCode {
|
||||
UniqueViolation = '23505',
|
||||
}
|
||||
6
apps/server/src/decorators/cookie.decorator.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const Cookie = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return data ? request.cookies?.[data] : request.cookies;
|
||||
});
|
||||
8
apps/server/src/decorators/user.decorator.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const User = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
});
|
||||
3
apps/server/src/environments/environment.prod.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
};
|
||||
3
apps/server/src/environments/environment.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
};
|
||||
22
apps/server/src/filters/http-exception.filter.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { TypeORMError } from 'typeorm';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const statusCode = exception.getStatus();
|
||||
const message = (exception.getResponse() as TypeORMError).message || exception.message;
|
||||
|
||||
response.status(statusCode).json({
|
||||
statusCode,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
17
apps/server/src/fonts/fonts.controller.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { CacheInterceptor, Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt.guard';
|
||||
|
||||
import { FontsService } from './fonts.service';
|
||||
|
||||
@Controller('fonts')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
export class FontsController {
|
||||
constructor(private fontsService: FontsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
getAll() {
|
||||
return this.fontsService.getAll();
|
||||
}
|
||||
}
|
||||
16
apps/server/src/fonts/fonts.module.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CacheModule, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { FontsController } from './fonts.controller';
|
||||
import { FontsService } from './fonts.service';
|
||||
|
||||
// Every week
|
||||
const cacheTTL = 60 * 60 * 24 * 7;
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, HttpModule, CacheModule.register({ ttl: cacheTTL })],
|
||||
controllers: [FontsController],
|
||||
providers: [FontsService],
|
||||
})
|
||||
export class FontsModule {}
|
||||
21
apps/server/src/fonts/fonts.service.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Font } from '@reactive-resume/schema';
|
||||
import { get } from 'lodash';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class FontsService {
|
||||
constructor(private configService: ConfigService, private httpService: HttpService) {}
|
||||
|
||||
async getAll(): Promise<Font[]> {
|
||||
const apiKey = this.configService.get<string>('google.apiKey');
|
||||
const url = 'https://www.googleapis.com/webfonts/v1/webfonts?key=' + apiKey;
|
||||
|
||||
const response = await firstValueFrom(this.httpService.get(url));
|
||||
const data = get(response.data, 'items', []);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
45
apps/server/src/integrations/integrations.controller.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Controller, HttpException, HttpStatus, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt.guard';
|
||||
import { User } from '@/decorators/user.decorator';
|
||||
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
|
||||
@Controller('integrations')
|
||||
export class IntegrationsController {
|
||||
constructor(private integrationsService: IntegrationsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('linkedin')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
linkedIn(@User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new HttpException('You must upload a valid zip archive downloaded from LinkedIn.', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return this.integrationsService.linkedIn(userId, file.path);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('json-resume')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
jsonResume(@User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new HttpException('You must upload a valid JSON file.', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return this.integrationsService.jsonResume(userId, file.path);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('reactive-resume')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
reactiveResume(@User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new HttpException('You must upload a valid JSON file.', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return this.integrationsService.reactiveResume(userId, file.path);
|
||||
}
|
||||
}
|
||||
37
apps/server/src/integrations/integrations.module.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
import { ResumeModule } from '@/resume/resume.module';
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
|
||||
import { IntegrationsController } from './integrations.controller';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ResumeModule,
|
||||
MulterModule.register({
|
||||
storage: diskStorage({
|
||||
destination: async (req, _, cb) => {
|
||||
const userId = (req.user as User).id;
|
||||
const destination = join(__dirname, `assets/integrations/${userId}`);
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
|
||||
cb(null, destination);
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const filename = new Date().getTime() + extname(file.originalname);
|
||||
|
||||
cb(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [IntegrationsController],
|
||||
providers: [IntegrationsService],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
619
apps/server/src/integrations/integrations.service.ts
Normal file
@ -0,0 +1,619 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Award,
|
||||
Certificate,
|
||||
Education,
|
||||
Interest,
|
||||
Language,
|
||||
Project,
|
||||
Publication,
|
||||
Reference,
|
||||
Resume,
|
||||
Skill,
|
||||
Volunteer,
|
||||
WorkExperience,
|
||||
} from '@reactive-resume/schema';
|
||||
import * as csv from 'csvtojson';
|
||||
import * as dayjs from 'dayjs';
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
import { cloneDeep, get, isEmpty, merge } from 'lodash';
|
||||
import * as SteamZip from 'node-stream-zip';
|
||||
import { DeepPartial } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { FILENAME_TIMESTAMP } from '@/constants/index';
|
||||
import defaultState from '@/resume/data/defaultState';
|
||||
import { Resume as ResumeEntity } from '@/resume/entities/resume.entity';
|
||||
import { ResumeService } from '@/resume/resume.service';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationsService {
|
||||
constructor(private resumeService: ResumeService) {}
|
||||
|
||||
async linkedIn(userId: number, path: string): Promise<ResumeEntity> {
|
||||
let archive: SteamZip.StreamZipAsync;
|
||||
|
||||
try {
|
||||
archive = new SteamZip.async({ file: path });
|
||||
|
||||
const resume: Partial<Resume> = cloneDeep(defaultState);
|
||||
|
||||
// Basics
|
||||
const timestamp = dayjs().format(FILENAME_TIMESTAMP);
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
name: `Imported from LinkedIn (${timestamp})`,
|
||||
slug: `imported-from-linkedin-${timestamp}`,
|
||||
});
|
||||
|
||||
// Profile
|
||||
try {
|
||||
const profileCSV = (await archive.entryData('Profile.csv')).toString();
|
||||
const profile = (await csv().fromString(profileCSV))[0];
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
basics: {
|
||||
name: `${get(profile, 'First Name')} ${get(profile, 'Last Name')}`,
|
||||
headline: get(profile, 'Headline'),
|
||||
location: {
|
||||
address: get(profile, 'Address'),
|
||||
postalCode: get(profile, 'Zip Code'),
|
||||
},
|
||||
summary: get(profile, 'Summary'),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Email
|
||||
try {
|
||||
const emailsCSV = (await archive.entryData('Email Addresses.csv')).toString();
|
||||
const email = (await csv().fromString(emailsCSV))[0];
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
basics: {
|
||||
email: get(email, 'Email Address'),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Phone Number
|
||||
try {
|
||||
const phoneNumbersCSV = (await archive.entryData('PhoneNumbers.csv')).toString();
|
||||
const phoneNumber = (await csv().fromString(phoneNumbersCSV))[0];
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
basics: {
|
||||
phone: get(phoneNumber, 'Number'),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Education
|
||||
try {
|
||||
const educationCSV = (await archive.entryData('Education.csv')).toString();
|
||||
const education = await csv().fromString(educationCSV);
|
||||
education.forEach((school) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
education: {
|
||||
items: [
|
||||
...get(resume, 'sections.education.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
institution: get(school, 'School Name'),
|
||||
degree: get(school, 'Degree Name'),
|
||||
date: {
|
||||
start: dayjs(get(school, 'Start Date')).toISOString(),
|
||||
end: dayjs(get(school, 'End Date')).toISOString(),
|
||||
},
|
||||
} as Education,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Positions
|
||||
try {
|
||||
const positionsCSV = (await archive.entryData('Positions.csv')).toString();
|
||||
const positions = await csv().fromString(positionsCSV);
|
||||
positions.forEach((position) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
work: {
|
||||
items: [
|
||||
...get(resume, 'sections.work.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(position, 'Company Name'),
|
||||
position: get(position, 'Title'),
|
||||
summary: get(position, 'Description'),
|
||||
date: {
|
||||
start: dayjs(get(position, 'Started On')).toISOString(),
|
||||
end: dayjs(get(position, 'Finished On')).toISOString(),
|
||||
},
|
||||
} as WorkExperience,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Certifications
|
||||
try {
|
||||
const certificationsCSV = (await archive.entryData('Certifications.csv')).toString();
|
||||
const certifications = await csv().fromString(certificationsCSV);
|
||||
certifications.forEach((certification) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
certifications: {
|
||||
items: [
|
||||
...get(resume, 'sections.certifications.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(certification, 'Name'),
|
||||
issuer: get(certification, 'Authority'),
|
||||
url: get(certification, 'Url'),
|
||||
date: dayjs(get(certification, 'Started On')).toISOString(),
|
||||
} as Certificate,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Languages
|
||||
try {
|
||||
const languagesCSV = (await archive.entryData('Languages.csv')).toString();
|
||||
const languages = await csv().fromString(languagesCSV);
|
||||
languages.forEach((language) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
languages: {
|
||||
items: [
|
||||
...get(resume, 'sections.languages.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(language, 'Name'),
|
||||
level: 'Beginner',
|
||||
levelNum: 5,
|
||||
} as Language,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Projects
|
||||
try {
|
||||
const projectsCSV = (await archive.entryData('Projects.csv')).toString();
|
||||
const projects = await csv().fromString(projectsCSV);
|
||||
projects.forEach((project) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
projects: {
|
||||
items: [
|
||||
...get(resume, 'sections.projects.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(project, 'Title'),
|
||||
description: get(project, 'Description'),
|
||||
url: get(project, 'Url'),
|
||||
date: {
|
||||
start: dayjs(get(project, 'Started On')).toISOString(),
|
||||
end: dayjs(get(project, 'Finished On')).toISOString(),
|
||||
},
|
||||
} as Project,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Skills
|
||||
try {
|
||||
const skillsCSV = (await archive.entryData('Skills.csv')).toString();
|
||||
const skills = await csv().fromString(skillsCSV);
|
||||
skills.forEach((skill) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
skills: {
|
||||
items: [
|
||||
...get(resume, 'sections.skills.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(skill, 'Name'),
|
||||
level: 'Beginner',
|
||||
levelNum: 5,
|
||||
} as Skill,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
return this.resumeService.import(resume, userId);
|
||||
} catch {
|
||||
throw new HttpException('You must upload a valid zip archive downloaded from LinkedIn.', HttpStatus.BAD_REQUEST);
|
||||
} finally {
|
||||
await unlink(path);
|
||||
!isEmpty(archive) && archive.close();
|
||||
}
|
||||
}
|
||||
|
||||
async jsonResume(userId: number, path: string) {
|
||||
try {
|
||||
const jsonResume = JSON.parse(await readFile(path, 'utf8'));
|
||||
|
||||
const resume: Partial<Resume> = cloneDeep(defaultState);
|
||||
|
||||
// Metadata
|
||||
const timestamp = dayjs().format(FILENAME_TIMESTAMP);
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
name: `Imported from JSON Resume (${timestamp})`,
|
||||
slug: `imported-from-json-resume-${timestamp}`,
|
||||
});
|
||||
|
||||
// Basics
|
||||
try {
|
||||
merge<Partial<Resume>, DeepPartial<Resume>>(resume, {
|
||||
basics: {
|
||||
name: get(jsonResume, 'basics.name'),
|
||||
headline: get(jsonResume, 'basics.label'),
|
||||
photo: {
|
||||
url: get(jsonResume, 'basics.image'),
|
||||
},
|
||||
email: get(jsonResume, 'basics.email'),
|
||||
phone: get(jsonResume, 'basics.phone'),
|
||||
website: get(jsonResume, 'basics.url'),
|
||||
summary: get(jsonResume, 'basics.summary'),
|
||||
location: {
|
||||
address: get(jsonResume, 'basics.location.address'),
|
||||
postalCode: get(jsonResume, 'basics.location.postalCode'),
|
||||
city: get(jsonResume, 'basics.location.city'),
|
||||
country: get(jsonResume, 'basics.location.countryCode'),
|
||||
region: get(jsonResume, 'basics.location.region'),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Profiles
|
||||
try {
|
||||
const profiles: any[] = get(jsonResume, 'basics.profiles', []);
|
||||
profiles.forEach((profile) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
basics: {
|
||||
profiles: [
|
||||
...resume.basics.profiles,
|
||||
{
|
||||
id: uuidv4(),
|
||||
url: get(profile, 'url'),
|
||||
network: get(profile, 'network'),
|
||||
username: get(profile, 'username'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Work
|
||||
try {
|
||||
const work: any[] = get(jsonResume, 'work', []);
|
||||
work.forEach((item) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
work: {
|
||||
items: [
|
||||
...get(resume, 'sections.work.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(item, 'name'),
|
||||
position: get(item, 'position'),
|
||||
summary: get(item, 'summary'),
|
||||
url: get(item, 'url'),
|
||||
date: {
|
||||
start: dayjs(get(item, 'startDate')).toISOString(),
|
||||
end: dayjs(get(item, 'endDate')).toISOString(),
|
||||
},
|
||||
} as WorkExperience,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Volunteer
|
||||
try {
|
||||
const volunteer: any[] = get(jsonResume, 'volunteer', []);
|
||||
volunteer.forEach((item) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
volunteer: {
|
||||
items: [
|
||||
...get(resume, 'sections.volunteer.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
organization: get(item, 'organization'),
|
||||
position: get(item, 'position'),
|
||||
summary: get(item, 'summary'),
|
||||
url: get(item, 'url'),
|
||||
date: {
|
||||
start: dayjs(get(item, 'startDate')).toISOString(),
|
||||
end: dayjs(get(item, 'endDate')).toISOString(),
|
||||
},
|
||||
} as Volunteer,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Education
|
||||
try {
|
||||
const education: any[] = get(jsonResume, 'education', []);
|
||||
education.forEach((item) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
education: {
|
||||
items: [
|
||||
...get(resume, 'sections.education.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
institution: get(item, 'institution'),
|
||||
degree: get(item, 'studyType'),
|
||||
score: get(item, 'score'),
|
||||
area: get(item, 'area'),
|
||||
url: get(item, 'url'),
|
||||
courses: get(item, 'courses', []),
|
||||
date: {
|
||||
start: dayjs(get(item, 'startDate')).toISOString(),
|
||||
end: dayjs(get(item, 'endDate')).toISOString(),
|
||||
},
|
||||
} as Education,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Awards
|
||||
try {
|
||||
const awards: any[] = get(jsonResume, 'awards', []);
|
||||
awards.forEach((award) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
awards: {
|
||||
items: [
|
||||
...get(resume, 'sections.awards.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
title: get(award, 'title'),
|
||||
awarder: get(award, 'awarder'),
|
||||
summary: get(award, 'summary'),
|
||||
url: get(award, 'url'),
|
||||
date: dayjs(get(award, 'date')).toISOString(),
|
||||
} as Award,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Publications
|
||||
try {
|
||||
const publications: any[] = get(jsonResume, 'publications', []);
|
||||
publications.forEach((publication) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
publications: {
|
||||
items: [
|
||||
...get(resume, 'sections.publications.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(publication, 'name'),
|
||||
publisher: get(publication, 'publisher'),
|
||||
summary: get(publication, 'summary'),
|
||||
url: get(publication, 'url'),
|
||||
date: dayjs(get(publication, 'releaseDate')).toISOString(),
|
||||
} as Publication,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Skills
|
||||
try {
|
||||
const skills: any[] = get(jsonResume, 'skills', []);
|
||||
skills.forEach((skill) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
skills: {
|
||||
items: [
|
||||
...get(resume, 'sections.skills.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(skill, 'name'),
|
||||
level: get(skill, 'level'),
|
||||
levelNum: 5,
|
||||
keywords: get(skill, 'keywords', []),
|
||||
} as Skill,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Languages
|
||||
try {
|
||||
const languages: any[] = get(jsonResume, 'languages', []);
|
||||
languages.forEach((language) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
languages: {
|
||||
items: [
|
||||
...get(resume, 'sections.languages.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(language, 'language'),
|
||||
level: get(language, 'fluency'),
|
||||
levelNum: 5,
|
||||
} as Language,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Interests
|
||||
try {
|
||||
const interests: any[] = get(jsonResume, 'interests', []);
|
||||
interests.forEach((interest) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
interests: {
|
||||
items: [
|
||||
...get(resume, 'sections.interests.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(interest, 'name'),
|
||||
keywords: get(interest, 'keywords', []),
|
||||
} as Interest,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// References
|
||||
try {
|
||||
const references: any[] = get(jsonResume, 'references', []);
|
||||
references.forEach((reference) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
references: {
|
||||
items: [
|
||||
...get(resume, 'sections.references.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(reference, 'name'),
|
||||
relationship: get(reference, 'reference'),
|
||||
} as Reference,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
// Projects
|
||||
try {
|
||||
const projects: any[] = get(jsonResume, 'projects', []);
|
||||
projects.forEach((project) => {
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
sections: {
|
||||
projects: {
|
||||
items: [
|
||||
...get(resume, 'sections.projects.items', []),
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: get(project, 'name'),
|
||||
description: get(project, 'description'),
|
||||
summary: get(project, 'highlights', []).join(', '),
|
||||
keywords: get(project, 'keywords'),
|
||||
url: get(project, 'url'),
|
||||
date: {
|
||||
start: dayjs(get(project, 'startDate')).toISOString(),
|
||||
end: dayjs(get(project, 'endDate')).toISOString(),
|
||||
},
|
||||
} as Project,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
|
||||
return this.resumeService.import(resume, userId);
|
||||
} catch {
|
||||
throw new HttpException('You must upload a valid JSON Resume file.', HttpStatus.BAD_REQUEST);
|
||||
} finally {
|
||||
await unlink(path);
|
||||
}
|
||||
}
|
||||
|
||||
async reactiveResume(userId: number, path: string): Promise<ResumeEntity> {
|
||||
try {
|
||||
const jsonResume = JSON.parse(await readFile(path, 'utf8'));
|
||||
|
||||
const resume: Partial<Resume> = cloneDeep(jsonResume);
|
||||
|
||||
// Metadata
|
||||
const timestamp = dayjs().format(FILENAME_TIMESTAMP);
|
||||
merge<Partial<Resume>, Partial<Resume>>(resume, {
|
||||
name: `Imported from Reactive Resume (${timestamp})`,
|
||||
slug: `imported-from-reactive-resume-${timestamp}`,
|
||||
});
|
||||
|
||||
return this.resumeService.import(resume, userId);
|
||||
} catch {
|
||||
throw new HttpException('You must upload a valid JSON Resume file.', HttpStatus.BAD_REQUEST);
|
||||
} finally {
|
||||
await unlink(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/server/src/mail/mail.controller.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Controller, Get, Render } from '@nestjs/common';
|
||||
|
||||
@Controller('mail')
|
||||
export class MailController {
|
||||
@Get('forgot-password')
|
||||
@Render('forgot-password')
|
||||
forgotPassword() {
|
||||
return { name: 'Amruth', url: 'https://amruthpillai.com/' };
|
||||
}
|
||||
}
|
||||
20
apps/server/src/mail/mail.module.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { DynamicModule, Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { MailController } from './mail.controller';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
})
|
||||
export class MailModule {
|
||||
static register(): DynamicModule {
|
||||
return {
|
||||
module: MailModule,
|
||||
controllers: [MailController],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
};
|
||||
}
|
||||
}
|
||||
42
apps/server/src/mail/mail.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readFileSync } from 'fs';
|
||||
import { compile } from 'handlebars';
|
||||
import { createTransport, Transporter } from 'nodemailer';
|
||||
import { join } from 'path';
|
||||
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly transporter: Transporter;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.transporter = createTransport(
|
||||
{
|
||||
host: this.configService.get<string>('mail.host'),
|
||||
port: this.configService.get<number>('mail.host'),
|
||||
auth: {
|
||||
user: this.configService.get<string>('mail.username'),
|
||||
pass: this.configService.get<string>('mail.password'),
|
||||
},
|
||||
},
|
||||
{
|
||||
from: this.configService.get<string>('mail.from'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async sendForgotPasswordEmail(user: User, resetToken: string) {
|
||||
const url = `http://localhost:3000?modal=auth.reset&resetToken=${resetToken}`;
|
||||
const templateSource = readFileSync(join(__dirname, 'templates/forgot-password.hbs'), 'utf-8');
|
||||
const template = compile(templateSource);
|
||||
const html = template({ name: user.name, url });
|
||||
|
||||
await this.transporter.sendMail({
|
||||
to: user.email,
|
||||
subject: 'Reset your Reactive Resume password',
|
||||
html,
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/server/src/mail/templates/forgot-password.hbs
Normal file
@ -0,0 +1,68 @@
|
||||
<html lang='en'>
|
||||
|
||||
<head>
|
||||
<meta charset='UTF-8' />
|
||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<title>Reset Password | Reactive Resume</title>
|
||||
|
||||
<link rel='preconnect' href='https://fonts.googleapis.com' />
|
||||
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
|
||||
<link href='https://fonts.googleapis.com/css2?family=Inter&display=swap' rel='stylesheet' />
|
||||
|
||||
<style>
|
||||
.container { margin: 0; padding: 48px 0; color: #eee; font-size: 14px; background-color: #222;
|
||||
font-family: 'Inter', sans-serif; display: flex; align-items: center; justify-content: center;
|
||||
} .inner { display: flex; align-items: center; flex-direction: column; } .logo {
|
||||
padding-bottom: 32px; } .box { max-width: 640px; padding: 8px 16px; border-radius: 4px;
|
||||
border: 1px solid #444; } .paragraph { line-height: 1.75em; } .button { display: inline-block;
|
||||
color: #222; margin: 6px 0; background-color: #eee; padding: 10px 16px; border-radius: 4px;
|
||||
text-decoration: none; } .footer { opacity: 0.5; align-self: flex-start; margin-top: 16px;
|
||||
max-width: 420px; font-size: 12px; } .footer span { display: block; line-height: 1.75em; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class='container'>
|
||||
|
||||
<div class='inner'>
|
||||
<a href='https://rxresu.me'>
|
||||
<img
|
||||
class='logo'
|
||||
src='https://raw.githubusercontent.com/AmruthPillai/Reactive-Resume/develop/static/images/logo.png'
|
||||
alt='Reactive Resume'
|
||||
width='128px'
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class='box'>
|
||||
<p class='paragraph'>Hey {{name}},</p>
|
||||
|
||||
<p class='paragraph'>
|
||||
Trouble signing in? Don't worry, resetting your password is easy.
|
||||
<br />
|
||||
We'll have you up and running in no time.
|
||||
</p>
|
||||
|
||||
<p class='paragraph'>
|
||||
Just click the button below to set a new password.
|
||||
<br />
|
||||
But hurry, because the link expires in 30 minutes.
|
||||
</p>
|
||||
|
||||
<a class='button' href={{url}} target='_blank'>
|
||||
Reset your password
|
||||
</a>
|
||||
|
||||
<p class='paragraph'>If you did not request to change your password, then you can safely
|
||||
ignore this email.</p>
|
||||
</div>
|
||||
|
||||
<footer class='footer'>
|
||||
<span>By the community, for the community.</span>
|
||||
<span>Thank you for using Reactive Resume.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
36
apps/server/src/main.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const bootstrap = async () => {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Prefix
|
||||
const globalPrefix = 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
|
||||
// Middleware
|
||||
app.enableCors({ credentials: true });
|
||||
app.enableShutdownHooks();
|
||||
app.use(cookieParser());
|
||||
|
||||
// Pipes
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
|
||||
// Email Templates
|
||||
app.setBaseViewsDir(join(__dirname, 'mail/templates'));
|
||||
app.setViewEngine('hbs');
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('app.port');
|
||||
|
||||
await app.listen(port);
|
||||
Logger.log(`🚀 Server is running on: http://localhost:${port}/${globalPrefix}`);
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
13
apps/server/src/printer/printer.controller.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
|
||||
import { PrinterService } from './printer.service';
|
||||
|
||||
@Controller('printer')
|
||||
export class PrinterController {
|
||||
constructor(private readonly printerService: PrinterService) {}
|
||||
|
||||
@Get('/:username/:slug')
|
||||
printAsPdf(@Param('username') username: string, @Param('slug') slug: string): Promise<string> {
|
||||
return this.printerService.printAsPdf(username, slug);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/printer/printer.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { PrinterController } from './printer.controller';
|
||||
import { PrinterService } from './printer.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [PrinterController],
|
||||
providers: [PrinterService],
|
||||
})
|
||||
export class PrinterModule {}
|
||||
86
apps/server/src/printer/printer.service.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { mkdir, unlink, writeFile } from 'fs/promises';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { join } from 'path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { Browser, chromium } from 'playwright';
|
||||
|
||||
export const DELETION_TIME = 10 * 1000; // 10 seconds
|
||||
|
||||
@Injectable()
|
||||
export class PrinterService implements OnModuleInit, OnModuleDestroy {
|
||||
private browser: Browser;
|
||||
|
||||
constructor(private readonly schedulerRegistry: SchedulerRegistry, private readonly configService: ConfigService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.browser = await chromium.launch();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.browser.close();
|
||||
}
|
||||
|
||||
async printAsPdf(username: string, slug: string): Promise<string> {
|
||||
const url = this.configService.get<string>('app.url');
|
||||
const secretKey = this.configService.get<string>('app.secretKey');
|
||||
const serverUrl = this.configService.get<string>('app.serverUrl');
|
||||
|
||||
const page = await this.browser.newPage();
|
||||
|
||||
await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
|
||||
await page.waitForSelector('html.wf-active');
|
||||
|
||||
const resumePages = await page.$$eval('[data-page]', (pages) => {
|
||||
return pages.map((page, index) => ({
|
||||
pageNumber: index + 1,
|
||||
innerHTML: page.innerHTML,
|
||||
height: page.clientHeight,
|
||||
}));
|
||||
});
|
||||
|
||||
const pdf = await PDFDocument.create();
|
||||
const directory = join(__dirname, 'assets', 'resumes');
|
||||
const filename = `RxResume_PDFExport_${nanoid()}.pdf`;
|
||||
const publicUrl = `${serverUrl}/resumes/${filename}`;
|
||||
|
||||
for (let index = 0; index < resumePages.length; index++) {
|
||||
await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]);
|
||||
|
||||
const buffer = await page.pdf({
|
||||
width: '210mm',
|
||||
printBackground: true,
|
||||
height: resumePages[index].height,
|
||||
});
|
||||
|
||||
const pageDoc = await PDFDocument.load(buffer);
|
||||
const copiedPages = await pdf.copyPages(pageDoc, [0]);
|
||||
|
||||
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage));
|
||||
}
|
||||
|
||||
await page.close();
|
||||
|
||||
const pdfBytes = await pdf.save();
|
||||
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, filename), pdfBytes);
|
||||
|
||||
// Delete PDF artifacts after DELETION_TIME ms
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
await unlink(join(directory, filename));
|
||||
|
||||
this.schedulerRegistry.deleteTimeout(`delete-${filename}`);
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
}, DELETION_TIME);
|
||||
|
||||
this.schedulerRegistry.addTimeout(`delete-${filename}`, timeout);
|
||||
|
||||
return publicUrl;
|
||||
}
|
||||
}
|
||||
41
apps/server/src/resume/data/covers.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export const covers = [
|
||||
'cover-0ee139.jpeg',
|
||||
'cover-1ab08.jpeg',
|
||||
'cover-1f8c9.jpeg',
|
||||
'cover-1fe54f.jpeg',
|
||||
'cover-253f4a.jpeg',
|
||||
'cover-33aec.jpeg',
|
||||
'cover-3sc.jpeg',
|
||||
'cover-466cb.jpeg',
|
||||
'cover-478b3.jpeg',
|
||||
'cover-4d9.jpeg',
|
||||
'cover-4ed.jpeg',
|
||||
'cover-4fd88.jpeg',
|
||||
'cover-50f3f3.jpeg',
|
||||
'cover-6b8ae.jpeg',
|
||||
'cover-6fa09.jpeg',
|
||||
'cover-713b2f.jpeg',
|
||||
'cover-737f2.jpeg',
|
||||
'cover-73dab8.jpeg',
|
||||
'cover-79df42.jpeg',
|
||||
'cover-7b601.jpeg',
|
||||
'cover-7dh.jpeg',
|
||||
'cover-7e6ae.jpeg',
|
||||
'cover-94b.jpeg',
|
||||
'cover-96bdd.jpeg',
|
||||
'cover-98afd.jpeg',
|
||||
'cover-9hk.jpeg',
|
||||
'cover-b26e75.jpeg',
|
||||
'cover-b6ea6.jpeg',
|
||||
'cover-c219f2.jpeg',
|
||||
'cover-c3642.jpeg',
|
||||
'cover-c584b.jpeg',
|
||||
'cover-c682cb.jpeg',
|
||||
'cover-c82a8.jpeg',
|
||||
'cover-d312a7.jpeg',
|
||||
'cover-dcbd8.jpeg',
|
||||
'cover-df274.jpeg',
|
||||
'cover-e26ee.jpeg',
|
||||
'cover-f3034.jpeg',
|
||||
'cover-fec87.jpeg',
|
||||
];
|
||||
161
apps/server/src/resume/data/defaultState.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { Resume } from '@reactive-resume/schema';
|
||||
|
||||
const defaultCSS = `/* Enter custom CSS here */
|
||||
|
||||
* {
|
||||
outline: 1px solid #000;
|
||||
}`;
|
||||
|
||||
const defaultState: Partial<Resume> = {
|
||||
basics: {
|
||||
email: '',
|
||||
headline: '',
|
||||
photo: {
|
||||
url: '',
|
||||
visible: true,
|
||||
filters: {
|
||||
size: 128,
|
||||
shape: 'square',
|
||||
border: false,
|
||||
grayscale: false,
|
||||
},
|
||||
},
|
||||
name: '',
|
||||
phone: '',
|
||||
summary: '',
|
||||
website: '',
|
||||
location: {
|
||||
address: '',
|
||||
city: '',
|
||||
country: '',
|
||||
region: '',
|
||||
postalCode: '',
|
||||
},
|
||||
profiles: [],
|
||||
},
|
||||
sections: {
|
||||
work: {
|
||||
id: 'work',
|
||||
name: 'Work Experience',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
education: {
|
||||
id: 'education',
|
||||
name: 'Education',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
awards: {
|
||||
id: 'awards',
|
||||
name: 'Awards',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
certifications: {
|
||||
id: 'certifications',
|
||||
name: 'Certifications',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
publications: {
|
||||
id: 'publications',
|
||||
name: 'Publications',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
skills: {
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
languages: {
|
||||
id: 'languages',
|
||||
name: 'Languages',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
interests: {
|
||||
id: 'interests',
|
||||
name: 'Interests',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
volunteer: {
|
||||
id: 'volunteer',
|
||||
name: 'Volunteer Experience',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
references: {
|
||||
id: 'references',
|
||||
name: 'References',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
projects: {
|
||||
id: 'projects',
|
||||
name: 'Projects',
|
||||
type: 'basic',
|
||||
columns: 2,
|
||||
visible: true,
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
css: {
|
||||
value: defaultCSS,
|
||||
visible: false,
|
||||
},
|
||||
theme: {
|
||||
text: '#000000',
|
||||
background: '#ffffff',
|
||||
primary: '#f44336',
|
||||
},
|
||||
date: {
|
||||
format: 'MMMM DD, YYYY',
|
||||
},
|
||||
layout: [
|
||||
[
|
||||
['work', 'education', 'projects', 'volunteer', 'references'],
|
||||
['skills', 'interests', 'languages', 'awards', 'certifications', 'publications'],
|
||||
],
|
||||
],
|
||||
language: 'en',
|
||||
template: 'kakuna',
|
||||
typography: {
|
||||
family: {
|
||||
heading: 'Open Sans',
|
||||
body: 'Open Sans',
|
||||
},
|
||||
size: {
|
||||
heading: 28,
|
||||
body: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
public: true,
|
||||
};
|
||||
|
||||
export default defaultState;
|
||||
459
apps/server/src/resume/data/sampleData.ts
Normal file
@ -0,0 +1,459 @@
|
||||
import { Resume } from '../entities/resume.entity';
|
||||
|
||||
const sampleData: Partial<Resume> = {
|
||||
basics: {
|
||||
name: 'Alexis Jones',
|
||||
email: 'alexis.jones@gmail.com',
|
||||
phone: '+1 800 1200 3820',
|
||||
photo: {
|
||||
url: `${process.env.APP_URL}/images/sample-photo.jpg`,
|
||||
filters: {
|
||||
size: 128,
|
||||
shape: 'rounded-square',
|
||||
grayscale: false,
|
||||
border: false,
|
||||
},
|
||||
visible: true,
|
||||
},
|
||||
summary:
|
||||
'I am a creative frontend developer offering 4+ years of experience providing high-impact web solutions for diverse industry organizations. Skilled in designing, developing and testing multiple web-based applications incorporating a range of technologies. Aspiring to combine broad background with strong technical skills to excel as a frontend web developer.',
|
||||
website: 'alexisjones.com',
|
||||
headline: 'Highly Creative Frontend Web Developer',
|
||||
location: {
|
||||
city: 'Stuttgart',
|
||||
region: 'Baden-Württemberg',
|
||||
address: 'Ollenhauer Str. 51',
|
||||
country: 'Germany',
|
||||
postalCode: '70376',
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
id: '4df61ffc-e48b-43f1-9434-add35d9cb155',
|
||||
url: 'https://linkedin.com/in/AlexisJones',
|
||||
network: 'LinkedIn',
|
||||
username: 'AlexisJones',
|
||||
},
|
||||
{
|
||||
id: '8f77327d-4484-40b4-92eb-65eaa6aae5f4',
|
||||
url: 'https://dribbble.com/AlexisJones',
|
||||
network: 'Dribbble',
|
||||
username: 'AlexisJones',
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: {
|
||||
work: {
|
||||
id: 'work',
|
||||
name: 'Work Experience',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
id: 'fe280c61-9d92-4dba-8a08-274866470096',
|
||||
url: 'https://www.espritcam.com',
|
||||
date: {
|
||||
end: '',
|
||||
start: '2015-09-01T16:34:27.000Z',
|
||||
},
|
||||
name: 'DP Technology Corp.',
|
||||
summary:
|
||||
'- Manage website development projects from initial design through completion, optimizing all cross-browser and multi-platform compatibility.\n- Work closely with programmers and clients to meet project requirements, goals, and desired functionality.\n- Develop and integrate customized themes into WordPress, PHP-Fusion, and Concrete5.\nConduct training for clients on handling website content management systems.\n- Enable site-wide promotions by programming HTML5 canvases to animate particles on web backgrounds.',
|
||||
position: 'Frontend Developer, Stuttgart DE',
|
||||
},
|
||||
{
|
||||
id: '285d78f8-df56-4569-ba6b-cff5ebe5381e',
|
||||
url: 'https://www.vokophone.com',
|
||||
date: {
|
||||
end: '2015-07-31T22:00:00.000Z',
|
||||
start: '2011-05-31T22:00:00.000Z',
|
||||
},
|
||||
name: 'Voko Communications',
|
||||
summary:
|
||||
'- Developed websites from front to backend using PHP, JavaScript, and HTML.\n- Enhanced user experience and accomplish webpage objectives by creating site structure, navigation, page optimization, and graphics integration.\n- Implemented enhancements that improved web functionality and responsiveness.\n- Designed and maintained both corporate and client websites, using scripting languages and content management systems including WordPress.',
|
||||
position: 'Frontend Developer',
|
||||
},
|
||||
],
|
||||
columns: 1,
|
||||
visible: true,
|
||||
},
|
||||
awards: {
|
||||
id: 'awards',
|
||||
name: 'Awards',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
title: 'Blitz Hackathon',
|
||||
awarder: '2nd Place',
|
||||
date: '2018-03-31T22:00:00.000Z',
|
||||
url: '',
|
||||
summary: '',
|
||||
id: '657cadb0-c07d-4a35-8351-9079598c7ac0',
|
||||
},
|
||||
{
|
||||
title: 'Carl-Zeiss Hackathon',
|
||||
awarder: '2nd Place',
|
||||
date: '2017-05-09T22:00:00.000Z',
|
||||
url: '',
|
||||
summary: '',
|
||||
id: 'db3bc5cb-483e-4221-9867-9c28ee5f2051',
|
||||
},
|
||||
{
|
||||
title: 'JP Morgan Chase - Code for Good',
|
||||
awarder: '3rd Place',
|
||||
date: '2018-03-12T23:00:00.000Z',
|
||||
url: '',
|
||||
summary: '',
|
||||
id: '31eb2547-4175-494f-a16a-0891aea483b7',
|
||||
},
|
||||
],
|
||||
columns: 3,
|
||||
visible: true,
|
||||
},
|
||||
skills: {
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
id: 'e27660b2-2b0f-48b0-9b04-3597f0282d06',
|
||||
name: 'Frontend Web Development',
|
||||
level: 'Expert',
|
||||
keywords: ['ReactJS', 'HTML/CSS', 'jQuery', 'PHP'],
|
||||
levelNum: 10,
|
||||
},
|
||||
{
|
||||
name: 'Backend Development',
|
||||
level: 'Novice',
|
||||
levelNum: 8,
|
||||
keywords: ['NodeJS', 'Springboot', 'Python/Flask', 'Postman'],
|
||||
id: '2f98e07e-21f7-4b40-81e3-4cf529d43339',
|
||||
},
|
||||
{
|
||||
id: 'bf4253f2-7829-432c-a1d5-07446e7ae873',
|
||||
name: 'Adobe Creative Cloud',
|
||||
level: 'Novice',
|
||||
keywords: ['Photoshop', 'Illustrator', 'InDesign', 'Fireworks'],
|
||||
levelNum: 8,
|
||||
},
|
||||
{
|
||||
id: '0b4a6206-7a2b-47a4-b71d-59c24ceee219',
|
||||
name: 'Content Management Systems',
|
||||
level: 'Intermediate',
|
||||
keywords: ['Wordpress', 'Joomla', 'Mailchimp'],
|
||||
levelNum: 6,
|
||||
},
|
||||
],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
projects: {
|
||||
id: 'projects',
|
||||
name: 'Projects',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
name: 'Fintech News Inc.',
|
||||
description: 'Backend Developer',
|
||||
date: {
|
||||
start: '2020-01-01T17:14:14.000Z',
|
||||
end: '2020-04-01T16:14:20.000Z',
|
||||
},
|
||||
url: '',
|
||||
summary:
|
||||
'- Created a content management system serving as a client interface that reduced download times by 30%.\n- Developed new admin panel, which improved internal operating efficiency by over 40%.\n- Created comprehensive testing regime using RSpec to ensure bug-free code.\n- Rebuilt entire website with up to date technologies and frameworks.',
|
||||
keywords: ['Python', 'PHP', 'Ruby', 'Javascript'],
|
||||
id: '8c12add5-605a-449f-a8a6-e7625c702e60',
|
||||
},
|
||||
{
|
||||
name: 'Systron Solutions, San Francisco, CA',
|
||||
description: 'Inside Sales Associate',
|
||||
date: {
|
||||
start: '2020-01-01T17:14:14.000Z',
|
||||
end: '2020-04-01T16:14:20.000Z',
|
||||
},
|
||||
url: '',
|
||||
summary:
|
||||
'- Performed an average of 90+ cold calls daily creating three new qualified prospects exceeding company average by 10%.\n- Managed a $1 million pipeline that supported the creation of 50 new accounts.\n- Sold SaaS and Cloud offering to key accounts including California State University, Ace Athetics and BMI, succeeding in reducing back-up time by 50%.\n\n**Key Projects:** Worked with IT team to create a new web-based leads-generating system, resulting in closed sales increasing by 18% contributing to a $1.5 million increase in profits.',
|
||||
keywords: ['Sales & Marketing', 'Chain Management'],
|
||||
id: 'ec58bb49-a6b1-49ed-9ff6-860a44663ed7',
|
||||
},
|
||||
],
|
||||
columns: 1,
|
||||
visible: true,
|
||||
},
|
||||
education: {
|
||||
id: 'education',
|
||||
name: 'Education',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
id: '3f0eded8-ee1f-4c0e-b4a7-7a0811c150db',
|
||||
url: 'https://www.greenriver.edu',
|
||||
area: 'Computer Science',
|
||||
date: {
|
||||
end: '',
|
||||
start: '2011-01-04T23:00:00.000Z',
|
||||
},
|
||||
score: 'Honors: cum laude (GPA: 3.6/4.0)',
|
||||
degree: 'Bachelor of Science',
|
||||
courses: ['Data Structures and Algorithms', 'Logic Design'],
|
||||
summary: '',
|
||||
institution: 'Green River College',
|
||||
},
|
||||
{
|
||||
id: 'e4977e01-25bf-4524-95c4-20c77c3cf700',
|
||||
url: 'https://www.lsu.edu',
|
||||
area: 'English Literature',
|
||||
date: {
|
||||
end: '2010-12-31T23:00:00.000Z',
|
||||
start: '2008-01-31T23:00:00.000Z',
|
||||
},
|
||||
score: 'Baton Rouge, LA',
|
||||
degree: 'Bachelor of Arts',
|
||||
courses: ['Copywriting', 'Product Analysis'],
|
||||
summary: '',
|
||||
institution: 'Louisiana State University',
|
||||
},
|
||||
],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
interests: {
|
||||
id: 'interests',
|
||||
name: 'Interests',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
name: 'Video Games',
|
||||
keywords: ['FIFA', 'Age of Empires'],
|
||||
id: 'ddebb0e1-0a49-4ca6-be8a-956f10f62307',
|
||||
},
|
||||
{
|
||||
name: 'Football',
|
||||
keywords: ['Manchester United', 'Los Angeles Rams'],
|
||||
id: '2df59b01-8dde-40d5-b3da-f5f5e698e8fa',
|
||||
},
|
||||
{
|
||||
name: 'Mindfulness',
|
||||
keywords: ['Yoga/Meditation', 'Hiking'],
|
||||
id: 'dc1bb429-1baf-4a0c-80ba-4d7a24f66e52',
|
||||
},
|
||||
{
|
||||
name: 'Artificial Intelligence',
|
||||
keywords: ['Machine Learning', 'GPT-3'],
|
||||
id: '9939e616-9f03-4ec0-bb8e-25183925c7fc',
|
||||
},
|
||||
],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
languages: {
|
||||
id: 'languages',
|
||||
name: 'Languages',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
name: 'English',
|
||||
level: 'Native',
|
||||
levelNum: 10,
|
||||
id: 'dd9eb2b8-2956-463b-b0b1-0ffef84f9fc2',
|
||||
},
|
||||
{
|
||||
name: 'German',
|
||||
level: 'B1 (Intermediate)',
|
||||
levelNum: 6,
|
||||
id: '6cf99d85-4efc-4ff8-9a7f-e76abd2d2857',
|
||||
},
|
||||
],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
volunteer: {
|
||||
id: 'volunteer',
|
||||
name: 'Volunteer Experience',
|
||||
type: 'basic',
|
||||
items: [],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
references: {
|
||||
id: 'references',
|
||||
name: 'References',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
name: 'Cindy J. Helmer',
|
||||
relationship: 'CEO/Founder, Copy.ai',
|
||||
phone: '916-609-9531',
|
||||
email: 'cindyjh@joupide.com',
|
||||
summary:
|
||||
'Lorem ipsum dolor sit amet, **consectetur adipiscing elit.** Nam scelerisque ac metus sit amet tempor. Sed luctus dui fermentum aliquet dapibus.',
|
||||
id: '5a114a83-b62c-4b90-a0ef-1ab5516dc0dd',
|
||||
},
|
||||
{
|
||||
name: 'Keisha Whaley',
|
||||
relationship: 'Solutions Architect, AWS',
|
||||
phone: '978-584-6675',
|
||||
email: 'keishawhaley@aws.de',
|
||||
summary:
|
||||
'Morbi a elit semper arcu tempor porta. _Sed tristique eu turpis vitae ultrices._ ~Nullam nec quam~ ac diam eleifend fringilla. Sed congue magna at ante bibendum posuere.',
|
||||
id: 'd866c929-4132-4dab-81c3-8dfcb33f5c0a',
|
||||
},
|
||||
],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
publications: {
|
||||
id: 'publications',
|
||||
name: 'Publications',
|
||||
type: 'basic',
|
||||
items: [],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
certifications: {
|
||||
id: 'certifications',
|
||||
name: 'Certifications',
|
||||
type: 'basic',
|
||||
items: [
|
||||
{
|
||||
name: 'Web Applications for Everbody',
|
||||
issuer: 'Coursera',
|
||||
date: '',
|
||||
url: 'https://www.coursera.org/',
|
||||
summary: '',
|
||||
id: '75b87dcb-56ef-498d-bd26-a7d646bec914',
|
||||
},
|
||||
{
|
||||
name: 'Full-Stack Web Development with Stack',
|
||||
issuer: 'Coursera',
|
||||
date: '',
|
||||
url: 'https://www.coursera.org/',
|
||||
summary: '',
|
||||
id: 'd1057a6c-c2b2-436f-9166-9e17ae591e71',
|
||||
},
|
||||
{
|
||||
name: 'Critical Thinking Masterclass',
|
||||
issuer: 'Khan Academy',
|
||||
date: '',
|
||||
url: 'https://www.khanacademy.org/',
|
||||
summary: '',
|
||||
id: '44fc2443-b6fc-4c39-8e29-55884cb2b8d0',
|
||||
},
|
||||
{
|
||||
name: 'Web Development Bootcamp',
|
||||
issuer: 'Udemy',
|
||||
date: '',
|
||||
url: 'https://udemy.com/',
|
||||
summary: '',
|
||||
id: '7308d2bc-0bb8-4f53-991a-e17506f5e6a1',
|
||||
},
|
||||
],
|
||||
columns: 2,
|
||||
visible: true,
|
||||
},
|
||||
'2d47a563-d0a0-4275-af18-fea3ba6b57b4': {
|
||||
name: 'Soft Skills',
|
||||
type: 'custom',
|
||||
items: [
|
||||
{
|
||||
id: 'bcd19f25-b015-4532-b555-dbcc6f556661',
|
||||
url: '',
|
||||
date: {
|
||||
end: '',
|
||||
start: '',
|
||||
},
|
||||
level: '',
|
||||
title: 'Leadership',
|
||||
summary: '',
|
||||
keywords: ['Collaboration', 'Communication'],
|
||||
levelNum: 8,
|
||||
subtitle: '',
|
||||
},
|
||||
{
|
||||
id: 'e6fde8df-dcc8-4481-b872-2c298e7a3bbf',
|
||||
url: '',
|
||||
date: {
|
||||
end: '',
|
||||
start: '',
|
||||
},
|
||||
level: '',
|
||||
title: 'Creativity',
|
||||
summary: '',
|
||||
keywords: ['Critical Thinking', 'Visual Thinking'],
|
||||
levelNum: 8,
|
||||
subtitle: '',
|
||||
},
|
||||
{
|
||||
id: '888db537-bed2-4d4d-901b-2c7f905f0464',
|
||||
url: '',
|
||||
date: {
|
||||
end: '',
|
||||
start: '',
|
||||
},
|
||||
level: '',
|
||||
title: 'Problem Solving',
|
||||
summary: '',
|
||||
keywords: ['Algorithms', 'Data Structures'],
|
||||
levelNum: 6,
|
||||
subtitle: '',
|
||||
},
|
||||
{
|
||||
id: '74b9984e-4f0f-4db3-bdc8-fddb647b8df8',
|
||||
url: '',
|
||||
date: {
|
||||
end: '',
|
||||
start: '',
|
||||
},
|
||||
level: '',
|
||||
title: 'Organization Skills',
|
||||
summary: '',
|
||||
keywords: ['Enthusiasm', 'Work Ethic', 'Supervision'],
|
||||
levelNum: 6,
|
||||
subtitle: '',
|
||||
},
|
||||
],
|
||||
columns: 4,
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
css: {
|
||||
value: '/* Enter custom CSS here */\n\n* {\n outline: 1px solid #000;\n}',
|
||||
visible: false,
|
||||
},
|
||||
date: {
|
||||
format: 'MMMM DD, YYYY',
|
||||
},
|
||||
theme: {
|
||||
text: '#000000',
|
||||
primary: '#1682cf',
|
||||
background: '#ffffff',
|
||||
},
|
||||
layout: [
|
||||
[
|
||||
['work', 'education'],
|
||||
['publications', 'volunteer'],
|
||||
],
|
||||
[
|
||||
['skills', '2d47a563-d0a0-4275-af18-fea3ba6b57b4', 'awards'],
|
||||
['certifications', 'interests', 'languages'],
|
||||
],
|
||||
[['projects'], ['references']],
|
||||
],
|
||||
language: 'en',
|
||||
template: 'kakuna',
|
||||
typography: {
|
||||
size: {
|
||||
body: 14,
|
||||
heading: 28,
|
||||
},
|
||||
family: {
|
||||
body: 'Open Sans',
|
||||
heading: 'Open Sans',
|
||||
},
|
||||
},
|
||||
},
|
||||
public: true,
|
||||
};
|
||||
|
||||
export default sampleData;
|
||||
18
apps/server/src/resume/dto/create-resume.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNotEmpty, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateResumeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@MinLength(3)
|
||||
@Transform(({ value }) => (value as string).toLowerCase().replace(/[ ]/gi, '-'))
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'slug must contain only lowercase characters, numbers and hyphens',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@IsBoolean()
|
||||
public?: boolean;
|
||||
}
|
||||
5
apps/server/src/resume/dto/update-resume.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { Resume } from '../entities/resume.entity';
|
||||
|
||||
export class UpdateResumeDto extends PartialType(Resume) {}
|
||||
52
apps/server/src/resume/entities/resume.entity.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Basics, Metadata, Section } from '@reactive-resume/schema';
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
|
||||
@Entity()
|
||||
@Unique(['user', 'slug'])
|
||||
export class Resume {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
shortId: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
slug: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
image?: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.resumes, {
|
||||
eager: true,
|
||||
cascade: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
basics: Basics;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
sections: Partial<Record<string, Section>>;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
metadata: Metadata;
|
||||
|
||||
@Column({ default: false })
|
||||
public: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(partial: Partial<Resume>) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
}
|
||||
110
apps/server/src/resume/resume.controller.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt.guard';
|
||||
import { OptionalJwtAuthGuard } from '@/auth/guards/optional-jwt.guard';
|
||||
import { User } from '@/decorators/user.decorator';
|
||||
|
||||
import { CreateResumeDto } from './dto/create-resume.dto';
|
||||
import { UpdateResumeDto } from './dto/update-resume.dto';
|
||||
import { ResumeService } from './resume.service';
|
||||
|
||||
@Controller('resume')
|
||||
export class ResumeController {
|
||||
constructor(private readonly resumeService: ResumeService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
create(@Body() createResumeDto: CreateResumeDto, @User('id') userId: number) {
|
||||
return this.resumeService.create(createResumeDto, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
async findAllByUser(@User('id') userId: number) {
|
||||
return this.resumeService.findAllByUser(userId);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
@Get('short/:shortId')
|
||||
findOneByShortId(
|
||||
@Param('shortId') shortId: string,
|
||||
@User('id') userId?: number,
|
||||
@Query('secretKey') secretKey?: string
|
||||
) {
|
||||
return this.resumeService.findOneByShortId(shortId, userId, secretKey);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
@Get(':username/:slug')
|
||||
findOneByIdentifier(
|
||||
@Param('username') username: string,
|
||||
@Param('slug') slug: string,
|
||||
@User('id') userId?: number,
|
||||
@Query('secretKey') secretKey?: string
|
||||
) {
|
||||
return this.resumeService.findOneByIdentifier(username, slug, userId, secretKey);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @User('id') userId?: number) {
|
||||
return this.resumeService.findOne(+id, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @User('id') userId: number, @Body() updateResumeDto: UpdateResumeDto) {
|
||||
return this.resumeService.update(+id, updateResumeDto, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @User('id') userId: number) {
|
||||
return this.resumeService.remove(+id, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':id/duplicate')
|
||||
duplicate(@Param('id') id: string, @User('id') userId: number) {
|
||||
return this.resumeService.duplicate(+id, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':id/sample')
|
||||
sample(@Param('id') id: string, @User('id') userId: number) {
|
||||
return this.resumeService.sample(+id, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':id/reset')
|
||||
reset(@Param('id') id: string, @User('id') userId: number) {
|
||||
return this.resumeService.reset(+id, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id/photo')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadPhoto(@Param('id') id: string, @User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.resumeService.uploadPhoto(+id, userId, file.filename);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id/photo')
|
||||
deletePhoto(@Param('id') id: string, @User('id') userId: number) {
|
||||
return this.resumeService.deletePhoto(+id, userId);
|
||||
}
|
||||
}
|
||||
46
apps/server/src/resume/resume.module.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
import { AuthModule } from '@/auth/auth.module';
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
import { UsersModule } from '@/users/users.module';
|
||||
|
||||
import { Resume } from './entities/resume.entity';
|
||||
import { ResumeController } from './resume.controller';
|
||||
import { ResumeService } from './resume.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TypeOrmModule.forFeature([Resume]),
|
||||
MulterModule.register({
|
||||
storage: diskStorage({
|
||||
destination: async (req, _, cb) => {
|
||||
const userId = (req.user as User).id;
|
||||
const resumeId = req.params.id;
|
||||
const destination = join(__dirname, `assets/uploads/${userId}/${resumeId}`);
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
|
||||
cb(null, destination);
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const filename = new Date().getTime() + extname(file.originalname);
|
||||
|
||||
cb(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [ResumeController],
|
||||
providers: [ResumeService],
|
||||
exports: [ResumeService],
|
||||
})
|
||||
export class ResumeModule {}
|
||||
242
apps/server/src/resume/resume.service.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Resume as ResumeSchema } from '@reactive-resume/schema';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { pick, sample, set } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { join } from 'path';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { PostgresErrorCode } from '@/database/errorCodes.enum';
|
||||
import { UsersService } from '@/users/users.service';
|
||||
|
||||
import { covers } from './data/covers';
|
||||
import defaultState from './data/defaultState';
|
||||
import sampleData from './data/sampleData';
|
||||
import { CreateResumeDto } from './dto/create-resume.dto';
|
||||
import { UpdateResumeDto } from './dto/update-resume.dto';
|
||||
import { Resume } from './entities/resume.entity';
|
||||
|
||||
export const SHORT_ID_LENGTH = 8;
|
||||
|
||||
@Injectable()
|
||||
export class ResumeService {
|
||||
constructor(
|
||||
@InjectRepository(Resume) private resumeRepository: Repository<Resume>,
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async create(createResumeDto: CreateResumeDto, userId: number) {
|
||||
try {
|
||||
const user = await this.usersService.findById(userId);
|
||||
const serverUrl = this.configService.get<string>('app.serverUrl');
|
||||
|
||||
const shortId = nanoid(SHORT_ID_LENGTH);
|
||||
const image = `${serverUrl}/covers/${sample(covers)}`;
|
||||
|
||||
const resume = this.resumeRepository.create({
|
||||
...defaultState,
|
||||
...createResumeDto,
|
||||
shortId,
|
||||
image,
|
||||
user,
|
||||
basics: {
|
||||
...defaultState.basics,
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
|
||||
return await this.resumeRepository.save(resume);
|
||||
} catch (error: any) {
|
||||
if (error?.code === PostgresErrorCode.UniqueViolation) {
|
||||
throw new HttpException(
|
||||
'A resume with the same slug already exists, please enter a unique slug and try again.',
|
||||
HttpStatus.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
'Something went wrong. Please try again later, or raise an issue on GitHub if the problem persists.',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async import(importResumeDto: Partial<ResumeSchema>, userId: number) {
|
||||
try {
|
||||
const user = await this.usersService.findById(userId);
|
||||
const serverUrl = this.configService.get<string>('app.serverUrl');
|
||||
|
||||
const shortId = nanoid(SHORT_ID_LENGTH);
|
||||
const image = `${serverUrl}/covers/${sample(covers)}`;
|
||||
|
||||
const resume = this.resumeRepository.create({
|
||||
...defaultState,
|
||||
...importResumeDto,
|
||||
shortId,
|
||||
image,
|
||||
user,
|
||||
});
|
||||
|
||||
return this.resumeRepository.save(resume);
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
'Something went wrong. Please try again later, or raise an issue on GitHub if the problem persists.',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.resumeRepository.find();
|
||||
}
|
||||
|
||||
findAllByUser(userId: number) {
|
||||
return this.resumeRepository.find({ user: { id: userId } });
|
||||
}
|
||||
|
||||
async findOne(id: number, userId?: number) {
|
||||
const resume = await this.resumeRepository.findOne(id);
|
||||
|
||||
if (!resume) {
|
||||
throw new HttpException('The resume you are looking does not exist, or maybe never did?', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const isPrivate = !resume.public;
|
||||
const isNotOwner = resume.user.id !== userId;
|
||||
|
||||
if (isPrivate && isNotOwner) {
|
||||
throw new HttpException('The resume you are looking does not exist, or maybe never did?', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async findOneByShortId(shortId: string, userId?: number, secretKey?: string) {
|
||||
const resume = await this.resumeRepository.findOne({ shortId });
|
||||
|
||||
if (!resume) {
|
||||
throw new HttpException('The resume you are looking does not exist, or maybe never did?', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const isPrivate = !resume.public;
|
||||
const isOwner = resume.user.id === userId;
|
||||
const isInternal = secretKey === this.configService.get<string>('app.secretKey');
|
||||
|
||||
if (!isInternal && isPrivate && !isOwner) {
|
||||
throw new HttpException('The resume you are looking does not exist, or maybe never did?', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async findOneByIdentifier(username: string, slug: string, userId?: number, secretKey?: string) {
|
||||
const resume = await this.resumeRepository.findOne({ user: { username }, slug });
|
||||
|
||||
if (!resume) {
|
||||
throw new HttpException('The resume you are looking does not exist, or maybe never did?', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const isPrivate = !resume.public;
|
||||
const isOwner = resume.user.id === userId;
|
||||
const isInternal = secretKey === this.configService.get<string>('app.secretKey');
|
||||
|
||||
if (!isInternal && isPrivate && !isOwner) {
|
||||
throw new HttpException('The resume you are looking does not exist, or maybe never did?', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async update(id: number, updateResumeDto: UpdateResumeDto, userId: number) {
|
||||
const resume = await this.findOne(id, userId);
|
||||
|
||||
const updatedResume = {
|
||||
...resume,
|
||||
...updateResumeDto,
|
||||
};
|
||||
|
||||
return this.resumeRepository.save<Resume>(updatedResume);
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
await this.resumeRepository.delete({ id, user: { id: userId } });
|
||||
}
|
||||
|
||||
async duplicate(id: number, userId: number) {
|
||||
try {
|
||||
const originalResume = await this.findOne(id, userId);
|
||||
const serverUrl = this.configService.get<string>('app.serverUrl');
|
||||
|
||||
const shortId = nanoid(SHORT_ID_LENGTH);
|
||||
const image = `${serverUrl}/covers/${sample(covers)}`;
|
||||
|
||||
const duplicatedResume: Partial<Resume> = {
|
||||
...pick(originalResume, ['name', 'slug', 'basics', 'metadata', 'sections', 'public']),
|
||||
name: `${originalResume.name} Copy`,
|
||||
slug: `${originalResume.slug}-copy`,
|
||||
shortId,
|
||||
image,
|
||||
};
|
||||
|
||||
const resume = this.resumeRepository.create({
|
||||
...duplicatedResume,
|
||||
user: { id: userId },
|
||||
});
|
||||
|
||||
return this.resumeRepository.save(resume);
|
||||
} catch (error: any) {
|
||||
if (error?.code === PostgresErrorCode.UniqueViolation) {
|
||||
throw new HttpException(
|
||||
'A resume with the same slug already exists, please enter a unique slug and try again.',
|
||||
HttpStatus.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
'Something went wrong. Please try again later, or raise an issue on GitHub if the problem persists.',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async sample(id: number, userId: number) {
|
||||
const resume = await this.findOne(id, userId);
|
||||
|
||||
const sampleResume = { ...resume, ...sampleData };
|
||||
|
||||
return this.resumeRepository.save<Resume>(sampleResume);
|
||||
}
|
||||
|
||||
async reset(id: number, userId: number) {
|
||||
const resume = await this.findOne(id, userId);
|
||||
|
||||
const prevResume = pick(resume, ['id', 'shortId', 'name', 'slug', 'image', 'user', 'createdAt']);
|
||||
const nextResume = { ...prevResume, ...defaultState };
|
||||
|
||||
return this.resumeRepository.update(id, nextResume);
|
||||
}
|
||||
|
||||
async uploadPhoto(id: number, userId: number, filename: string) {
|
||||
const resume = await this.findOne(id, userId);
|
||||
const serverUrl = this.configService.get<string>('app.serverUrl');
|
||||
|
||||
const url = `${serverUrl}/uploads/${userId}/${id}/${filename}`;
|
||||
const updatedResume = set(resume, 'basics.photo.url', url);
|
||||
|
||||
return this.resumeRepository.save<Resume>(updatedResume);
|
||||
}
|
||||
|
||||
async deletePhoto(id: number, userId: number) {
|
||||
const resume = await this.findOne(id, userId);
|
||||
const key = new URL(resume.basics.photo.url).pathname;
|
||||
const photoPath = join(__dirname, 'assets', key);
|
||||
const updatedResume = set(resume, 'basics.photo.url', '');
|
||||
|
||||
await unlink(photoPath);
|
||||
|
||||
return this.resumeRepository.save<Resume>(updatedResume);
|
||||
}
|
||||
}
|
||||
23
apps/server/src/users/dto/create-google-user.dto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateGoogleUserDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@MinLength(3)
|
||||
@Transform(({ value }) => (value as string).toLowerCase().replace(/[ ]/gi, '-'))
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'username can contain only lowercase characters, numbers and hyphens',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: 'google';
|
||||
}
|
||||
30
apps/server/src/users/dto/create-user.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@MinLength(3)
|
||||
@Transform(({ value }) => (value as string).toLowerCase().replace(/[ ]/gi, '-'))
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'username can contain only lowercase characters, numbers and hyphens',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: 'email';
|
||||
|
||||
@IsString()
|
||||
resetToken?: string;
|
||||
}
|
||||
5
apps/server/src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
43
apps/server/src/users/entities/user.entity.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
import { Resume } from '@/resume/entities/resume.entity';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Exclude()
|
||||
password?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Exclude()
|
||||
resetToken?: string;
|
||||
|
||||
@OneToMany(() => Resume, (resume) => resume.user)
|
||||
resumes: Resume[];
|
||||
|
||||
@Column()
|
||||
provider: 'email' | 'google';
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(partial: Partial<User>) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
}
|
||||
14
apps/server/src/users/users.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailModule } from '@/mail/mail.module';
|
||||
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User]), MailModule],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
127
apps/server/src/users/users.service.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Connection, Repository } from 'typeorm';
|
||||
|
||||
import { MailService } from '@/mail/mail.service';
|
||||
|
||||
import { CreateGoogleUserDto } from './dto/create-google-user.dto';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
export const DELETION_TIME = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User) private userRepository: Repository<User>,
|
||||
private schedulerRegistry: SchedulerRegistry,
|
||||
private mailService: MailService,
|
||||
private connection: Connection
|
||||
) {}
|
||||
|
||||
async findById(id: number): Promise<User> {
|
||||
const user = await this.userRepository.findOne({ id });
|
||||
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
throw new HttpException('A user with this username/email does not exist.', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({ email });
|
||||
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
throw new HttpException('A user with this email does not exist.', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
async findByIdentifier(identifier: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: [{ username: identifier }, { email: identifier }],
|
||||
});
|
||||
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
throw new HttpException('A user with this username/email does not exist.', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
async findByResetToken(resetToken: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({ resetToken });
|
||||
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
throw new HttpException('The reset token provided may be invalid or expired.', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
async create(createUserDto: CreateUserDto | CreateGoogleUserDto): Promise<User> {
|
||||
const user = this.userRepository.create(createUserDto);
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||
const user = await this.findById(id);
|
||||
const updatedUser = {
|
||||
...user,
|
||||
...updateUserDto,
|
||||
};
|
||||
|
||||
await this.userRepository.save(updatedUser);
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.userRepository.delete(id);
|
||||
}
|
||||
|
||||
async generateResetToken(email: string): Promise<void> {
|
||||
try {
|
||||
const user = await this.findByEmail(email);
|
||||
|
||||
const resetToken = randomBytes(32).toString('hex');
|
||||
const queryRunner = this.connection.createQueryRunner();
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
await this.userRepository.update(user.id, { resetToken: null });
|
||||
}, DELETION_TIME);
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
await queryRunner.manager.update(User, user.id, { resetToken });
|
||||
|
||||
this.schedulerRegistry.addTimeout(`clear-resetToken-${user.id}`, timeout);
|
||||
|
||||
await this.mailService.sendForgotPasswordEmail(user, resetToken);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch {
|
||||
await queryRunner.rollbackTransaction();
|
||||
|
||||
throw new HttpException(
|
||||
'Please wait at least 30 minutes before resetting your password again.',
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
} catch {
|
||||
// pass through
|
||||
}
|
||||
}
|
||||
}
|
||||