🚀 release: v3.0.0

This commit is contained in:
Amruth Pillai
2022-03-02 17:44:11 +01:00
parent 2175256310
commit 295172687b
352 changed files with 30932 additions and 0 deletions

1
apps/server/.env.example Normal file
View File

@ -0,0 +1 @@
PORT=3100

View File

@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View 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
View 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": []
}

View 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 {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

View 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>

View 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);
}
}

View 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 {}

View 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);
}
}
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ForgotPasswordDto {
@IsString()
@IsNotEmpty()
email: string;
}

View File

@ -0,0 +1,10 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@IsNotEmpty()
identifier: string;
@MinLength(6)
password: string;
}

View 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;
}

View File

@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@IsString()
@IsNotEmpty()
resetToken: string;
@IsString()
@MinLength(6)
password: string;
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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',
}));

View 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),
}));

View 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 {}

View 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,
}));

View 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,
}));

View 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,
}));

View File

@ -0,0 +1,2 @@
// Date Formats
export const FILENAME_TIMESTAMP = 'DDMMYYYYHHmmss';

View 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 {}

View File

@ -0,0 +1,3 @@
export enum PostgresErrorCode {
UniqueViolation = '23505',
}

View 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;
});

View 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;
});

View File

@ -0,0 +1,3 @@
export const environment = {
production: true,
};

View File

@ -0,0 +1,3 @@
export const environment = {
production: false,
};

View 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,
});
}
}

View 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();
}
}

View 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 {}

View 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;
}
}

View 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);
}
}

View 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 {}

View 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);
}
}
}

View 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/' };
}
}

View 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],
};
}
}

View 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,
});
}
}

View 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
View 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();

View 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);
}
}

View 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 {}

View 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;
}
}

View 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',
];

View 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;

View 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;

View 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;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { Resume } from '../entities/resume.entity';
export class UpdateResumeDto extends PartialType(Resume) {}

View 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);
}
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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';
}

View 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;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View 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);
}
}

View 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 {}

View 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
}
}
}

Some files were not shown because too many files have changed in this diff Show More