feat(resume): implement resume locking feature

This commit is contained in:
Amruth Pillai
2023-11-06 13:57:12 +01:00
parent 9a0402d525
commit 015e284318
23 changed files with 288 additions and 83 deletions

View File

@ -489,5 +489,7 @@ export const sampleResume: ResumeData = {
lineHeight: 1.5, lineHeight: 1.5,
underlineLinks: true, underlineLinks: true,
}, },
notes:
"<p>I sent this resume to Deloitte back in July 2022. I am yet to hear back from them.</p>",
}, },
}; };

View File

@ -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 { 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 { cn } from "@reactive-resume/utils";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -11,8 +11,10 @@ export const BuilderHeader = () => {
const { isDesktop } = useBreakpoint(); const { isDesktop } = useBreakpoint();
const defaultPanelSize = isDesktop ? 25 : 0; const defaultPanelSize = isDesktop ? 25 : 0;
const toggle = useBuilderStore((state) => state.toggle);
const title = useResumeStore((state) => state.resume.title); const title = useResumeStore((state) => state.resume.title);
const locked = useResumeStore((state) => state.resume.locked);
const toggle = useBuilderStore((state) => state.toggle);
const isDragging = useBuilderStore( const isDragging = useBuilderStore(
(state) => state.panel.left.isDragging || state.panel.right.isDragging, (state) => state.panel.left.isDragging || state.panel.right.isDragging,
); );
@ -48,6 +50,12 @@ export const BuilderHeader = () => {
<span className="mr-2 text-xs opacity-40">{"/"}</span> <span className="mr-2 text-xs opacity-40">{"/"}</span>
<h1 className="font-medium">{title}</h1> <h1 className="font-medium">{title}</h1>
{locked && (
<Tooltip content="This resume is locked, please unlock to make further changes.">
<Lock size={14} className="ml-2 opacity-75" />
</Tooltip>
)}
</div> </div>
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}> <Button size="icon" variant="ghost" onClick={() => onToggle("right")}>

View File

@ -2,9 +2,9 @@ import {
ArrowCounterClockwise, ArrowCounterClockwise,
Broom, Broom,
Columns, Columns,
DotsThreeVertical,
Eye, Eye,
EyeSlash, EyeSlash,
List,
PencilSimple, PencilSimple,
Plus, Plus,
TrashSimple, TrashSimple,
@ -55,7 +55,7 @@ export const SectionOptions = ({ id }: Props) => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<DotsThreeVertical weight="bold" /> <List weight="bold" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48"> <DropdownMenuContent className="w-48">

View File

@ -7,6 +7,7 @@ import { ThemeSwitch } from "@/client/components/theme-switch";
import { ExportSection } from "./sections/export"; import { ExportSection } from "./sections/export";
import { InformationSection } from "./sections/information"; import { InformationSection } from "./sections/information";
import { LayoutSection } from "./sections/layout"; import { LayoutSection } from "./sections/layout";
import { NotesSection } from "./sections/notes";
import { PageSection } from "./sections/page"; import { PageSection } from "./sections/page";
import { SharingSection } from "./sections/sharing"; import { SharingSection } from "./sections/sharing";
import { StatisticsSection } from "./sections/statistics"; import { StatisticsSection } from "./sections/statistics";
@ -43,6 +44,8 @@ export const RightSidebar = () => {
<Separator /> <Separator />
<ExportSection /> <ExportSection />
<Separator /> <Separator />
<NotesSection />
<Separator />
<InformationSection /> <InformationSection />
<Separator /> <Separator />
<Copyright className="text-center" /> <Copyright className="text-center" />
@ -63,6 +66,18 @@ export const RightSidebar = () => {
<SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} /> <SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} />
<SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} /> <SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} />
<SectionIcon id="sharing" name="Sharing" onClick={() => scrollIntoView("#sharing")} /> <SectionIcon id="sharing" name="Sharing" onClick={() => scrollIntoView("#sharing")} />
<SectionIcon
id="statistics"
name="Statistics"
onClick={() => scrollIntoView("#statistics")}
/>
<SectionIcon id="export" name="Export" onClick={() => scrollIntoView("#export")} />
<SectionIcon id="notes" name="Notes" onClick={() => scrollIntoView("#notes")} />
<SectionIcon
id="information"
name="Information"
onClick={() => scrollIntoView("#information")}
/>
</div> </div>
<ThemeSwitch size={14} /> <ThemeSwitch size={14} />

View File

@ -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 (
<section id="notes" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("notes")}
<h2 className="line-clamp-1 text-3xl font-bold">Notes</h2>
</div>
</header>
<main className="grid gap-y-4">
<p className="leading-relaxed">
This section is reserved for your personal notes specific to this resume. The content here
remains private and is not shared with anyone else.
</p>
<div className="space-y-1.5">
<RichInput content={notes} onChange={(content) => setValue("metadata.notes", content)} />
<p className="text-xs leading-relaxed opacity-75">
For example, information regarding which companies you sent this resume to or the links
to the job descriptions can be noted down here.
</p>
</div>
</main>
</section>
);
};

