+ {section.items.map((item: ListItem) => {
+ const id = item.id,
+ title: string = parseListItemPath(item, titlePath),
+ subtitle: string = parseListItemPath(item, subtitlePath),
+ headline: string = parseListItemPath(item, headlinePath),
+ keywords: string[] = get(item, keywordsPath),
+ url: string = get(item, 'url'),
+ summary: string = get(item, 'summary'),
+ level: string = get(item, 'level'),
+ levelNum: number = get(item, 'levelNum'),
+ phone: string = get(item, 'phone'),
+ email: string = get(item, 'email'),
+ date: string = formatDateString(get(item, 'date'), dateFormat);
+
+ return (
+
+
+
+ {title && {title} }
+ {subtitle && {subtitle} }
+
+
+
+ {date &&
({date})
}
+ {headline &&
{headline} }
+
+
+
+ {(level || levelNum > 0) && (
+
+ {level &&
{level} }
+ {levelNum > 0 && (
+
+ {[...Array(5).keys()].map((_, index) => (
+
index ? primaryColor : '',
+ }}
+ />
+ ))}
+
+ )}
+
+ )}
+
+ {summary &&
{summary} }
+
+ {url && (
+
} link={url} className="text-xs">
+ {url}
+
+ )}
+
+ {keywords && (
+
+ Keywords:
+
+ {keywords.join(', ')}
+
+ )}
+
+ {(phone || email) && (
+
+ {phone && (
+ } link={`tel:${phone}`}>
+ {phone}
+
+ )}
+
+ {email && (
+ } link={`mailto:${email}`}>
+ {email}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+export default Section;
diff --git a/apps/client/templates/Pikachu/Pikachu.module.scss b/apps/client/templates/Pikachu/Pikachu.module.scss
new file mode 100644
index 00000000..ef5d9fbb
--- /dev/null
+++ b/apps/client/templates/Pikachu/Pikachu.module.scss
@@ -0,0 +1,19 @@
+.page {
+ @apply px-6 py-4;
+
+ a {
+ @apply font-semibold;
+ }
+}
+
+.container {
+ @apply grid grid-cols-6 gap-4;
+
+ .main {
+ @apply col-span-4 flex flex-col gap-4;
+ }
+
+ .sidebar {
+ @apply col-span-2 flex flex-col gap-4;
+ }
+}
diff --git a/apps/client/templates/Pikachu/Pikachu.tsx b/apps/client/templates/Pikachu/Pikachu.tsx
new file mode 100644
index 00000000..9bb1c760
--- /dev/null
+++ b/apps/client/templates/Pikachu/Pikachu.tsx
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+
+import { useAppSelector } from '@/store/hooks';
+import { PageProps } from '@/utils/template';
+
+import { getSectionById } from '../sectionMap';
+import styles from './Pikachu.module.scss';
+import { MastheadMain, MastheadSidebar } from './widgets/Masthead';
+import Section from './widgets/Section';
+
+const Pikachu: React.FC
= ({ page }) => {
+ const isFirstPage = useMemo(() => page === 0, [page]);
+ const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
+
+ return (
+
+
+
+ {isFirstPage && }
+
+ {layout[1].map((key) => getSectionById(key, Section))}
+
+
+ {isFirstPage && }
+
+ {layout[0].map((key) => getSectionById(key, Section))}
+
+
+
+ );
+};
+
+export default Pikachu;
diff --git a/apps/client/templates/Pikachu/preview.jpg b/apps/client/templates/Pikachu/preview.jpg
new file mode 100644
index 00000000..b0c05927
Binary files /dev/null and b/apps/client/templates/Pikachu/preview.jpg differ
diff --git a/apps/client/templates/Pikachu/widgets/Heading.tsx b/apps/client/templates/Pikachu/widgets/Heading.tsx
new file mode 100644
index 00000000..734cc96e
--- /dev/null
+++ b/apps/client/templates/Pikachu/widgets/Heading.tsx
@@ -0,0 +1,19 @@
+import { Theme } from '@reactive-resume/schema';
+import get from 'lodash/get';
+
+import { useAppSelector } from '@/store/hooks';
+
+const Heading: React.FC = ({ children }) => {
+ const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Heading;
diff --git a/apps/client/templates/Pikachu/widgets/Masthead.tsx b/apps/client/templates/Pikachu/widgets/Masthead.tsx
new file mode 100644
index 00000000..d74a3481
--- /dev/null
+++ b/apps/client/templates/Pikachu/widgets/Masthead.tsx
@@ -0,0 +1,80 @@
+import { Email, Phone, Public, Room } from '@mui/icons-material';
+import { Theme } from '@reactive-resume/schema';
+import get from 'lodash/get';
+import isEmpty from 'lodash/isEmpty';
+import Image from 'next/image';
+import { useMemo } from 'react';
+
+import Markdown from '@/components/shared/Markdown';
+import { useAppSelector } from '@/store/hooks';
+import DataDisplay from '@/templates/shared/DataDisplay';
+import getProfileIcon from '@/utils/getProfileIcon';
+import { getContrastColor } from '@/utils/styles';
+import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
+
+export const MastheadSidebar: React.FC = () => {
+ const { name, photo, email, phone, website, location, profiles } = useAppSelector((state) => state.resume.basics);
+
+ return (
+
+ {photo.visible && !isEmpty(photo.url) && (
+
+
+
+ )}
+
+
+ } className="text-xs">
+ {formatLocation(location)}
+
+
+ } className="text-xs" link={`mailto:${email}`}>
+ {email}
+
+
+ } className="text-xs" link={`tel:${phone}`}>
+ {phone}
+
+
+ } link={addHttp(website)} className="text-xs">
+ {website}
+
+
+ {profiles.map(({ id, username, network, url }) => (
+
+ {username}
+
+ ))}
+
+
+ );
+};
+
+export const MastheadMain: React.FC = () => {
+ const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
+ const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
+
+ const { name, summary, headline } = useAppSelector((state) => state.resume.basics);
+
+ return (
+
+ );
+};
diff --git a/apps/client/templates/Pikachu/widgets/Section.tsx b/apps/client/templates/Pikachu/widgets/Section.tsx
new file mode 100644
index 00000000..81cfc68d
--- /dev/null
+++ b/apps/client/templates/Pikachu/widgets/Section.tsx
@@ -0,0 +1,118 @@
+import { Email, Link, Phone } from '@mui/icons-material';
+import { ListItem, Section } from '@reactive-resume/schema';
+import get from 'lodash/get';
+import isArray from 'lodash/isArray';
+import isEmpty from 'lodash/isEmpty';
+
+import Markdown from '@/components/shared/Markdown';
+import { useAppSelector } from '@/store/hooks';
+import { SectionProps } from '@/templates/sectionMap';
+import DataDisplay from '@/templates/shared/DataDisplay';
+import { formatDateString } from '@/utils/date';
+import { parseListItemPath } from '@/utils/template';
+
+import Heading from './Heading';
+
+const Section: React.FC = ({
+ path,
+ titlePath = 'title',
+ subtitlePath = 'subtitle',
+ headlinePath = 'headline',
+ keywordsPath = 'keywords',
+}) => {
+ const section: Section = useAppSelector((state) => get(state.resume, path, {}));
+ const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
+ const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
+
+ if (!section.visible) return null;
+
+ if (isArray(section.items) && isEmpty(section.items)) return null;
+
+ return (
+
+ {section.name}
+
+
+ {section.items.map((item: ListItem) => {
+ const id = item.id,
+ title: string = parseListItemPath(item, titlePath),
+ subtitle: string = parseListItemPath(item, subtitlePath),
+ headline: string = parseListItemPath(item, headlinePath),
+ keywords: string[] = get(item, keywordsPath),
+ url: string = get(item, 'url'),
+ summary: string = get(item, 'summary'),
+ level: string = get(item, 'level'),
+ levelNum: number = get(item, 'levelNum'),
+ phone: string = get(item, 'phone'),
+ email: string = get(item, 'email'),
+ date: string = formatDateString(get(item, 'date'), dateFormat);
+
+ return (
+
+
+
+ {title && {title} }
+ {subtitle && {subtitle} }
+
+
+
+ {date &&
({date})
}
+ {headline &&
{headline} }
+
+
+
+ {(level || levelNum > 0) && (
+
+ {level &&
{level} }
+ {levelNum > 0 && (
+
+ )}
+
+ )}
+
+ {summary &&
{summary} }
+
+ {url && (
+
} link={url}>
+ {url}
+
+ )}
+
+ {keywords && (
+
+ Keywords:
+
+ {keywords.join(', ')}
+
+ )}
+
+ {(phone || email) && (
+
+ {phone && (
+ } link={`tel:${phone}`}>
+ {phone}
+
+ )}
+
+ {email && (
+ } link={`mailto:${email}`}>
+ {email}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+export default Section;
diff --git a/apps/client/templates/sectionMap.tsx b/apps/client/templates/sectionMap.tsx
new file mode 100644
index 00000000..edd6ec8f
--- /dev/null
+++ b/apps/client/templates/sectionMap.tsx
@@ -0,0 +1,54 @@
+import get from 'lodash/get';
+import React from 'react';
+import { validate } from 'uuid';
+
+export type SectionProps = {
+ path: string;
+ titlePath?: string | string[];
+ subtitlePath?: string | string[];
+ headlinePath?: string | string[];
+ keywordsPath?: string;
+};
+
+const sectionMap = (Section: React.FC): Record => ({
+ work: ,
+ education: (
+
+ ),
+ awards: ,
+ certifications: (
+
+ ),
+ publications: ,
+ skills: ,
+ languages: ,
+ interests: ,
+ projects: (
+
+ ),
+ volunteer: ,
+ references: ,
+});
+
+export const getSectionById = (id: string, Section: React.FC): JSX.Element => {
+ if (validate(id)) {
+ return ;
+ }
+
+ return get(sectionMap(Section), id);
+};
+
+export default sectionMap;
diff --git a/apps/client/templates/shared/DataDisplay.tsx b/apps/client/templates/shared/DataDisplay.tsx
new file mode 100644
index 00000000..51418667
--- /dev/null
+++ b/apps/client/templates/shared/DataDisplay.tsx
@@ -0,0 +1,32 @@
+import clsx from 'clsx';
+import isEmpty from 'lodash/isEmpty';
+
+type Props = {
+ icon?: JSX.Element;
+ link?: string;
+ className?: string;
+};
+
+const DataDisplay: React.FC = ({ icon, link, className, children }) => {
+ if (isEmpty(children)) return null;
+
+ if (!isEmpty(link)) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {icon}
+ {children}
+
+ );
+};
+
+export default DataDisplay;
diff --git a/apps/client/templates/templateMap.tsx b/apps/client/templates/templateMap.tsx
new file mode 100644
index 00000000..de341c48
--- /dev/null
+++ b/apps/client/templates/templateMap.tsx
@@ -0,0 +1,56 @@
+import { PageProps } from '@/utils/template';
+
+import Castform from './Castform/Castform';
+import Gengar from './Gengar/Gengar';
+import Glalie from './Glalie/Glalie';
+import Kakuna from './Kakuna/Kakuna';
+import Onyx from './Onyx/Onyx';
+import Pikachu from './Pikachu/Pikachu';
+
+export type TemplateMeta = {
+ id: string;
+ name: string;
+ preview: string;
+ component: React.FC;
+};
+
+const templateMap: Record = {
+ kakuna: {
+ id: 'kakuna',
+ name: 'Kakuna',
+ preview: require('./Kakuna/preview.jpg'),
+ component: Kakuna,
+ },
+ onyx: {
+ id: 'onyx',
+ name: 'Onyx',
+ preview: require('./Onyx/preview.jpg'),
+ component: Onyx,
+ },
+ pikachu: {
+ id: 'pikachu',
+ name: 'Pikachu',
+ preview: require('./Pikachu/preview.jpg'),
+ component: Pikachu,
+ },
+ gengar: {
+ id: 'gengar',
+ name: 'Gengar',
+ preview: require('./Gengar/preview.jpg'),
+ component: Gengar,
+ },
+ castform: {
+ id: 'castform',
+ name: 'Castform',
+ preview: require('./Castform/preview.jpg'),
+ component: Castform,
+ },
+ glalie: {
+ id: 'glalie',
+ name: 'Glalie',
+ preview: require('./Glalie/preview.jpg'),
+ component: Glalie,
+ },
+};
+
+export default templateMap;
diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json
new file mode 100644
index 00000000..694bdb95
--- /dev/null
+++ b/apps/client/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "allowJs": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "types": ["node", "jest"],
+ "strict": false,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "incremental": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/components/*": ["components/*"],
+ "@/config/*": ["config/*"],
+ "@/modals/*": ["modals/*"],
+ "@/pages/*": ["pages/*"],
+ "@/public/*": ["public/*"],
+ "@/services/*": ["services/*"],
+ "@/store/*": ["store/*"],
+ "@/constants/*": ["constants/*"],
+ "@/styles/*": ["styles/*"],
+ "@/templates/*": ["templates/*"],
+ "@/types/*": ["types/*"],
+ "@/utils/*": ["utils/*"],
+ "@/wrappers/*": ["wrappers/*"],
+ "@reactive-resume/schema": ["../../libs/schema/src/index.ts"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "next-env.d.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/client/tsconfig.spec.json b/apps/client/tsconfig.spec.json
new file mode 100644
index 00000000..5b4e7354
--- /dev/null
+++ b/apps/client/tsconfig.spec.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["jest", "node"],
+ "jsx": "react"
+ },
+ "include": [
+ "**/*.test.ts",
+ "**/*.spec.ts",
+ "**/*.test.tsx",
+ "**/*.spec.tsx",
+ "**/*.test.js",
+ "**/*.spec.js",
+ "**/*.test.jsx",
+ "**/*.spec.jsx",
+ "**/*.d.ts"
+ ]
+}
diff --git a/apps/client/types/app.d.ts b/apps/client/types/app.d.ts
new file mode 100644
index 00000000..3c9fe976
--- /dev/null
+++ b/apps/client/types/app.d.ts
@@ -0,0 +1,7 @@
+import React from 'react';
+
+export type SidebarSection = {
+ id: string;
+ icon: React.ReactElement;
+ component: React.ReactElement;
+};
diff --git a/apps/client/types/environment.d.ts b/apps/client/types/environment.d.ts
new file mode 100644
index 00000000..2e7f12fa
--- /dev/null
+++ b/apps/client/types/environment.d.ts
@@ -0,0 +1,15 @@
+declare global {
+ namespace NodeJS {
+ interface ProcessEnv {
+ TZ: string;
+ ANALYZE?: boolean;
+ NODE_ENV: 'development' | 'production';
+
+ // Public Environment Variables
+ NEXT_PUBLIC_APP_VERSION?: string;
+ NEXT_PUBLIC_GOOGLE_CLIENT_ID?: string;
+ }
+ }
+}
+
+export {};
diff --git a/apps/client/types/next-env.d.ts b/apps/client/types/next-env.d.ts
new file mode 100644
index 00000000..4f11a03d
--- /dev/null
+++ b/apps/client/types/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/apps/client/utils/date.ts b/apps/client/utils/date.ts
new file mode 100644
index 00000000..80347327
--- /dev/null
+++ b/apps/client/utils/date.ts
@@ -0,0 +1,42 @@
+import { DateRange } from '@reactive-resume/schema';
+import dayjs from 'dayjs';
+import isEmpty from 'lodash/isEmpty';
+import isString from 'lodash/isString';
+
+export const dateFormatOptions: string[] = [
+ 'MMMM DD, YYYY',
+ 'DD MMMM YYYY',
+ 'DD.MM.YYYY',
+ 'DD/MM/YYYY',
+ 'MM.DD.YYYY',
+ 'MM/DD/YYYY',
+ 'YYYY.MM.DD',
+ 'YYYY/MM/DD',
+ 'MMMM YYYY',
+ 'MMM YYYY',
+ 'YYYY',
+];
+
+export const getRelativeTime = (timestamp: dayjs.ConfigType): string => dayjs(timestamp).toNow(true);
+
+export const formatDateString = (date: string | DateRange, formatStr: string): string => {
+ if (isEmpty(date)) return null;
+
+ // If `date` is a string
+ if (isString(date)) {
+ if (!dayjs(date).isValid()) return null;
+
+ return dayjs(date).format(formatStr);
+ }
+
+ // If `date` is a DateRange
+ if (isEmpty(date.start)) return null;
+
+ if (!dayjs(date.start).isValid()) return null;
+
+ if (!isEmpty(date.end) && dayjs(date.end).isValid()) {
+ return `${dayjs(date.start).format(formatStr)} - ${dayjs(date.end).format(formatStr)}`;
+ }
+
+ return dayjs(date.start).format(formatStr);
+};
diff --git a/apps/client/utils/getGravatarUrl.ts b/apps/client/utils/getGravatarUrl.ts
new file mode 100644
index 00000000..ec66bcd2
--- /dev/null
+++ b/apps/client/utils/getGravatarUrl.ts
@@ -0,0 +1,9 @@
+import md5Hex from 'md5-hex';
+
+const getGravatarUrl = (email: string, size: number) => {
+ const hash = md5Hex(email);
+
+ return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`;
+};
+
+export default getGravatarUrl;
diff --git a/apps/client/utils/getProfileIcon.tsx b/apps/client/utils/getProfileIcon.tsx
new file mode 100644
index 00000000..81f8c430
--- /dev/null
+++ b/apps/client/utils/getProfileIcon.tsx
@@ -0,0 +1,37 @@
+import { Link } from '@mui/icons-material';
+import get from 'lodash/get';
+import {
+ FaBehance,
+ FaDribbble,
+ FaFacebookF,
+ FaGithub,
+ FaGitlab,
+ FaInstagram,
+ FaLinkedinIn,
+ FaSkype,
+ FaSoundcloud,
+ FaStackOverflow,
+ FaTelegram,
+ FaTwitter,
+ FaYoutube,
+} from 'react-icons/fa';
+
+const profileIconMap: Record = {
+ facebook: ,
+ twitter: ,
+ linkedin: ,
+ dribbble: ,
+ soundcloud: ,
+ github: ,
+ instagram: ,
+ stackoverflow: ,
+ behance: ,
+ gitlab: ,
+ telegram: ,
+ skype: ,
+ youtube: ,
+};
+
+const getProfileIcon = (network: string): JSX.Element => get(profileIconMap, network.toLowerCase(), );
+
+export default getProfileIcon;
diff --git a/apps/client/utils/getResumeUrl.ts b/apps/client/utils/getResumeUrl.ts
new file mode 100644
index 00000000..e24266a4
--- /dev/null
+++ b/apps/client/utils/getResumeUrl.ts
@@ -0,0 +1,35 @@
+import { Resume } from '@reactive-resume/schema';
+import get from 'lodash/get';
+
+type Options = {
+ withHost?: boolean;
+ shortUrl?: boolean;
+ buildUrl?: boolean;
+};
+
+const defaultOptions: Options = {
+ withHost: false,
+ shortUrl: false,
+ buildUrl: false,
+};
+
+const getResumeUrl = (resume: Resume, options: Options = defaultOptions): string => {
+ const username: string = get(resume, 'user.username');
+ const shortId: string = get(resume, 'shortId');
+ const slug: string = get(resume, 'slug');
+
+ let url = '';
+ let hostname = '';
+
+ if (typeof window !== 'undefined') {
+ hostname = window.location.origin;
+ }
+
+ url = options.withHost ? `${hostname}` : url;
+ url = options.shortUrl ? `${url}/r/${shortId}` : `${url}/${username}/${slug}`;
+ url = options.buildUrl ? `${url}/build` : url;
+
+ return url;
+};
+
+export default getResumeUrl;
diff --git a/apps/client/utils/isBrowser.ts b/apps/client/utils/isBrowser.ts
new file mode 100644
index 00000000..b6d5249a
--- /dev/null
+++ b/apps/client/utils/isBrowser.ts
@@ -0,0 +1,3 @@
+const isBrowser = typeof window !== 'undefined';
+
+export default isBrowser;
diff --git a/apps/client/utils/styles.ts b/apps/client/utils/styles.ts
new file mode 100644
index 00000000..a2bcbc8e
--- /dev/null
+++ b/apps/client/utils/styles.ts
@@ -0,0 +1,63 @@
+import { Theme, Typography } from '@reactive-resume/schema';
+
+import { hexColorPattern } from '@/config/colors';
+
+export const generateTypographyStyles = ({ family, size }: Typography): string => `
+ font-size: ${size.body}px;
+ font-family: ${family.body};
+
+ svg { font-size: ${size.body}px; }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-weight: bold;
+ font-family: ${family.heading};
+ }
+
+ h1 { font-size: ${size.heading}px; line-height: ${size.heading}px; }
+ h2 { font-size: ${size.heading / 1.5}px; line-height: ${size.heading / 1.5}px; }
+ h3 { font-size: ${size.heading / 2}px; line-height: ${size.heading / 2}px; }
+ h4 { font-size: ${size.heading / 2.5}px; line-height: ${size.heading / 2.5}px; }
+ h5 { font-size: ${size.heading / 3}px; line-height: ${size.heading / 3}px; }
+ h6 { font-size: ${size.heading / 3.5}px; line-height: ${size.heading / 3.5}px; }
+`;
+
+export const generateThemeStyles = ({ text, background, primary }: Theme): string => `
+ color: ${text};
+ background-color: ${background};
+ --primary-color: ${primary};
+
+ svg {
+ color: var(--primary-color);
+ }
+`;
+
+export const hexToRgb = (hex: string): { red: number; green: number; blue: number } => {
+ const result = hexColorPattern.exec(hex);
+
+ return result
+ ? {
+ red: parseInt(result[1], 16),
+ green: parseInt(result[2], 16),
+ blue: parseInt(result[3], 16),
+ }
+ : null;
+};
+
+export const getContrastColor = (color: string): 'dark' | 'light' => {
+ const rgb = hexToRgb(color);
+
+ if (rgb) {
+ const { red, green, blue } = rgb;
+
+ if (red * 0.299 + green * 0.587 + blue * 0.114 > 186) {
+ return 'dark';
+ } else {
+ return 'light';
+ }
+ }
+};
diff --git a/apps/client/utils/template.ts b/apps/client/utils/template.ts
new file mode 100644
index 00000000..b0e52ffe
--- /dev/null
+++ b/apps/client/utils/template.ts
@@ -0,0 +1,58 @@
+import { ListItem, Location, PhotoFilters } from '@reactive-resume/schema';
+import clsx from 'clsx';
+import get from 'lodash/get';
+import isArray from 'lodash/isArray';
+import isEmpty from 'lodash/isEmpty';
+
+export type PageProps = {
+ page: number;
+};
+
+export const formatLocation = (location: Location): string => {
+ const locationArr = [location.address, location.city, location.region, location.postalCode, location.country];
+ const filteredLocationArr = locationArr.filter((x) => !isEmpty(x));
+
+ return filteredLocationArr.join(', ');
+};
+
+export const addHttp = (url: string) => {
+ if (url.search(/^http[s]?:\/\//) == -1) {
+ url = 'http://' + url;
+ }
+
+ return url;
+};
+
+export const isValidUrl = (string: string): boolean => {
+ let url: URL;
+
+ try {
+ url = new URL(string);
+ } catch (_) {
+ return false;
+ }
+
+ return url.protocol === 'http:' || url.protocol === 'https:';
+};
+
+type Separator = ', ' | ' / ' | ' | ';
+
+export const parseListItemPath = (item: ListItem, path: string | string[], separator: Separator = ', '): string => {
+ if (isArray(path)) {
+ const value = path.map((_path) => get(item, _path));
+
+ return value.join(separator);
+ } else {
+ const value = get(item, path);
+
+ return value;
+ }
+};
+
+export const getPhotoClassNames = (filters: PhotoFilters) =>
+ clsx({
+ grayscale: filters.grayscale,
+ '!border-[4px] !border-solid': filters.border,
+ 'rounded-lg': filters.shape === 'rounded-square',
+ 'rounded-full': filters.shape === 'circle',
+ });
diff --git a/apps/client/wrappers/DateWrapper.tsx b/apps/client/wrappers/DateWrapper.tsx
new file mode 100644
index 00000000..50716149
--- /dev/null
+++ b/apps/client/wrappers/DateWrapper.tsx
@@ -0,0 +1,13 @@
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import { useEffect } from 'react';
+
+const DateWrapper: React.FC = ({ children }) => {
+ useEffect(() => {
+ dayjs.extend(relativeTime);
+ }, []);
+
+ return <>{children}>;
+};
+
+export default DateWrapper;
diff --git a/apps/client/wrappers/FontWrapper.tsx b/apps/client/wrappers/FontWrapper.tsx
new file mode 100644
index 00000000..3f0a9d21
--- /dev/null
+++ b/apps/client/wrappers/FontWrapper.tsx
@@ -0,0 +1,29 @@
+import get from 'lodash/get';
+import isEmpty from 'lodash/isEmpty';
+import { useCallback, useEffect } from 'react';
+
+import { useAppSelector } from '@/store/hooks';
+
+const FontWrapper: React.FC = ({ children }) => {
+ const typography = useAppSelector((state) => get(state.resume, 'metadata.typography'));
+
+ const loadFonts = useCallback(async () => {
+ const WebFont = (await import('webfontloader')).default;
+ const families = Object.values(typography.family).reduce(
+ (acc, family) => [...acc, `${family}:400,600,700`],
+ []
+ );
+
+ WebFont.load({ google: { families } });
+ }, [typography]);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && !isEmpty(typography)) {
+ loadFonts();
+ }
+ }, [typography, loadFonts]);
+
+ return <>{children}>;
+};
+
+export default FontWrapper;
diff --git a/apps/client/wrappers/HotkeysWrapper.tsx b/apps/client/wrappers/HotkeysWrapper.tsx
new file mode 100644
index 00000000..6536e1f9
--- /dev/null
+++ b/apps/client/wrappers/HotkeysWrapper.tsx
@@ -0,0 +1,17 @@
+import { useHotkeys } from 'react-hotkeys-hook';
+
+import { toggleSidebar } from '@/store/build/buildSlice';
+import { useAppDispatch } from '@/store/hooks';
+
+const HotkeysWrapper: React.FC = ({ children }) => {
+ const dispatch = useAppDispatch();
+
+ useHotkeys('ctrl+/, cmd+/', () => {
+ dispatch(toggleSidebar({ sidebar: 'left' }));
+ dispatch(toggleSidebar({ sidebar: 'right' }));
+ });
+
+ return <>{children}>;
+};
+
+export default HotkeysWrapper;
diff --git a/apps/client/wrappers/LocaleWrapper.tsx b/apps/client/wrappers/LocaleWrapper.tsx
new file mode 100644
index 00000000..93892887
--- /dev/null
+++ b/apps/client/wrappers/LocaleWrapper.tsx
@@ -0,0 +1,5 @@
+const LocaleWrapper: React.FC = ({ children }) => {
+ return <>{children}>;
+};
+
+export default LocaleWrapper;
diff --git a/apps/client/wrappers/ThemeWrapper.tsx b/apps/client/wrappers/ThemeWrapper.tsx
new file mode 100644
index 00000000..a73e4b7a
--- /dev/null
+++ b/apps/client/wrappers/ThemeWrapper.tsx
@@ -0,0 +1,34 @@
+import { ThemeProvider, useMediaQuery } from '@mui/material';
+import { useEffect, useMemo } from 'react';
+
+import { darkTheme, lightTheme } from '@/config/theme';
+import { setTheme, Theme } from '@/store/build/buildSlice';
+import { useAppDispatch, useAppSelector } from '@/store/hooks';
+
+const ThemeWrapper: React.FC = ({ children }) => {
+ const dispatch = useAppDispatch();
+
+ const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
+ const theme: Theme = useAppSelector((state) => state.build.theme);
+ const isDarkMode = useMemo(() => theme === 'dark', [theme]);
+
+ const muiTheme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
+
+ useEffect(() => {
+ if (theme === undefined) {
+ dispatch(setTheme({ theme: prefersDarkMode ? 'dark' : 'light' }));
+ }
+ }, [theme, dispatch, prefersDarkMode]);
+
+ useEffect(() => {
+ if (isDarkMode) {
+ document.documentElement.classList.add('dark');
+ } else {
+ document.documentElement.classList.remove('dark');
+ }
+ }, [isDarkMode]);
+
+ return {children} ;
+};
+
+export default ThemeWrapper;
diff --git a/apps/client/wrappers/index.tsx b/apps/client/wrappers/index.tsx
new file mode 100644
index 00000000..f6bdd099
--- /dev/null
+++ b/apps/client/wrappers/index.tsx
@@ -0,0 +1,23 @@
+import DateWrapper from './DateWrapper';
+import FontWrapper from './FontWrapper';
+import HotkeysWrapper from './HotkeysWrapper';
+import LocaleWrapper from './LocaleWrapper';
+import ThemeWrapper from './ThemeWrapper';
+
+const WrapperRegistry: React.FC = ({ children }) => {
+ return (
+
+
+
+
+
+ <>{children}>
+
+
+
+
+
+ );
+};
+
+export default WrapperRegistry;
diff --git a/apps/server/.env.example b/apps/server/.env.example
new file mode 100644
index 00000000..f4f6f0a4
--- /dev/null
+++ b/apps/server/.env.example
@@ -0,0 +1 @@
+PORT=3100
diff --git a/apps/server/.eslintrc.json b/apps/server/.eslintrc.json
new file mode 100644
index 00000000..9d9c0db5
--- /dev/null
+++ b/apps/server/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/apps/server/jest.config.js b/apps/server/jest.config.js
new file mode 100644
index 00000000..10dca74c
--- /dev/null
+++ b/apps/server/jest.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+ displayName: 'server',
+ preset: '../../jest.preset.js',
+ globals: {
+ 'ts-jest': {
+ tsconfig: '/tsconfig.spec.json',
+ },
+ },
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.[tj]s$': 'ts-jest',
+ },
+ moduleFileExtensions: ['ts', 'js', 'html'],
+ coverageDirectory: '../../coverage/apps/server',
+};
diff --git a/apps/server/project.json b/apps/server/project.json
new file mode 100644
index 00000000..50c5c4c5
--- /dev/null
+++ b/apps/server/project.json
@@ -0,0 +1,52 @@
+{
+ "root": "apps/server",
+ "sourceRoot": "apps/server/src",
+ "projectType": "application",
+ "targets": {
+ "build": {
+ "executor": "@nrwl/node:build",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/apps/server",
+ "main": "apps/server/src/main.ts",
+ "tsConfig": "apps/server/tsconfig.app.json",
+ "assets": ["apps/server/src/assets"]
+ },
+ "configurations": {
+ "production": {
+ "optimization": true,
+ "extractLicenses": true,
+ "inspect": false,
+ "fileReplacements": [
+ {
+ "replace": "apps/server/src/environments/environment.ts",
+ "with": "apps/server/src/environments/environment.prod.ts"
+ }
+ ]
+ }
+ }
+ },
+ "serve": {
+ "executor": "@nrwl/node:execute",
+ "options": {
+ "buildTarget": "server:build"
+ }
+ },
+ "lint": {
+ "executor": "@nrwl/linter:eslint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": ["apps/server/**/*.ts"]
+ }
+ },
+ "test": {
+ "executor": "@nrwl/jest:jest",
+ "outputs": ["coverage/apps/server"],
+ "options": {
+ "jestConfig": "apps/server/jest.config.js",
+ "passWithNoTests": true
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
new file mode 100644
index 00000000..ec27bebe
--- /dev/null
+++ b/apps/server/src/app.module.ts
@@ -0,0 +1,46 @@
+import { ClassSerializerInterceptor, Module } from '@nestjs/common';
+import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
+import { ScheduleModule } from '@nestjs/schedule';
+import { ServeStaticModule } from '@nestjs/serve-static';
+import { join } from 'path';
+
+import { AuthModule } from './auth/auth.module';
+import { ConfigModule } from './config/config.module';
+import { DatabaseModule } from './database/database.module';
+import { HttpExceptionFilter } from './filters/http-exception.filter';
+import { FontsModule } from './fonts/fonts.module';
+import { IntegrationsModule } from './integrations/integrations.module';
+import { MailModule } from './mail/mail.module';
+import { PrinterModule } from './printer/printer.module';
+import { ResumeModule } from './resume/resume.module';
+import { UsersModule } from './users/users.module';
+
+@Module({
+ imports: [
+ ServeStaticModule.forRoot({
+ rootPath: join(__dirname, 'assets'),
+ }),
+ ConfigModule,
+ DatabaseModule,
+ ScheduleModule.forRoot(),
+ AppModule,
+ AuthModule,
+ MailModule.register(),
+ UsersModule,
+ ResumeModule,
+ FontsModule,
+ IntegrationsModule,
+ PrinterModule,
+ ],
+ providers: [
+ {
+ provide: APP_INTERCEPTOR,
+ useClass: ClassSerializerInterceptor,
+ },
+ {
+ provide: APP_FILTER,
+ useClass: HttpExceptionFilter,
+ },
+ ],
+})
+export class AppModule {}
diff --git a/apps/server/src/assets/covers/cover-0ee139.jpeg b/apps/server/src/assets/covers/cover-0ee139.jpeg
new file mode 100644
index 00000000..a130c36a
Binary files /dev/null and b/apps/server/src/assets/covers/cover-0ee139.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-1ab08.jpeg b/apps/server/src/assets/covers/cover-1ab08.jpeg
new file mode 100644
index 00000000..1ce574a9
Binary files /dev/null and b/apps/server/src/assets/covers/cover-1ab08.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-1f8c9.jpeg b/apps/server/src/assets/covers/cover-1f8c9.jpeg
new file mode 100644
index 00000000..15fa1eb4
Binary files /dev/null and b/apps/server/src/assets/covers/cover-1f8c9.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-1fe54f.jpeg b/apps/server/src/assets/covers/cover-1fe54f.jpeg
new file mode 100644
index 00000000..e20ee78a
Binary files /dev/null and b/apps/server/src/assets/covers/cover-1fe54f.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-253f4a.jpeg b/apps/server/src/assets/covers/cover-253f4a.jpeg
new file mode 100644
index 00000000..7ec32979
Binary files /dev/null and b/apps/server/src/assets/covers/cover-253f4a.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-33aec.jpeg b/apps/server/src/assets/covers/cover-33aec.jpeg
new file mode 100644
index 00000000..9f45d44e
Binary files /dev/null and b/apps/server/src/assets/covers/cover-33aec.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-3sc.jpeg b/apps/server/src/assets/covers/cover-3sc.jpeg
new file mode 100644
index 00000000..eea886d5
Binary files /dev/null and b/apps/server/src/assets/covers/cover-3sc.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-466cb.jpeg b/apps/server/src/assets/covers/cover-466cb.jpeg
new file mode 100644
index 00000000..e4f8a7e4
Binary files /dev/null and b/apps/server/src/assets/covers/cover-466cb.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-478b3.jpeg b/apps/server/src/assets/covers/cover-478b3.jpeg
new file mode 100644
index 00000000..929f607b
Binary files /dev/null and b/apps/server/src/assets/covers/cover-478b3.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-4d9.jpeg b/apps/server/src/assets/covers/cover-4d9.jpeg
new file mode 100644
index 00000000..7875ffab
Binary files /dev/null and b/apps/server/src/assets/covers/cover-4d9.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-4ed.jpeg b/apps/server/src/assets/covers/cover-4ed.jpeg
new file mode 100644
index 00000000..3aef1ba8
Binary files /dev/null and b/apps/server/src/assets/covers/cover-4ed.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-4fd88.jpeg b/apps/server/src/assets/covers/cover-4fd88.jpeg
new file mode 100644
index 00000000..4270b0f3
Binary files /dev/null and b/apps/server/src/assets/covers/cover-4fd88.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-50f3f3.jpeg b/apps/server/src/assets/covers/cover-50f3f3.jpeg
new file mode 100644
index 00000000..a536b339
Binary files /dev/null and b/apps/server/src/assets/covers/cover-50f3f3.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-6b8ae.jpeg b/apps/server/src/assets/covers/cover-6b8ae.jpeg
new file mode 100644
index 00000000..bda9a796
Binary files /dev/null and b/apps/server/src/assets/covers/cover-6b8ae.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-6fa09.jpeg b/apps/server/src/assets/covers/cover-6fa09.jpeg
new file mode 100644
index 00000000..9e9fc06f
Binary files /dev/null and b/apps/server/src/assets/covers/cover-6fa09.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-713b2f.jpeg b/apps/server/src/assets/covers/cover-713b2f.jpeg
new file mode 100644
index 00000000..311fe1ab
Binary files /dev/null and b/apps/server/src/assets/covers/cover-713b2f.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-737f2.jpeg b/apps/server/src/assets/covers/cover-737f2.jpeg
new file mode 100644
index 00000000..f96874f8
Binary files /dev/null and b/apps/server/src/assets/covers/cover-737f2.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-73dab8.jpeg b/apps/server/src/assets/covers/cover-73dab8.jpeg
new file mode 100644
index 00000000..c9f826df
Binary files /dev/null and b/apps/server/src/assets/covers/cover-73dab8.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-79df42.jpeg b/apps/server/src/assets/covers/cover-79df42.jpeg
new file mode 100644
index 00000000..89a80313
Binary files /dev/null and b/apps/server/src/assets/covers/cover-79df42.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-7b601.jpeg b/apps/server/src/assets/covers/cover-7b601.jpeg
new file mode 100644
index 00000000..ef944813
Binary files /dev/null and b/apps/server/src/assets/covers/cover-7b601.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-7dh.jpeg b/apps/server/src/assets/covers/cover-7dh.jpeg
new file mode 100644
index 00000000..2bacce82
Binary files /dev/null and b/apps/server/src/assets/covers/cover-7dh.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-7e6ae.jpeg b/apps/server/src/assets/covers/cover-7e6ae.jpeg
new file mode 100644
index 00000000..77e55e8c
Binary files /dev/null and b/apps/server/src/assets/covers/cover-7e6ae.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-94b.jpeg b/apps/server/src/assets/covers/cover-94b.jpeg
new file mode 100644
index 00000000..9101e76d
Binary files /dev/null and b/apps/server/src/assets/covers/cover-94b.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-96bdd.jpeg b/apps/server/src/assets/covers/cover-96bdd.jpeg
new file mode 100644
index 00000000..3c088ce9
Binary files /dev/null and b/apps/server/src/assets/covers/cover-96bdd.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-98afd.jpeg b/apps/server/src/assets/covers/cover-98afd.jpeg
new file mode 100644
index 00000000..94368155
Binary files /dev/null and b/apps/server/src/assets/covers/cover-98afd.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-9hk.jpeg b/apps/server/src/assets/covers/cover-9hk.jpeg
new file mode 100644
index 00000000..8813e4c8
Binary files /dev/null and b/apps/server/src/assets/covers/cover-9hk.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-b26e75.jpeg b/apps/server/src/assets/covers/cover-b26e75.jpeg
new file mode 100644
index 00000000..b7a70009
Binary files /dev/null and b/apps/server/src/assets/covers/cover-b26e75.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-b6ea6.jpeg b/apps/server/src/assets/covers/cover-b6ea6.jpeg
new file mode 100644
index 00000000..396765c9
Binary files /dev/null and b/apps/server/src/assets/covers/cover-b6ea6.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-c219f2.jpeg b/apps/server/src/assets/covers/cover-c219f2.jpeg
new file mode 100644
index 00000000..5e45b14f
Binary files /dev/null and b/apps/server/src/assets/covers/cover-c219f2.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-c3642.jpeg b/apps/server/src/assets/covers/cover-c3642.jpeg
new file mode 100644
index 00000000..045874e6
Binary files /dev/null and b/apps/server/src/assets/covers/cover-c3642.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-c584b.jpeg b/apps/server/src/assets/covers/cover-c584b.jpeg
new file mode 100644
index 00000000..5122eb6e
Binary files /dev/null and b/apps/server/src/assets/covers/cover-c584b.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-c682cb.jpeg b/apps/server/src/assets/covers/cover-c682cb.jpeg
new file mode 100644
index 00000000..3a0c5d20
Binary files /dev/null and b/apps/server/src/assets/covers/cover-c682cb.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-c82a8.jpeg b/apps/server/src/assets/covers/cover-c82a8.jpeg
new file mode 100644
index 00000000..bf624b15
Binary files /dev/null and b/apps/server/src/assets/covers/cover-c82a8.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-d312a7.jpeg b/apps/server/src/assets/covers/cover-d312a7.jpeg
new file mode 100644
index 00000000..80fe4a62
Binary files /dev/null and b/apps/server/src/assets/covers/cover-d312a7.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-dcbd8.jpeg b/apps/server/src/assets/covers/cover-dcbd8.jpeg
new file mode 100644
index 00000000..f33804ce
Binary files /dev/null and b/apps/server/src/assets/covers/cover-dcbd8.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-df274.jpeg b/apps/server/src/assets/covers/cover-df274.jpeg
new file mode 100644
index 00000000..a5014556
Binary files /dev/null and b/apps/server/src/assets/covers/cover-df274.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-e26ee.jpeg b/apps/server/src/assets/covers/cover-e26ee.jpeg
new file mode 100644
index 00000000..abd24e52
Binary files /dev/null and b/apps/server/src/assets/covers/cover-e26ee.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-f3034.jpeg b/apps/server/src/assets/covers/cover-f3034.jpeg
new file mode 100644
index 00000000..51adec82
Binary files /dev/null and b/apps/server/src/assets/covers/cover-f3034.jpeg differ
diff --git a/apps/server/src/assets/covers/cover-fec87.jpeg b/apps/server/src/assets/covers/cover-fec87.jpeg
new file mode 100644
index 00000000..618400fd
Binary files /dev/null and b/apps/server/src/assets/covers/cover-fec87.jpeg differ
diff --git a/apps/server/src/assets/index.html b/apps/server/src/assets/index.html
new file mode 100644
index 00000000..71b51233
--- /dev/null
+++ b/apps/server/src/assets/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Server | Reactive Resume
+
+
+
+ There's nothing here but a bunch of APIs.
+
+
diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts
new file mode 100644
index 00000000..48149c7f
--- /dev/null
+++ b/apps/server/src/auth/auth.controller.ts
@@ -0,0 +1,66 @@
+import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common';
+
+import { User } from '@/decorators/user.decorator';
+import { User as UserEntity } from '@/users/entities/user.entity';
+
+import { AuthService } from './auth.service';
+import { ForgotPasswordDto } from './dto/forgot-password.dto';
+import { RegisterDto } from './dto/register.dto';
+import { ResetPasswordDto } from './dto/reset-password.dto';
+import { JwtAuthGuard } from './guards/jwt.guard';
+import { LocalAuthGuard } from './guards/local.guard';
+
+@Controller('auth')
+export class AuthController {
+ constructor(private readonly authService: AuthService) {}
+
+ @UseGuards(JwtAuthGuard)
+ @Get()
+ authenticate(@User() user: UserEntity) {
+ return user;
+ }
+
+ @Post('google')
+ async loginWithGoogle(@Body('accessToken') googleAccessToken: string) {
+ const user = await this.authService.authenticateWithGoogle(googleAccessToken);
+ const accessToken = this.authService.getAccessToken(user.id);
+
+ return { user, accessToken };
+ }
+
+ @Post('register')
+ async register(@Body() registerDto: RegisterDto) {
+ const user = await this.authService.register(registerDto);
+ const accessToken = this.authService.getAccessToken(user.id);
+
+ return { user, accessToken };
+ }
+
+ @HttpCode(200)
+ @UseGuards(LocalAuthGuard)
+ @Post('login')
+ async login(@User() user: UserEntity) {
+ const accessToken = this.authService.getAccessToken(user.id);
+
+ return { user, accessToken };
+ }
+
+ @HttpCode(200)
+ @Post('forgot-password')
+ forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
+ return this.authService.forgotPassword(forgotPasswordDto.email);
+ }
+
+ @HttpCode(200)
+ @Post('reset-password')
+ resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
+ return this.authService.resetPassword(resetPasswordDto);
+ }
+
+ @HttpCode(200)
+ @UseGuards(JwtAuthGuard)
+ @Delete()
+ async remove(@User('id') id: number) {
+ await this.authService.removeUser(id);
+ }
+}
diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts
new file mode 100644
index 00000000..b5ab02ec
--- /dev/null
+++ b/apps/server/src/auth/auth.module.ts
@@ -0,0 +1,33 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { JwtModule } from '@nestjs/jwt';
+import { PassportModule } from '@nestjs/passport';
+
+import { UsersModule } from '@/users/users.module';
+
+import { AuthController } from './auth.controller';
+import { AuthService } from './auth.service';
+import { JwtStrategy } from './strategy/jwt.strategy';
+import { LocalStrategy } from './strategy/local.strategy';
+
+@Module({
+ imports: [
+ ConfigModule,
+ UsersModule,
+ PassportModule,
+ JwtModule.registerAsync({
+ imports: [ConfigModule],
+ inject: [ConfigService],
+ useFactory: async (configService: ConfigService) => ({
+ secret: configService.get('auth.jwtSecret'),
+ signOptions: {
+ expiresIn: `${configService.get('auth.jwtExpiryTime')}s`,
+ },
+ }),
+ }),
+ ],
+ providers: [AuthService, LocalStrategy, JwtStrategy],
+ controllers: [AuthController],
+ exports: [AuthService],
+})
+export class AuthModule {}
diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts
new file mode 100644
index 00000000..b6a67e99
--- /dev/null
+++ b/apps/server/src/auth/auth.service.ts
@@ -0,0 +1,141 @@
+import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { JwtService } from '@nestjs/jwt';
+import { SchedulerRegistry } from '@nestjs/schedule';
+import * as bcrypt from 'bcrypt';
+import { google } from 'googleapis';
+
+import { PostgresErrorCode } from '@/database/errorCodes.enum';
+import { CreateGoogleUserDto } from '@/users/dto/create-google-user.dto';
+import { User } from '@/users/entities/user.entity';
+import { UsersService } from '@/users/users.service';
+
+import { RegisterDto } from './dto/register.dto';
+import { ResetPasswordDto } from './dto/reset-password.dto';
+
+@Injectable()
+export class AuthService {
+ constructor(
+ private schedulerRegistry: SchedulerRegistry,
+ private configService: ConfigService,
+ private usersService: UsersService,
+ private jwtService: JwtService
+ ) {}
+
+ async register(registerDto: RegisterDto) {
+ const hashedPassword = await bcrypt.hash(registerDto.password, 10);
+
+ try {
+ const createdUser = await this.usersService.create({
+ ...registerDto,
+ password: hashedPassword,
+ provider: 'email',
+ });
+
+ return createdUser;
+ } catch (error: any) {
+ if (error?.code === PostgresErrorCode.UniqueViolation) {
+ throw new HttpException('A user with that username and/or email already exists.', HttpStatus.UNAUTHORIZED);
+ }
+
+ throw new HttpException(
+ 'Something went wrong. Please try again later, or raise an issue on GitHub if the problem persists.',
+ HttpStatus.INTERNAL_SERVER_ERROR
+ );
+ }
+ }
+
+ async getUser(identifier: string, password: string) {
+ try {
+ const user = await this.usersService.findByIdentifier(identifier);
+
+ await this.verifyPassword(password, user.password);
+
+ return user;
+ } catch (error) {
+ throw new HttpException(
+ 'The username/email and password combination provided was incorrect.',
+ HttpStatus.UNAUTHORIZED
+ );
+ }
+ }
+
+ async verifyPassword(password: string, hashedPassword: string) {
+ const isPasswordMatching = await bcrypt.compare(password, hashedPassword);
+
+ if (!isPasswordMatching) {
+ throw new HttpException(
+ 'The username/email and password combination provided was incorrect.',
+ HttpStatus.UNAUTHORIZED
+ );
+ }
+ }
+
+ forgotPassword(email: string) {
+ this.usersService.generateResetToken(email);
+ }
+
+ async resetPassword(resetPasswordDto: ResetPasswordDto) {
+ const user = await this.usersService.findByResetToken(resetPasswordDto.resetToken);
+ const hashedPassword = await bcrypt.hash(resetPasswordDto.password, 10);
+
+ await this.usersService.update(user.id, { password: hashedPassword, resetToken: null });
+
+ try {
+ this.schedulerRegistry.deleteTimeout(`clear-resetToken-${user.id}`);
+ } catch {
+ // pass through
+ }
+ }
+
+ removeUser(id: number) {
+ return this.usersService.remove(id);
+ }
+
+ getAccessToken(id: number) {
+ const expiresIn = this.configService.get('auth.jwtExpiryTime');
+
+ return this.jwtService.sign({ id }, { expiresIn });
+ }
+
+ getUserFromAccessToken(accessToken: string) {
+ const payload: User = this.jwtService.verify(accessToken, {
+ secret: this.configService.get('auth.jwtSecret'),
+ });
+
+ return this.usersService.findById(payload.id);
+ }
+
+ async authenticateWithGoogle(googleAccessToken: string) {
+ const clientID = this.configService.get('google.clientID');
+ const clientSecret = this.configService.get('google.clientSecret');
+
+ const OAuthClient = new google.auth.OAuth2(clientID, clientSecret);
+ OAuthClient.setCredentials({ access_token: googleAccessToken });
+
+ const { email } = await OAuthClient.getTokenInfo(googleAccessToken);
+
+ try {
+ const user = await this.usersService.findByEmail(email);
+
+ return user;
+ } catch (error) {
+ if (error.status !== HttpStatus.NOT_FOUND) {
+ throw new Error('Something went wrong, please try again later.');
+ }
+
+ const UserInfoClient = google.oauth2('v2').userinfo;
+ const { data } = await UserInfoClient.get({ auth: OAuthClient });
+ const username = data.email.split('@').at(0);
+
+ const createUserDto: CreateGoogleUserDto = {
+ name: `${data.given_name} ${data.family_name}`,
+ username,
+ email: data.email,
+ provider: 'google',
+ };
+
+ return this.usersService.create(createUserDto);
+ }
+ }
+}
diff --git a/apps/server/src/auth/dto/forgot-password.dto.ts b/apps/server/src/auth/dto/forgot-password.dto.ts
new file mode 100644
index 00000000..cdf91db8
--- /dev/null
+++ b/apps/server/src/auth/dto/forgot-password.dto.ts
@@ -0,0 +1,7 @@
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class ForgotPasswordDto {
+ @IsString()
+ @IsNotEmpty()
+ email: string;
+}
diff --git a/apps/server/src/auth/dto/login.dto.ts b/apps/server/src/auth/dto/login.dto.ts
new file mode 100644
index 00000000..44c1fd39
--- /dev/null
+++ b/apps/server/src/auth/dto/login.dto.ts
@@ -0,0 +1,10 @@
+import { IsNotEmpty, IsString, MinLength } from 'class-validator';
+
+export class LoginDto {
+ @IsString()
+ @IsNotEmpty()
+ identifier: string;
+
+ @MinLength(6)
+ password: string;
+}
diff --git a/apps/server/src/auth/dto/register.dto.ts b/apps/server/src/auth/dto/register.dto.ts
new file mode 100644
index 00000000..6462612f
--- /dev/null
+++ b/apps/server/src/auth/dto/register.dto.ts
@@ -0,0 +1,18 @@
+import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
+
+export class RegisterDto {
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsString()
+ @IsNotEmpty()
+ username: string;
+
+ @IsEmail()
+ @IsNotEmpty()
+ email: string;
+
+ @MinLength(6)
+ password: string;
+}
diff --git a/apps/server/src/auth/dto/reset-password.dto.ts b/apps/server/src/auth/dto/reset-password.dto.ts
new file mode 100644
index 00000000..496488e8
--- /dev/null
+++ b/apps/server/src/auth/dto/reset-password.dto.ts
@@ -0,0 +1,11 @@
+import { IsNotEmpty, IsString, MinLength } from 'class-validator';
+
+export class ResetPasswordDto {
+ @IsString()
+ @IsNotEmpty()
+ resetToken: string;
+
+ @IsString()
+ @MinLength(6)
+ password: string;
+}
diff --git a/apps/server/src/auth/guards/jwt.guard.ts b/apps/server/src/auth/guards/jwt.guard.ts
new file mode 100644
index 00000000..2155290e
--- /dev/null
+++ b/apps/server/src/auth/guards/jwt.guard.ts
@@ -0,0 +1,5 @@
+import { Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+@Injectable()
+export class JwtAuthGuard extends AuthGuard('jwt') {}
diff --git a/apps/server/src/auth/guards/local.guard.ts b/apps/server/src/auth/guards/local.guard.ts
new file mode 100644
index 00000000..ccf962b6
--- /dev/null
+++ b/apps/server/src/auth/guards/local.guard.ts
@@ -0,0 +1,5 @@
+import { Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+@Injectable()
+export class LocalAuthGuard extends AuthGuard('local') {}
diff --git a/apps/server/src/auth/guards/optional-jwt.guard.ts b/apps/server/src/auth/guards/optional-jwt.guard.ts
new file mode 100644
index 00000000..1c1730b4
--- /dev/null
+++ b/apps/server/src/auth/guards/optional-jwt.guard.ts
@@ -0,0 +1,11 @@
+import { Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+import { User } from '@/users/entities/user.entity';
+
+@Injectable()
+export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
+ handleRequest(err: Error, user: TUser): TUser {
+ return user;
+ }
+}
diff --git a/apps/server/src/auth/strategy/jwt.strategy.ts b/apps/server/src/auth/strategy/jwt.strategy.ts
new file mode 100644
index 00000000..533f2f08
--- /dev/null
+++ b/apps/server/src/auth/strategy/jwt.strategy.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PassportStrategy } from '@nestjs/passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+
+import { User } from '@/users/entities/user.entity';
+import { UsersService } from '@/users/users.service';
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy) {
+ constructor(configService: ConfigService, private readonly usersService: UsersService) {
+ super({
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ secretOrKey: configService.get('auth.jwtSecret'),
+ ignoreExpiration: false,
+ });
+ }
+
+ validate({ id }: User): Promise {
+ return this.usersService.findById(id);
+ }
+}
diff --git a/apps/server/src/auth/strategy/local.strategy.ts b/apps/server/src/auth/strategy/local.strategy.ts
new file mode 100644
index 00000000..ad1ec9ef
--- /dev/null
+++ b/apps/server/src/auth/strategy/local.strategy.ts
@@ -0,0 +1,18 @@
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { Strategy } from 'passport-local';
+
+import { User } from '@/users/entities/user.entity';
+
+import { AuthService } from '../auth.service';
+
+@Injectable()
+export class LocalStrategy extends PassportStrategy(Strategy) {
+ constructor(private authService: AuthService) {
+ super({ usernameField: 'identifier' });
+ }
+
+ async validate(identifier: string, password: string): Promise {
+ return this.authService.getUser(identifier, password);
+ }
+}
diff --git a/apps/server/src/config/app.config.ts b/apps/server/src/config/app.config.ts
new file mode 100644
index 00000000..643fbd0b
--- /dev/null
+++ b/apps/server/src/config/app.config.ts
@@ -0,0 +1,10 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('app', () => ({
+ timezone: process.env.TZ,
+ environment: process.env.NODE_ENV,
+ secretKey: process.env.SECRET_KEY,
+ port: parseInt(process.env.PORT, 10) || 3100,
+ url: process.env.APP_URL || 'http://localhost:3000',
+ serverUrl: process.env.SERVER_URL || 'http://localhost:3100',
+}));
diff --git a/apps/server/src/config/auth.config.ts b/apps/server/src/config/auth.config.ts
new file mode 100644
index 00000000..00caf95b
--- /dev/null
+++ b/apps/server/src/config/auth.config.ts
@@ -0,0 +1,6 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('auth', () => ({
+ jwtSecret: process.env.JWT_SECRET,
+ jwtExpiryTime: parseInt(process.env.JWT_EXPIRY_TIME, 10),
+}));
diff --git a/apps/server/src/config/config.module.ts b/apps/server/src/config/config.module.ts
new file mode 100644
index 00000000..d822c9c5
--- /dev/null
+++ b/apps/server/src/config/config.module.ts
@@ -0,0 +1,49 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule as NestConfigModule } from '@nestjs/config';
+import * as Joi from 'joi';
+
+import appConfig from './app.config';
+import authConfig from './auth.config';
+import databaseConfig from './database.config';
+import googleConfig from './google.config';
+import mailConfig from './mail.config';
+
+const validationSchema = Joi.object({
+ // App
+ TZ: Joi.string().default('UTC'),
+ PORT: Joi.number().default(3100),
+ SECRET_KEY: Joi.string().required(),
+ APP_URL: Joi.string().default('http://localhost:3000'),
+ SERVER_URL: Joi.string().default('http://localhost:3100'),
+ NODE_ENV: Joi.string().valid('development', 'production').default('development'),
+
+ // Database
+ POSTGRES_HOST: Joi.string().required(),
+ POSTGRES_PORT: Joi.string().required(),
+ POSTGRES_USERNAME: Joi.string().required(),
+ POSTGRES_PASSWORD: Joi.string().required(),
+ POSTGRES_DATABASE: Joi.string().required(),
+
+ // Auth
+ JWT_SECRET: Joi.string().required(),
+ JWT_EXPIRY_TIME: Joi.number().required(),
+
+ // Google
+ GOOGLE_API_KEY: Joi.string().allow(''),
+
+ // Mail
+ MAIL_HOST: Joi.string().allow(''),
+ MAIL_PORT: Joi.string().allow(''),
+ MAIL_USERNAME: Joi.string().allow(''),
+ MAIL_PASSWORD: Joi.string().allow(''),
+});
+
+@Module({
+ imports: [
+ NestConfigModule.forRoot({
+ load: [appConfig, authConfig, databaseConfig, googleConfig, mailConfig],
+ validationSchema: validationSchema,
+ }),
+ ],
+})
+export class ConfigModule {}
diff --git a/apps/server/src/config/database.config.ts b/apps/server/src/config/database.config.ts
new file mode 100644
index 00000000..d6dbccdc
--- /dev/null
+++ b/apps/server/src/config/database.config.ts
@@ -0,0 +1,9 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('postgres', () => ({
+ host: process.env.POSTGRES_HOST,
+ port: parseInt(process.env.POSTGRES_PORT, 10) || 5432,
+ username: process.env.POSTGRES_USERNAME,
+ password: process.env.POSTGRES_PASSWORD,
+ database: process.env.POSTGRES_DATABASE,
+}));
diff --git a/apps/server/src/config/google.config.ts b/apps/server/src/config/google.config.ts
new file mode 100644
index 00000000..babd6b42
--- /dev/null
+++ b/apps/server/src/config/google.config.ts
@@ -0,0 +1,7 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('google', () => ({
+ apiKey: process.env.GOOGLE_API_KEY,
+ clientId: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+}));
diff --git a/apps/server/src/config/mail.config.ts b/apps/server/src/config/mail.config.ts
new file mode 100644
index 00000000..1049df3f
--- /dev/null
+++ b/apps/server/src/config/mail.config.ts
@@ -0,0 +1,9 @@
+import { registerAs } from '@nestjs/config';
+
+export default registerAs('mail', () => ({
+ host: process.env.MAIL_HOST,
+ port: parseInt(process.env.MAIL_PORT, 10),
+ username: process.env.MAIL_USERNAME,
+ password: process.env.MAIL_PASSWORD,
+ from: process.env.MAIL_FROM,
+}));
diff --git a/apps/server/src/constants/index.ts b/apps/server/src/constants/index.ts
new file mode 100644
index 00000000..d7b53f75
--- /dev/null
+++ b/apps/server/src/constants/index.ts
@@ -0,0 +1,2 @@
+// Date Formats
+export const FILENAME_TIMESTAMP = 'DDMMYYYYHHmmss';
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
new file mode 100644
index 00000000..11d9621e
--- /dev/null
+++ b/apps/server/src/database/database.module.ts
@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+@Module({
+ imports: [
+ TypeOrmModule.forRootAsync({
+ imports: [ConfigModule],
+ inject: [ConfigService],
+ useFactory: (configService: ConfigService) => ({
+ type: 'postgres',
+ host: configService.get('postgres.host'),
+ port: configService.get('postgres.port'),
+ username: configService.get('postgres.username'),
+ password: configService.get('postgres.password'),
+ database: configService.get('postgres.database'),
+ synchronize: configService.get('app.environment') === 'development',
+ autoLoadEntities: true,
+ }),
+ }),
+ ],
+})
+export class DatabaseModule {}
diff --git a/apps/server/src/database/errorCodes.enum.ts b/apps/server/src/database/errorCodes.enum.ts
new file mode 100644
index 00000000..a6a3170b
--- /dev/null
+++ b/apps/server/src/database/errorCodes.enum.ts
@@ -0,0 +1,3 @@
+export enum PostgresErrorCode {
+ UniqueViolation = '23505',
+}
diff --git a/apps/server/src/decorators/cookie.decorator.ts b/apps/server/src/decorators/cookie.decorator.ts
new file mode 100644
index 00000000..46168685
--- /dev/null
+++ b/apps/server/src/decorators/cookie.decorator.ts
@@ -0,0 +1,6 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+
+export const Cookie = createParamDecorator((data: string, ctx: ExecutionContext) => {
+ const request = ctx.switchToHttp().getRequest();
+ return data ? request.cookies?.[data] : request.cookies;
+});
diff --git a/apps/server/src/decorators/user.decorator.ts b/apps/server/src/decorators/user.decorator.ts
new file mode 100644
index 00000000..6b523894
--- /dev/null
+++ b/apps/server/src/decorators/user.decorator.ts
@@ -0,0 +1,8 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+
+export const User = createParamDecorator((data: string, ctx: ExecutionContext) => {
+ const request = ctx.switchToHttp().getRequest();
+ const user = request.user;
+
+ return data ? user?.[data] : user;
+});
diff --git a/apps/server/src/environments/environment.prod.ts b/apps/server/src/environments/environment.prod.ts
new file mode 100644
index 00000000..c9669790
--- /dev/null
+++ b/apps/server/src/environments/environment.prod.ts
@@ -0,0 +1,3 @@
+export const environment = {
+ production: true,
+};
diff --git a/apps/server/src/environments/environment.ts b/apps/server/src/environments/environment.ts
new file mode 100644
index 00000000..a20cfe55
--- /dev/null
+++ b/apps/server/src/environments/environment.ts
@@ -0,0 +1,3 @@
+export const environment = {
+ production: false,
+};
diff --git a/apps/server/src/filters/http-exception.filter.ts b/apps/server/src/filters/http-exception.filter.ts
new file mode 100644
index 00000000..c6f6f481
--- /dev/null
+++ b/apps/server/src/filters/http-exception.filter.ts
@@ -0,0 +1,22 @@
+import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
+import type { Request, Response } from 'express';
+import { TypeORMError } from 'typeorm';
+
+@Catch(HttpException)
+export class HttpExceptionFilter implements ExceptionFilter {
+ catch(exception: HttpException, host: ArgumentsHost) {
+ const ctx = host.switchToHttp();
+ const response = ctx.getResponse();
+ const request = ctx.getRequest();
+
+ const statusCode = exception.getStatus();
+ const message = (exception.getResponse() as TypeORMError).message || exception.message;
+
+ response.status(statusCode).json({
+ statusCode,
+ message,
+ timestamp: new Date().toISOString(),
+ path: request.url,
+ });
+ }
+}
diff --git a/apps/server/src/fonts/fonts.controller.ts b/apps/server/src/fonts/fonts.controller.ts
new file mode 100644
index 00000000..23d038a5
--- /dev/null
+++ b/apps/server/src/fonts/fonts.controller.ts
@@ -0,0 +1,17 @@
+import { CacheInterceptor, Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
+
+import { JwtAuthGuard } from '@/auth/guards/jwt.guard';
+
+import { FontsService } from './fonts.service';
+
+@Controller('fonts')
+@UseInterceptors(CacheInterceptor)
+export class FontsController {
+ constructor(private fontsService: FontsService) {}
+
+ @UseGuards(JwtAuthGuard)
+ @Get()
+ getAll() {
+ return this.fontsService.getAll();
+ }
+}
diff --git a/apps/server/src/fonts/fonts.module.ts b/apps/server/src/fonts/fonts.module.ts
new file mode 100644
index 00000000..ed5b7389
--- /dev/null
+++ b/apps/server/src/fonts/fonts.module.ts
@@ -0,0 +1,16 @@
+import { HttpModule } from '@nestjs/axios';
+import { CacheModule, Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+
+import { FontsController } from './fonts.controller';
+import { FontsService } from './fonts.service';
+
+// Every week
+const cacheTTL = 60 * 60 * 24 * 7;
+
+@Module({
+ imports: [ConfigModule, HttpModule, CacheModule.register({ ttl: cacheTTL })],
+ controllers: [FontsController],
+ providers: [FontsService],
+})
+export class FontsModule {}
diff --git a/apps/server/src/fonts/fonts.service.ts b/apps/server/src/fonts/fonts.service.ts
new file mode 100644
index 00000000..8efa75e6
--- /dev/null
+++ b/apps/server/src/fonts/fonts.service.ts
@@ -0,0 +1,21 @@
+import { HttpService } from '@nestjs/axios';
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Font } from '@reactive-resume/schema';
+import { get } from 'lodash';
+import { firstValueFrom } from 'rxjs';
+
+@Injectable()
+export class FontsService {
+ constructor(private configService: ConfigService, private httpService: HttpService) {}
+
+ async getAll(): Promise {
+ const apiKey = this.configService.get('google.apiKey');
+ const url = 'https://www.googleapis.com/webfonts/v1/webfonts?key=' + apiKey;
+
+ const response = await firstValueFrom(this.httpService.get(url));
+ const data = get(response.data, 'items', []);
+
+ return data;
+ }
+}
diff --git a/apps/server/src/integrations/integrations.controller.ts b/apps/server/src/integrations/integrations.controller.ts
new file mode 100644
index 00000000..4ad621ca
--- /dev/null
+++ b/apps/server/src/integrations/integrations.controller.ts
@@ -0,0 +1,45 @@
+import { Controller, HttpException, HttpStatus, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+
+import { JwtAuthGuard } from '@/auth/guards/jwt.guard';
+import { User } from '@/decorators/user.decorator';
+
+import { IntegrationsService } from './integrations.service';
+
+@Controller('integrations')
+export class IntegrationsController {
+ constructor(private integrationsService: IntegrationsService) {}
+
+ @UseGuards(JwtAuthGuard)
+ @Post('linkedin')
+ @UseInterceptors(FileInterceptor('file'))
+ linkedIn(@User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
+ if (!file) {
+ throw new HttpException('You must upload a valid zip archive downloaded from LinkedIn.', HttpStatus.BAD_REQUEST);
+ }
+
+ return this.integrationsService.linkedIn(userId, file.path);
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Post('json-resume')
+ @UseInterceptors(FileInterceptor('file'))
+ jsonResume(@User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
+ if (!file) {
+ throw new HttpException('You must upload a valid JSON file.', HttpStatus.BAD_REQUEST);
+ }
+
+ return this.integrationsService.jsonResume(userId, file.path);
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Post('reactive-resume')
+ @UseInterceptors(FileInterceptor('file'))
+ reactiveResume(@User('id') userId: number, @UploadedFile() file: Express.Multer.File) {
+ if (!file) {
+ throw new HttpException('You must upload a valid JSON file.', HttpStatus.BAD_REQUEST);
+ }
+
+ return this.integrationsService.reactiveResume(userId, file.path);
+ }
+}
diff --git a/apps/server/src/integrations/integrations.module.ts b/apps/server/src/integrations/integrations.module.ts
new file mode 100644
index 00000000..7f8f5b86
--- /dev/null
+++ b/apps/server/src/integrations/integrations.module.ts
@@ -0,0 +1,37 @@
+import { Module } from '@nestjs/common';
+import { MulterModule } from '@nestjs/platform-express';
+import { mkdir } from 'fs/promises';
+import { diskStorage } from 'multer';
+import { extname, join } from 'path';
+
+import { ResumeModule } from '@/resume/resume.module';
+import { User } from '@/users/entities/user.entity';
+
+import { IntegrationsController } from './integrations.controller';
+import { IntegrationsService } from './integrations.service';
+
+@Module({
+ imports: [
+ ResumeModule,
+ MulterModule.register({
+ storage: diskStorage({
+ destination: async (req, _, cb) => {
+ const userId = (req.user as User).id;
+ const destination = join(__dirname, `assets/integrations/${userId}`);
+
+ await mkdir(destination, { recursive: true });
+
+ cb(null, destination);
+ },
+ filename: (_, file, cb) => {
+ const filename = new Date().getTime() + extname(file.originalname);
+
+ cb(null, filename);
+ },
+ }),
+ }),
+ ],
+ controllers: [IntegrationsController],
+ providers: [IntegrationsService],
+})
+export class IntegrationsModule {}
diff --git a/apps/server/src/integrations/integrations.service.ts b/apps/server/src/integrations/integrations.service.ts
new file mode 100644
index 00000000..da9999cd
--- /dev/null
+++ b/apps/server/src/integrations/integrations.service.ts
@@ -0,0 +1,619 @@
+import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
+import {
+ Award,
+ Certificate,
+ Education,
+ Interest,
+ Language,
+ Project,
+ Publication,
+ Reference,
+ Resume,
+ Skill,
+ Volunteer,
+ WorkExperience,
+} from '@reactive-resume/schema';
+import * as csv from 'csvtojson';
+import * as dayjs from 'dayjs';
+import { readFile, unlink } from 'fs/promises';
+import { cloneDeep, get, isEmpty, merge } from 'lodash';
+import * as SteamZip from 'node-stream-zip';
+import { DeepPartial } from 'typeorm';
+import { v4 as uuidv4 } from 'uuid';
+
+import { FILENAME_TIMESTAMP } from '@/constants/index';
+import defaultState from '@/resume/data/defaultState';
+import { Resume as ResumeEntity } from '@/resume/entities/resume.entity';
+import { ResumeService } from '@/resume/resume.service';
+
+@Injectable()
+export class IntegrationsService {
+ constructor(private resumeService: ResumeService) {}
+
+ async linkedIn(userId: number, path: string): Promise {
+ let archive: SteamZip.StreamZipAsync;
+
+ try {
+ archive = new SteamZip.async({ file: path });
+
+ const resume: Partial = cloneDeep(defaultState);
+
+ // Basics
+ const timestamp = dayjs().format(FILENAME_TIMESTAMP);
+ merge, Partial>(resume, {
+ name: `Imported from LinkedIn (${timestamp})`,
+ slug: `imported-from-linkedin-${timestamp}`,
+ });
+
+ // Profile
+ try {
+ const profileCSV = (await archive.entryData('Profile.csv')).toString();
+ const profile = (await csv().fromString(profileCSV))[0];
+ merge, Partial>(resume, {
+ basics: {
+ name: `${get(profile, 'First Name')} ${get(profile, 'Last Name')}`,
+ headline: get(profile, 'Headline'),
+ location: {
+ address: get(profile, 'Address'),
+ postalCode: get(profile, 'Zip Code'),
+ },
+ summary: get(profile, 'Summary'),
+ },
+ });
+ } catch {
+ // pass through
+ }
+
+ // Email
+ try {
+ const emailsCSV = (await archive.entryData('Email Addresses.csv')).toString();
+ const email = (await csv().fromString(emailsCSV))[0];
+ merge, Partial>(resume, {
+ basics: {
+ email: get(email, 'Email Address'),
+ },
+ });
+ } catch {
+ // pass through
+ }
+
+ // Phone Number
+ try {
+ const phoneNumbersCSV = (await archive.entryData('PhoneNumbers.csv')).toString();
+ const phoneNumber = (await csv().fromString(phoneNumbersCSV))[0];
+ merge, Partial>(resume, {
+ basics: {
+ phone: get(phoneNumber, 'Number'),
+ },
+ });
+ } catch {
+ // pass through
+ }
+
+ // Education
+ try {
+ const educationCSV = (await archive.entryData('Education.csv')).toString();
+ const education = await csv().fromString(educationCSV);
+ education.forEach((school) => {
+ merge, Partial>(resume, {
+ sections: {
+ education: {
+ items: [
+ ...get(resume, 'sections.education.items', []),
+ {
+ id: uuidv4(),
+ institution: get(school, 'School Name'),
+ degree: get(school, 'Degree Name'),
+ date: {
+ start: dayjs(get(school, 'Start Date')).toISOString(),
+ end: dayjs(get(school, 'End Date')).toISOString(),
+ },
+ } as Education,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Positions
+ try {
+ const positionsCSV = (await archive.entryData('Positions.csv')).toString();
+ const positions = await csv().fromString(positionsCSV);
+ positions.forEach((position) => {
+ merge, Partial>(resume, {
+ sections: {
+ work: {
+ items: [
+ ...get(resume, 'sections.work.items', []),
+ {
+ id: uuidv4(),
+ name: get(position, 'Company Name'),
+ position: get(position, 'Title'),
+ summary: get(position, 'Description'),
+ date: {
+ start: dayjs(get(position, 'Started On')).toISOString(),
+ end: dayjs(get(position, 'Finished On')).toISOString(),
+ },
+ } as WorkExperience,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Certifications
+ try {
+ const certificationsCSV = (await archive.entryData('Certifications.csv')).toString();
+ const certifications = await csv().fromString(certificationsCSV);
+ certifications.forEach((certification) => {
+ merge, Partial>(resume, {
+ sections: {
+ certifications: {
+ items: [
+ ...get(resume, 'sections.certifications.items', []),
+ {
+ id: uuidv4(),
+ name: get(certification, 'Name'),
+ issuer: get(certification, 'Authority'),
+ url: get(certification, 'Url'),
+ date: dayjs(get(certification, 'Started On')).toISOString(),
+ } as Certificate,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Languages
+ try {
+ const languagesCSV = (await archive.entryData('Languages.csv')).toString();
+ const languages = await csv().fromString(languagesCSV);
+ languages.forEach((language) => {
+ merge, Partial>(resume, {
+ sections: {
+ languages: {
+ items: [
+ ...get(resume, 'sections.languages.items', []),
+ {
+ id: uuidv4(),
+ name: get(language, 'Name'),
+ level: 'Beginner',
+ levelNum: 5,
+ } as Language,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Projects
+ try {
+ const projectsCSV = (await archive.entryData('Projects.csv')).toString();
+ const projects = await csv().fromString(projectsCSV);
+ projects.forEach((project) => {
+ merge, Partial>(resume, {
+ sections: {
+ projects: {
+ items: [
+ ...get(resume, 'sections.projects.items', []),
+ {
+ id: uuidv4(),
+ name: get(project, 'Title'),
+ description: get(project, 'Description'),
+ url: get(project, 'Url'),
+ date: {
+ start: dayjs(get(project, 'Started On')).toISOString(),
+ end: dayjs(get(project, 'Finished On')).toISOString(),
+ },
+ } as Project,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Skills
+ try {
+ const skillsCSV = (await archive.entryData('Skills.csv')).toString();
+ const skills = await csv().fromString(skillsCSV);
+ skills.forEach((skill) => {
+ merge, Partial>(resume, {
+ sections: {
+ skills: {
+ items: [
+ ...get(resume, 'sections.skills.items', []),
+ {
+ id: uuidv4(),
+ name: get(skill, 'Name'),
+ level: 'Beginner',
+ levelNum: 5,
+ } as Skill,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ return this.resumeService.import(resume, userId);
+ } catch {
+ throw new HttpException('You must upload a valid zip archive downloaded from LinkedIn.', HttpStatus.BAD_REQUEST);
+ } finally {
+ await unlink(path);
+ !isEmpty(archive) && archive.close();
+ }
+ }
+
+ async jsonResume(userId: number, path: string) {
+ try {
+ const jsonResume = JSON.parse(await readFile(path, 'utf8'));
+
+ const resume: Partial = cloneDeep(defaultState);
+
+ // Metadata
+ const timestamp = dayjs().format(FILENAME_TIMESTAMP);
+ merge, Partial>(resume, {
+ name: `Imported from JSON Resume (${timestamp})`,
+ slug: `imported-from-json-resume-${timestamp}`,
+ });
+
+ // Basics
+ try {
+ merge, DeepPartial>(resume, {
+ basics: {
+ name: get(jsonResume, 'basics.name'),
+ headline: get(jsonResume, 'basics.label'),
+ photo: {
+ url: get(jsonResume, 'basics.image'),
+ },
+ email: get(jsonResume, 'basics.email'),
+ phone: get(jsonResume, 'basics.phone'),
+ website: get(jsonResume, 'basics.url'),
+ summary: get(jsonResume, 'basics.summary'),
+ location: {
+ address: get(jsonResume, 'basics.location.address'),
+ postalCode: get(jsonResume, 'basics.location.postalCode'),
+ city: get(jsonResume, 'basics.location.city'),
+ country: get(jsonResume, 'basics.location.countryCode'),
+ region: get(jsonResume, 'basics.location.region'),
+ },
+ },
+ });
+ } catch {
+ // pass through
+ }
+
+ // Profiles
+ try {
+ const profiles: any[] = get(jsonResume, 'basics.profiles', []);
+ profiles.forEach((profile) => {
+ merge, Partial>(resume, {
+ basics: {
+ profiles: [
+ ...resume.basics.profiles,
+ {
+ id: uuidv4(),
+ url: get(profile, 'url'),
+ network: get(profile, 'network'),
+ username: get(profile, 'username'),
+ },
+ ],
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Work
+ try {
+ const work: any[] = get(jsonResume, 'work', []);
+ work.forEach((item) => {
+ merge, Partial>(resume, {
+ sections: {
+ work: {
+ items: [
+ ...get(resume, 'sections.work.items', []),
+ {
+ id: uuidv4(),
+ name: get(item, 'name'),
+ position: get(item, 'position'),
+ summary: get(item, 'summary'),
+ url: get(item, 'url'),
+ date: {
+ start: dayjs(get(item, 'startDate')).toISOString(),
+ end: dayjs(get(item, 'endDate')).toISOString(),
+ },
+ } as WorkExperience,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Volunteer
+ try {
+ const volunteer: any[] = get(jsonResume, 'volunteer', []);
+ volunteer.forEach((item) => {
+ merge, Partial>(resume, {
+ sections: {
+ volunteer: {
+ items: [
+ ...get(resume, 'sections.volunteer.items', []),
+ {
+ id: uuidv4(),
+ organization: get(item, 'organization'),
+ position: get(item, 'position'),
+ summary: get(item, 'summary'),
+ url: get(item, 'url'),
+ date: {
+ start: dayjs(get(item, 'startDate')).toISOString(),
+ end: dayjs(get(item, 'endDate')).toISOString(),
+ },
+ } as Volunteer,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Education
+ try {
+ const education: any[] = get(jsonResume, 'education', []);
+ education.forEach((item) => {
+ merge, Partial>(resume, {
+ sections: {
+ education: {
+ items: [
+ ...get(resume, 'sections.education.items', []),
+ {
+ id: uuidv4(),
+ institution: get(item, 'institution'),
+ degree: get(item, 'studyType'),
+ score: get(item, 'score'),
+ area: get(item, 'area'),
+ url: get(item, 'url'),
+ courses: get(item, 'courses', []),
+ date: {
+ start: dayjs(get(item, 'startDate')).toISOString(),
+ end: dayjs(get(item, 'endDate')).toISOString(),
+ },
+ } as Education,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Awards
+ try {
+ const awards: any[] = get(jsonResume, 'awards', []);
+ awards.forEach((award) => {
+ merge, Partial>(resume, {
+ sections: {
+ awards: {
+ items: [
+ ...get(resume, 'sections.awards.items', []),
+ {
+ id: uuidv4(),
+ title: get(award, 'title'),
+ awarder: get(award, 'awarder'),
+ summary: get(award, 'summary'),
+ url: get(award, 'url'),
+ date: dayjs(get(award, 'date')).toISOString(),
+ } as Award,
+ ],
+ },
+ },
+ });
+ });
+ } catch {
+ // pass through
+ }
+
+ // Publications
+ try {
+ const publications: any[] = get(jsonResume, 'publications', []);
+ publications.forEach((publication) => {
+ merge, Partial