diff --git a/apps/client/src/providers/dialog.tsx b/apps/client/src/providers/dialog.tsx
index e8a34d7d0..58f9e5cb3 100644
--- a/apps/client/src/providers/dialog.tsx
+++ b/apps/client/src/providers/dialog.tsx
@@ -12,6 +12,7 @@ import { ReferencesDialog } from "../pages/builder/sidebars/left/dialogs/referen
import { SkillsDialog } from "../pages/builder/sidebars/left/dialogs/skills";
import { VolunteerDialog } from "../pages/builder/sidebars/left/dialogs/volunteer";
import { ImportDialog } from "../pages/dashboard/resumes/_dialogs/import";
+import { LockDialog } from "../pages/dashboard/resumes/_dialogs/lock";
import { ResumeDialog } from "../pages/dashboard/resumes/_dialogs/resume";
import { TwoFactorDialog } from "../pages/dashboard/settings/_dialogs/two-factor";
import { useResumeStore } from "../stores/resume";
@@ -29,6 +30,7 @@ export const DialogProvider = ({ children }: Props) => {
+
diff --git a/apps/client/src/services/resume/lock.ts b/apps/client/src/services/resume/lock.ts
new file mode 100644
index 000000000..a03941cde
--- /dev/null
+++ b/apps/client/src/services/resume/lock.ts
@@ -0,0 +1,38 @@
+import { ResumeDto } from "@reactive-resume/dto";
+import { useMutation } from "@tanstack/react-query";
+
+import { axios } from "@/client/libs/axios";
+import { queryClient } from "@/client/libs/query-client";
+
+type LockResumeArgs = {
+ id: string;
+ set: boolean;
+};
+
+export const lockResume = async ({ id, set }: LockResumeArgs) => {
+ const response = await axios.patch(`/resume/${id}/lock`, { set });
+
+ queryClient.setQueryData(["resume", { id: response.data.id }], response.data);
+
+ queryClient.setQueryData(["resumes"], (cache) => {
+ if (!cache) return [response.data];
+ return cache.map((resume) => {
+ if (resume.id === response.data.id) return response.data;
+ return resume;
+ });
+ });
+
+ return response.data;
+};
+
+export const useLockResume = () => {
+ const {
+ error,
+ isPending: loading,
+ mutateAsync: lockResumeFn,
+ } = useMutation({
+ mutationFn: lockResume,
+ });
+
+ return { lockResume: lockResumeFn, loading, error };
+};
diff --git a/apps/client/src/services/resume/update.ts b/apps/client/src/services/resume/update.ts
index 3bc4334a8..57bf5f292 100644
--- a/apps/client/src/services/resume/update.ts
+++ b/apps/client/src/services/resume/update.ts
@@ -1,28 +1,41 @@
import { ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
import { useMutation } from "@tanstack/react-query";
-import { AxiosResponse } from "axios";
+import { AxiosError, AxiosResponse } from "axios";
import debounce from "lodash.debounce";
+import { toast } from "@/client/hooks/use-toast";
import { axios } from "@/client/libs/axios";
import { queryClient } from "@/client/libs/query-client";
export const updateResume = async (data: UpdateResumeDto) => {
- const response = await axios.patch, UpdateResumeDto>(
- `/resume/${data.id}`,
- data,
- );
+ try {
+ const response = await axios.patch, UpdateResumeDto>(
+ `/resume/${data.id}`,
+ data,
+ );
- queryClient.setQueryData(["resume", { id: response.data.id }], response.data);
+ queryClient.setQueryData(["resume", { id: response.data.id }], response.data);
- queryClient.setQueryData(["resumes"], (cache) => {
- if (!cache) return [response.data];
- return cache.map((resume) => {
- if (resume.id === response.data.id) return response.data;
- return resume;
+ queryClient.setQueryData(["resumes"], (cache) => {
+ if (!cache) return [response.data];
+ return cache.map((resume) => {
+ if (resume.id === response.data.id) return response.data;
+ return resume;
+ });
});
- });
- return response.data;
+ return response.data;
+ } catch (error) {
+ if (error instanceof AxiosError) {
+ const message = error.response?.data.message ?? error.message;
+
+ toast({
+ variant: "error",
+ title: "There was an error while updating your resume.",
+ description: message,
+ });
+ }
+ }
};
export const debouncedUpdateResume = debounce(updateResume, 500);
@@ -34,17 +47,6 @@ export const useUpdateResume = () => {
mutateAsync: updateResumeFn,
} = useMutation({
mutationFn: updateResume,
- onSuccess: (data) => {
- queryClient.setQueryData(["resume", { id: data.id }], data);
-
- queryClient.setQueryData(["resumes"], (cache) => {
- if (!cache) return [data];
- return cache.map((resume) => {
- if (resume.id === data.id) return data;
- return resume;
- });
- });
- },
});
return { updateResume: updateResumeFn, loading, error };
diff --git a/apps/client/src/stores/dialog.ts b/apps/client/src/stores/dialog.ts
index 8eb4d542a..285cae878 100644
--- a/apps/client/src/stores/dialog.ts
+++ b/apps/client/src/stores/dialog.ts
@@ -1,7 +1,7 @@
import { SectionKey } from "@reactive-resume/schema";
import { create } from "zustand";
-export type DialogName = "resume" | "import" | "two-factor" | SectionKey;
+export type DialogName = "resume" | "lock" | "import" | "two-factor" | SectionKey;
export type DialogMode = "create" | "update" | "duplicate" | "delete";
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 1996d52bd..c62e81936 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -2,7 +2,7 @@ import { HttpException, Module } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { ServeStaticModule } from "@nestjs/serve-static";
import { RavenInterceptor, RavenModule } from "nest-raven";
-import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";
+import { ZodValidationPipe } from "nestjs-zod";
import { join } from "path";
import { AuthModule } from "./auth/auth.module";
@@ -44,10 +44,6 @@ import { UtilsModule } from "./utils/utils.module";
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
- {
- provide: APP_INTERCEPTOR,
- useClass: ZodSerializerInterceptor,
- },
{
provide: APP_INTERCEPTOR,
useValue: new RavenInterceptor({
diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts
index 4dd4e54ab..c64d1b87f 100644
--- a/apps/server/src/auth/auth.controller.ts
+++ b/apps/server/src/auth/auth.controller.ts
@@ -16,19 +16,16 @@ import {
authResponseSchema,
backupCodesSchema,
ForgotPasswordDto,
- MessageDto,
messageSchema,
RegisterDto,
ResetPasswordDto,
TwoFactorBackupDto,
TwoFactorDto,
UpdatePasswordDto,
- UserDto,
userSchema,
UserWithSecrets,
} from "@reactive-resume/dto";
import type { Response } from "express";
-import { ZodSerializerDto } from "nestjs-zod";
import { ErrorMessage } from "../constants/error-message";
import { User } from "../user/decorators/user.decorator";
@@ -151,7 +148,6 @@ export class AuthController {
@Patch("password")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(MessageDto)
async updatePassword(@User("email") email: string, @Body() { password }: UpdatePasswordDto) {
await this.authService.updatePassword(email, password);
@@ -174,7 +170,6 @@ export class AuthController {
@ApiTags("Two-Factor Auth")
@Post("2fa/setup")
@UseGuards(JwtGuard)
- @ZodSerializerDto(MessageDto)
async setup2FASecret(@User("email") email: string) {
return this.authService.setup2FASecret(email);
}
@@ -204,7 +199,6 @@ export class AuthController {
@HttpCode(200)
@Post("2fa/disable")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(MessageDto)
async disable2FA(@User("email") email: string) {
await this.authService.disable2FA(email);
@@ -215,7 +209,6 @@ export class AuthController {
@HttpCode(200)
@Post("2fa/verify")
@UseGuards(JwtGuard)
- @ZodSerializerDto(UserDto)
async verify2FACode(
@User() user: UserWithSecrets,
@Body() { code }: TwoFactorDto,
@@ -235,7 +228,6 @@ export class AuthController {
@HttpCode(200)
@Post("2fa/backup")
@UseGuards(JwtGuard)
- @ZodSerializerDto(UserDto)
async useBackup2FACode(
@User("id") id: string,
@User("email") email: string,
@@ -267,7 +259,6 @@ export class AuthController {
@ApiTags("Password Reset")
@HttpCode(200)
@Post("reset-password")
- @ZodSerializerDto(MessageDto)
async resetPassword(@Body() { token, password }: ResetPasswordDto) {
try {
await this.authService.resetPassword(token, password);
@@ -282,7 +273,6 @@ export class AuthController {
@ApiTags("Email Verification")
@Post("verify-email")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(MessageDto)
async verifyEmail(
@User("id") id: string,
@User("emailVerified") emailVerified: boolean,
@@ -302,7 +292,6 @@ export class AuthController {
@ApiTags("Email Verification")
@Post("verify-email/resend")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(MessageDto)
async resendVerificationEmail(
@User("email") email: string,
@User("emailVerified") emailVerified: boolean,
diff --git a/apps/server/src/constants/error-message.ts b/apps/server/src/constants/error-message.ts
index 4d9e443e0..4bce8135e 100644
--- a/apps/server/src/constants/error-message.ts
+++ b/apps/server/src/constants/error-message.ts
@@ -21,6 +21,8 @@ export const ErrorMessage = {
ResumeSlugAlreadyExists:
"A resume with this slug already exists, please pick a different unique identifier.",
ResumeNotFound: "It looks like the resume you're looking for doesn't exist.",
+ ResumeLocked:
+ "The resume you want to update is locked, please unlock if you wish to make any changes to it.",
ResumePrinterError:
"Something went wrong while printing your resume. Please try again later or raise an issue on GitHub.",
ResumePreviewError:
diff --git a/apps/server/src/resume/resume.controller.ts b/apps/server/src/resume/resume.controller.ts
index b923125de..9b1871e65 100644
--- a/apps/server/src/resume/resume.controller.ts
+++ b/apps/server/src/resume/resume.controller.ts
@@ -16,16 +16,8 @@ import {
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 { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } 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";
@@ -91,7 +83,6 @@ export class ResumeController {
@Get(":id/statistics")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(StatisticsDto)
findOneStatistics(@User("id") userId: string, @Param("id") id: string) {
return this.resumeService.findOneStatistics(userId, id);
}
@@ -111,15 +102,20 @@ export class ResumeController {
return this.resumeService.update(user.id, id, updateResumeDto);
}
+ @Patch(":id/lock")
+ @UseGuards(TwoFactorGuard)
+ lock(@User() user: UserEntity, @Param("id") id: string, @Body("set") set: boolean = true) {
+ return this.resumeService.lock(user.id, id, set);
+ }
+
@Delete(":id")
@UseGuards(TwoFactorGuard)
- remove(@User() user: UserEntity, @Param("id") id: string) {
- return this.resumeService.remove(user.id, id);
+ async remove(@User() user: UserEntity, @Param("id") id: string) {
+ await 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);
@@ -133,7 +129,6 @@ export class ResumeController {
@Get("/print/:id/preview")
@UseGuards(TwoFactorGuard, ResumeGuard)
- @ZodSerializerDto(UrlDto)
async printPreview(@Resume() resume: ResumeDto) {
try {
const url = await this.resumeService.printPreview(resume);
diff --git a/apps/server/src/resume/resume.service.ts b/apps/server/src/resume/resume.service.ts
index ad3b2c1ab..30135f4c2 100644
--- a/apps/server/src/resume/resume.service.ts
+++ b/apps/server/src/resume/resume.service.ts
@@ -1,5 +1,5 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
-import { Inject, Injectable, Logger } from "@nestjs/common";
+import { BadRequestException, 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";
@@ -13,6 +13,7 @@ import { PrismaService } from "nestjs-prisma";
import { PrinterService } from "@/server/printer/printer.service";
+import { ErrorMessage } from "../constants/error-message";
import { StorageService } from "../storage/storage.service";
import { UtilsService } from "../utils/utils.service";
@@ -129,22 +130,44 @@ export class ResumeService {
}
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}`),
- ]);
+ try {
+ const resume = await 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 }, locked: false },
+ });
- return this.prisma.resume.update({
- data: {
- title: updateResumeDto.title,
- slug: updateResumeDto.slug,
- visibility: updateResumeDto.visibility,
- data: updateResumeDto.data as unknown as Prisma.JsonObject,
- },
+ await Promise.all([
+ this.cache.set(`user:${userId}:resume:${id}`, resume),
+ this.cache.del(`user:${userId}:resumes`),
+ this.cache.del(`user:${userId}:storage:resumes:${id}`),
+ this.cache.del(`user:${userId}:storage:previews:${id}`),
+ ]);
+
+ return resume;
+ } catch (error) {
+ if (error.code === "P2025") {
+ throw new BadRequestException(ErrorMessage.ResumeLocked);
+ }
+ }
+ }
+
+ async lock(userId: string, id: string, set: boolean) {
+ const resume = await this.prisma.resume.update({
+ data: { locked: set },
where: { userId_id: { userId, id } },
});
+
+ await Promise.all([
+ this.cache.set(`user:${userId}:resume:${id}`, resume),
+ this.cache.del(`user:${userId}:resumes`),
+ ]);
+
+ return resume;
}
async remove(userId: string, id: string) {
@@ -156,9 +179,10 @@ export class ResumeService {
// 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 } } });
+ // Remove resume from database
+ this.prisma.resume.delete({ where: { userId_id: { userId, id } } }),
+ ]);
}
async printResume(resume: ResumeDto) {
diff --git a/apps/server/src/user/user.controller.ts b/apps/server/src/user/user.controller.ts
index 4937ffccd..40d85f555 100644
--- a/apps/server/src/user/user.controller.ts
+++ b/apps/server/src/user/user.controller.ts
@@ -1,8 +1,7 @@
import { Body, Controller, Delete, Get, Patch, Res, UseGuards } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
-import { MessageDto, UpdateUserDto, UserDto } from "@reactive-resume/dto";
+import { UpdateUserDto, UserDto } from "@reactive-resume/dto";
import type { Response } from "express";
-import { ZodSerializerDto } from "nestjs-zod";
import { AuthService } from "../auth/auth.service";
import { TwoFactorGuard } from "../auth/guards/two-factor.guard";
@@ -19,14 +18,12 @@ export class UserController {
@Get("me")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(UserDto)
fetch(@User() user: UserDto) {
return user;
}
@Patch("me")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(UserDto)
async update(@User("email") email: string, @Body() updateUserDto: UpdateUserDto) {
// If user is updating their email, send a verification email
if (updateUserDto.email && updateUserDto.email !== email) {
@@ -50,7 +47,6 @@ export class UserController {
@Delete("me")
@UseGuards(TwoFactorGuard)
- @ZodSerializerDto(MessageDto)
async delete(@User("id") id: string, @Res({ passthrough: true }) response: Response) {
await this.userService.deleteOneById(id);
diff --git a/libs/dto/src/resume/resume.ts b/libs/dto/src/resume/resume.ts
index 40c9b8749..766bdfc8c 100644
--- a/libs/dto/src/resume/resume.ts
+++ b/libs/dto/src/resume/resume.ts
@@ -10,6 +10,7 @@ export const resumeSchema = z.object({
slug: z.string(),
data: resumeDataSchema.default(defaultResumeData),
visibility: z.enum(["private", "public"]).default("private"),
+ locked: z.boolean().default(false),
userId: idSchema,
user: userSchema.optional(),
createdAt: z.date().or(z.dateString()),
diff --git a/libs/dto/src/resume/update.ts b/libs/dto/src/resume/update.ts
index 3a632c273..7e591bf9c 100644
--- a/libs/dto/src/resume/update.ts
+++ b/libs/dto/src/resume/update.ts
@@ -2,6 +2,6 @@ import { createZodDto } from "nestjs-zod/dto";
import { resumeSchema } from "./resume";
-export const updateResumeSchema = resumeSchema;
+export const updateResumeSchema = resumeSchema.partial();
export class UpdateResumeDto extends createZodDto(updateResumeSchema) {}
diff --git a/libs/schema/src/metadata/index.ts b/libs/schema/src/metadata/index.ts
index 999bca529..16c86a65f 100644
--- a/libs/schema/src/metadata/index.ts
+++ b/libs/schema/src/metadata/index.ts
@@ -39,6 +39,7 @@ export const metadataSchema = z.object({
lineHeight: z.number().default(1.5),
underlineLinks: z.boolean().default(true),
}),
+ notes: z.string().default(""),
});
// Type
@@ -76,4 +77,5 @@ export const defaultMetadata: Metadata = {
lineHeight: 1.5,
underlineLinks: true,
},
+ notes: "",
};
diff --git a/tools/prisma/migrations/20231106120428_implement_resume_locking/migration.sql b/tools/prisma/migrations/20231106120428_implement_resume_locking/migration.sql
new file mode 100644
index 000000000..287341b8c
--- /dev/null
+++ b/tools/prisma/migrations/20231106120428_implement_resume_locking/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Resume" ADD COLUMN "locked" BOOLEAN NOT NULL DEFAULT false;
diff --git a/tools/prisma/schema.prisma b/tools/prisma/schema.prisma
index c872b9f68..9a6b3081d 100644
--- a/tools/prisma/schema.prisma
+++ b/tools/prisma/schema.prisma
@@ -53,6 +53,7 @@ model Resume {
slug String
data Json @default("{}")
visibility Visibility @default(private)
+ locked Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())