= (args) => (
24 | <>
25 |
26 |
27 | >
28 | );
29 |
30 | export const Default = Template.bind({});
31 | Default.args = {
32 | color: 'secondary',
33 | message: 'Toast message',
34 | };
35 |
--------------------------------------------------------------------------------
/app/src/components/Toast.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | faCircleInfo,
3 | faTriangleExclamation,
4 | faXmark,
5 | } from '@fortawesome/free-solid-svg-icons';
6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
7 | import cn from 'classnames';
8 | import React from 'react';
9 | import baseToast from 'react-hot-toast';
10 |
11 | export type ToastArgs = {
12 | color?: 'primary' | 'secondary' | 'danger';
13 | message: string;
14 | };
15 |
16 | const toast = ({ color = 'primary', message }: ToastArgs) => {
17 | baseToast(
18 | (t) => (
19 |
20 |
21 |
28 | {message}
29 |
30 |
31 |
34 |
35 |
36 | ),
37 | {
38 | className: 'min-w-[100%] sm:min-w-[400px]',
39 | style: {
40 | ...(color === 'primary' && { background: '#6B26D9' }),
41 | ...(color === 'secondary' && { background: '#27272A' }),
42 | ...(color === 'danger' && { background: '#B91C1C' }),
43 | borderRadius: 6,
44 | padding: 0,
45 | margin: 0,
46 | },
47 | ariaProps: {
48 | role: 'status',
49 | 'aria-live': 'polite',
50 | },
51 | duration: 10000,
52 | }
53 | );
54 | };
55 |
56 | export default toast;
57 |
--------------------------------------------------------------------------------
/app/src/components/WithAuthentication.test.tsx:
--------------------------------------------------------------------------------
1 | import 'next';
2 | import { setupServer } from 'msw/node';
3 | import { render, screen, mockRouter, mockSession, waitFor } from '@lib/testing';
4 | import WithAuthentication from './WithAuthentication';
5 |
6 | const server = setupServer();
7 |
8 | beforeAll(() => server.listen());
9 | afterEach(() => server.resetHandlers());
10 | afterAll(() => server.close());
11 |
12 | const PageWithAuthentication = WithAuthentication(() => (
13 | This page requires auth
14 | ));
15 |
16 | describe('WithAuthentication', () => {
17 | it('redirects to the login page when the user is not authenticated', async () => {
18 | const session = undefined;
19 | server.resetHandlers(mockSession(session));
20 | render(, { session });
21 |
22 | await waitFor(() => {
23 | expect(mockRouter.push).toHaveBeenCalledWith('/api/auth/signin');
24 | });
25 | });
26 |
27 | it('renders the page when the user is authenticated', async () => {
28 | const session = {
29 | user: {},
30 | userId: 'user_1',
31 | companyId: 'company_1',
32 | expires: '',
33 | };
34 | server.resetHandlers(mockSession(session));
35 | render(, {
36 | session,
37 | });
38 |
39 | await screen.findByText('This page requires auth');
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/app/src/components/WithAuthentication.tsx:
--------------------------------------------------------------------------------
1 | import { useSession } from 'next-auth/react';
2 | import { useRouter } from 'next/router';
3 | import { useEffect } from 'react';
4 | import Routes from '@lib/routes';
5 |
6 | const WithAuthentication = (
7 | Component: React.ComponentType
8 | ) => {
9 | function RequireAuthentication(props: P) {
10 | const router = useRouter();
11 | const { status } = useSession();
12 |
13 | useEffect(() => {
14 | if (status === 'unauthenticated') {
15 | void router.push(Routes.signIn);
16 | }
17 | }, [router, status]);
18 |
19 | return status === 'authenticated' ? : null;
20 | }
21 |
22 | return RequireAuthentication;
23 | };
24 |
25 | export default WithAuthentication;
26 |
--------------------------------------------------------------------------------
/app/src/components/WithNoAuthentication.test.tsx:
--------------------------------------------------------------------------------
1 | import 'next';
2 | import { setupServer } from 'msw/node';
3 | import { render, screen, mockRouter, mockSession, waitFor } from '@lib/testing';
4 | import WithNoAuthentication from './WithNoAuthentication';
5 |
6 | const server = setupServer();
7 |
8 | beforeAll(() => server.listen());
9 | afterEach(() => server.resetHandlers());
10 | afterAll(() => server.close());
11 |
12 | const PageWithNoAuthentication = WithNoAuthentication(() => (
13 | This page requires an unauthenticated user
14 | ));
15 |
16 | describe('WithAuthentication', () => {
17 | beforeEach(() => {
18 | jest.resetAllMocks();
19 | });
20 |
21 | it('renders the page when the user is not authenticated', async () => {
22 | const session = undefined;
23 | server.resetHandlers(mockSession(session));
24 | render(, { session });
25 |
26 | await screen.findByText('This page requires an unauthenticated user');
27 | });
28 |
29 | it('redirects to the home page when the user is authenticated', async () => {
30 | const session = {
31 | user: {},
32 | userId: 'user_1',
33 | companyId: 'company_1',
34 | expires: '',
35 | };
36 | server.resetHandlers(mockSession(session));
37 | render(, { session });
38 |
39 | await waitFor(() => {
40 | expect(mockRouter.push).toHaveBeenCalledWith('/');
41 | });
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/app/src/components/WithNoAuthentication.tsx:
--------------------------------------------------------------------------------
1 | import { useSession } from 'next-auth/react';
2 | import { useRouter } from 'next/router';
3 | import { useEffect } from 'react';
4 | import Routes from '@lib/routes';
5 |
6 | const WithNoAuthentication =
(
7 | Component: React.ComponentType
8 | ) => {
9 | function RequireNoAuthentication(props: P) {
10 | const router = useRouter();
11 | const { status } = useSession();
12 |
13 | useEffect(() => {
14 | if (status === 'authenticated') {
15 | void router.push(Routes.home);
16 | }
17 | }, [router, status]);
18 |
19 | return status === 'unauthenticated' ? : null;
20 | }
21 |
22 | return RequireNoAuthentication;
23 | };
24 |
25 | export default WithNoAuthentication;
26 |
--------------------------------------------------------------------------------
/app/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client';
2 | import type { AppRouter } from '@server/router';
3 |
4 | export const getBaseUrl = (): string => {
5 | if (process.env.NEXT_PUBLIC_BASE_URL) {
6 | return process.env.NEXT_PUBLIC_BASE_URL;
7 | }
8 | if (process.env.NEXT_PUBLIC_VERCEL_URL) {
9 | return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`;
10 | }
11 | if (process.env.RENDER_INTERNAL_HOSTNAME) {
12 | return `http://${process.env.RENDER_INTERNAL_HOSTNAME}:${process.env.PORT}`;
13 | }
14 |
15 | return `http://localhost:${process.env.PORT ?? 3000}`;
16 | };
17 |
18 | const baseUrl = getBaseUrl();
19 |
20 | const client = createTRPCProxyClient({
21 | links: [
22 | process.env.TEST_ENVIRONMENT === 'true'
23 | ? httpLink({
24 | url: `${baseUrl}/api/trpc`,
25 | })
26 | : httpBatchLink({
27 | url: `${baseUrl}/api/trpc`,
28 | maxURLLength: 2083,
29 | }),
30 | ],
31 | });
32 |
33 | export default client;
34 |
--------------------------------------------------------------------------------
/app/src/lib/appName.ts:
--------------------------------------------------------------------------------
1 | const AppName = 'Beet Bill';
2 | export default AppName;
3 |
--------------------------------------------------------------------------------
/app/src/lib/capitalizeFirstLetter.ts:
--------------------------------------------------------------------------------
1 | const capitalizeFirstLetter = (string: string) =>
2 | `${string.charAt(0).toUpperCase()}${string.slice(1)}`;
3 |
4 | export default capitalizeFirstLetter;
5 |
--------------------------------------------------------------------------------
/app/src/lib/clients/queryKeys.ts:
--------------------------------------------------------------------------------
1 | const QueryKeys = {
2 | clients: ['clients'],
3 | client: (id: string) => ['clients', id],
4 | };
5 |
6 | export default QueryKeys;
7 |
--------------------------------------------------------------------------------
/app/src/lib/clients/useClient.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useClient = (id: string) =>
6 | useQuery(QueryKeys.client(id), () => api.getClient.query({ id }));
7 |
8 | export default useClient;
9 |
--------------------------------------------------------------------------------
/app/src/lib/clients/useClients.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useClients = () =>
6 | useQuery(QueryKeys.clients, () => api.getClients.query());
7 |
8 | export default useClients;
9 |
--------------------------------------------------------------------------------
/app/src/lib/clients/useCreateClient.ts:
--------------------------------------------------------------------------------
1 | import cuid from 'cuid';
2 | import api from '@lib/api';
3 | import useMutation from '@lib/useMutation';
4 | import type {
5 | CreateClientInput,
6 | Client,
7 | GetClientsOutput,
8 | } from '@server/clients/types';
9 | import QueryKeys from './queryKeys';
10 |
11 | type UseCreateClientArgs =
12 | | {
13 | onSuccess?: (client: Client) => void;
14 | }
15 | | undefined;
16 |
17 | const useCreateClient = ({ onSuccess }: UseCreateClientArgs = {}) =>
18 | useMutation({
19 | mutationFn: api.createClient.mutate,
20 | cacheKey: QueryKeys.clients,
21 | cacheUpdater: (clients, input) => {
22 | const now = new Date().toISOString();
23 | clients.push({
24 | id: `new-client${cuid()}`,
25 | companyId: '',
26 | number: null,
27 | createdAt: now,
28 | updatedAt: now,
29 | vatNumber: null,
30 | contactName: null,
31 | email: null,
32 | country: null,
33 | address: null,
34 | postCode: null,
35 | city: null,
36 | paymentTerms: 7,
37 | ...input,
38 | });
39 | },
40 | successMessage: () => 'Successfully created client!',
41 | errorMessage: () => 'Failed to create client',
42 | onSuccess,
43 | });
44 |
45 | export default useCreateClient;
46 |
--------------------------------------------------------------------------------
/app/src/lib/clients/useDeleteClient.ts:
--------------------------------------------------------------------------------
1 | import api from '@lib/api';
2 | import useMutation from '@lib/useMutation';
3 | import type {
4 | DeleteClientInput,
5 | GetClientsOutput,
6 | } from '@server/clients/types';
7 | import QueryKeys from './queryKeys';
8 |
9 | const useDeleteClient = () =>
10 | useMutation({
11 | mutationFn: api.deleteClient.mutate,
12 | cacheKey: QueryKeys.clients,
13 | cacheUpdater: (clients, input) => {
14 | const clientIndex = clients.findIndex(({ id }) => id === input.id);
15 | if (clientIndex !== -1) {
16 | clients.splice(clientIndex, 1);
17 | }
18 | },
19 | successMessage: () => 'Successfully deleted client!',
20 | errorMessage: () => 'Failed to delete client',
21 | });
22 |
23 | export default useDeleteClient;
24 |
--------------------------------------------------------------------------------
/app/src/lib/clients/useUpdateClient.ts:
--------------------------------------------------------------------------------
1 | import api from '@lib/api';
2 | import useMutation from '@lib/useMutation';
3 | import type {
4 | UpdateClientInput,
5 | GetClientsOutput,
6 | Client,
7 | } from '@server/clients/types';
8 | import QueryKeys from './queryKeys';
9 |
10 | type UseUpdateClientArgs =
11 | | {
12 | onSuccess?: (client: Client) => void;
13 | }
14 | | undefined;
15 |
16 | const useUpdateClient = ({ onSuccess }: UseUpdateClientArgs = {}) =>
17 | useMutation({
18 | mutationFn: api.updateClient.mutate,
19 | cacheKey: QueryKeys.clients,
20 | cacheUpdater: (clients, input) => {
21 | const clientIndex = clients.findIndex(({ id }) => id === input.id);
22 | if (clientIndex !== -1) {
23 | clients[clientIndex] = {
24 | ...clients[clientIndex],
25 | ...input,
26 | };
27 | }
28 | },
29 | successMessage: () => 'Successfully updated client!',
30 | errorMessage: () => 'Failed to update client',
31 | onSuccess,
32 | });
33 |
34 | export default useUpdateClient;
35 |
--------------------------------------------------------------------------------
/app/src/lib/companies/queryKeys.ts:
--------------------------------------------------------------------------------
1 | const QueryKeys = {
2 | company: ['company'],
3 | };
4 |
5 | export default QueryKeys;
6 |
--------------------------------------------------------------------------------
/app/src/lib/companies/useCompany.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useCompany = () => {
6 | const { data, ...rest } = useQuery(QueryKeys.company, () =>
7 | api.getCompany.query()
8 | );
9 | return {
10 | data,
11 | isValid: !!data && data.name && data.number,
12 | ...rest,
13 | };
14 | };
15 |
16 | export default useCompany;
17 |
--------------------------------------------------------------------------------
/app/src/lib/companies/useUpdateCompany.ts:
--------------------------------------------------------------------------------
1 | import api from '@lib/api';
2 | import useMutation from '@lib/useMutation';
3 | import type {
4 | UpdateCompanyInput,
5 | GetCompanyOutput,
6 | } from '@server/company/types';
7 | import QueryKeys from './queryKeys';
8 |
9 | const useUpdateCompany = () =>
10 | useMutation({
11 | mutationFn: api.updateCompany.mutate,
12 | cacheKey: QueryKeys.company,
13 | cacheUpdater: (company, input) => {
14 | if (company) {
15 | Object.assign(company, input);
16 | }
17 | },
18 | successMessage: () => 'Successfully updated company!',
19 | errorMessage: () => 'Failed to update company',
20 | });
21 |
22 | export default useUpdateCompany;
23 |
--------------------------------------------------------------------------------
/app/src/lib/emailRegexp.ts:
--------------------------------------------------------------------------------
1 | const EmailRegexp =
2 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
3 |
4 | export default EmailRegexp;
5 |
--------------------------------------------------------------------------------
/app/src/lib/format.ts:
--------------------------------------------------------------------------------
1 | import format from 'date-fns/format';
2 |
3 | export const formatDate = (date: Date | string) =>
4 | format(new Date(date), 'dd MMMM yyyy');
5 |
6 | export const safeFormatDate = (date: Date | string) => {
7 | try {
8 | return formatDate(date);
9 | } catch (e) {}
10 | };
11 |
12 | export const datePickerFormat = (date: Date) => format(date, 'yyyy-MM-dd');
13 |
14 | export const formatNumber = (value: number) => DecimalFormatter.format(value);
15 |
16 | export const formatPercentage = (value: number) =>
17 | PercentFormatter.format(value);
18 |
19 | export const formatAmount = (amount: number, currency: string | undefined) =>
20 | isNaN(amount)
21 | ? '-'
22 | : new Intl.NumberFormat('en-UK', {
23 | style: 'currency',
24 | currency: currency || 'EUR',
25 | }).format(amount);
26 |
27 | const DecimalFormatter = new Intl.NumberFormat('en-UK', { style: 'decimal' });
28 | const PercentFormatter = new Intl.NumberFormat('en-UK', { style: 'percent' });
29 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/calculateTotal.ts:
--------------------------------------------------------------------------------
1 | import type { Product } from '@server/products/types';
2 |
3 | interface LineItem {
4 | product: Product;
5 | date: string;
6 | quantity: number;
7 | }
8 |
9 | const calculateTotal = (items: LineItem[]) => {
10 | if (items.length === 0) {
11 | return {
12 | exclVat: 0,
13 | total: 0,
14 | currency: '',
15 | };
16 | }
17 | const currency = items[0].product.currency;
18 | const [exclVat, total] = items.reduce(
19 | ([exclVatAcc, totalAcc], { product, quantity }) => {
20 | const basePrice = product.price * quantity;
21 | const priceExclVat = product.includesVat
22 | ? basePrice / (1 + product.vat / 100.0)
23 | : basePrice;
24 | const priceWithVat = priceExclVat * (1 + product.vat / 100.0);
25 | return [exclVatAcc + priceExclVat, totalAcc + priceWithVat];
26 | },
27 | [0, 0] as [number, number]
28 | );
29 | return {
30 | exclVat,
31 | total,
32 | currency,
33 | };
34 | };
35 |
36 | export default calculateTotal;
37 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/downloadInvoice.tsx:
--------------------------------------------------------------------------------
1 | import saveAs from 'file-saver';
2 | import { pdf } from '@react-pdf/renderer';
3 | import InvoicePDF from '@components/InvoicePDF';
4 | import type { Invoice } from '@server/invoices/types';
5 |
6 | const downloadInvoice = async (invoice: Invoice) => {
7 | const title = `invoice ${invoice.company.name} ${[
8 | invoice.prefix,
9 | invoice.number,
10 | ]
11 | .filter((part) => !!part)
12 | .join(' ')}`.replace(' ', '_');
13 | const doc = ;
14 | const asPdf = pdf();
15 | asPdf.updateContainer(doc);
16 | const blob = await asPdf.toBlob();
17 | saveAs(blob, title);
18 | };
19 |
20 | export default downloadInvoice;
21 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/getTitle.ts:
--------------------------------------------------------------------------------
1 | import type { Invoice } from '@server/invoices/types';
2 |
3 | const getTitle = (invoice: Invoice) => {
4 | if (invoice.number && invoice.prefix) {
5 | return `${invoice.prefix} - ${invoice.number}`;
6 | } else if (invoice.number) {
7 | return invoice.number.toString();
8 | } else if (invoice.prefix) {
9 | return `${invoice.prefix} -`;
10 | }
11 | return '';
12 | };
13 |
14 | export default getTitle;
15 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/queryKeys.ts:
--------------------------------------------------------------------------------
1 | const QueryKeys = {
2 | invoices: ['invoices'],
3 | invoice: (id: string) => ['invoices', id],
4 | };
5 |
6 | export default QueryKeys;
7 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/useCreateInvoice.ts:
--------------------------------------------------------------------------------
1 | import cuid from 'cuid';
2 | import api from '@lib/api';
3 | import useMutation from '@lib/useMutation';
4 | import type {
5 | Invoice,
6 | CreateInvoiceInput,
7 | GetInvoicesOutput,
8 | } from '@server/invoices/types';
9 | import type { Company } from '@server/company/types';
10 | import type { Client } from '@server/clients/types';
11 | import QueryKeys from './queryKeys';
12 |
13 | type UseCreateInvoicesArgs =
14 | | {
15 | onSuccess?: (invoice: Invoice) => void;
16 | }
17 | | undefined;
18 |
19 | type UseCreateInvoicesInput = CreateInvoiceInput & {
20 | company: Company;
21 | client: Client;
22 | };
23 |
24 | const useCreateInvoice = ({ onSuccess }: UseCreateInvoicesArgs = {}) =>
25 | useMutation({
26 | mutationFn: ({ company: _company, client: _client, ...input }) =>
27 | api.createInvoice.mutate(input),
28 | cacheKey: QueryKeys.invoices,
29 | cacheUpdater: (invoices, input) => {
30 | const now = new Date().toISOString();
31 | invoices.push({
32 | id: `new-invoice${cuid()}`,
33 | status: 'DRAFT',
34 | prefix: '',
35 | message: '',
36 | number: 0,
37 | date: now,
38 | createdAt: now,
39 | updatedAt: now,
40 | ...input,
41 | items: [] as Invoice['items'],
42 | });
43 | },
44 | successMessage: () => 'Successfully created invoice!',
45 | errorMessage: () => 'Failed to create invoice',
46 | onSuccess,
47 | });
48 |
49 | export default useCreateInvoice;
50 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/useDeleteInvoice.ts:
--------------------------------------------------------------------------------
1 | import api from '@lib/api';
2 | import useMutation from '@lib/useMutation';
3 | import type {
4 | DeleteInvoiceInput,
5 | GetInvoicesOutput,
6 | } from '@server/invoices/types';
7 | import QueryKeys from './queryKeys';
8 |
9 | const useDeleteInvoice = () =>
10 | useMutation({
11 | mutationFn: api.deleteInvoice.mutate,
12 | cacheKey: QueryKeys.invoices,
13 | cacheUpdater: (invoices, input) => {
14 | const invoiceIndex = invoices.findIndex(({ id }) => id === input.id);
15 | if (invoiceIndex !== -1) {
16 | invoices.splice(invoiceIndex, 1);
17 | }
18 | },
19 | successMessage: () => 'Successfully deleted invoice!',
20 | errorMessage: () => 'Failed to delete invoice',
21 | });
22 |
23 | export default useDeleteInvoice;
24 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/useInvoice.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useInvoice = (id: string) =>
6 | useQuery(QueryKeys.invoice(id), () => api.getInvoice.query({ id }));
7 |
8 | export default useInvoice;
9 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/useInvoices.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useInvoices = () =>
6 | useQuery(QueryKeys.invoices, () => api.getInvoices.query());
7 |
8 | export default useInvoices;
9 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/useLatestNumberByPrefix.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import useInvoices from './useInvoices';
3 |
4 | const useLatestNumberByPrefix = () => {
5 | const { data: invoices, ...rest } = useInvoices();
6 | const numbersByPrefix = useMemo(
7 | () =>
8 | (invoices || []).reduce((acc, invoice) => {
9 | const { prefix, number, status } = invoice;
10 | if (status === 'DRAFT') {
11 | return acc;
12 | }
13 |
14 | const current = acc[prefix] || 0;
15 | return {
16 | ...acc,
17 | [prefix]: Math.max(current, number || 0),
18 | };
19 | }, {} as { [key: string]: number }),
20 | [invoices]
21 | );
22 |
23 | return {
24 | ...rest,
25 | data: numbersByPrefix,
26 | };
27 | };
28 |
29 | export default useLatestNumberByPrefix;
30 |
--------------------------------------------------------------------------------
/app/src/lib/invoices/useUpdateInvoice.ts:
--------------------------------------------------------------------------------
1 | import omit from 'lodash.omit';
2 | import api from '@lib/api';
3 | import useMutation from '@lib/useMutation';
4 | import type {
5 | UpdateInvoiceInput,
6 | GetInvoicesOutput,
7 | Invoice,
8 | } from '@server/invoices/types';
9 | import QueryKeys from './queryKeys';
10 |
11 | type UseUpdateInvoiceArgs =
12 | | {
13 | onSuccess?: (invoice: Invoice) => void;
14 | }
15 | | undefined;
16 |
17 | const useUpdateInvoice = ({ onSuccess }: UseUpdateInvoiceArgs = {}) =>
18 | useMutation({
19 | mutationFn: api.updateInvoice.mutate,
20 | cacheKey: QueryKeys.invoices,
21 | cacheUpdater: (invoices, input) => {
22 | const invoiceIndex = invoices.findIndex(({ id }) => id === input.id);
23 | if (invoiceIndex !== -1) {
24 | invoices[invoiceIndex] = {
25 | ...invoices[invoiceIndex],
26 | ...omit(input, 'items'),
27 | };
28 | }
29 | },
30 | successMessage: () => 'Successfully updated invoice!',
31 | errorMessage: () => 'Failed to update invoice',
32 | onSuccess,
33 | });
34 |
35 | export default useUpdateInvoice;
36 |
--------------------------------------------------------------------------------
/app/src/lib/products/queryKeys.ts:
--------------------------------------------------------------------------------
1 | const QueryKeys = {
2 | products: ['products'],
3 | product: (id: string) => ['products', id],
4 | };
5 |
6 | export default QueryKeys;
7 |
--------------------------------------------------------------------------------
/app/src/lib/products/useCreateProduct.ts:
--------------------------------------------------------------------------------
1 | import cuid from 'cuid';
2 | import api from '@lib/api';
3 | import useMutation from '@lib/useMutation';
4 | import type {
5 | CreateProductInput,
6 | GetProductsOutput,
7 | Product,
8 | } from '@server/products/types';
9 | import QueryKeys from './queryKeys';
10 |
11 | type UseCreateProductArgs =
12 | | {
13 | onSuccess?: (product: Product) => void;
14 | }
15 | | undefined;
16 |
17 | const useCreateProduct = ({ onSuccess }: UseCreateProductArgs = {}) =>
18 | useMutation({
19 | mutationFn: api.createProduct.mutate,
20 | cacheKey: QueryKeys.products,
21 | cacheUpdater: (products, input) => {
22 | const now = new Date().toISOString();
23 | products.push({
24 | id: `new-product${cuid()}`,
25 | companyId: '',
26 | createdAt: now,
27 | updatedAt: now,
28 | includesVat: false,
29 | price: 0,
30 | currency: 'EUR',
31 | vat: 0,
32 | unit: 'hours',
33 | ...input,
34 | });
35 | },
36 | successMessage: () => 'Successfully created product!',
37 | errorMessage: () => 'Failed to create product',
38 | onSuccess,
39 | });
40 |
41 | export default useCreateProduct;
42 |
--------------------------------------------------------------------------------
/app/src/lib/products/useDeleteProduct.ts:
--------------------------------------------------------------------------------
1 | import api from '@lib/api';
2 | import useMutation from '@lib/useMutation';
3 | import type {
4 | DeleteProductInput,
5 | GetProductsOutput,
6 | } from '@server/products/types';
7 | import QueryKeys from './queryKeys';
8 |
9 | const useDeleteProduct = () =>
10 | useMutation({
11 | mutationFn: api.deleteProduct.mutate,
12 | cacheKey: QueryKeys.products,
13 | cacheUpdater: (products, input) => {
14 | const productIndex = products.findIndex(({ id }) => id === input.id);
15 | if (productIndex !== -1) {
16 | products.splice(productIndex, 1);
17 | }
18 | },
19 | successMessage: () => 'Successfully deleted product!',
20 | errorMessage: () => 'Failed to delete product',
21 | });
22 |
23 | export default useDeleteProduct;
24 |
--------------------------------------------------------------------------------
/app/src/lib/products/useProduct.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useProduct = (id: string) =>
6 | useQuery(QueryKeys.product(id), () => api.getProduct.query({ id }));
7 |
8 | export default useProduct;
9 |
--------------------------------------------------------------------------------
/app/src/lib/products/useProducts.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import api from '@lib/api';
3 | import QueryKeys from './queryKeys';
4 |
5 | const useProducts = () =>
6 | useQuery(QueryKeys.products, () => api.getProducts.query());
7 |
8 | export default useProducts;
9 |
--------------------------------------------------------------------------------
/app/src/lib/products/useUpdateProduct.ts:
--------------------------------------------------------------------------------
1 | import api from '@lib/api';
2 | import useMutation from '@lib/useMutation';
3 | import type {
4 | UpdateProductInput,
5 | GetProductsOutput,
6 | Product,
7 | } from '@server/products/types';
8 | import QueryKeys from './queryKeys';
9 |
10 | type UseUpdateProductArgs =
11 | | {
12 | onSuccess?: (data: Product) => void;
13 | }
14 | | undefined;
15 |
16 | const useUpdateProduct = ({ onSuccess }: UseUpdateProductArgs = {}) =>
17 | useMutation({
18 | mutationFn: api.updateProduct.mutate,
19 | cacheKey: QueryKeys.products,
20 | cacheUpdater: (products, input) => {
21 | const productIndex = products.findIndex(({ id }) => id === input.id);
22 | if (productIndex !== -1) {
23 | products[productIndex] = {
24 | ...products[productIndex],
25 | ...input,
26 | };
27 | }
28 | },
29 | onSuccess,
30 | successMessage: () => 'Successfully updated product!',
31 | errorMessage: () => 'Failed to update product',
32 | });
33 |
34 | export default useUpdateProduct;
35 |
--------------------------------------------------------------------------------
/app/src/lib/routes.ts:
--------------------------------------------------------------------------------
1 | const Routes = {
2 | home: '/',
3 | company: '/company',
4 | products: '/products',
5 | createProduct: '/products/new',
6 | product: (productId: string) => `/products/${productId}`,
7 | clients: '/clients',
8 | createClient: '/clients/new',
9 | client: (clientId: string) => `/clients/${clientId}`,
10 | invoices: '/invoices',
11 | createInvoice: '/invoices/new',
12 | invoice: (invoiceId: string) => `/invoices/${invoiceId}`,
13 | invoicePreview: (invoiceId: string) => `/invoices/${invoiceId}/preview`,
14 | signIn: '/api/auth/signin',
15 | notFound: '/404',
16 | privacyPolicy: '/beetbill_privacy_policy.pdf',
17 | termsAndConditions: '/beetbill_terms_and_conditions.pdf',
18 | cookiePolicy: '/beetbill_cookie_policy.pdf',
19 | };
20 |
21 | export default Routes;
22 |
--------------------------------------------------------------------------------
/app/src/lib/useDisclosure.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 |
3 | const useDisclosure = () => {
4 | const [isOpen, setOpen] = useState(false);
5 | const onOpen = useCallback(() => setOpen(true), [setOpen]);
6 | const onClose = useCallback(() => setOpen(false), [setOpen]);
7 | return {
8 | isOpen,
9 | onOpen,
10 | onClose,
11 | };
12 | };
13 |
14 | export default useDisclosure;
15 |
--------------------------------------------------------------------------------
/app/src/lib/useDisclosureForId.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 |
3 | const useDisclosureForId = () => {
4 | const [openFor, setOpenFor] = useState();
5 | const onOpen = useCallback((id: string) => setOpenFor(id), [setOpenFor]);
6 | const onClose = useCallback(() => setOpenFor(undefined), [setOpenFor]);
7 | return {
8 | isOpen: !!openFor,
9 | openFor,
10 | onOpen,
11 | onClose,
12 | };
13 | };
14 |
15 | export default useDisclosureForId;
16 |
--------------------------------------------------------------------------------
/app/src/lib/useDropdownAnchor.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 |
3 | const useDropdownAnchor = () => {
4 | const anchorRef = useRef(null);
5 | const [rect, setRect] = useState(() => new DOMRect());
6 | const handleUpdateRect = useCallback(() => {
7 | if (anchorRef.current) {
8 | setRect(anchorRef.current.getBoundingClientRect());
9 | }
10 | }, [anchorRef, setRect]);
11 | useEffect(() => {
12 | handleUpdateRect();
13 | window.addEventListener('scroll', handleUpdateRect);
14 | return () => window.removeEventListener('scroll', handleUpdateRect);
15 | }, [handleUpdateRect]);
16 |
17 | return {
18 | anchorRef,
19 | top: rect.top + rect.height,
20 | width: rect.width,
21 | };
22 | };
23 |
24 | export default useDropdownAnchor;
25 |
--------------------------------------------------------------------------------
/app/src/lib/useFilterFromUrl.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useCallback } from 'react';
3 |
4 | const useFilterFromUrl = (): [string, (newFilter: string) => void] => {
5 | const { query, replace } = useRouter();
6 | const setFilter = useCallback(
7 | (newFilter: string) => {
8 | void replace({ query: { ...query, filter: newFilter } }, undefined, {
9 | shallow: true,
10 | });
11 | },
12 | [query, replace]
13 | );
14 |
15 | return [(query.filter || '') as string, setFilter];
16 | };
17 |
18 | export default useFilterFromUrl;
19 |
--------------------------------------------------------------------------------
/app/src/lib/useSortFromUrl.ts:
--------------------------------------------------------------------------------
1 | import type { ColumnSort } from '@tanstack/react-table';
2 | import { useRouter } from 'next/router';
3 | import { useCallback, useMemo } from 'react';
4 |
5 | const useSortFromUrl = (defaultSort: ColumnSort | undefined = undefined) => {
6 | const { query, push } = useRouter();
7 | const hasSort = 'sortBy' in query;
8 | const sortBy = query.sortBy as string | undefined;
9 | const sortDir = query.sortDir as string | undefined;
10 | const sorting: ColumnSort[] = useMemo(() => {
11 | if (hasSort) {
12 | return sortBy ? [{ id: sortBy, desc: sortDir === 'desc' }] : [];
13 | }
14 | return defaultSort ? [defaultSort] : [];
15 | }, [sortBy, sortDir, defaultSort, hasSort]);
16 | const toggleSort = useCallback(
17 | (id: string) => {
18 | const currentSortBy = sortBy ?? defaultSort?.id;
19 | const currentSortDir = sortDir ?? (defaultSort?.desc ? 'desc' : 'asc');
20 | let newSortBy: string | undefined;
21 | let newSortDir: string | undefined;
22 |
23 | if (currentSortBy === id && currentSortDir === 'desc') {
24 | newSortBy = id;
25 | newSortDir = 'asc';
26 | } else if (currentSortBy === id && currentSortDir === 'asc') {
27 | newSortBy = undefined;
28 | newSortDir = undefined;
29 | } else {
30 | newSortBy = id;
31 | newSortDir = 'desc';
32 | }
33 | void push(
34 | {
35 | query: {
36 | ...query,
37 | sortBy: newSortBy,
38 | sortDir: newSortDir,
39 | },
40 | },
41 | undefined,
42 | { shallow: true }
43 | );
44 | },
45 | [push, query, sortBy, sortDir, defaultSort]
46 | );
47 | return {
48 | sorting,
49 | toggleSort,
50 | };
51 | };
52 |
53 | export default useSortFromUrl;
54 |
--------------------------------------------------------------------------------
/app/src/lib/useSticky.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 |
3 | const useSticky = () => {
4 | const ref = useRef(null);
5 | const [height, setHeight] = useState(0);
6 |
7 | useEffect(() => {
8 | setHeight(ref.current?.offsetHeight ?? 0);
9 | }, [ref]);
10 |
11 | return {
12 | ref,
13 | height,
14 | };
15 | };
16 |
17 | export default useSticky;
18 |
--------------------------------------------------------------------------------
/app/src/lib/userName.ts:
--------------------------------------------------------------------------------
1 | import type { Session } from 'next-auth';
2 | import capitalizeFirstLetter from './capitalizeFirstLetter';
3 |
4 | export const getFullName = (user: NonNullable) => {
5 | if (user.name) {
6 | const [firstName, lastName] = user.name.split(' ');
7 | return firstName && lastName ? `${firstName} ${lastName}` : firstName;
8 | }
9 | if (user.email) {
10 | const [email] = user.email.split('@');
11 | const [firstName, lastName] = email.split('.');
12 | return firstName && lastName
13 | ? `${capitalizeFirstLetter(firstName)} ${capitalizeFirstLetter(lastName)}`
14 | : firstName;
15 | }
16 | return '';
17 | };
18 |
19 | export const getInitials = (user: NonNullable) => {
20 | if (user.name) {
21 | const [firstName, lastName] = user.name.split(' ');
22 | return firstName && lastName
23 | ? `${firstName[0]}${lastName[0]}`
24 | : firstName[0];
25 | }
26 | if (user.email) {
27 | const [email] = user.email.split('@');
28 | const [firstName, lastName] = email.split('.');
29 | return firstName && lastName
30 | ? `${capitalizeFirstLetter(firstName)[0]}${
31 | capitalizeFirstLetter(lastName)[0]
32 | }`
33 | : capitalizeFirstLetter(firstName[0]);
34 | }
35 | return '';
36 | };
37 |
--------------------------------------------------------------------------------
/app/src/lib/utilityTypes.ts:
--------------------------------------------------------------------------------
1 | export type ArrayElement =
2 | ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
3 |
--------------------------------------------------------------------------------
/app/src/pages/404.page.test.tsx:
--------------------------------------------------------------------------------
1 | import 'next';
2 | import { setupServer } from 'msw/node';
3 | import { screen, render, mockSession } from '@lib/testing';
4 | import NotFoundPage from './404.page';
5 |
6 | const session = undefined;
7 | const server = setupServer();
8 |
9 | beforeAll(() => server.listen());
10 | beforeEach(() => server.resetHandlers(mockSession(session)));
11 | afterAll(() => server.close());
12 |
13 | describe('NotFoundPage', () => {
14 | it('renders the page', async () => {
15 | render(, { session });
16 |
17 | await screen.findByRole('link', { name: 'Home' });
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/app/src/pages/404.page.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import { faHouse } from '@fortawesome/free-solid-svg-icons';
3 | import AppName from '@lib/appName';
4 | import Routes from '@lib/routes';
5 | import LinkButton from '@components/LinkButton';
6 | import Card from '@components/Card';
7 |
8 | const NotFoundPage = () => (
9 |
10 |
11 | {`404 - ${AppName}`}
12 |
13 |
14 |
15 |
16 | Home
17 |
18 |
19 |
20 | );
21 |
22 | export default NotFoundPage;
23 |
--------------------------------------------------------------------------------
/app/src/pages/_document.next.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document';
2 |
3 | const Document = () => (
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
20 | export default Document;
21 |
--------------------------------------------------------------------------------
/app/src/pages/api/auth/[...nextauth].route.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 | import authOptions from '@server/auth/authOptions';
3 |
4 | export default NextAuth(authOptions);
5 |
--------------------------------------------------------------------------------
/app/src/pages/api/health.route.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 | import prisma from '@server/prisma';
3 |
4 | type SuccessResponse = {
5 | invoiceCount: number;
6 | };
7 |
8 | type ErrorResponse = {
9 | message: string;
10 | };
11 |
12 | type ResponseData = SuccessResponse | ErrorResponse;
13 |
14 | export default async function handler(
15 | req: NextApiRequest,
16 | res: NextApiResponse
17 | ) {
18 | if (req.method !== 'GET') {
19 | return res.status(405).json({ message: 'Method Not Allowed' });
20 | }
21 |
22 | const apiKey = req.query.apiKey as string | undefined;
23 | if (apiKey !== process.env.API_KEY) {
24 | return res.status(401).json({ message: 'Unauthorized' });
25 | }
26 |
27 | const invoiceCount = await prisma.invoice.count();
28 | return res.status(200).json({ invoiceCount });
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/pages/api/trpc/[trpc].route.ts:
--------------------------------------------------------------------------------
1 | import { createNextApiHandler } from '@trpc/server/adapters/next';
2 | import createContext from '@server/createContext';
3 | import router from '@server/router';
4 |
5 | export default createNextApiHandler({
6 | router,
7 | createContext,
8 | batching: { enabled: true },
9 | });
10 |
--------------------------------------------------------------------------------
/app/src/pages/auth/error.page.test.tsx:
--------------------------------------------------------------------------------
1 | import 'next';
2 | import { setupServer } from 'msw/node';
3 | import { screen, render, mockSession } from '@lib/testing';
4 | import ErrorPage from './error.page';
5 |
6 | const session = undefined;
7 | const server = setupServer();
8 |
9 | beforeAll(() => server.listen());
10 | afterAll(() => server.close());
11 |
12 | describe('ErrorPage', () => {
13 | beforeEach(() => {
14 | jest.resetAllMocks();
15 | server.resetHandlers(mockSession(session));
16 | });
17 |
18 | it('renders the page and shows the correct error message', async () => {
19 | render(, { session });
20 |
21 | await screen.findByRole('link', { name: 'Sign in' });
22 | await screen.findByText(
23 | 'The sign in link is no longer valid. It may have been used or it may have expired.'
24 | );
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/app/src/pages/auth/error.page.tsx:
--------------------------------------------------------------------------------
1 | import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
2 | import Head from 'next/head';
3 | import {
4 | faArrowRight,
5 | faTriangleExclamation,
6 | } from '@fortawesome/free-solid-svg-icons';
7 | import LinkButton from '@components/LinkButton';
8 | import AppName from '@lib/appName';
9 | import Routes from '@lib/routes';
10 | import WithNoAuthentication from '@components/WithNoAuthentication';
11 | import Card from '@components/Card';
12 |
13 | const Messages: Record = {
14 | Configuration: 'The application is misconfigured, please contact support.',
15 | AccessDenied: 'Your account has been blocked, please contact support.',
16 | Verification:
17 | 'The sign in link is no longer valid. It may have been used or it may have expired.',
18 | Default: 'Something went wrong, please try again.',
19 | };
20 |
21 | const ErrorPage = ({
22 | error,
23 | }: InferGetServerSidePropsType) => {
24 | const message = Messages[error] || Messages.Default;
25 | return (
26 |
27 |
28 | {`Log in - ${AppName}`}
29 |
30 | {message}
31 |
32 |
33 | Sign in
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export const getServerSideProps: GetServerSideProps = async (context) => ({
41 | props: {
42 | error: context.query.error || '',
43 | },
44 | });
45 |
46 | export default WithNoAuthentication(ErrorPage);
47 |
--------------------------------------------------------------------------------
/app/src/pages/auth/signin.page.tsx:
--------------------------------------------------------------------------------
1 | import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
2 | import { getProviders } from 'next-auth/react';
3 | import Head from 'next/head';
4 | import SignInForm from '@components/SignInForm';
5 | import WithNoAuthentication from '@components/WithNoAuthentication';
6 | import AppName from '@lib/appName';
7 |
8 | const SignInPage = ({
9 | providers,
10 | callbackUrl,
11 | error,
12 | }: InferGetServerSidePropsType) => (
13 | <>
14 |
15 | {`Log in - ${AppName}`}
16 |
17 |
18 | >
19 | );
20 |
21 | export const getServerSideProps: GetServerSideProps = async (context) => {
22 | const providers = await getProviders();
23 |
24 | return {
25 | props: {
26 | providers,
27 | callbackUrl: context.query.callbackUrl || '',
28 | error: context.query.error || '',
29 | },
30 | };
31 | };
32 |
33 | export default WithNoAuthentication(SignInPage);
34 |
--------------------------------------------------------------------------------
/app/src/pages/auth/verify.page.test.tsx:
--------------------------------------------------------------------------------
1 | import 'next';
2 | import { setupServer } from 'msw/node';
3 | import { screen, render, mockSession } from '@lib/testing';
4 | import VerifyPage from './verify.page';
5 |
6 | const session = undefined;
7 | const server = setupServer();
8 |
9 | beforeAll(() => server.listen());
10 | afterEach(() => server.resetHandlers());
11 | afterAll(() => server.close());
12 |
13 | describe('VerifyPage', () => {
14 | beforeEach(() => {
15 | jest.resetAllMocks();
16 | server.resetHandlers(mockSession(session));
17 | });
18 |
19 | it('renders the page', async () => {
20 | render(, { session });
21 |
22 | await screen.findByRole('link', { name: 'Back' });
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/app/src/pages/auth/verify.page.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
3 | import AppName from '@lib/appName';
4 | import LinkButton from '@components/LinkButton';
5 | import Routes from '@lib/routes';
6 | import WithNoAuthentication from '@components/WithNoAuthentication';
7 | import Card from '@components/Card';
8 |
9 | const VerifyPage = () => (
10 |
11 |
12 | {`Log in - ${AppName}`}
13 |
14 |
15 | We just emailed you a link that will log you in securely.
16 |
17 |
18 |
19 | Back
20 |
21 |
22 |
23 | );
24 |
25 | export default WithNoAuthentication(VerifyPage);
26 |
--------------------------------------------------------------------------------
/app/src/pages/clients.page.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { faAdd } from '@fortawesome/free-solid-svg-icons';
3 | import Head from 'next/head';
4 | import EmptyContent from '@components/EmptyContent';
5 | import WithAuthentication from '@components/WithAuthentication';
6 | import Routes from '@lib/routes';
7 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
8 | import LinkButton from '@components/LinkButton';
9 | import useClients from '@lib/clients/useClients';
10 | import useDeleteClient from '@lib/clients/useDeleteClient';
11 | import ClientsTable from '@components/ClientsTable';
12 | import AppName from '@lib/appName';
13 |
14 | const ClientsPage = () => {
15 | const { data: clients, isLoading } = useClients();
16 | const { mutate: deleteClient } = useDeleteClient();
17 | const handleDelete = useCallback(
18 | (clientId: string) => deleteClient({ id: clientId }),
19 | [deleteClient]
20 | );
21 |
22 | let content = null;
23 |
24 | if (isLoading) {
25 | content = ;
26 | } else if (!clients || clients.length === 0) {
27 | content = (
28 |
33 | );
34 | } else {
35 | content = (
36 |
37 |
38 |
Your clients
39 |
40 | Add client
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | return (
49 | <>
50 |
51 | {`Clients - ${AppName}`}
52 |
53 | {content}
54 | >
55 | );
56 | };
57 |
58 | export default WithAuthentication(ClientsPage);
59 |
--------------------------------------------------------------------------------
/app/src/pages/clients/[clientId].page.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useEffect } from 'react';
3 | import Head from 'next/head';
4 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
5 | import WithAuthentication from '@components/WithAuthentication';
6 | import Routes from '@lib/routes';
7 | import CreateEditClientForm from '@components/CreateEditClientForm';
8 | import useClient from '@lib/clients/useClient';
9 | import AppName from '@lib/appName';
10 |
11 | const EditClientPage = () => {
12 | const router = useRouter();
13 | const { data: client, isError } = useClient(router.query.clientId as string);
14 |
15 | useEffect(() => {
16 | if (isError) {
17 | void router.push(Routes.notFound);
18 | }
19 | }, [router, isError]);
20 |
21 | const content = client ? (
22 |
23 | ) : (
24 |
25 | );
26 |
27 | return (
28 | <>
29 |
30 | {`${client?.name ?? 'Client'} - ${AppName}`}
31 |
32 | {content}
33 | >
34 | );
35 | };
36 |
37 | export default WithAuthentication(EditClientPage);
38 |
--------------------------------------------------------------------------------
/app/src/pages/clients/new.page.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import CreateEditClientForm from '@components/CreateEditClientForm';
3 | import WithAuthentication from '@components/WithAuthentication';
4 | import AppName from '@lib/appName';
5 |
6 | const NewClientPage = () => (
7 | <>
8 |
9 | {`New client - ${AppName}`}
10 |
11 |
12 | >
13 | );
14 |
15 | export default WithAuthentication(NewClientPage);
16 |
--------------------------------------------------------------------------------
/app/src/pages/company.page.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import EditCompanyForm from '@components/EditCompanyForm';
3 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
4 | import WithAuthentication from '@components/WithAuthentication';
5 | import useCompany from '@lib/companies/useCompany';
6 | import AppName from '@lib/appName';
7 |
8 | const CompanyPage = () => {
9 | const { data: company } = useCompany();
10 | const content = company ? (
11 |
12 | ) : (
13 |
14 | );
15 |
16 | return (
17 | <>
18 |
19 | {`${company?.name ?? 'Company'} - ${AppName}`}
20 |
21 | {content}
22 | >
23 | );
24 | };
25 |
26 | export default WithAuthentication(CompanyPage);
27 |
--------------------------------------------------------------------------------
/app/src/pages/index.page.tsx:
--------------------------------------------------------------------------------
1 | import type { GetServerSideProps, NextPage } from 'next';
2 |
3 | const Home: NextPage = () => null;
4 |
5 | export default Home;
6 |
7 | export const getServerSideProps: GetServerSideProps = async () => ({
8 | redirect: {
9 | destination: '/invoices',
10 | permantent: false,
11 | },
12 | props: {},
13 | });
14 |
--------------------------------------------------------------------------------
/app/src/pages/index.test.tsx:
--------------------------------------------------------------------------------
1 | import 'next';
2 |
3 | import { useSession } from 'next-auth/react';
4 | import { render, screen } from '@lib/testing';
5 | import IndexPage from './index.page';
6 |
7 | jest.mock('next-auth/react');
8 |
9 | const useSessionFn = useSession as jest.Mock;
10 |
11 | describe('IndexPage', () => {
12 | it('renders correctly', async () => {
13 | useSessionFn.mockReturnValue({
14 | status: 'unauthenticated',
15 | data: null,
16 | });
17 |
18 | render();
19 | screen.queryByText('Not signed in');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/app/src/pages/invoices.page.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import Head from 'next/head';
3 | import { faAdd } from '@fortawesome/free-solid-svg-icons';
4 | import EmptyContent from '@components/EmptyContent';
5 | import WithAuthentication from '@components/WithAuthentication';
6 | import Routes from '@lib/routes';
7 | import AppName from '@lib/appName';
8 | import useInvoices from '@lib/invoices/useInvoices';
9 | import useDeleteInvoice from '@lib/invoices/useDeleteInvoice';
10 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
11 | import LinkButton from '@components/LinkButton';
12 | import InvoicesTable from '@components/InvoicesTable';
13 |
14 | const InvoicesPage = () => {
15 | const { data: invoices, isLoading } = useInvoices();
16 | const { mutate: deleteInvoice } = useDeleteInvoice();
17 | const handleDelete = useCallback(
18 | (invoiceId: string) => deleteInvoice({ id: invoiceId }),
19 | [deleteInvoice]
20 | );
21 |
22 | let content = null;
23 | if (isLoading) {
24 | content = ;
25 | } else if (!invoices || invoices.length === 0) {
26 | content = (
27 |
32 | );
33 | } else {
34 | content = (
35 |
36 |
37 |
Your invoices
38 |
39 | Add invoice
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | return (
48 | <>
49 |
50 | {`Invoices - ${AppName}`}
51 |
52 | {content}
53 | >
54 | );
55 | };
56 |
57 | export default WithAuthentication(InvoicesPage);
58 |
--------------------------------------------------------------------------------
/app/src/pages/invoices/[invoiceId]/preview.page.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useEffect } from 'react';
3 | import Head from 'next/head';
4 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
5 | import WithAuthentication from '@components/WithAuthentication';
6 | import Routes from '@lib/routes';
7 | import AppName from '@lib/appName';
8 | import useInvoice from '@lib/invoices/useInvoice';
9 | import InvoicePreview from '@components/InvoicePreview';
10 |
11 | const PreviewInvoicePage = () => {
12 | const router = useRouter();
13 | const { data: invoice, isError } = useInvoice(
14 | router.query.invoiceId as string
15 | );
16 |
17 | useEffect(() => {
18 | if (isError) {
19 | void router.push(Routes.notFound);
20 | }
21 | }, [router, isError]);
22 |
23 | const content = invoice ? (
24 |
25 | ) : (
26 |
27 | );
28 |
29 | return (
30 | <>
31 |
32 | {`Invoice preview - ${AppName}`}
33 |
34 | {content}
35 | >
36 | );
37 | };
38 |
39 | export default WithAuthentication(PreviewInvoicePage);
40 |
--------------------------------------------------------------------------------
/app/src/pages/products.page.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { faAdd } from '@fortawesome/free-solid-svg-icons';
3 | import Head from 'next/head';
4 | import EmptyContent from '@components/EmptyContent';
5 | import WithAuthentication from '@components/WithAuthentication';
6 | import useDeleteProduct from '@lib/products/useDeleteProduct';
7 | import useProducts from '@lib/products/useProducts';
8 | import Routes from '@lib/routes';
9 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
10 | import ProductsTable from '@components/ProductsTable';
11 | import LinkButton from '@components/LinkButton';
12 | import AppName from '@lib/appName';
13 |
14 | const ProductsPage = () => {
15 | const { data: products, isLoading } = useProducts();
16 | const { mutate: deleteProduct } = useDeleteProduct();
17 | const handleDelete = useCallback(
18 | (productId: string) => deleteProduct({ id: productId }),
19 | [deleteProduct]
20 | );
21 |
22 | let content = null;
23 | if (isLoading) {
24 | content = ;
25 | } else if (!products || products.length === 0) {
26 | content = (
27 |
32 | );
33 | } else {
34 | content = (
35 |
36 |
37 |
Your products
38 |
39 | Add product
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | return (
48 | <>
49 |
50 | {`Products - ${AppName}`}
51 |
52 | {content}
53 | >
54 | );
55 | };
56 |
57 | export default WithAuthentication(ProductsPage);
58 |
--------------------------------------------------------------------------------
/app/src/pages/products/[productId].page.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useEffect } from 'react';
3 | import Head from 'next/head';
4 | import CreateEditProductForm from '@components/CreateEditProductForm';
5 | import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
6 | import WithAuthentication from '@components/WithAuthentication';
7 | import useProduct from '@lib/products/useProduct';
8 | import Routes from '@lib/routes';
9 | import AppName from '@lib/appName';
10 |
11 | const EditProductPage = () => {
12 | const router = useRouter();
13 | const { data: product, isError } = useProduct(
14 | router.query.productId as string
15 | );
16 |
17 | useEffect(() => {
18 | if (isError) {
19 | void router.push(Routes.notFound);
20 | }
21 | }, [router, isError]);
22 |
23 | const content = product ? (
24 |
25 | ) : (
26 |
27 | );
28 |
29 | return (
30 | <>
31 |
32 | {`${product?.name ?? 'Product'} - ${AppName}`}
33 |
34 | {content}
35 | >
36 | );
37 | };
38 |
39 | export default WithAuthentication(EditProductPage);
40 |
--------------------------------------------------------------------------------
/app/src/pages/products/new.page.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import CreateEditProductForm from '@components/CreateEditProductForm';
3 | import WithAuthentication from '@components/WithAuthentication';
4 | import AppName from '@lib/appName';
5 |
6 | const NewProductPage = () => (
7 | <>
8 |
9 | {`New product - ${AppName}`}
10 |
11 |
12 | >
13 | );
14 |
15 | export default WithAuthentication(NewProductPage);
16 |
--------------------------------------------------------------------------------
/app/src/server/auth/authOptions.ts:
--------------------------------------------------------------------------------
1 | import { PrismaAdapter } from '@next-auth/prisma-adapter';
2 | import EmailProvider from 'next-auth/providers/email';
3 | import GoogleProvider from 'next-auth/providers/google';
4 | import prisma from '@server/prisma';
5 | import { sendVerificationRequest } from './email';
6 | import callbacks from './callbacks';
7 | import events from './events';
8 |
9 | const authOptions = {
10 | adapter: PrismaAdapter(prisma),
11 | providers: [
12 | EmailProvider({
13 | server: {
14 | host: process.env.EMAIL_SERVER_HOST,
15 | port: parseInt(process.env.EMAIL_SERVER_PORT || '1025'),
16 | auth: {
17 | user: process.env.EMAIL_SERVER_USER,
18 | pass: process.env.EMAIL_SERVER_PASSWORD,
19 | },
20 | },
21 | from: process.env.EMAIL_FROM,
22 | sendVerificationRequest,
23 | }),
24 | ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
25 | ? [
26 | GoogleProvider({
27 | clientId: process.env.GOOGLE_CLIENT_ID,
28 | clientSecret: process.env.GOOGLE_CLIENT_SECRET,
29 | allowDangerousEmailAccountLinking: true,
30 | }),
31 | ]
32 | : []),
33 | ],
34 | secret: process.env.NEXT_AUTH_SECRET,
35 | callbacks,
36 | events,
37 | pages: {
38 | signIn: '/auth/signin',
39 | verifyRequest: '/auth/verify',
40 | error: '/auth/error',
41 | },
42 | };
43 |
44 | export default authOptions;
45 |
--------------------------------------------------------------------------------
/app/src/server/auth/callbacks.ts:
--------------------------------------------------------------------------------
1 | import type { CallbacksOptions } from 'next-auth';
2 | import prisma from '@server/prisma';
3 |
4 | const session: CallbacksOptions['session'] = async ({ session, user }) => {
5 | const company = await prisma.company.findUnique({
6 | where: { userId: user.id },
7 | });
8 | session.userId = user.id;
9 | session.companyId = company?.id as string;
10 | return session;
11 | };
12 |
13 | const callbacks = {
14 | session,
15 | };
16 |
17 | export default callbacks;
18 |
--------------------------------------------------------------------------------
/app/src/server/auth/email.test.tsx:
--------------------------------------------------------------------------------
1 | import { generateHtmlEmail } from './email';
2 |
3 | describe('generateHtmlEmail', () => {
4 | it('generates a valid mjml template', () => {
5 | const url = 'https://invoicing.saltares.com/auth/magic-link';
6 | const host = 'invoicing.saltares.com';
7 | const email = 'ada@lovelace.com';
8 |
9 | const html = generateHtmlEmail({ url, host, email });
10 |
11 | expect(typeof html).toBe('string');
12 | expect(html).toContain(url);
13 | expect(html).toContain(email);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/app/src/server/auth/events.ts:
--------------------------------------------------------------------------------
1 | import type { EventCallbacks } from 'next-auth';
2 | import prisma from '@server/prisma';
3 |
4 | const createUser: EventCallbacks['createUser'] = async ({ user }) => {
5 | await prisma.company.create({
6 | data: {
7 | userId: user.id,
8 | states: {
9 | create: {
10 | name: '',
11 | number: '',
12 | vatNumber: '',
13 | email: '',
14 | website: '',
15 | country: '',
16 | address: '',
17 | postCode: '',
18 | city: '',
19 | iban: '',
20 | },
21 | },
22 | },
23 | });
24 | };
25 |
26 | const events = {
27 | createUser,
28 | };
29 |
30 | export default events;
31 |
--------------------------------------------------------------------------------
/app/src/server/clients/createClient.ts:
--------------------------------------------------------------------------------
1 | import { type Procedure, procedure } from '@server/trpc';
2 | import prisma from '@server/prisma';
3 | import { CreateClientOutput, CreateClientInput } from './types';
4 | import mapClientEntity from './mapClientEntity';
5 |
6 | export const createClient: Procedure<
7 | CreateClientInput,
8 | CreateClientOutput
9 | > = async ({ ctx: { session }, input }) => {
10 | const client = await prisma.client.create({
11 | data: {
12 | companyId: session?.companyId as string,
13 | states: { create: input },
14 | },
15 | include: { states: true },
16 | });
17 | return mapClientEntity(client, []);
18 | };
19 |
20 | export default procedure
21 | .input(CreateClientInput)
22 | .output(CreateClientOutput)
23 | .mutation(createClient);
24 |
--------------------------------------------------------------------------------
/app/src/server/clients/deleteClient.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import prisma from '@server/prisma';
3 | import { type Procedure, procedure } from '@server/trpc';
4 | import { DeleteClientOutput, DeleteClientInput } from './types';
5 |
6 | export const deleteClient: Procedure<
7 | DeleteClientInput,
8 | DeleteClientOutput
9 | > = async ({ ctx: { session }, input: { id } }) => {
10 | const clientInNonDraftInvoice = await prisma.client.findFirst({
11 | where: {
12 | id,
13 | companyId: session?.companyId as string,
14 | states: {
15 | some: {
16 | invoices: {
17 | some: {
18 | deletedAt: null,
19 | },
20 | },
21 | },
22 | },
23 | },
24 | });
25 | if (clientInNonDraftInvoice) {
26 | throw new TRPCError({
27 | code: 'PRECONDITION_FAILED',
28 | message: 'Clients associated to approved invoices cannot be deleted.',
29 | });
30 | }
31 |
32 | const existingClient = await prisma.client.findFirst({
33 | where: { id, companyId: session?.companyId as string },
34 | });
35 |
36 | if (existingClient) {
37 | await prisma.client.update({
38 | where: { id },
39 | data: { deletedAt: new Date() },
40 | });
41 | }
42 |
43 | return id;
44 | };
45 |
46 | export default procedure
47 | .input(DeleteClientInput)
48 | .output(DeleteClientOutput)
49 | .mutation(deleteClient);
50 |
--------------------------------------------------------------------------------
/app/src/server/clients/getClient.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import { type Procedure, procedure } from '@server/trpc';
3 | import prisma, { Prisma } from '@server/prisma';
4 | import { GetClientInput, GetClientOutput } from './types';
5 | import mapClientEntity from './mapClientEntity';
6 | import { getInvoicesForClient } from './utils';
7 |
8 | export const getClient: Procedure = async ({
9 | ctx: { session },
10 | input: { id },
11 | }) => {
12 | try {
13 | const [client, invoices] = await Promise.all([
14 | prisma.client.findFirstOrThrow({
15 | where: { id, companyId: session?.companyId, deletedAt: null },
16 | include: {
17 | states: {
18 | orderBy: { createdAt: 'desc' },
19 | take: 1,
20 | },
21 | },
22 | }),
23 | getInvoicesForClient(id),
24 | ]);
25 | return mapClientEntity(client, invoices);
26 | } catch (e) {
27 | if (
28 | e instanceof Prisma.PrismaClientKnownRequestError &&
29 | e.code === 'P2025'
30 | ) {
31 | throw new TRPCError({
32 | code: 'NOT_FOUND',
33 | message: 'The client does not exist.',
34 | });
35 | }
36 | throw e;
37 | }
38 | };
39 |
40 | export default procedure
41 | .input(GetClientInput)
42 | .output(GetClientOutput)
43 | .query(getClient);
44 |
--------------------------------------------------------------------------------
/app/src/server/clients/getClients.ts:
--------------------------------------------------------------------------------
1 | import { type Procedure, procedure } from '@server/trpc';
2 | import prisma from '@server/prisma';
3 | import { getInvoices } from '@server/invoices/getInvoices';
4 | import type { Invoice } from '@server/invoices/types';
5 | import { GetClientsOutput } from './types';
6 | import mapClientEntity from './mapClientEntity';
7 |
8 | export const getClients: Procedure = async ({
9 | ctx,
10 | }) => {
11 | const clients = await prisma.client.findMany({
12 | where: {
13 | companyId: ctx.session?.companyId,
14 | deletedAt: null,
15 | },
16 | include: {
17 | states: {
18 | orderBy: { createdAt: 'desc' },
19 | take: 1,
20 | },
21 | },
22 | });
23 | const invoices = await getInvoices({ ctx, input: {} });
24 | const invoicesByClientId = invoices.reduce(
25 | (acc, invoice) => ({
26 | ...acc,
27 | [invoice.client.id]: [...(acc[invoice.client.id] || []), invoice],
28 | }),
29 | {} as Record
30 | );
31 | return clients.map((client) =>
32 | mapClientEntity(client, invoicesByClientId[client.id] || [])
33 | );
34 | };
35 |
36 | export default procedure.output(GetClientsOutput).query(getClients);
37 |
--------------------------------------------------------------------------------
/app/src/server/clients/mapClientEntity.ts:
--------------------------------------------------------------------------------
1 | import type { Client, ClientState } from '@prisma/client';
2 | import calculateTotal from '@lib/invoices/calculateTotal';
3 | import type { Invoice } from '@server/invoices/types';
4 | import type { Client as APIClient } from './types';
5 |
6 | type Entity = Client & { states: ClientState[] };
7 | const mapClientEntity = (
8 | { states, ...client }: Entity,
9 | invoices: Invoice[]
10 | ): APIClient => {
11 | const { toBePaid, paid } = getAmounts(invoices);
12 |
13 | return {
14 | ...states[0],
15 | ...client,
16 | createdAt: client.createdAt.toISOString(),
17 | updatedAt: client.updatedAt.toISOString(),
18 | toBePaid,
19 | paid,
20 | };
21 | };
22 |
23 | const getAmounts = (invoices: Invoice[]) => {
24 | if (
25 | invoices.length === 0 ||
26 | invoices[0].items.length === 0 ||
27 | !hasSameCurrency(invoices)
28 | ) {
29 | return {
30 | toBePaid: undefined,
31 | paid: undefined,
32 | };
33 | }
34 |
35 | const currency = invoices[0].items[0].product.currency;
36 | const { toBePaid, paid } = invoices.reduce(
37 | (acc, invoice) => {
38 | const { total } = calculateTotal(invoice.items);
39 | if (invoice.status === 'PAID') {
40 | return { ...acc, paid: acc.paid + total };
41 | } else if (invoice.status === 'SENT') {
42 | return { ...acc, toBePaid: acc.toBePaid + total };
43 | }
44 | return acc;
45 | },
46 | {
47 | toBePaid: 0,
48 | paid: 0,
49 | }
50 | );
51 | return {
52 | toBePaid: { currency, value: toBePaid },
53 | paid: { currency, value: paid },
54 | };
55 | };
56 |
57 | const hasSameCurrency = (invoices: Invoice[]) => {
58 | const currencies = invoices.map(
59 | (invoice) => invoice.items[0]?.product.currency
60 | );
61 | return currencies.every((currency) => currency === currencies[0]);
62 | };
63 |
64 | export default mapClientEntity;
65 |
--------------------------------------------------------------------------------
/app/src/server/clients/updateClient.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import omit from 'lodash.omit';
3 | import { type Procedure, procedure } from '@server/trpc';
4 | import prisma from '@server/prisma';
5 | import { UpdateClientOutput, UpdateClientInput } from './types';
6 | import mapClientEntity from './mapClientEntity';
7 | import { getInvoicesForClient } from './utils';
8 |
9 | export const updateClient: Procedure<
10 | UpdateClientInput,
11 | UpdateClientOutput
12 | > = async ({ ctx: { session }, input: { id, ...data } }) => {
13 | const [existingClient, invoices] = await Promise.all([
14 | prisma.client.findFirst({
15 | where: { id, companyId: session?.companyId as string },
16 | include: {
17 | states: {
18 | orderBy: { createdAt: 'desc' },
19 | take: 1,
20 | },
21 | },
22 | }),
23 | getInvoicesForClient(id),
24 | ]);
25 | if (!existingClient) {
26 | throw new TRPCError({
27 | code: 'NOT_FOUND',
28 | message: 'The client does not exist.',
29 | });
30 | }
31 |
32 | const stateData = {
33 | ...omit(existingClient.states[0], 'id', 'createdAt'),
34 | ...data,
35 | clientId: id,
36 | };
37 | const newState = await prisma.clientState.create({ data: stateData });
38 | return mapClientEntity(
39 | {
40 | ...existingClient,
41 | states: [newState],
42 | },
43 | invoices
44 | );
45 | };
46 |
47 | export default procedure
48 | .input(UpdateClientInput)
49 | .output(UpdateClientOutput)
50 | .mutation(updateClient);
51 |
--------------------------------------------------------------------------------
/app/src/server/clients/utils.ts:
--------------------------------------------------------------------------------
1 | import mapInvoiceEntity from '@server/invoices/mapInvoiceEntity';
2 | import prisma from '@server/prisma';
3 |
4 | export const getInvoicesForClient = async (id: string) => {
5 | const invoices = await prisma.invoice.findMany({
6 | where: {
7 | deletedAt: null,
8 | clientState: {
9 | clientId: id,
10 | },
11 | },
12 | include: {
13 | companyState: {
14 | include: {
15 | company: true,
16 | },
17 | },
18 | clientState: {
19 | include: {
20 | client: true,
21 | },
22 | },
23 | items: {
24 | include: {
25 | productState: {
26 | include: {
27 | product: true,
28 | },
29 | },
30 | },
31 | },
32 | },
33 | });
34 | return invoices.map(mapInvoiceEntity);
35 | };
36 |
--------------------------------------------------------------------------------
/app/src/server/company/getCompany.ts:
--------------------------------------------------------------------------------
1 | import { type Procedure, procedure } from '@server/trpc';
2 | import prisma from '@server/prisma';
3 | import { GetCompanyOutput } from './types';
4 | import mapCompanyEntity from './mapCompanyEntity';
5 |
6 | export const getCompany: Procedure = async ({
7 | ctx,
8 | }) => {
9 | const company = await prisma.company.findUniqueOrThrow({
10 | where: {
11 | id: ctx.session?.companyId,
12 | },
13 | include: {
14 | states: {
15 | orderBy: { createdAt: 'desc' },
16 | take: 1,
17 | },
18 | },
19 | });
20 | return mapCompanyEntity(company);
21 | };
22 |
23 | export default procedure.output(GetCompanyOutput).query(getCompany);
24 |
--------------------------------------------------------------------------------
/app/src/server/company/mapCompanyEntity.ts:
--------------------------------------------------------------------------------
1 | import type { Company, CompanyState } from '@prisma/client';
2 | import type { Company as APICompany } from './types';
3 |
4 | type Entity = Company & { states: CompanyState[] };
5 |
6 | const mapCompanyEntity = ({
7 | states,
8 | createdAt,
9 | ...company
10 | }: Entity): APICompany => ({
11 | ...states[0],
12 | ...company,
13 | createdAt: createdAt.toISOString(),
14 | });
15 |
16 | export default mapCompanyEntity;
17 |
--------------------------------------------------------------------------------
/app/src/server/company/types.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const Company = z.object({
4 | id: z.string(),
5 | name: z.string(),
6 | number: z.string(),
7 | vatNumber: z.string().nullable(),
8 | contactName: z.string().nullable(),
9 | email: z.string().nullable(),
10 | website: z.string().nullable(),
11 | country: z.string().nullable(),
12 | address: z.string().nullable(),
13 | postCode: z.string().nullable(),
14 | city: z.string().nullable(),
15 | iban: z.string().nullable(),
16 | message: z.string().nullable(),
17 | userId: z.string(),
18 | createdAt: z.string(),
19 | });
20 |
21 | export const GetCompanyOutput = Company.nullish();
22 | export const UpdateCompanyInput = Company.omit({
23 | createdAt: true,
24 | userId: true,
25 | }).partial();
26 | export const UpdateCompanyOutput = Company;
27 |
28 | export type Company = z.infer;
29 | export type GetCompanyOutput = z.infer;
30 | export type UpdateCompanyInput = z.infer;
31 | export type UpdateCompanyOutput = z.infer;
32 |
--------------------------------------------------------------------------------
/app/src/server/company/updateCompany.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import omit from 'lodash.omit';
3 | import { type Procedure, procedure } from '@server/trpc';
4 | import prisma from '@server/prisma';
5 | import { UpdateCompanyOutput, UpdateCompanyInput } from './types';
6 | import mapCompanyEntity from './mapCompanyEntity';
7 |
8 | export const updateCompany: Procedure<
9 | UpdateCompanyInput,
10 | UpdateCompanyOutput
11 | > = async ({ ctx: { session }, input: { id: _id, ...data } }) => {
12 | const existingCompany = await prisma.company.findUnique({
13 | where: { id: session?.companyId as string },
14 | include: {
15 | states: {
16 | orderBy: { createdAt: 'desc' },
17 | take: 1,
18 | },
19 | },
20 | });
21 | if (!existingCompany) {
22 | throw new TRPCError({
23 | code: 'NOT_FOUND',
24 | message: 'The company does not exist.',
25 | });
26 | }
27 | const stateData = {
28 | ...omit(existingCompany.states[0], 'id', 'createdAt'),
29 | ...data,
30 | companyId: existingCompany.id,
31 | };
32 | const newState = await prisma.companyState.create({ data: stateData });
33 | return mapCompanyEntity({
34 | ...existingCompany,
35 | states: [newState],
36 | });
37 | };
38 |
39 | export default procedure
40 | .input(UpdateCompanyInput)
41 | .output(UpdateCompanyOutput)
42 | .mutation(updateCompany);
43 |
--------------------------------------------------------------------------------
/app/src/server/createContext.ts:
--------------------------------------------------------------------------------
1 | import type { inferAsyncReturnType } from '@trpc/server';
2 | import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
3 | import type { Session } from 'next-auth';
4 | import { getServerSession } from 'next-auth';
5 | import authOptions from './auth/authOptions';
6 |
7 | const createContext = async ({ req, res }: CreateNextContextOptions) => ({
8 | session: (await getServerSession(req, res, authOptions)) as Session | null,
9 | });
10 |
11 | export default createContext;
12 |
13 | export type Context = inferAsyncReturnType;
14 |
--------------------------------------------------------------------------------
/app/src/server/invoices/deleteInvoice.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import prisma from '@server/prisma';
3 | import { type Procedure, procedure } from '@server/trpc';
4 | import { DeleteInvoiceOutput, DeleteInvoiceInput } from './types';
5 |
6 | export const deleteInvoice: Procedure<
7 | DeleteInvoiceInput,
8 | DeleteInvoiceOutput
9 | > = async ({ ctx: { session }, input: { id } }) => {
10 | const invoice = await prisma.invoice.findFirst({
11 | where: {
12 | id,
13 | deletedAt: null,
14 | companyState: {
15 | companyId: session?.companyId as string,
16 | },
17 | },
18 | });
19 |
20 | if (!invoice) {
21 | return id;
22 | }
23 |
24 | if (invoice.status !== 'DRAFT') {
25 | throw new TRPCError({
26 | code: 'PRECONDITION_FAILED',
27 | message: 'Appoved invoices cannot be deleted.',
28 | });
29 | }
30 |
31 | await prisma.invoice.update({
32 | where: { id },
33 | data: { deletedAt: new Date() },
34 | });
35 |
36 | return id;
37 | };
38 |
39 | export default procedure
40 | .input(DeleteInvoiceInput)
41 | .output(DeleteInvoiceOutput)
42 | .mutation(deleteInvoice);
43 |
--------------------------------------------------------------------------------
/app/src/server/invoices/getInvoice.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import { type Procedure, procedure } from '@server/trpc';
3 | import prisma, { Prisma } from '@server/prisma';
4 | import { GetInvoiceInput, GetInvoiceOutput } from './types';
5 | import mapInvoiceEntity from './mapInvoiceEntity';
6 |
7 | export const getInvoice: Procedure = async ({
8 | ctx: { session },
9 | input: { id },
10 | }) => {
11 | try {
12 | const invoice = await prisma.invoice.findFirstOrThrow({
13 | where: {
14 | id,
15 | deletedAt: null,
16 | companyState: { companyId: session?.companyId as string },
17 | },
18 | orderBy: { createdAt: 'desc' },
19 | include: {
20 | companyState: {
21 | include: {
22 | company: true,
23 | },
24 | },
25 | clientState: {
26 | include: {
27 | client: true,
28 | },
29 | },
30 | items: {
31 | include: {
32 | productState: {
33 | include: {
34 | product: true,
35 | },
36 | },
37 | },
38 | orderBy: { order: 'asc' },
39 | },
40 | },
41 | });
42 | return mapInvoiceEntity(invoice);
43 | } catch (e) {
44 | if (
45 | e instanceof Prisma.PrismaClientKnownRequestError &&
46 | e.code === 'P2025'
47 | ) {
48 | throw new TRPCError({
49 | code: 'NOT_FOUND',
50 | message: 'The invoice does not exist.',
51 | });
52 | }
53 | throw e;
54 | }
55 | };
56 |
57 | export default procedure
58 | .input(GetInvoiceInput)
59 | .output(GetInvoiceOutput)
60 | .query(getInvoice);
61 |
--------------------------------------------------------------------------------
/app/src/server/invoices/getInvoices.ts:
--------------------------------------------------------------------------------
1 | import { type Procedure, procedure } from '@server/trpc';
2 | import prisma from '@server/prisma';
3 | import { GetInvoicesInput, GetInvoicesOutput } from './types';
4 | import mapInvoiceEntity from './mapInvoiceEntity';
5 |
6 | export const getInvoices: Procedure<
7 | GetInvoicesInput,
8 | GetInvoicesOutput
9 | > = async ({ ctx: { session } }) => {
10 | const invoices = await prisma.invoice.findMany({
11 | where: {
12 | deletedAt: null,
13 | companyState: { companyId: session?.companyId as string },
14 | },
15 | include: {
16 | companyState: {
17 | include: {
18 | company: true,
19 | },
20 | },
21 | clientState: {
22 | include: {
23 | client: true,
24 | },
25 | },
26 | items: {
27 | include: {
28 | productState: {
29 | include: {
30 | product: true,
31 | },
32 | },
33 | },
34 | orderBy: { order: 'asc' },
35 | },
36 | },
37 | });
38 | return invoices.map(mapInvoiceEntity);
39 | };
40 |
41 | export default procedure
42 | .input(GetInvoicesInput)
43 | .output(GetInvoicesOutput)
44 | .query(getInvoices);
45 |
--------------------------------------------------------------------------------
/app/src/server/invoices/mapInvoiceEntity.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Client,
3 | ClientState,
4 | Company,
5 | CompanyState,
6 | Invoice,
7 | LineItem,
8 | Product,
9 | ProductState,
10 | } from '@prisma/client';
11 | import mapCompanyEntity from '@server/company/mapCompanyEntity';
12 | import mapClientEntity from '@server/clients/mapClientEntity';
13 | import mapProductEntity from '@server/products/mapProductEntity';
14 | import type { Invoice as APIInvoice } from './types';
15 |
16 | type Entity = Invoice & {
17 | companyState: CompanyState & {
18 | company: Company;
19 | };
20 | clientState: ClientState & {
21 | client: Client;
22 | };
23 | items: (LineItem & {
24 | productState: ProductState & {
25 | product: Product;
26 | };
27 | })[];
28 | };
29 |
30 | const mapInvoiceEntity = ({
31 | id,
32 | status,
33 | prefix,
34 | number,
35 | date,
36 | message,
37 | createdAt,
38 | updatedAt,
39 | companyState: { company, ...companyState },
40 | clientState: { client, ...clientState },
41 | items,
42 | }: Entity): APIInvoice => ({
43 | id,
44 | status,
45 | prefix,
46 | number,
47 | date: date.toISOString(),
48 | message,
49 | createdAt: createdAt.toISOString(),
50 | updatedAt: updatedAt.toISOString(),
51 | company: mapCompanyEntity({
52 | ...company,
53 | states: [companyState],
54 | }),
55 | client: mapClientEntity(
56 | {
57 | ...client,
58 | states: [clientState],
59 | },
60 | []
61 | ),
62 | items: items.map(
63 | ({
64 | id,
65 | invoiceId,
66 | quantity,
67 | date,
68 | createdAt,
69 | updatedAt,
70 | productState: { product, ...productState },
71 | }) => ({
72 | id,
73 | invoiceId,
74 | quantity,
75 | date: date.toISOString(),
76 | createdAt: createdAt.toISOString(),
77 | updatedAt: updatedAt.toISOString(),
78 | product: mapProductEntity({
79 | ...product,
80 | states: [productState],
81 | }),
82 | })
83 | ),
84 | });
85 |
86 | export default mapInvoiceEntity;
87 |
--------------------------------------------------------------------------------
/app/src/server/invoices/utils.ts:
--------------------------------------------------------------------------------
1 | import prisma from '@server/prisma';
2 |
3 | type GetLastInvoiceNumberArgs = {
4 | companyId: string;
5 | prefix?: string;
6 | };
7 |
8 | export const getLastInvoiceNumber = async ({
9 | companyId,
10 | prefix,
11 | }: GetLastInvoiceNumberArgs) => {
12 | const aggregate = await prisma.invoice.aggregate({
13 | where: {
14 | companyState: {
15 | companyId,
16 | },
17 | prefix,
18 | deletedAt: null,
19 | },
20 | _max: {
21 | number: true,
22 | },
23 | });
24 |
25 | return aggregate._max.number || 0;
26 | };
27 |
--------------------------------------------------------------------------------
/app/src/server/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 | export { Prisma } from '@prisma/client';
3 |
4 | const prisma = global._prisma || new PrismaClient();
5 |
6 | if (process.env.NODE_ENV !== 'production') {
7 | global._prisma = prisma;
8 | }
9 |
10 | export default prisma;
11 |
--------------------------------------------------------------------------------
/app/src/server/products/createProduct.ts:
--------------------------------------------------------------------------------
1 | import { type Procedure, procedure } from '@server/trpc';
2 | import prisma from '@server/prisma';
3 | import { CreateProductOutput, CreateProductInput } from './types';
4 | import mapProductEntity from './mapProductEntity';
5 |
6 | export const createProduct: Procedure<
7 | CreateProductInput,
8 | CreateProductOutput
9 | > = async ({ ctx: { session }, input }) => {
10 | const product = await prisma.product.create({
11 | data: {
12 | companyId: session?.companyId as string,
13 | states: { create: input },
14 | },
15 | include: { states: true },
16 | });
17 | return mapProductEntity(product);
18 | };
19 |
20 | export default procedure
21 | .input(CreateProductInput)
22 | .output(CreateProductOutput)
23 | .mutation(createProduct);
24 |
--------------------------------------------------------------------------------
/app/src/server/products/deleteProduct.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import prisma from '@server/prisma';
3 | import { type Procedure, procedure } from '@server/trpc';
4 | import { DeleteProductOutput, DeleteProductInput } from './types';
5 |
6 | export const deleteProduct: Procedure<
7 | DeleteProductInput,
8 | DeleteProductOutput
9 | > = async ({ ctx: { session }, input: { id } }) => {
10 | const productInNonDraftInvoice = await prisma.product.findFirst({
11 | where: {
12 | id,
13 | companyId: session?.companyId as string,
14 | states: {
15 | some: {
16 | lineItems: {
17 | some: {
18 | invoice: { deletedAt: null },
19 | },
20 | },
21 | },
22 | },
23 | },
24 | });
25 | if (productInNonDraftInvoice) {
26 | throw new TRPCError({
27 | code: 'PRECONDITION_FAILED',
28 | message: 'Products associated to approve invoices cannot be deleted.',
29 | });
30 | }
31 |
32 | const existingProduct = await prisma.product.findFirst({
33 | where: { id, companyId: session?.companyId as string },
34 | });
35 |
36 | if (!existingProduct) {
37 | return id;
38 | }
39 |
40 | await prisma.product.update({
41 | where: { id },
42 | data: { deletedAt: new Date() },
43 | });
44 |
45 | return id;
46 | };
47 |
48 | export default procedure
49 | .input(DeleteProductInput)
50 | .output(DeleteProductOutput)
51 | .mutation(deleteProduct);
52 |
--------------------------------------------------------------------------------
/app/src/server/products/getProduct.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import { type Procedure, procedure } from '@server/trpc';
3 | import prisma, { Prisma } from '@server/prisma';
4 | import { GetProductInput, GetProductOutput } from './types';
5 | import mapProductEntity from './mapProductEntity';
6 |
7 | export const getProduct: Procedure = async ({
8 | ctx: { session },
9 | input: { id },
10 | }) => {
11 | try {
12 | const product = await prisma.product.findFirstOrThrow({
13 | where: { id, companyId: session?.companyId, deletedAt: null },
14 | include: {
15 | states: {
16 | orderBy: { createdAt: 'desc' },
17 | take: 1,
18 | },
19 | },
20 | });
21 | return mapProductEntity(product);
22 | } catch (e) {
23 | if (
24 | e instanceof Prisma.PrismaClientKnownRequestError &&
25 | e.code === 'P2025'
26 | ) {
27 | throw new TRPCError({
28 | code: 'NOT_FOUND',
29 | message: 'The product does not exist.',
30 | });
31 | }
32 | throw e;
33 | }
34 | };
35 |
36 | export default procedure
37 | .input(GetProductInput)
38 | .output(GetProductOutput)
39 | .query(getProduct);
40 |
--------------------------------------------------------------------------------
/app/src/server/products/getProducts.ts:
--------------------------------------------------------------------------------
1 | import { type Procedure, procedure } from '@server/trpc';
2 | import prisma from '@server/prisma';
3 | import { GetProductsOutput } from './types';
4 | import mapProductEntity from './mapProductEntity';
5 |
6 | export const getProducts: Procedure = async ({
7 | ctx,
8 | }) => {
9 | const products = await prisma.product.findMany({
10 | where: { companyId: ctx.session?.companyId, deletedAt: null },
11 | include: {
12 | states: {
13 | orderBy: { createdAt: 'desc' },
14 | take: 1,
15 | },
16 | },
17 | });
18 | return products.map(mapProductEntity);
19 | };
20 |
21 | export default procedure.output(GetProductsOutput).query(getProducts);
22 |
--------------------------------------------------------------------------------
/app/src/server/products/mapProductEntity.ts:
--------------------------------------------------------------------------------
1 | import type { Product, ProductState } from '@prisma/client';
2 | import type { Product as APIProduct } from './types';
3 |
4 | type Entity = Product & { states: ProductState[] };
5 |
6 | const mapProductEntity = ({
7 | states,
8 | createdAt,
9 | updatedAt,
10 | ...product
11 | }: Entity): APIProduct => ({
12 | ...states[0],
13 | ...product,
14 | createdAt: createdAt.toISOString(),
15 | updatedAt: updatedAt.toISOString(),
16 | });
17 |
18 | export default mapProductEntity;
19 |
--------------------------------------------------------------------------------
/app/src/server/products/types.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const Product = z.object({
4 | id: z.string(),
5 | name: z.string(),
6 | includesVat: z.boolean(),
7 | price: z.number(),
8 | currency: z.string(),
9 | vat: z.number(),
10 | unit: z.string(),
11 | companyId: z.string(),
12 | createdAt: z.string(),
13 | updatedAt: z.string(),
14 | });
15 | export const GetProductInput = z.object({
16 | id: z.string(),
17 | });
18 | export const GetProductOutput = Product;
19 | export const GetProductsOutput = Product.array();
20 | export const CreateProductInput = Product.omit({
21 | id: true,
22 | companyId: true,
23 | createdAt: true,
24 | updatedAt: true,
25 | }).partial({
26 | includesVat: true,
27 | price: true,
28 | currency: true,
29 | vat: true,
30 | unit: true,
31 | });
32 | export const CreateProductOutput = Product;
33 | export const DeleteProductInput = z.object({ id: z.string() });
34 | export const DeleteProductOutput = z.string();
35 | export const UpdateProductInput = Product.omit({
36 | companyId: true,
37 | createdAt: true,
38 | updatedAt: true,
39 | })
40 | .partial()
41 | .merge(z.object({ id: z.string() }));
42 | export const UpdateProductOutput = Product;
43 |
44 | export type Product = z.infer;
45 | export type GetProductInput = z.infer;
46 | export type GetProductOutput = z.infer;
47 | export type GetProductsOutput = z.infer;
48 | export type CreateProductInput = z.infer;
49 | export type CreateProductOutput = z.infer;
50 | export type DeleteProductInput = z.infer;
51 | export type DeleteProductOutput = z.infer;
52 | export type UpdateProductInput = z.infer;
53 | export type UpdateProductOutput = z.infer;
54 |
--------------------------------------------------------------------------------
/app/src/server/products/updateProduct.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import omit from 'lodash.omit';
3 | import { type Procedure, procedure } from '@server/trpc';
4 | import prisma from '@server/prisma';
5 | import { UpdateProductOutput, UpdateProductInput } from './types';
6 | import mapProductEntity from './mapProductEntity';
7 |
8 | export const updateProduct: Procedure<
9 | UpdateProductInput,
10 | UpdateProductOutput
11 | > = async ({ ctx: { session }, input: { id, ...data } }) => {
12 | const existingProduct = await prisma.product.findFirst({
13 | where: { id, companyId: session?.companyId as string },
14 | include: {
15 | states: {
16 | orderBy: { createdAt: 'desc' },
17 | take: 1,
18 | },
19 | },
20 | });
21 | if (!existingProduct) {
22 | throw new TRPCError({
23 | code: 'NOT_FOUND',
24 | message: 'The product does not exist.',
25 | });
26 | }
27 |
28 | const stateData = {
29 | ...omit(existingProduct.states[0], 'id', 'createdAt'),
30 | ...data,
31 | productId: id,
32 | };
33 | const newState = await prisma.productState.create({ data: stateData });
34 | return mapProductEntity({
35 | ...existingProduct,
36 | states: [newState],
37 | });
38 | };
39 |
40 | export default procedure
41 | .input(UpdateProductInput)
42 | .output(UpdateProductOutput)
43 | .mutation(updateProduct);
44 |
--------------------------------------------------------------------------------
/app/src/server/router.ts:
--------------------------------------------------------------------------------
1 | import getCompany from './company/getCompany';
2 | import updateCompany from './company/updateCompany';
3 | import createClient from './clients/createClient';
4 | import deleteClient from './clients/deleteClient';
5 | import getClient from './clients/getClient';
6 | import getClients from './clients/getClients';
7 | import updateClient from './clients/updateClient';
8 | import createProduct from './products/createProduct';
9 | import deleteProduct from './products/deleteProduct';
10 | import getProduct from './products/getProduct';
11 | import getProducts from './products/getProducts';
12 | import updateProduct from './products/updateProduct';
13 | import getInvoice from './invoices/getInvoice';
14 | import getInvoices from './invoices/getInvoices';
15 | import createInvoice from './invoices/createInvoice';
16 | import updateInvoice from './invoices/updateInvoice';
17 | import deleteInvoice from './invoices/deleteInvoice';
18 | import trpc from './trpc';
19 |
20 | const router = trpc.router({
21 | getProduct,
22 | getProducts,
23 | createProduct,
24 | updateProduct,
25 | deleteProduct,
26 | getClient,
27 | getClients,
28 | createClient,
29 | updateClient,
30 | deleteClient,
31 | getCompany,
32 | updateCompany,
33 | getInvoice,
34 | getInvoices,
35 | createInvoice,
36 | updateInvoice,
37 | deleteInvoice,
38 | });
39 |
40 | export default router;
41 |
42 | export type AppRouter = typeof router;
43 |
--------------------------------------------------------------------------------
/app/src/server/trpc.ts:
--------------------------------------------------------------------------------
1 | import { initTRPC, TRPCError } from '@trpc/server';
2 | import type { Session } from 'next-auth';
3 | import type { Context } from './createContext';
4 |
5 | interface Meta {
6 | withoutAuth: boolean;
7 | [key: string]: unknown;
8 | }
9 |
10 | export type ProcedureArgs = {
11 | ctx: {
12 | session: Session | null;
13 | };
14 | meta?: Meta;
15 | input: TInput;
16 | };
17 | export type Procedure = (
18 | args: ProcedureArgs
19 | ) => Promise;
20 |
21 | const trpc = initTRPC.context().meta().create();
22 |
23 | const isAuthed = trpc.middleware(async ({ meta, next, ctx }) => {
24 | if (!meta?.withoutAuth && !ctx.session) {
25 | throw new TRPCError({
26 | code: 'UNAUTHORIZED',
27 | message: 'You are not logged in.',
28 | });
29 | }
30 | return next({ ctx });
31 | });
32 |
33 | export const procedure = trpc.procedure.use(isAuthed);
34 |
35 | export default trpc;
36 |
--------------------------------------------------------------------------------
/app/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer utilities {
6 | .focus-base {
7 | @apply outline-none ring-1 ring-violet-500 ring-offset-0;
8 | }
9 |
10 | .focus-ring {
11 | @apply focus-visible:focus-base focus:focus-base;
12 | }
13 |
14 | .width-without-sidebar {
15 | width: calc(100% - 242px);
16 | }
17 |
18 | .main-content {
19 | @apply w-full lg:width-without-sidebar
20 | }
21 | }
22 |
23 | html {
24 | @apply h-full;
25 | }
26 |
27 | body {
28 | @apply h-full;
29 | }
30 |
31 | #__next {
32 | @apply h-full;
33 | }
34 |
35 | .sb-show-main.sb-main-centered #root {
36 | display: flex;
37 | align-items: center;
38 | justify-content: center;
39 | width: 100%;
40 | padding: 0;
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/tests-integration/clients/createClient.test.ts:
--------------------------------------------------------------------------------
1 | import type { Company, User } from '@prisma/client';
2 | import type { Session } from 'next-auth';
3 | import cuid from 'cuid';
4 | import omit from 'lodash.omit';
5 | import prisma from '@server/prisma';
6 | import { createTestCompany, createTestUser } from '../testData';
7 | import { createClient } from '@server/clients/createClient';
8 |
9 | let user: User;
10 | let company: Company;
11 | let session: Session;
12 |
13 | describe('createClient', () => {
14 | beforeEach(async () => {
15 | user = await createTestUser();
16 | company = await createTestCompany(user.id);
17 | session = { userId: user.id, companyId: company.id, expires: '' };
18 | });
19 |
20 | it('creates a client for the company in the session', async () => {
21 | const input = {
22 | name: 'Test Client',
23 | number: cuid(),
24 | };
25 | const result = await createClient({
26 | ctx: { session },
27 | input,
28 | });
29 | const dbClient = await prisma.client.findUniqueOrThrow({
30 | where: { id: result.id },
31 | include: { states: { orderBy: { createdAt: 'desc' }, take: 1 } },
32 | });
33 | expect(result).toMatchObject(input);
34 | expect(result).toMatchObject(omit(dbClient.states[0], 'id', 'createdAt'));
35 | });
36 |
37 | it('throws when the company does not exist', async () => {
38 | await expect(
39 | createClient({
40 | ctx: { session: { ...session, companyId: 'invalid_company' } },
41 | input: {
42 | name: 'Test Client',
43 | number: cuid(),
44 | },
45 | })
46 | ).rejects.toThrow();
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/app/src/tests-integration/company/getCompany.test.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import omit from 'lodash.omit';
3 | import { getCompany } from '@server/company/getCompany';
4 | import { createTestCompany, createTestUser } from '../testData';
5 |
6 | let user: User;
7 | let company: Awaited>;
8 |
9 | describe('getCompany', () => {
10 | beforeEach(async () => {
11 | user = await createTestUser();
12 | company = await createTestCompany(user.id);
13 | });
14 |
15 | it('throws when the company does not exist', async () => {
16 | await expect(
17 | getCompany({
18 | ctx: {
19 | session: {
20 | userId: 'invalid_user',
21 | companyId: 'invalid_company',
22 | expires: '',
23 | },
24 | },
25 | input: {},
26 | })
27 | ).rejects.toThrow();
28 | });
29 |
30 | it('returns the company for the user in the session', async () => {
31 | const result = await getCompany({
32 | ctx: {
33 | session: {
34 | userId: user.id,
35 | companyId: company.id,
36 | expires: '',
37 | },
38 | },
39 | input: {},
40 | });
41 | expect(result).toMatchObject(omit(company.states[0], 'id', 'createdAt'));
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/app/src/tests-integration/company/updateCompany.test.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import { TRPCError } from '@trpc/server';
3 | import omit from 'lodash.omit';
4 | import prisma from '@server/prisma';
5 | import { updateCompany } from '@server/company/updateCompany';
6 | import { createTestCompany, createTestUser } from '../testData';
7 |
8 | let user: User;
9 | let company: Awaited>;
10 |
11 | describe('updateCompany', () => {
12 | beforeEach(async () => {
13 | user = await createTestUser();
14 | company = await createTestCompany(user.id);
15 | });
16 |
17 | it('returns updated company', async () => {
18 | const newName = 'new company name';
19 | const result = await updateCompany({
20 | ctx: { session: { userId: user.id, companyId: company.id, expires: '' } },
21 | input: {
22 | name: newName,
23 | },
24 | });
25 | const dbCompany = await prisma.company.findUniqueOrThrow({
26 | where: { id: company.id },
27 | include: {
28 | states: {
29 | orderBy: { createdAt: 'desc' },
30 | take: 1,
31 | },
32 | },
33 | });
34 | expect(result.name).toEqual(newName);
35 | expect(result).toMatchObject(omit(dbCompany.states[0], 'id', 'createdAt'));
36 | });
37 |
38 | it('throws when the company does not exist', async () => {
39 | const newName = 'new company name';
40 | await expect(
41 | updateCompany({
42 | ctx: {
43 | session: {
44 | userId: user.id,
45 | companyId: 'invalid_company',
46 | expires: '',
47 | },
48 | },
49 | input: {
50 | name: newName,
51 | },
52 | })
53 | ).rejects.toEqual(
54 | new TRPCError({
55 | code: 'NOT_FOUND',
56 | message: 'The company does not exist.',
57 | })
58 | );
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/app/src/tests-integration/products/createProduct.test.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import type { Session } from 'next-auth';
3 | import omit from 'lodash.omit';
4 | import prisma from '@server/prisma';
5 | import { createTestCompany, createTestUser } from '../testData';
6 | import { createProduct } from '@server/products/createProduct';
7 |
8 | let user: User;
9 | let company: Awaited>;
10 | let session: Session;
11 |
12 | describe('createProduct', () => {
13 | beforeEach(async () => {
14 | user = await createTestUser();
15 | company = await createTestCompany(user.id);
16 | session = { userId: user.id, companyId: company.id, expires: '' };
17 | });
18 |
19 | it('creates a product for the company in the session', async () => {
20 | const input = {
21 | name: 'Test Product',
22 | };
23 | const result = await createProduct({
24 | ctx: { session },
25 | input,
26 | });
27 | const dbClient = await prisma.product.findUnique({
28 | where: { id: result.id },
29 | include: {
30 | states: true,
31 | },
32 | });
33 | expect(result).toMatchObject(input);
34 | expect(result).toMatchObject(omit(dbClient?.states[0], 'id', 'createdAt'));
35 | });
36 |
37 | it('throws when the company does not exist', async () => {
38 | await expect(
39 | createProduct({
40 | ctx: { session: { ...session, companyId: 'invalid_company' } },
41 | input: {
42 | name: 'Test Product',
43 | },
44 | })
45 | ).rejects.toThrow();
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/app/src/tests-integration/products/getProduct.test.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import type { Session } from 'next-auth';
3 | import { TRPCError } from '@trpc/server';
4 | import omit from 'lodash.omit';
5 | import { getProduct } from '@server/products/getProduct';
6 | import {
7 | createTestCompany,
8 | createTestProduct,
9 | createTestUser,
10 | } from '../testData';
11 |
12 | let user1: User;
13 | let user2: User;
14 | let company1: Awaited>;
15 | let company2: Awaited>;
16 | let session: Session;
17 |
18 | describe('getProduct', () => {
19 | beforeEach(async () => {
20 | [user1, user2] = await Promise.all([createTestUser(), createTestUser()]);
21 | [company1, company2] = await Promise.all([
22 | createTestCompany(user1.id),
23 | createTestCompany(user2.id),
24 | ]);
25 | session = { userId: user1.id, companyId: company1.id, expires: '' };
26 | });
27 |
28 | it('returns the product', async () => {
29 | const dbProduct = await createTestProduct(company1.id);
30 |
31 | const product = await getProduct({
32 | ctx: { session },
33 | input: { id: dbProduct.id },
34 | });
35 | expect(product).toMatchObject(omit(dbProduct.states[0], 'id', 'createdAt'));
36 | });
37 |
38 | it('throws a NOT_FOUND error when the product does not exist', async () => {
39 | await expect(
40 | getProduct({ ctx: { session }, input: { id: 'invalid' } })
41 | ).rejects.toEqual(
42 | new TRPCError({
43 | code: 'NOT_FOUND',
44 | message: 'The product does not exist.',
45 | })
46 | );
47 | });
48 |
49 | it('throws a NOT_FOUND error when the product belongs to a different user', async () => {
50 | const dbProduct = await createTestProduct(company2.id);
51 |
52 | await expect(
53 | getProduct({ ctx: { session }, input: { id: dbProduct.id } })
54 | ).rejects.toEqual(
55 | new TRPCError({
56 | code: 'NOT_FOUND',
57 | message: 'The product does not exist.',
58 | })
59 | );
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/app/src/tests-integration/products/getProducts.test.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import type { Session } from 'next-auth';
3 | import omit from 'lodash.omit';
4 | import { getProducts } from '@server/products/getProducts';
5 | import {
6 | createTestCompany,
7 | createTestProduct,
8 | createTestUser,
9 | } from '../testData';
10 |
11 | let user1: User;
12 | let user2: User;
13 | let company1: Awaited>;
14 | let company2: Awaited>;
15 | let session: Session;
16 |
17 | describe('getProducts', () => {
18 | beforeEach(async () => {
19 | [user1, user2] = await Promise.all([createTestUser(), createTestUser()]);
20 | [company1, company2] = await Promise.all([
21 | createTestCompany(user1.id),
22 | createTestCompany(user2.id),
23 | ]);
24 | session = { userId: user1.id, companyId: company1.id, expires: '' };
25 | });
26 |
27 | it('returns an empty array when there are no products', async () => {
28 | await createTestProduct(company2.id);
29 |
30 | const products = await getProducts({ ctx: { session }, input: {} });
31 | expect(products).toEqual([]);
32 | });
33 |
34 | it('returns the products for the company in the session', async () => {
35 | await createTestProduct(company2.id);
36 | const product1 = await createTestProduct(company1.id);
37 | const product2 = await createTestProduct(company1.id);
38 |
39 | const products = await getProducts({ ctx: { session }, input: {} });
40 | expect(products).toEqual(
41 | expect.arrayContaining([
42 | expect.objectContaining(omit(product1.states[0], 'id', 'createdAt')),
43 | expect.objectContaining(omit(product2.states[0], 'id', 'createdAt')),
44 | ])
45 | );
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/app/src/tests-integration/products/updateProduct.test.ts:
--------------------------------------------------------------------------------
1 | import type { User } from '@prisma/client';
2 | import type { Session } from 'next-auth';
3 | import { TRPCError } from '@trpc/server';
4 | import omit from 'lodash.omit';
5 | import { updateProduct } from '@server/products/updateProduct';
6 | import {
7 | createTestCompany,
8 | createTestProduct,
9 | createTestUser,
10 | } from '../testData';
11 | import prisma from '@server/prisma';
12 |
13 | let user: User;
14 | let company: Awaited>;
15 | let session: Session;
16 |
17 | describe('updateProduct', () => {
18 | beforeEach(async () => {
19 | user = await createTestUser();
20 | company = await createTestCompany(user.id);
21 | session = { userId: user.id, companyId: company.id, expires: '' };
22 | });
23 |
24 | it('throws when trying to update a non existing product', async () => {
25 | await expect(
26 | updateProduct({
27 | ctx: { session },
28 | input: {
29 | id: 'invalid_product',
30 | name: 'Test Product',
31 | },
32 | })
33 | ).rejects.toEqual(
34 | new TRPCError({
35 | code: 'NOT_FOUND',
36 | message: 'The product does not exist.',
37 | })
38 | );
39 | });
40 |
41 | it('updates the product', async () => {
42 | const product = await createTestProduct(company.id);
43 | const newName = 'Updated Product';
44 | const updatedProduct = await updateProduct({
45 | ctx: { session },
46 | input: {
47 | id: product.id,
48 | name: newName,
49 | },
50 | });
51 | const dbProduct = await prisma.product.findUnique({
52 | where: { id: product.id },
53 | });
54 |
55 | expect(updatedProduct.name).toEqual(newName);
56 | expect(updatedProduct).toMatchObject(
57 | omit(dbProduct, 'id', 'createdAt', 'updatedAt')
58 | );
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/app/src/tests-integration/setup.ts:
--------------------------------------------------------------------------------
1 | import 'tsconfig-paths/register';
2 | import prisma from '@server/prisma';
3 |
4 | const setup = async () => {
5 | await prisma.invoice.deleteMany();
6 |
7 | await Promise.all([
8 | prisma.account.deleteMany(),
9 | prisma.user.deleteMany(),
10 | prisma.session.deleteMany(),
11 | prisma.verificationToken.deleteMany(),
12 | prisma.company.deleteMany(),
13 | prisma.product.deleteMany(),
14 | prisma.client.deleteMany(),
15 | ]);
16 | };
17 |
18 | export default setup;
19 |
--------------------------------------------------------------------------------
/app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './src/pages/**/*.{js,ts,jsx,tsx}',
5 | './src/components/**/*.{js,ts,jsx,tsx}',
6 | './.storybook/**/*.{js,ts,jsx,tsx}',
7 | ],
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | sans: ['Inter', 'sans-serif'],
12 | },
13 | },
14 | },
15 | plugins: [],
16 | };
17 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": false,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@lib/*": ["./src/lib/*"],
20 | "@components/*": ["./src/components/*"],
21 | "@pages/*": ["./src/pages/*"],
22 | "@server/*": ["./src/server/*"],
23 | },
24 | "types": ["@types/jest"]
25 | },
26 | "include": ["next-env.d.ts", "./jest-setup.tsx", "**/*.ts", "**/*.tsx"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/app/types.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-var */
2 | import 'next-auth';
3 | import type { PrismaClient, Company, User } from '@prisma/client';
4 |
5 | declare global {
6 | var _prisma: PrismaClient | undefined;
7 | }
8 |
9 | declare module 'next-auth' {
10 | export interface Session {
11 | userId: User['id'];
12 | companyId: Company['id'];
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/app/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "enabled": false
4 | }
5 | }
6 |
--------------------------------------------------------------------------------