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,
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 { 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 = () => {
<span className="mr-2 text-xs opacity-40">{"/"}</span>
<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>
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}>

View File

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

View File

@ -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 = () => {
<Separator />
<ExportSection />
<Separator />
<NotesSection />
<Separator />
<InformationSection />
<Separator />
<Copyright className="text-center" />
@ -63,6 +66,18 @@ export const RightSidebar = () => {
<SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} />
<SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} />
<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>
<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,
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 <Note size={18} {...props} />;
case "template":
return <DiamondsFour size={18} {...props} />;
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,
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<ResumeDto>("resume");
const { open: lockOpen } = useDialog<ResumeDto>("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 (
<ContextMenu>
<ContextMenuTrigger>
<BaseCard onClick={onOpen}>
<BaseCard onClick={onOpen} className="space-y-0">
<AnimatePresence presenceAffectsLayout>
{loading && (
<motion.div
@ -85,6 +92,19 @@ export const ResumeCard = ({ resume }: Props) => {
)}
</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
className={cn(
"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" />
Duplicate
</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 />
<ContextMenuItem onClick={onDelete} className="text-error">
<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 { 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) => {
<div id="dialog-root">
<ResumeDialog />
<LockDialog />
<ImportDialog />
<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 { 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<ResumeDto, AxiosResponse<ResumeDto>, UpdateResumeDto>(
`/resume/${data.id}`,
data,
);
try {
const response = await axios.patch<ResumeDto, AxiosResponse<ResumeDto>, UpdateResumeDto>(
`/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) => {
if (!cache) return [response.data];
return cache.map((resume) => {
if (resume.id === response.data.id) return response.data;
return resume;
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;
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<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 };

View File

@ -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";

View File

@ -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({

View File

@ -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,

View File

@ -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:

View File

@ -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);

View File

@ -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) {

View File

@ -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);

View File

@ -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()),

View File

@ -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) {}

View File

@ -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: "",
};

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