diff --git a/apps/client/src/constants/sample-resume.ts b/apps/client/src/constants/sample-resume.ts index 54113ebe2..af3d0355b 100644 --- a/apps/client/src/constants/sample-resume.ts +++ b/apps/client/src/constants/sample-resume.ts @@ -489,5 +489,7 @@ export const sampleResume: ResumeData = { lineHeight: 1.5, underlineLinks: true, }, + notes: + "

I sent this resume to Deloitte back in July 2022. I am yet to hear back from them.

", }, }; diff --git a/apps/client/src/pages/builder/_components/header.tsx b/apps/client/src/pages/builder/_components/header.tsx index e7e78d9d9..a9479b377 100644 --- a/apps/client/src/pages/builder/_components/header.tsx +++ b/apps/client/src/pages/builder/_components/header.tsx @@ -1,6 +1,6 @@ -import { HouseSimple, SidebarSimple } from "@phosphor-icons/react"; +import { HouseSimple, Lock, SidebarSimple } from "@phosphor-icons/react"; import { useBreakpoint } from "@reactive-resume/hooks"; -import { Button } from "@reactive-resume/ui"; +import { Button, Tooltip } from "@reactive-resume/ui"; import { cn } from "@reactive-resume/utils"; import { Link } from "react-router-dom"; @@ -11,8 +11,10 @@ export const BuilderHeader = () => { const { isDesktop } = useBreakpoint(); const defaultPanelSize = isDesktop ? 25 : 0; - const toggle = useBuilderStore((state) => state.toggle); const title = useResumeStore((state) => state.resume.title); + const locked = useResumeStore((state) => state.resume.locked); + + const toggle = useBuilderStore((state) => state.toggle); const isDragging = useBuilderStore( (state) => state.panel.left.isDragging || state.panel.right.isDragging, ); @@ -48,6 +50,12 @@ export const BuilderHeader = () => { {"/"}

{title}

+ + {locked && ( + + + + )} diff --git a/apps/client/src/pages/builder/sidebars/right/index.tsx b/apps/client/src/pages/builder/sidebars/right/index.tsx index c8d6d4363..e844d7c20 100644 --- a/apps/client/src/pages/builder/sidebars/right/index.tsx +++ b/apps/client/src/pages/builder/sidebars/right/index.tsx @@ -7,6 +7,7 @@ import { ThemeSwitch } from "@/client/components/theme-switch"; import { ExportSection } from "./sections/export"; import { InformationSection } from "./sections/information"; import { LayoutSection } from "./sections/layout"; +import { NotesSection } from "./sections/notes"; import { PageSection } from "./sections/page"; import { SharingSection } from "./sections/sharing"; import { StatisticsSection } from "./sections/statistics"; @@ -43,6 +44,8 @@ export const RightSidebar = () => { + + @@ -63,6 +66,18 @@ export const RightSidebar = () => { scrollIntoView("#theme")} /> scrollIntoView("#page")} /> scrollIntoView("#sharing")} /> + scrollIntoView("#statistics")} + /> + scrollIntoView("#export")} /> + scrollIntoView("#notes")} /> + scrollIntoView("#information")} + /> diff --git a/apps/client/src/pages/builder/sidebars/right/sections/notes.tsx b/apps/client/src/pages/builder/sidebars/right/sections/notes.tsx new file mode 100644 index 000000000..fe017058a --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/notes.tsx @@ -0,0 +1,37 @@ +import { RichInput } from "@reactive-resume/ui"; + +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const NotesSection = () => { + const setValue = useResumeStore((state) => state.setValue); + const notes = useResumeStore((state) => state.resume.data.metadata.notes); + + return ( +
+
+
+ {getSectionIcon("notes")} +

Notes

+
+
+ +
+

+ This section is reserved for your personal notes specific to this resume. The content here + remains private and is not shared with anyone else. +

+ +
+ setValue("metadata.notes", content)} /> + +

+ For example, information regarding which companies you sent this resume to or the links + to the job descriptions can be noted down here. +

+
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx b/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx index 608f7f268..2e99af472 100644 --- a/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx +++ b/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx @@ -4,6 +4,7 @@ import { IconProps, Info, Layout, + Note, Palette, ReadCvLogo, ShareFat, @@ -13,6 +14,7 @@ import { import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui"; export type MetadataKey = + | "notes" | "template" | "layout" | "typography" @@ -26,6 +28,8 @@ export type MetadataKey = export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => { switch (id) { // Left Sidebar + case "notes": + return ; case "template": return ; case "layout": diff --git a/apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx b/apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx new file mode 100644 index 000000000..53a1f89c2 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx @@ -0,0 +1,58 @@ +import { ResumeDto } from "@reactive-resume/dto"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@reactive-resume/ui"; + +import { useLockResume } from "@/client/services/resume/lock"; +import { useDialog } from "@/client/stores/dialog"; + +export const LockDialog = () => { + const { isOpen, mode, payload, close } = useDialog("lock"); + + const isLockMode = mode === "create"; + const isUnlockMode = mode === "update"; + + const { lockResume, loading } = useLockResume(); + + const onSubmit = async () => { + if (!payload.item) return; + + await lockResume({ id: payload.item.id, set: isLockMode }); + + close(); + }; + + return ( + + + + + {isLockMode && "Are you sure you want to lock this resume?"} + {isUnlockMode && "Are you sure you want to unlock this resume?"} + + + {isLockMode && + "Locking a resume will prevent any further changes to it. This is useful when you have already shared your resume with someone and you don't want to accidentally make any changes to it."} + {isUnlockMode && "Unlocking a resume will allow you to make changes to it again."} + + + + + Cancel + + + {isLockMode && "Lock"} + {isUnlockMode && "Unlock"} + + + + + ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx index 800e07267..4a353cae1 100644 --- a/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx +++ b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx @@ -2,6 +2,8 @@ import { CircleNotch, CopySimple, FolderOpen, + Lock, + LockOpen, PencilSimple, TrashSimple, } from "@phosphor-icons/react"; @@ -30,6 +32,7 @@ type Props = { export const ResumeCard = ({ resume }: Props) => { const navigate = useNavigate(); const { open } = useDialog("resume"); + const { open: lockOpen } = useDialog("lock"); const { url, loading } = useResumePreview(resume.id); @@ -47,6 +50,10 @@ export const ResumeCard = ({ resume }: Props) => { open("duplicate", { id: "resume", item: resume }); }; + const onLockChange = () => { + lockOpen(resume.locked ? "update" : "create", { id: "lock", item: resume }); + }; + const onDelete = () => { open("delete", { id: "resume", item: resume }); }; @@ -54,7 +61,7 @@ export const ResumeCard = ({ resume }: Props) => { return ( - + {loading && ( { )} + + {resume.locked && ( + + + + )} + +
{ Duplicate + {resume.locked ? ( + + + Unlock + + ) : ( + + + Lock + + )} 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())