View File

@ -4,6 +4,7 @@ import {
IconProps, IconProps,
Info, Info,
Layout, Layout,
Note,
Palette, Palette,
ReadCvLogo, ReadCvLogo,
ShareFat, ShareFat,
@ -13,6 +14,7 @@ import {
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui"; import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
export type MetadataKey = export type MetadataKey =
| "notes"
| "template" | "template"
| "layout" | "layout"
| "typography" | "typography"
@ -26,6 +28,8 @@ export type MetadataKey =
export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => { export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
switch (id) { switch (id) {
// Left Sidebar // Left Sidebar
case "notes":
return <Note size={18} {...props} />;
case "template": case "template":
return <DiamondsFour size={18} {...props} />; return <DiamondsFour size={18} {...props} />;
case "layout": case "layout":

View File

@ -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<ResumeDto>("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 (
<AlertDialog open={isOpen} onOpenChange={close}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isLockMode && "Are you sure you want to lock this resume?"}
{isUnlockMode && "Are you sure you want to unlock this resume?"}
</AlertDialogTitle>
<AlertDialogDescription>
{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."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="info" disabled={loading} onClick={onSubmit}>
{isLockMode && "Lock"}
{isUnlockMode && "Unlock"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -2,6 +2,8 @@ import {
CircleNotch, CircleNotch,
CopySimple, CopySimple,
FolderOpen, FolderOpen,
Lock,
LockOpen,
PencilSimple, PencilSimple,
TrashSimple, TrashSimple,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
@ -30,6 +32,7 @@ type Props = {
export const ResumeCard = ({ resume }: Props) => { export const ResumeCard = ({ resume }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { open } = useDialog<ResumeDto>("resume"); const { open } = useDialog<ResumeDto>("resume");
const { open: lockOpen } = useDialog<ResumeDto>("lock");
const { url, loading } = useResumePreview(resume.id); const { url, loading } = useResumePreview(resume.id);
@ -47,6 +50,10 @@ export const ResumeCard = ({ resume }: Props) => {
open("duplicate", { id: "resume", item: resume }); open("duplicate", { id: "resume", item: resume });
}; };
const onLockChange = () => {
lockOpen(resume.locked ? "update" : "create", { id: "lock", item: resume });
};
const onDelete = () => { const onDelete = () => {
open("delete", { id: "resume", item: resume }); open("delete", { id: "resume", item: resume });
}; };
@ -54,7 +61,7 @@ export const ResumeCard = ({ resume }: Props) => {
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
<BaseCard onClick={onOpen}> <BaseCard onClick={onOpen} className="space-y-0">
<AnimatePresence presenceAffectsLayout> <AnimatePresence presenceAffectsLayout>
{loading && ( {loading && (
<motion.div <motion.div
@ -85,6 +92,19 @@ export const ResumeCard = ({ resume }: Props) => {
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence>
{resume.locked && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex items-center justify-center bg-background/75 backdrop-blur-sm"
>
<Lock size={42} />
</motion.div>
)}
</AnimatePresence>
<div <div
className={cn( className={cn(
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12", "absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
@ -110,6 +130,17 @@ export const ResumeCard = ({ resume }: Props) => {
<CopySimple size={14} className="mr-2" /> <CopySimple size={14} className="mr-2" />
Duplicate Duplicate
</ContextMenuItem> </ContextMenuItem>
{resume.locked ? (
<ContextMenuItem onClick={onLockChange}>
<LockOpen size={14} className="mr-2" />
Unlock
</ContextMenuItem>
) : (
<ContextMenuItem onClick={onLockChange}>
<Lock size={14} className="mr-2" />
Lock
</ContextMenuItem>
)}
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-error"> <ContextMenuItem onClick={onDelete} className="text-error">
<TrashSimple size={14} className="mr-2" /> <TrashSimple size={14} className="mr-2" />

View File

@ -12,6 +12,7 @@ import { ReferencesDialog } from "../pages/builder/sidebars/left/dialogs/referen
import { SkillsDialog } from "../pages/builder/sidebars/left/dialogs/skills"; import { SkillsDialog } from "../pages/builder/sidebars/left/dialogs/skills";
import { VolunteerDialog } from "../pages/builder/sidebars/left/dialogs/volunteer"; import { VolunteerDialog } from "../pages/builder/sidebars/left/dialogs/volunteer";
import { ImportDialog } from "../pages/dashboard/resumes/_dialogs/import"; 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 { ResumeDialog } from "../pages/dashboard/resumes/_dialogs/resume";
import { TwoFactorDialog } from "../pages/dashboard/settings/_dialogs/two-factor"; import { TwoFactorDialog } from "../pages/dashboard/settings/_dialogs/two-factor";
import { useResumeStore } from "../stores/resume"; import { useResumeStore } from "../stores/resume";
@ -29,6 +30,7 @@ export const DialogProvider = ({ children }: Props) => {
<div id="dialog-root"> <div id="dialog-root">
<ResumeDialog /> <ResumeDialog />
<LockDialog />
<ImportDialog /> <ImportDialog />
<TwoFactorDialog /> <TwoFactorDialog />

View File

@ -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<ResumeDto>(["resume", { id: response.data.id }], response.data);
queryClient.setQueryData<ResumeDto[]>(["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 };
};

View File

@ -1,28 +1,41 @@
import { ResumeDto, UpdateResumeDto } from "@reactive-resume/dto"; import { ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { AxiosResponse } from "axios"; import { AxiosError, AxiosResponse } from "axios";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import { toast } from "@/client/hooks/use-toast";
import { axios } from "@/client/libs/axios"; import { axios } from "@/client/libs/axios";
import { queryClient } from "@/client/libs/query-client"; import { queryClient } from "@/client/libs/query-client";
export const updateResume = async (data: UpdateResumeDto) => { export const updateResume = async (data: UpdateResumeDto) => {
const response = await axios.patch<ResumeDto, AxiosResponse<ResumeDto>, UpdateResumeDto>( try {
`/resume/${data.id}`, const response = await axios.patch<ResumeDto, AxiosResponse<ResumeDto>, UpdateResumeDto>(
data, `/resume/${data.id}`,
); data,
);
queryClient.setQueryData<ResumeDto>(["resume", { id: response.data.id }], response.data); queryClient.setQueryData<ResumeDto>(["resume", { id: response.data.id }], response.data);
queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => { queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => {
if (!cache) return [response.data]; if (!cache) return [response.data];
return cache.map((resume) => { return cache.map((resume) => {
if (resume.id === response.data.id) return response.data; if (resume.id === response.data.id) return response.data;
return resume; 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); export const debouncedUpdateResume = debounce(updateResume, 500);
@ -34,17 +47,6 @@ export const useUpdateResume = () => {
mutateAsync: updateResumeFn, mutateAsync: updateResumeFn,
} = useMutation({ } = useMutation({
mutationFn: updateResume, mutationFn: updateResume,
onSuccess: (data) => {
queryClient.setQueryData<ResumeDto>(["resume", { id: data.id }], data);
queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => {
if (!cache) return [data];
return cache.map((resume) => {
if (resume.id === data.id) return data;
return resume;
});
});
},
}); });
return { updateResume: updateResumeFn, loading, error }; return { updateResume: updateResumeFn, loading, error };

View File

@ -1,7 +1,7 @@
import { SectionKey } from "@reactive-resume/schema"; import { SectionKey } from "@reactive-resume/schema";
import { create } from "zustand"; 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"; export type DialogMode = "create" | "update" | "duplicate" | "delete";

View File

@ -2,7 +2,7 @@ import { HttpException, Module } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticModule } from "@nestjs/serve-static";
import { RavenInterceptor, RavenModule } from "nest-raven"; import { RavenInterceptor, RavenModule } from "nest-raven";
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { join } from "path"; import { join } from "path";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
@ -44,10 +44,6 @@ import { UtilsModule } from "./utils/utils.module";
provide: APP_PIPE, provide: APP_PIPE,
useClass: ZodValidationPipe, useClass: ZodValidationPipe,
}, },
{
provide: APP_INTERCEPTOR,
useClass: ZodSerializerInterceptor,
},
{ {
provide: APP_INTERCEPTOR, provide: APP_INTERCEPTOR,
useValue: new RavenInterceptor({ useValue: new RavenInterceptor({

View File

@ -16,19 +16,16 @@ import {
authResponseSchema, authResponseSchema,
backupCodesSchema, backupCodesSchema,
ForgotPasswordDto, ForgotPasswordDto,
MessageDto,
messageSchema, messageSchema,
RegisterDto, RegisterDto,
ResetPasswordDto, ResetPasswordDto,
TwoFactorBackupDto, TwoFactorBackupDto,
TwoFactorDto, TwoFactorDto,
UpdatePasswordDto, UpdatePasswordDto,
UserDto,
userSchema, userSchema,
UserWithSecrets, UserWithSecrets,
} from "@reactive-resume/dto"; } from "@reactive-resume/dto";
import type { Response } from "express"; import type { Response } from "express";
import { ZodSerializerDto } from "nestjs-zod";
import { ErrorMessage } from "../constants/error-message"; import { ErrorMessage } from "../constants/error-message";
import { User } from "../user/decorators/user.decorator"; import { User } from "../user/decorators/user.decorator";
@ -151,7 +148,6 @@ export class AuthController {
@Patch("password") @Patch("password")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(MessageDto)
async updatePassword(@User("email") email: string, @Body() { password }: UpdatePasswordDto) { async updatePassword(@User("email") email: string, @Body() { password }: UpdatePasswordDto) {
await this.authService.updatePassword(email, password); await this.authService.updatePassword(email, password);
@ -174,7 +170,6 @@ export class AuthController {
@ApiTags("Two-Factor Auth") @ApiTags("Two-Factor Auth")
@Post("2fa/setup") @Post("2fa/setup")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ZodSerializerDto(MessageDto)
async setup2FASecret(@User("email") email: string) { async setup2FASecret(@User("email") email: string) {
return this.authService.setup2FASecret(email); return this.authService.setup2FASecret(email);
} }
@ -204,7 +199,6 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
@Post("2fa/disable") @Post("2fa/disable")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(MessageDto)
async disable2FA(@User("email") email: string) { async disable2FA(@User("email") email: string) {
await this.authService.disable2FA(email); await this.authService.disable2FA(email);
@ -215,7 +209,6 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
@Post("2fa/verify") @Post("2fa/verify")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ZodSerializerDto(UserDto)
async verify2FACode( async verify2FACode(
@User() user: UserWithSecrets, @User() user: UserWithSecrets,
@Body() { code }: TwoFactorDto, @Body() { code }: TwoFactorDto,
@ -235,7 +228,6 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
@Post("2fa/backup") @Post("2fa/backup")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ZodSerializerDto(UserDto)
async useBackup2FACode( async useBackup2FACode(
@User("id") id: string, @User("id") id: string,
@User("email") email: string, @User("email") email: string,
@ -267,7 +259,6 @@ export class AuthController {
@ApiTags("Password Reset") @ApiTags("Password Reset")
@HttpCode(200) @HttpCode(200)
@Post("reset-password") @Post("reset-password")
@ZodSerializerDto(MessageDto)
async resetPassword(@Body() { token, password }: ResetPasswordDto) { async resetPassword(@Body() { token, password }: ResetPasswordDto) {
try { try {
await this.authService.resetPassword(token, password); await this.authService.resetPassword(token, password);
@ -282,7 +273,6 @@ export class AuthController {
@ApiTags("Email Verification") @ApiTags("Email Verification")
@Post("verify-email") @Post("verify-email")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(MessageDto)
async verifyEmail( async verifyEmail(
@User("id") id: string, @User("id") id: string,
@User("emailVerified") emailVerified: boolean, @User("emailVerified") emailVerified: boolean,
@ -302,7 +292,6 @@ export class AuthController {
@ApiTags("Email Verification") @ApiTags("Email Verification")
@Post("verify-email/resend") @Post("verify-email/resend")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(MessageDto)
async resendVerificationEmail( async resendVerificationEmail(
@User("email") email: string, @User("email") email: string,
@User("emailVerified") emailVerified: boolean, @User("emailVerified") emailVerified: boolean,

View File

@ -21,6 +21,8 @@ export const ErrorMessage = {
ResumeSlugAlreadyExists: ResumeSlugAlreadyExists:
"A resume with this slug already exists, please pick a different unique identifier.", "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.", 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: ResumePrinterError:
"Something went wrong while printing your resume. Please try again later or raise an issue on GitHub.", "Something went wrong while printing your resume. Please try again later or raise an issue on GitHub.",
ResumePreviewError: ResumePreviewError:

View File

@ -16,16 +16,8 @@ import {
import { ApiTags } from "@nestjs/swagger"; import { ApiTags } from "@nestjs/swagger";
import { User as UserEntity } from "@prisma/client"; import { User as UserEntity } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
CreateResumeDto,
ImportResumeDto,
ResumeDto,
StatisticsDto,
UpdateResumeDto,
UrlDto,
} from "@reactive-resume/dto";
import { resumeDataSchema } from "@reactive-resume/schema"; import { resumeDataSchema } from "@reactive-resume/schema";
import { ZodSerializerDto } from "nestjs-zod";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { User } from "@/server/user/decorators/user.decorator"; import { User } from "@/server/user/decorators/user.decorator";
@ -91,7 +83,6 @@ export class ResumeController {
@Get(":id/statistics") @Get(":id/statistics")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(StatisticsDto)
findOneStatistics(@User("id") userId: string, @Param("id") id: string) { findOneStatistics(@User("id") userId: string, @Param("id") id: string) {
return this.resumeService.findOneStatistics(userId, id); return this.resumeService.findOneStatistics(userId, id);
} }
@ -111,15 +102,20 @@ export class ResumeController {
return this.resumeService.update(user.id, id, updateResumeDto); 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") @Delete(":id")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
remove(@User() user: UserEntity, @Param("id") id: string) { async remove(@User() user: UserEntity, @Param("id") id: string) {
return this.resumeService.remove(user.id, id); await this.resumeService.remove(user.id, id);
} }
@Get("/print/:id") @Get("/print/:id")
@UseGuards(OptionalGuard, ResumeGuard) @UseGuards(OptionalGuard, ResumeGuard)
@ZodSerializerDto(UrlDto)
async printResume(@Resume() resume: ResumeDto) { async printResume(@Resume() resume: ResumeDto) {
try { try {
const url = await this.resumeService.printResume(resume); const url = await this.resumeService.printResume(resume);
@ -133,7 +129,6 @@ export class ResumeController {
@Get("/print/:id/preview") @Get("/print/:id/preview")
@UseGuards(TwoFactorGuard, ResumeGuard) @UseGuards(TwoFactorGuard, ResumeGuard)
@ZodSerializerDto(UrlDto)
async printPreview(@Resume() resume: ResumeDto) { async printPreview(@Resume() resume: ResumeDto) {
try { try {
const url = await this.resumeService.printPreview(resume); const url = await this.resumeService.printPreview(resume);

View File

@ -1,5 +1,5 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager"; 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 { Prisma } from "@prisma/client";
import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto"; import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
import { defaultResumeData, ResumeData } from "@reactive-resume/schema"; import { defaultResumeData, ResumeData } from "@reactive-resume/schema";
@ -13,6 +13,7 @@ import { PrismaService } from "nestjs-prisma";
import { PrinterService } from "@/server/printer/printer.service"; import { PrinterService } from "@/server/printer/printer.service";
import { ErrorMessage } from "../constants/error-message";
import { StorageService } from "../storage/storage.service"; import { StorageService } from "../storage/storage.service";
import { UtilsService } from "../utils/utils.service"; import { UtilsService } from "../utils/utils.service";
@ -129,22 +130,44 @@ export class ResumeService {
} }
async update(userId: string, id: string, updateResumeDto: UpdateResumeDto) { async update(userId: string, id: string, updateResumeDto: UpdateResumeDto) {
await Promise.all([ try {
this.cache.set(`user:${userId}:resume:${id}`, updateResumeDto), const resume = await this.prisma.resume.update({
this.cache.del(`user:${userId}:resumes`), data: {
this.cache.del(`user:${userId}:storage:resumes:${id}`), title: updateResumeDto.title,
this.cache.del(`user:${userId}:storage:previews:${id}`), 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({ await Promise.all([
data: { this.cache.set(`user:${userId}:resume:${id}`, resume),
title: updateResumeDto.title, this.cache.del(`user:${userId}:resumes`),
slug: updateResumeDto.slug, this.cache.del(`user:${userId}:storage:resumes:${id}`),
visibility: updateResumeDto.visibility, this.cache.del(`user:${userId}:storage:previews:${id}`),
data: updateResumeDto.data as unknown as Prisma.JsonObject, ]);
},
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 } }, 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) { async remove(userId: string, id: string) {
@ -156,9 +179,10 @@ export class ResumeService {
// Remove files in storage, and their cached keys // Remove files in storage, and their cached keys
this.storageService.deleteObject(userId, "resumes", id), this.storageService.deleteObject(userId, "resumes", id),
this.storageService.deleteObject(userId, "previews", 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) { async printResume(resume: ResumeDto) {

View File

@ -1,8 +1,7 @@
import { Body, Controller, Delete, Get, Patch, Res, UseGuards } from "@nestjs/common"; import { Body, Controller, Delete, Get, Patch, Res, UseGuards } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger"; 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 type { Response } from "express";
import { ZodSerializerDto } from "nestjs-zod";
import { AuthService } from "../auth/auth.service"; import { AuthService } from "../auth/auth.service";
import { TwoFactorGuard } from "../auth/guards/two-factor.guard"; import { TwoFactorGuard } from "../auth/guards/two-factor.guard";
@ -19,14 +18,12 @@ export class UserController {
@Get("me") @Get("me")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(UserDto)
fetch(@User() user: UserDto) { fetch(@User() user: UserDto) {
return user; return user;
} }
@Patch("me") @Patch("me")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(UserDto)
async update(@User("email") email: string, @Body() updateUserDto: UpdateUserDto) { async update(@User("email") email: string, @Body() updateUserDto: UpdateUserDto) {
// If user is updating their email, send a verification email // If user is updating their email, send a verification email
if (updateUserDto.email && updateUserDto.email !== email) { if (updateUserDto.email && updateUserDto.email !== email) {
@ -50,7 +47,6 @@ export class UserController {
@Delete("me") @Delete("me")
@UseGuards(TwoFactorGuard) @UseGuards(TwoFactorGuard)
@ZodSerializerDto(MessageDto)
async delete(@User("id") id: string, @Res({ passthrough: true }) response: Response) { async delete(@User("id") id: string, @Res({ passthrough: true }) response: Response) {
await this.userService.deleteOneById(id); await this.userService.deleteOneById(id);

View File

@ -10,6 +10,7 @@ export const resumeSchema = z.object({
slug: z.string(), slug: z.string(),
data: resumeDataSchema.default(defaultResumeData), data: resumeDataSchema.default(defaultResumeData),
visibility: z.enum(["private", "public"]).default("private"), visibility: z.enum(["private", "public"]).default("private"),
locked: z.boolean().default(false),
userId: idSchema, userId: idSchema,
user: userSchema.optional(), user: userSchema.optional(),
createdAt: z.date().or(z.dateString()), createdAt: z.date().or(z.dateString()),

View File

@ -2,6 +2,6 @@ import { createZodDto } from "nestjs-zod/dto";
import { resumeSchema } from "./resume"; import { resumeSchema } from "./resume";
export const updateResumeSchema = resumeSchema; export const updateResumeSchema = resumeSchema.partial();
export class UpdateResumeDto extends createZodDto(updateResumeSchema) {} export class UpdateResumeDto extends createZodDto(updateResumeSchema) {}

View File

@ -39,6 +39,7 @@ export const metadataSchema = z.object({
lineHeight: z.number().default(1.5), lineHeight: z.number().default(1.5),
underlineLinks: z.boolean().default(true), underlineLinks: z.boolean().default(true),
}), }),
notes: z.string().default(""),
}); });
// Type // Type
@ -76,4 +77,5 @@ export const defaultMetadata: Metadata = {
lineHeight: 1.5, lineHeight: 1.5,
underlineLinks: true, underlineLinks: true,
}, },
notes: "",
}; };

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Resume" ADD COLUMN "locked" BOOLEAN NOT NULL DEFAULT false;

View File

@ -53,6 +53,7 @@ model Resume {
slug String slug String
data Json @default("{}") data Json @default("{}")
visibility Visibility @default(private) visibility Visibility @default(private)
locked Boolean @default(false)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())