mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-10 04:22:27 +10:00
feat(resume): ✨ implement resume locking feature
This commit is contained in:
@ -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>",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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")}>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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":
|
||||||
|
|||||||
58
apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx
Normal file
58
apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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" />
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|
||||||
|
|||||||
38
apps/client/src/services/resume/lock.ts
Normal file
38
apps/client/src/services/resume/lock.ts
Normal 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 };
|
||||||
|
};
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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()),
|
||||||
|
|||||||
@ -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) {}
|
||||||
|
|||||||
@ -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: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Resume" ADD COLUMN "locked" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user