mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-26 14:33:59 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
10
apps/server/src/resume/decorators/resume.decorator.ts
Normal file
10
apps/server/src/resume/decorators/resume.decorator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
|
||||
export const Resume = createParamDecorator((data: keyof ResumeDto, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const resume = request.payload?.resume as ResumeDto;
|
||||
|
||||
return data ? resume?.[data] : resume;
|
||||
});
|
||||
43
apps/server/src/resume/guards/resume.guard.ts
Normal file
43
apps/server/src/resume/guards/resume.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { UserWithSecrets } from "@reactive-resume/dto";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
|
||||
import { ResumeService } from "../resume.service";
|
||||
|
||||
@Injectable()
|
||||
export class ResumeGuard implements CanActivate {
|
||||
constructor(private readonly resumeService: ResumeService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as UserWithSecrets | false;
|
||||
|
||||
try {
|
||||
const resume = await this.resumeService.findOne(
|
||||
request.params.id,
|
||||
user ? user.id : undefined,
|
||||
);
|
||||
|
||||
// First check if the resume is public, if yes, attach the resume to the request payload.
|
||||
if (resume.visibility === "public") {
|
||||
request.payload = { resume };
|
||||
}
|
||||
|
||||
// If the resume is private and the user is authenticated and is the owner of the resume, attach the resume to the request payload.
|
||||
// Else, if either the user is not authenticated or is not the owner of the resume, throw a 404 error.
|
||||
if (resume.visibility === "private") {
|
||||
if (user && user && user.id === resume.userId) {
|
||||
request.payload = { resume };
|
||||
} else {
|
||||
throw new NotFoundException(ErrorMessage.ResumeNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new NotFoundException(ErrorMessage.ResumeNotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
apps/server/src/resume/resume.controller.ts
Normal file
144
apps/server/src/resume/resume.controller.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { User as UserEntity } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
CreateResumeDto,
|
||||
ImportResumeDto,
|
||||
ResumeDto,
|
||||
StatisticsDto,
|
||||
UpdateResumeDto,
|
||||
UrlDto,
|
||||
} from "@reactive-resume/dto";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema";
|
||||
import { ZodSerializerDto } from "nestjs-zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
import { User } from "@/server/user/decorators/user.decorator";
|
||||
|
||||
import { OptionalGuard } from "../auth/guards/optional.guard";
|
||||
import { TwoFactorGuard } from "../auth/guards/two-factor.guard";
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { Resume } from "./decorators/resume.decorator";
|
||||
import { ResumeGuard } from "./guards/resume.guard";
|
||||
import { ResumeService } from "./resume.service";
|
||||
|
||||
@ApiTags("Resume")
|
||||
@Controller("resume")
|
||||
export class ResumeController {
|
||||
constructor(private readonly resumeService: ResumeService) {}
|
||||
|
||||
@Get("schema")
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey("resume:schema")
|
||||
async getSchema() {
|
||||
return zodToJsonSchema(resumeDataSchema);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(TwoFactorGuard)
|
||||
create(@User() user: UserEntity, @Body() createResumeDto: CreateResumeDto) {
|
||||
try {
|
||||
return this.resumeService.create(user.id, createResumeDto);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new BadRequestException(ErrorMessage.ResumeSlugAlreadyExists);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Post("import")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
import(@User() user: UserEntity, @Body() importResumeDto: ImportResumeDto) {
|
||||
try {
|
||||
return this.resumeService.import(user.id, importResumeDto);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new BadRequestException(ErrorMessage.ResumeSlugAlreadyExists);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(TwoFactorGuard)
|
||||
findAll(@User() user: UserEntity) {
|
||||
return this.resumeService.findAll(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@UseGuards(TwoFactorGuard, ResumeGuard)
|
||||
findOne(@Resume() resume: ResumeDto) {
|
||||
return resume;
|
||||
}
|
||||
|
||||
@Get(":id/statistics")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(StatisticsDto)
|
||||
findOneStatistics(@User("id") userId: string, @Param("id") id: string) {
|
||||
return this.resumeService.findOneStatistics(userId, id);
|
||||
}
|
||||
|
||||
@Get("/public/:username/:slug")
|
||||
findOneByUsernameSlug(@Param("username") username: string, @Param("slug") slug: string) {
|
||||
return this.resumeService.findOneByUsernameSlug(username, slug);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
update(
|
||||
@User() user: UserEntity,
|
||||
@Param("id") id: string,
|
||||
@Body() updateResumeDto: UpdateResumeDto,
|
||||
) {
|
||||
return this.resumeService.update(user.id, id, updateResumeDto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
remove(@User() user: UserEntity, @Param("id") id: string) {
|
||||
return this.resumeService.remove(user.id, id);
|
||||
}
|
||||
|
||||
@Get("/print/:id")
|
||||
@UseGuards(OptionalGuard, ResumeGuard)
|
||||
@ZodSerializerDto(UrlDto)
|
||||
async printResume(@Resume() resume: ResumeDto) {
|
||||
try {
|
||||
const url = await this.resumeService.printResume(resume);
|
||||
|
||||
return { url };
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.ResumePrinterError, error);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/print/:id/preview")
|
||||
@UseGuards(TwoFactorGuard, ResumeGuard)
|
||||
@ZodSerializerDto(UrlDto)
|
||||
async printPreview(@Resume() resume: ResumeDto) {
|
||||
try {
|
||||
const url = await this.resumeService.printPreview(resume);
|
||||
|
||||
return { url };
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.ResumePreviewError);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
apps/server/src/resume/resume.module.ts
Normal file
16
apps/server/src/resume/resume.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { AuthModule } from "@/server/auth/auth.module";
|
||||
import { PrinterModule } from "@/server/printer/printer.module";
|
||||
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { ResumeController } from "./resume.controller";
|
||||
import { ResumeService } from "./resume.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrinterModule, StorageModule],
|
||||
controllers: [ResumeController],
|
||||
providers: [ResumeService],
|
||||
exports: [ResumeService],
|
||||
})
|
||||
export class ResumeModule {}
|
||||
176
apps/server/src/resume/resume.service.ts
Normal file
176
apps/server/src/resume/resume.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
|
||||
import { defaultResumeData, ResumeData } from "@reactive-resume/schema";
|
||||
import type { DeepPartial } from "@reactive-resume/utils";
|
||||
import { generateRandomName, kebabCase } from "@reactive-resume/utils";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import { Cache } from "cache-manager";
|
||||
import deepmerge from "deepmerge";
|
||||
import Redis from "ioredis";
|
||||
import { PrismaService } from "nestjs-prisma";
|
||||
|
||||
import { PrinterService } from "@/server/printer/printer.service";
|
||||
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
|
||||
@Injectable()
|
||||
export class ResumeService {
|
||||
private readonly redis: Redis;
|
||||
private readonly logger = new Logger(ResumeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly printerService: PrinterService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly utils: UtilsService,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {
|
||||
this.redis = this.redisService.getClient();
|
||||
}
|
||||
|
||||
async create(userId: string, createResumeDto: CreateResumeDto) {
|
||||
const { name, email, picture } = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { name: true, email: true, picture: true },
|
||||
});
|
||||
|
||||
const data = deepmerge(defaultResumeData, {
|
||||
basics: { name, email, picture: { url: picture ?? "" } },
|
||||
} satisfies DeepPartial<ResumeData>);
|
||||
|
||||
const resume = await this.prisma.resume.create({
|
||||
data: {
|
||||
data,
|
||||
userId,
|
||||
title: createResumeDto.title,
|
||||
visibility: createResumeDto.visibility,
|
||||
slug: createResumeDto.slug ?? kebabCase(createResumeDto.title),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.set(`user:${userId}:resume:${resume.id}`, resume),
|
||||
]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async import(userId: string, importResumeDto: ImportResumeDto) {
|
||||
const randomTitle = generateRandomName();
|
||||
|
||||
const resume = await this.prisma.resume.create({
|
||||
data: {
|
||||
userId,
|
||||
visibility: "private",
|
||||
data: importResumeDto.data,
|
||||
title: importResumeDto.title || randomTitle,
|
||||
slug: importResumeDto.slug || kebabCase(randomTitle),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.set(`user:${userId}:resume:${resume.id}`, resume),
|
||||
]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
findAll(userId: string) {
|
||||
return this.utils.getCachedOrSet(`user:${userId}:resumes`, () =>
|
||||
this.prisma.resume.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
findOne(id: string, userId?: string) {
|
||||
if (userId) {
|
||||
return this.utils.getCachedOrSet(`user:${userId}:resume:${id}`, () =>
|
||||
this.prisma.resume.findUniqueOrThrow({
|
||||
where: { userId_id: { userId, id } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.utils.getCachedOrSet(`user:public:resume:${id}`, () =>
|
||||
this.prisma.resume.findUniqueOrThrow({
|
||||
where: { id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findOneStatistics(userId: string, id: string) {
|
||||
const result = await Promise.all([
|
||||
this.redis.get(`user:${userId}:resume:${id}:views`),
|
||||
this.redis.get(`user:${userId}:resume:${id}:downloads`),
|
||||
]);
|
||||
|
||||
const [views, downloads] = result.map((value) => Number(value) || 0);
|
||||
|
||||
return { views, downloads };
|
||||
}
|
||||
|
||||
async findOneByUsernameSlug(username: string, slug: string) {
|
||||
const resume = await this.prisma.resume.findFirstOrThrow({
|
||||
where: { user: { username }, slug, visibility: "public" },
|
||||
});
|
||||
|
||||
// Update statistics: increment the number of views by 1
|
||||
await this.redis.incr(`user:${resume.userId}:resume:${resume.id}:views`);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, updateResumeDto: UpdateResumeDto) {
|
||||
await Promise.all([
|
||||
this.cache.set(`user:${userId}:resume:${id}`, updateResumeDto),
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.del(`user:${userId}:storage:resumes:${id}`),
|
||||
this.cache.del(`user:${userId}:storage:previews:${id}`),
|
||||
]);
|
||||
|
||||
return this.prisma.resume.update({
|
||||
data: {
|
||||
title: updateResumeDto.title,
|
||||
slug: updateResumeDto.slug,
|
||||
visibility: updateResumeDto.visibility,
|
||||
data: updateResumeDto.data as unknown as Prisma.JsonObject,
|
||||
},
|
||||
where: { userId_id: { userId, id } },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: string, id: string) {
|
||||
await Promise.all([
|
||||
// Remove cached keys
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.del(`user:${userId}:resume:${id}`),
|
||||
|
||||
// Remove files in storage, and their cached keys
|
||||
this.storageService.deleteObject(userId, "resumes", id),
|
||||
this.storageService.deleteObject(userId, "previews", id),
|
||||
]);
|
||||
|
||||
return this.prisma.resume.delete({ where: { userId_id: { userId, id } } });
|
||||
}
|
||||
|
||||
async printResume(resume: ResumeDto) {
|
||||
const url = await this.printerService.printResume(resume);
|
||||
|
||||
// Update statistics: increment the number of downloads by 1
|
||||
await this.redis.incr(`user:${resume.userId}:resume:${resume.id}:downloads`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
printPreview(resume: ResumeDto) {
|
||||
return this.printerService.printPreview(resume);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user