├── .dockerignore ├── .env ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── app ├── Date.ts ├── authentication │ ├── ForgotPasswordForm.tsx │ ├── FormCard.tsx │ ├── InfoPageTemplate.tsx │ ├── LoginForm.tsx │ ├── Notification.tsx │ ├── RegisterForm.tsx │ ├── ResetPasswordForm.tsx │ ├── VerifyAccount.tsx │ ├── VerifyAccountError.tsx │ ├── Welcome.tsx │ ├── usePasswordConfirmation.tsx │ └── useVerifyAccount.tsx ├── entry.client.tsx ├── entry.server.tsx ├── error │ └── ErrorPage.tsx ├── homepage │ ├── AddTodoListForm.tsx │ ├── ArchiveTodoList.tsx │ ├── HomePage.tsx │ ├── TodoListItem.tsx │ ├── TodoListTimeInfo.tsx │ └── TodoListsSection.tsx ├── palette.tsx ├── root.tsx ├── routes │ ├── forgot-password.tsx │ ├── index.tsx │ ├── l │ │ ├── $todoListId.tsx │ │ └── $todoListId │ │ │ ├── archive.ts │ │ │ ├── grant-access.ts │ │ │ ├── reorder-todo.ts │ │ │ ├── revoke-access.ts │ │ │ ├── todo.$todoId.add-tag.ts │ │ │ ├── todo.$todoId.delete.ts │ │ │ ├── todo.$todoId.remove-tag.ts │ │ │ ├── todo.$todoId.ts │ │ │ ├── todo.$todoId.update.ts │ │ │ └── update.ts │ ├── login.tsx │ ├── logout.ts │ ├── register.tsx │ ├── reset-password.tsx │ ├── verify-account.tsx │ └── welcome.tsx ├── todolist │ ├── Sharing │ │ ├── ContributorPin.tsx │ │ ├── ContributorPinsMenu.tsx │ │ ├── ManageContributors.tsx │ │ ├── RevokeAccess.tsx │ │ ├── ShareButton.tsx │ │ ├── ShareTodoListDialogContent.tsx │ │ └── ShareTodoListForm.tsx │ ├── TodoItem │ │ ├── EditTodoTitle.tsx │ │ ├── Popover │ │ │ ├── AddTag.tsx │ │ │ ├── DeleteTodo.tsx │ │ │ ├── SelectableTag.tsx │ │ │ ├── SelectedTag.tsx │ │ │ ├── SendToBottom.tsx │ │ │ ├── SendToTop.tsx │ │ │ ├── TagsList.tsx │ │ │ ├── TodoActions.tsx │ │ │ ├── TodoDangerZone.tsx │ │ │ ├── TodoPopoverContent.tsx │ │ │ └── TodoTags.tsx │ │ ├── ReorderableTodoItem.tsx │ │ ├── TodoItem.tsx │ │ ├── TodoPopoverMenu.tsx │ │ ├── TodoTags.tsx │ │ └── useReorderTodo.tsx │ ├── TodoList.tsx │ ├── TodoList │ │ ├── AddTodoForm.tsx │ │ ├── TagFilters.tsx │ │ ├── TodoListCompletion.tsx │ │ ├── TodoListEditableTitle.tsx │ │ ├── TodoListHeader.tsx │ │ ├── Todos.tsx │ │ └── TodosHeading.tsx │ ├── TodoListOutdatedMessage.tsx │ ├── TodoTag.tsx │ ├── state.ts │ └── useTodosOrderPreview.tsx └── ui │ ├── Button.tsx │ ├── CheckboxOption.tsx │ ├── Dialog.tsx │ ├── EditableContent.tsx │ ├── EditableContentDisplayMode.tsx │ ├── EditableContentEditionMode.tsx │ ├── EmptyMessage.tsx │ ├── FloatingLabelInput.tsx │ ├── PageTitle.tsx │ ├── Popover.tsx │ ├── RootLayout.tsx │ ├── SkipToContent.tsx │ └── Tooltip.tsx ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── managing-a-todo-list.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── doc └── cover.png ├── docker-compose.yml ├── jest.config.js ├── nest-cli.json ├── package.json ├── patches └── recoil+0.7.3.patch ├── prisma ├── migrations │ ├── 20220507222731_init │ │ └── migration.sql │ ├── 20220508101228_unique_email │ │ └── migration.sql │ ├── 20220508112709_add_todos_order │ │ └── migration.sql │ ├── 20220518071633_add_todo_tags │ │ └── migration.sql │ ├── 20220521090745_add_account_verification │ │ └── migration.sql │ ├── 20220521090857_remove_default_verification_status │ │ └── migration.sql │ ├── 20220522080328_add_password_reset_token │ │ └── migration.sql │ ├── 20220526195155_add_todo_list_permissions │ │ └── migration.sql │ ├── 20220527112419_remove_owner_id_columns │ │ └── migration.sql │ ├── 20220527210827_add_collaborator_permission │ │ └── migration.sql │ ├── 20220604180009_add_completion_date │ │ └── migration.sql │ ├── 20220604185050_remove_complete_todos_from_order │ │ └── migration.sql │ ├── 20220607205640_model_renaming │ │ └── migration.sql │ ├── 20220611114123_add_todo_list_events │ │ └── migration.sql │ ├── 20220611115953_index_todo_list_id_in_events │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public └── assets │ ├── fonts │ ├── Metropolis-Regular.woff2 │ └── Metropolis-SemiBold.woff2 │ └── images │ ├── favicon-16x16.png │ ├── favicon-256x256.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── todos-white.svg │ └── todos.svg ├── remix.config.js ├── remix.env.d.ts ├── src ├── authentication │ ├── __tests__ │ │ └── domain │ │ │ ├── ForgotPassword.test.ts │ │ │ ├── LoginFlow.test.ts │ │ │ ├── ResetPassword.test.ts │ │ │ ├── VerifyAccount.test.ts │ │ │ ├── builders │ │ │ └── Account.ts │ │ │ └── fakes │ │ │ ├── AccountsInMemory.ts │ │ │ └── FakePasswordHasher.ts │ ├── application │ │ ├── AuthenticationApplicationService.ts │ │ └── AuthenticationEventsConsumer.ts │ ├── domain │ │ ├── Account.ts │ │ ├── Accounts.ts │ │ ├── Authenticator.ts │ │ ├── FetchAuthenticationStatus.ts │ │ ├── PasswordHasher.ts │ │ ├── error │ │ │ ├── AccountAlreadyVerifiedError.ts │ │ │ ├── AccountNotFoundError.ts │ │ │ ├── AccountNotVerifiedError.ts │ │ │ ├── EmailAlreadyInUseError.ts │ │ │ ├── InvalidCredentialsError.ts │ │ │ ├── InvalidPasswordResetTokenError.ts │ │ │ ├── InvalidVerificationTokenError.ts │ │ │ └── PasswordResetTokenExpiredError.ts │ │ └── event │ │ │ ├── PasswordChanged.ts │ │ │ ├── PasswordForgotten.ts │ │ │ └── UserRegistered.ts │ ├── index.ts │ ├── infrastructure │ │ ├── AccountDatabaseRepository.ts │ │ ├── BCryptPasswordHasher.ts │ │ └── FetchAuthenticationStatusSessionQuery.ts │ └── usecase │ │ ├── ForgotPassword.ts │ │ ├── LoginFlow.ts │ │ ├── RegisterFlow.ts │ │ ├── ResetPassword.ts │ │ └── VerifyAccount.ts ├── keys.ts ├── main.ts ├── shared │ ├── client │ │ ├── Authentication.ts │ │ ├── HomePage.ts │ │ ├── Todo.ts │ │ ├── TodoList.ts │ │ └── index.ts │ ├── database │ │ ├── Prisma.ts │ │ ├── PrismaRepository.ts │ │ └── index.ts │ ├── events │ │ ├── CollectEvents.ts │ │ ├── Event.ts │ │ ├── Events.ts │ │ ├── NestEvents.ts │ │ └── index.ts │ ├── id │ │ ├── GenerateId.ts │ │ ├── GenerateTestId.ts │ │ ├── GenerateUUID.ts │ │ └── index.ts │ ├── lib │ │ ├── array.ts │ │ └── index.ts │ ├── mail │ │ ├── FakeMailer.ts │ │ ├── Mail.ts │ │ ├── Mailer.ts │ │ ├── SendGridMailer.ts │ │ └── index.ts │ └── time │ │ ├── Clock.ts │ │ ├── FixedClock.ts │ │ ├── RealClock.ts │ │ └── index.ts ├── todo-list-manager │ ├── __tests__ │ │ ├── persistence │ │ │ ├── TodoDatabaseRepository.test.ts │ │ │ ├── TodoListDatabaseRepository.test.ts │ │ │ ├── TodoListPermissionsDatabaseRepository.test.ts │ │ │ ├── TodoListQuery.test.ts │ │ │ └── database.ts │ │ └── usecase │ │ │ ├── AddTagToTodo.test.ts │ │ │ ├── AddTodoToTodoList.test.ts │ │ │ ├── ArchiveTodoList.test.ts │ │ │ ├── CreateTodoList.test.ts │ │ │ ├── DeleteTodoFromTodoList.test.ts │ │ │ ├── GrantAccess.test.ts │ │ │ ├── MarkTodo.test.ts │ │ │ ├── RemoveTagFromTodo.test.ts │ │ │ ├── ReorderTodo.test.ts │ │ │ ├── RevokeAccess.test.ts │ │ │ ├── UpdateTodoListTitle.test.ts │ │ │ ├── UpdateTodoTitle.test.ts │ │ │ ├── ViewHomePage.test.ts │ │ │ ├── builders │ │ │ ├── Todo.ts │ │ │ ├── TodoList.ts │ │ │ └── TodoListPermission.ts │ │ │ └── fakes │ │ │ ├── ContributorsInMemory.ts │ │ │ ├── TodoListPermissionsInMemory.ts │ │ │ ├── TodoListQueryInMemory.ts │ │ │ ├── TodoListsInMemory.ts │ │ │ └── TodosInMemory.ts │ ├── application │ │ ├── TodoApplicationService.ts │ │ └── TodoListApplicationService.ts │ ├── domain │ │ ├── Contributor.ts │ │ ├── ContributorId.ts │ │ ├── Contributors.ts │ │ ├── OwnerId.ts │ │ ├── Todo.ts │ │ ├── TodoList.ts │ │ ├── TodoListPermission.ts │ │ ├── TodoListPermissions.ts │ │ ├── TodoListQuery.ts │ │ ├── TodoLists.ts │ │ ├── Todos.ts │ │ ├── error │ │ │ ├── ContributorNotFoundError.ts │ │ │ ├── TodoListNotFoundError.ts │ │ │ └── TodoListPermissionDeniedError.ts │ │ └── event │ │ │ ├── TagAddedToTodo.ts │ │ │ ├── TagRemovedFromTodo.ts │ │ │ ├── TodoAdded.ts │ │ │ ├── TodoCompletionChanged.ts │ │ │ ├── TodoDeleted.ts │ │ │ ├── TodoListAccessGranted.ts │ │ │ ├── TodoListAccessRevoked.ts │ │ │ ├── TodoListCreated.ts │ │ │ ├── TodoListEvent.ts │ │ │ ├── TodoListUpdated.ts │ │ │ ├── TodoReordered.ts │ │ │ └── TodoUpdated.ts │ ├── index.ts │ ├── infrastructure │ │ ├── ContributorsAdapter.ts │ │ ├── TodoDatabaseRepository.ts │ │ ├── TodoListDatabaseQuery.ts │ │ ├── TodoListDatabaseRepository.ts │ │ ├── TodoListEventDatabaseRepository.ts │ │ ├── TodoListEvents.ts │ │ └── TodoListPermissionsDatabaseRepository.ts │ └── usecase │ │ ├── AddTagToTodo.ts │ │ ├── AddTodoToTodoList.ts │ │ ├── ArchiveTodoList.ts │ │ ├── CreateTodoList.ts │ │ ├── DeleteTodoFromTodoList.ts │ │ ├── GrantAccess.ts │ │ ├── MarkTodo.ts │ │ ├── RemoveTagFromTodo.ts │ │ ├── ReorderTodo.ts │ │ ├── RevokeAccess.ts │ │ ├── UpdateTodoListTitle.ts │ │ ├── UpdateTodoTitle.ts │ │ ├── ViewHomePage.ts │ │ └── ViewTodoList.ts └── web │ ├── ApplicationModule.ts │ ├── authenticator │ ├── Authenticated.ts │ ├── SessionAuthenticator.ts │ └── SessionManager.ts │ ├── controllers │ ├── TodoListEventsConsumer.ts │ └── TodoListEventsController.ts │ ├── index.ts │ └── remix │ ├── Actions.ts │ ├── Loaders.ts │ ├── RemixAppContext.ts │ ├── RemixController.ts │ ├── decorators │ ├── Body.ts │ ├── DataFunction.ts │ ├── MapError.ts │ ├── Params.ts │ └── Query.ts │ ├── dtos │ ├── AddTagToTodo.ts │ ├── AddTodo.ts │ ├── ArchiveTodoList.ts │ ├── CreateTodoList.ts │ ├── DeleteTodoFromTodoList.ts │ ├── FetchTodoList.ts │ ├── ForgotPassword.ts │ ├── GrantAccess.ts │ ├── Login.ts │ ├── MarkTodo.ts │ ├── Register.ts │ ├── RemoveTagFromTodo.ts │ ├── ReorderTodo.ts │ ├── ResetPassword.ts │ ├── RevokeAccess.ts │ ├── UpdateTodoListTitle.ts │ ├── UpdateTodoTitle.ts │ └── VerifyAccount.ts │ └── types.ts ├── styles └── app.css ├── tailwind.config.js ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist 4 | public 5 | build -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://user:password@localhost:6060/todolistmanager-dev 2 | BASE_URL=http://localhost:3000 3 | SESSION_SECRET=session-secret 4 | NODE_ENV=development 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@remix-run/eslint-config", 4 | "@remix-run/eslint-config/jest-testing-library" 5 | ], 6 | "overrides": [{ 7 | "files": ["src/**/*.ts"], 8 | "rules": { 9 | "@typescript-eslint/consistent-type-imports": "off" 10 | } 11 | }] 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /dist 6 | /public/build 7 | /app/styles/app.css 8 | 9 | prisma/*.db 10 | prisma/*.db-journal 11 | .idea 12 | 13 | cypress/videos 14 | cypress/screenshots -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-slim as builder 2 | 3 | # OpenSSL is required to build prisma on M1 Mac 4 | # (See https://github.com/prisma/prisma/issues/861#issuecomment-881992292) 5 | RUN apt-get update 6 | RUN apt-get install -y openssl 7 | WORKDIR /usr/src/app 8 | 9 | COPY package.json yarn.lock ./ 10 | RUN yarn install 11 | 12 | COPY . . 13 | 14 | RUN yarn prisma:setup 15 | RUN yarn build 16 | 17 | FROM node:18-slim as runner 18 | RUN apt-get update 19 | RUN apt-get install -y openssl 20 | WORKDIR /usr/src/app 21 | 22 | ARG NODE_ENV=production 23 | ENV NODE_ENV=${NODE_ENV} 24 | 25 | COPY package.json yarn.lock ./ 26 | 27 | RUN yarn install --production 28 | 29 | # dev dependnecies are required for generating the prisma client, 30 | # so we copy the one generated in the builder container 31 | COPY --from=builder /usr/src/app/node_modules/.prisma ./node_modules/.prisma 32 | COPY --from=builder /usr/src/app/node_modules/@prisma/client ./node_modules/@prisma/client 33 | 34 | # Nest.js build output 35 | COPY --from=builder /usr/src/app/dist ./dist 36 | # Remix build output (client) 37 | COPY --from=builder /usr/src/app/public ./public 38 | # Remix build output (server) 39 | COPY --from=builder /usr/src/app/build ./build 40 | 41 | CMD ["node", "dist/main"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Antoine Chalifour 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn prisma:migrate && node dist/src/main.js -------------------------------------------------------------------------------- /app/Date.ts: -------------------------------------------------------------------------------- 1 | import format from "date-fns/formatDistanceToNow"; 2 | 3 | export const displayDate = (createdAt: string) => 4 | format(new Date(createdAt), { addSuffix: true }); 5 | -------------------------------------------------------------------------------- /app/authentication/ForgotPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react"; 2 | import { FloatingLabelInput } from "front/ui/FloatingLabelInput"; 3 | import { ButtonPrimary } from "front/ui/Button"; 4 | import { FormCard } from "front/authentication/FormCard"; 5 | import { Notification } from "front/authentication/Notification"; 6 | 7 | export const ForgotPasswordForm = () => { 8 | const resetPassword = useFetcher(); 9 | 10 | return ( 11 | 12 | 13 | {resetPassword.type === "done" && ( 14 | 15 | If an account is link to this email address, an email will be sent 16 | shortly. 17 | 18 | )} 19 | 20 | 29 | 30 | Reset my password 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/authentication/FormCard.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { PageTitle } from "front/ui/PageTitle"; 3 | 4 | type FormCardProps = { 5 | title: string; 6 | children: ReactNode; 7 | }; 8 | 9 | export const FormCard = ({ title, children }: FormCardProps) => ( 10 |
11 | {title} 12 | 13 | {children} 14 |
15 | ); 16 | -------------------------------------------------------------------------------- /app/authentication/InfoPageTemplate.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode, ComponentType } from "react"; 2 | import type { IconProps } from "@radix-ui/react-icons/dist/types"; 3 | import { PageTitle } from "front/ui/PageTitle"; 4 | 5 | type InfoPageTemplateProps = { 6 | title: string; 7 | children: ReactNode; 8 | iconComponent: ComponentType; 9 | }; 10 | export const InfoPageTemplate = ({ 11 | title, 12 | children, 13 | iconComponent: Icon, 14 | }: InfoPageTemplateProps) => ( 15 |
16 | 17 | 18 | {title} 19 | 20 | {children} 21 |
22 | ); 23 | -------------------------------------------------------------------------------- /app/authentication/Notification.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import classNames from "classnames"; 3 | 4 | type NotificationProps = { 5 | level?: "info" | "error"; 6 | children: ReactNode; 7 | }; 8 | 9 | export const Notification = ({ 10 | level = "info", 11 | children, 12 | }: NotificationProps) => ( 13 |

23 | {children} 24 |

25 | ); 26 | -------------------------------------------------------------------------------- /app/authentication/VerifyAccount.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircledIcon } from "@radix-ui/react-icons"; 2 | import { useVerifyAccount } from "front/authentication/useVerifyAccount"; 3 | import { InfoPageTemplate } from "front/authentication/InfoPageTemplate"; 4 | 5 | export const VerifyAccount = () => { 6 | useVerifyAccount(); 7 | 8 | return ( 9 | 10 |

11 | Your account has been verified. You'll be redirected to the app in a few 12 | seconds. 13 |

14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /app/authentication/VerifyAccountError.tsx: -------------------------------------------------------------------------------- 1 | import type { ThrownResponse } from "@remix-run/react"; 2 | import { useCatch } from "@remix-run/react"; 3 | import { CrossCircledIcon } from "@radix-ui/react-icons"; 4 | import { InfoPageTemplate } from "front/authentication/InfoPageTemplate"; 5 | 6 | export const VerifyAccountError = () => { 7 | const error = useCatch>(); 8 | 9 | return ( 10 | 11 |

12 | An error occurred while verifying your account :{" "} 13 | {error.data.message} 14 | . 15 |

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/authentication/Welcome.tsx: -------------------------------------------------------------------------------- 1 | import { RocketIcon } from "@radix-ui/react-icons"; 2 | import { InfoPageTemplate } from "front/authentication/InfoPageTemplate"; 3 | 4 | export const Welcome = () => ( 5 | 6 |

7 | A email containing a verification link has been sent to your address. 8 | You'll be ready when your account will be verified! 9 |

10 |
11 | ); 12 | -------------------------------------------------------------------------------- /app/authentication/useVerifyAccount.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "@remix-run/react"; 2 | import { useEffect } from "react"; 3 | 4 | export function useVerifyAccount() { 5 | const navigate = useNavigate(); 6 | 7 | useEffect(() => { 8 | const timeout = setTimeout(() => { 9 | navigate("/"); 10 | }, 5000); 11 | 12 | return () => clearTimeout(timeout); 13 | }, [navigate]); 14 | } 15 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "@remix-run/react"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/error/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const ErrorPage: React.FC = ({ children }) => ( 4 |
{children}
5 | ); 6 | 7 | export const ErrorPageHero: React.FC = ({ children }) => ( 8 |

{children}

9 | ); 10 | 11 | export const ErrorPageMessage: React.FC = ({ children }) =>

{children}

; 12 | -------------------------------------------------------------------------------- /app/homepage/AddTodoListForm.tsx: -------------------------------------------------------------------------------- 1 | import type { AddTodoListErrorDto } from "shared/client"; 2 | 3 | import { useFetcher } from "@remix-run/react"; 4 | import { FloatingLabelInput } from "front/ui/FloatingLabelInput"; 5 | import { ButtonPrimary } from "front/ui/Button"; 6 | 7 | type ActionData = { 8 | errors?: AddTodoListErrorDto; 9 | }; 10 | 11 | export const AddTodoListForm = () => { 12 | const addTodoList = useFetcher(); 13 | 14 | return ( 15 | 19 | 27 | 28 | 32 | Add 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/homepage/ArchiveTodoList.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoListSummaryWithPermissionDto } from "shared/client"; 2 | import type { useFetcher } from "@remix-run/react"; 3 | import React from "react"; 4 | import { TrashIcon } from "@radix-ui/react-icons"; 5 | import { Button } from "front/ui/Button"; 6 | 7 | export type ArchivetodoListProps = { 8 | todoList: TodoListSummaryWithPermissionDto; 9 | fetcher: ReturnType; 10 | }; 11 | 12 | export const ArchiveTodoList = ({ 13 | todoList, 14 | fetcher: archiveTodoListFetcher, 15 | }: ArchivetodoListProps) => ( 16 | 22 | 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /app/homepage/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import type { HomePageDto } from "shared/client"; 2 | 3 | import React from "react"; 4 | import { PageTitle } from "front/ui/PageTitle"; 5 | import { AddTodoListForm } from "front/homepage/AddTodoListForm"; 6 | import { useLoaderData } from "@remix-run/react"; 7 | import { TodoListsSection } from "front/homepage/TodoListsSection"; 8 | 9 | export const HomePage = () => { 10 | const { todoListsOwned, todoListsContributed } = useLoaderData(); 11 | 12 | return ( 13 |
14 | Welcome 15 | 16 | 17 | 18 | 23 | 24 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /app/homepage/TodoListItem.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoListSummaryWithPermissionDto } from "shared/client"; 2 | import React from "react"; 3 | import { Link, useFetcher } from "@remix-run/react"; 4 | import classNames from "classnames"; 5 | import { ArchiveTodoList } from "front/homepage/ArchiveTodoList"; 6 | import { TodoListTimeInfo } from "front/homepage/TodoListTimeInfo"; 7 | 8 | interface TodoListItemProps { 9 | todoList: TodoListSummaryWithPermissionDto; 10 | } 11 | 12 | export const TodoListItem = ({ todoList }: TodoListItemProps) => { 13 | const archiveTodoListFetcher = useFetcher(); 14 | const isArchiving = archiveTodoListFetcher.state !== "idle"; 15 | 16 | return ( 17 |
26 |

27 | {todoList.title}{" "} 28 | 29 | ({todoList.numberOfTodos}) 30 | 31 |

32 | 33 |
34 | {todoList.permissions.archive && ( 35 | 39 | )} 40 |
41 | 42 | 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /app/homepage/TodoListTimeInfo.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoListSummaryDto } from "shared/client"; 2 | import React from "react"; 3 | import { displayDate } from "front/Date"; 4 | 5 | export type TodoListTimeInfoProps = { todoList: TodoListSummaryDto }; 6 | export const TodoListTimeInfo = ({ todoList }: TodoListTimeInfoProps) => { 7 | let message = `Created ${displayDate(todoList.createdAt)}`; 8 | 9 | if (todoList.lastUpdatedAt != null) { 10 | message = `Modified ${displayDate(todoList.lastUpdatedAt)}`; 11 | } 12 | 13 | return

↳ {message}

; 14 | }; 15 | -------------------------------------------------------------------------------- /app/homepage/TodoListsSection.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoListSummaryWithPermissionDto } from "shared/client"; 2 | import React from "react"; 3 | import { isEmpty } from "fp-ts/Array"; 4 | import { EmptyMessage } from "front/ui/EmptyMessage"; 5 | import { TodoListItem } from "front/homepage/TodoListItem"; 6 | 7 | export type TodoListsSectionProps = { 8 | title: string; 9 | emptyMessage: string; 10 | todoLists: TodoListSummaryWithPermissionDto[]; 11 | }; 12 | export const TodoListsSection = ({ 13 | title, 14 | emptyMessage, 15 | todoLists, 16 | }: TodoListsSectionProps) => ( 17 |
18 |

{title}

19 | 20 |
21 | {isEmpty(todoLists) ? ( 22 | {emptyMessage} 23 | ) : ( 24 |
    25 | {todoLists.map((todoList) => ( 26 |
  1. 27 | 28 |
  2. 29 | ))} 30 |
31 | )} 32 |
33 |
34 | ); 35 | -------------------------------------------------------------------------------- /app/palette.tsx: -------------------------------------------------------------------------------- 1 | export function colorFromString(str: string) { 2 | const palette = [ 3 | "bg-gradient-to-r from-teal-200 to-cyan-200 text-green-900", 4 | "bg-gradient-to-r from-indigo-200 to-sky-200 text-sky-900", 5 | "bg-gradient-to-r from-red-200 to-orange-200 text-orange-900", 6 | "bg-gradient-to-r from-fuchsia-200 to-rose-200 text-rose-900", 7 | "bg-gradient-to-r from-green-200 to-emerald-200 text-green-900", 8 | "bg-gradient-to-r from-amber-200 to-orange-200 text-amber-900", 9 | "bg-gradient-to-r from-purple-200 to-indigo-200 text-indigo-900", 10 | "bg-gradient-to-r from-pink-200 to-yellow-200 text-yellow-900", 11 | ]; 12 | const sum = str 13 | .split("") 14 | .map((letter) => letter.charCodeAt(0)) 15 | .reduce((a, b) => a + b, 0); 16 | 17 | const index = sum % palette.length; 18 | 19 | return palette[index]; 20 | } 21 | -------------------------------------------------------------------------------- /app/routes/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, MetaFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | import { ForgotPasswordForm } from "front/authentication/ForgotPasswordForm"; 4 | 5 | export const meta: MetaFunction = () => ({ 6 | title: "Todos | Forgot your password?", 7 | }); 8 | 9 | export const action: ActionFunction = (args) => 10 | (args.context as RemixAppContext).actions.forgotPassword(args); 11 | 12 | export default ForgotPasswordForm; 13 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunction, 3 | LoaderFunction, 4 | MetaFunction, 5 | } from "@remix-run/node"; 6 | import type { HomePageDto } from "shared/client"; 7 | import type { RemixAppContext } from "web"; 8 | 9 | import { HomePage } from "front/homepage/HomePage"; 10 | 11 | export const meta: MetaFunction = ({ data: homePage }) => ({ 12 | title: `Todos | Your todo lists (${homePage.totalNumberOfDoingTodos})`, 13 | description: "Welcome to Todo List Manager!", 14 | }); 15 | 16 | export const loader: LoaderFunction = async (args): Promise => 17 | (args.context as RemixAppContext).loaders.homePage(args); 18 | 19 | export const action: ActionFunction = async (args) => 20 | (args.context as RemixAppContext).actions.createTodoList(args); 21 | 22 | export default HomePage; 23 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunction, 3 | LoaderFunction, 4 | MetaFunction, 5 | } from "@remix-run/node"; 6 | import { useLoaderData } from "@remix-run/react"; 7 | import type { TodoListDetailsDto, TodoListPageDto } from "shared/client"; 8 | import type { RemixAppContext } from "web"; 9 | 10 | import { DndProvider } from "react-dnd"; 11 | import { HTML5Backend } from "react-dnd-html5-backend"; 12 | import { RecoilRoot } from "recoil"; 13 | import { TodoList } from "front/todolist/TodoList"; 14 | import { makeInitialState } from "front/todolist/state"; 15 | 16 | export const meta: MetaFunction = ({ data }) => ({ 17 | title: `Todos | ${data?.todoList.title} (${data?.todoList.doingTodos.length})`, 18 | description: `Created by you on ${data?.todoList.createdAt}`, 19 | }); 20 | 21 | export const loader: LoaderFunction = async ( 22 | args 23 | ): Promise => 24 | (args.context as RemixAppContext).loaders.todoList(args); 25 | 26 | export const action: ActionFunction = async (args) => 27 | (args.context as RemixAppContext).actions.addTodoToTodoList(args); 28 | 29 | export default function TodoListPage() { 30 | const todoListPage = useLoaderData(); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/archive.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = async (args) => 5 | (args.context as RemixAppContext).actions.archiveTodoList(args); 6 | 7 | export default function Noop() { 8 | return null; 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/grant-access.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = (args) => 5 | (args.context as RemixAppContext).actions.grantAccess(args); 6 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/reorder-todo.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = (args) => 5 | (args.context as RemixAppContext).actions.reorderTodo(args); 6 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/revoke-access.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = (args) => 5 | (args.context as RemixAppContext).actions.revokeAccess(args); 6 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/todo.$todoId.add-tag.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = (args) => 5 | (args.context as RemixAppContext).actions.addTagToTodo(args); 6 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/todo.$todoId.delete.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = async (args) => 5 | (args.context as RemixAppContext).actions.deleteTodoFromTodoList(args); 6 | 7 | export default function Noop() { 8 | return null; 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/todo.$todoId.remove-tag.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = (args) => 5 | (args.context as RemixAppContext).actions.removeTagFromTodo(args); 6 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/todo.$todoId.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = async (args) => 5 | (args.context as RemixAppContext).actions.markTodo(args); 6 | 7 | export default function Noop() { 8 | return null; 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/todo.$todoId.update.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = (args) => 5 | (args.context as RemixAppContext).actions.updateTodoTitle(args); 6 | -------------------------------------------------------------------------------- /app/routes/l/$todoListId/update.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const action: ActionFunction = async (args) => 5 | (args.context as RemixAppContext).actions.updateTodoListTitle(args); 6 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction, MetaFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | import { LoginForm } from "front/authentication/LoginForm"; 5 | 6 | export const meta: MetaFunction = () => ({ 7 | title: "Todos | Login", 8 | }); 9 | 10 | export const loader: LoaderFunction = (args) => 11 | (args.context as RemixAppContext).loaders.login(args); 12 | 13 | export const action: ActionFunction = (args) => 14 | (args.context as RemixAppContext).actions.login(args); 15 | 16 | export default LoginForm; 17 | -------------------------------------------------------------------------------- /app/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | export const loader: LoaderFunction = (args) => 5 | (args.context as RemixAppContext).loaders.logout(args); 6 | -------------------------------------------------------------------------------- /app/routes/register.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction, MetaFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | import { RegisterForm } from "front/authentication/RegisterForm"; 4 | 5 | export const meta: MetaFunction = () => ({ 6 | title: "Todos | Register", 7 | }); 8 | 9 | export const loader: LoaderFunction = (args) => 10 | (args.context as RemixAppContext).loaders.register(args); 11 | 12 | export const action: ActionFunction = (args) => 13 | (args.context as RemixAppContext).actions.register(args); 14 | 15 | export default RegisterForm; 16 | -------------------------------------------------------------------------------- /app/routes/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, MetaFunction } from "@remix-run/node"; 2 | import { useCatch } from "@remix-run/react"; 3 | import type { RemixAppContext } from "web"; 4 | 5 | import { ResetPasswordForm } from "front/authentication/ResetPasswordForm"; 6 | import { PageTitle } from "front/ui/PageTitle"; 7 | 8 | export const meta: MetaFunction = () => ({ 9 | title: "Todos | Reset your password", 10 | }); 11 | 12 | export const action: ActionFunction = (args) => 13 | (args.context as RemixAppContext).actions.resetPassword(args); 14 | 15 | export default ResetPasswordForm; 16 | 17 | export const CatchBoundary = () => { 18 | const error = useCatch(); 19 | 20 | return ( 21 |
22 | Your password could not be reset 23 | 24 |

{error.data.message}

25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /app/routes/verify-account.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction, MetaFunction } from "@remix-run/node"; 2 | import type { RemixAppContext } from "web"; 3 | 4 | import { VerifyAccount } from "front/authentication/VerifyAccount"; 5 | import { VerifyAccountError } from "front/authentication/VerifyAccountError"; 6 | 7 | export const meta: MetaFunction = () => ({ 8 | title: "Todos | Verifying your account...", 9 | }); 10 | 11 | export const loader: LoaderFunction = (args) => 12 | (args.context as RemixAppContext).loaders.verifyAccount(args); 13 | 14 | export default VerifyAccount; 15 | export const CatchBoundary = VerifyAccountError; 16 | -------------------------------------------------------------------------------- /app/routes/welcome.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | 3 | import { Welcome } from "front/authentication/Welcome"; 4 | 5 | export const meta: MetaFunction = () => ({ 6 | title: "Todos | Welcome", 7 | }); 8 | 9 | export default Welcome; 10 | -------------------------------------------------------------------------------- /app/todolist/Sharing/ContributorPin.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import { colorFromString } from "front/palette"; 4 | import { Tooltip } from "front/ui/Tooltip"; 5 | 6 | export type ContributorPinProps = { children: string; label?: string }; 7 | 8 | export const ContributorPin = ({ children, label }: ContributorPinProps) => ( 9 | 10 | 11 | 12 |
20 | {children} 21 |
22 |
23 | 24 | {label != null && ( 25 | 26 | 27 | 28 | {label} 29 | 30 | )} 31 |
32 |
33 | ); 34 | -------------------------------------------------------------------------------- /app/todolist/Sharing/ContributorPinsMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import { ContributorPin } from "front/todolist/Sharing/ContributorPin"; 4 | import { ShareButton } from "front/todolist/Sharing/ShareButton"; 5 | import { useContributors } from "front/todolist/state"; 6 | 7 | export type ContributorPinsMenuProps = { 8 | canShare: boolean; 9 | className?: string; 10 | }; 11 | 12 | export const ContributorPinsMenu = ({ 13 | canShare, 14 | className, 15 | }: ContributorPinsMenuProps) => { 16 | const contributors = useContributors(); 17 | 18 | return ( 19 |
    20 | {contributors.map((contributor) => ( 21 |
  • 22 | 23 | {contributor.shortName} 24 | 25 |
  • 26 | ))} 27 | 28 | {canShare && ( 29 |
  • 30 | 31 |
  • 32 | )} 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /app/todolist/Sharing/ManageContributors.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { isEmpty } from "fp-ts/Array"; 3 | import { useContributors } from "front/todolist/state"; 4 | import { ContributorPin } from "front/todolist/Sharing/ContributorPin"; 5 | import { RevokeAccess } from "front/todolist/Sharing/RevokeAccess"; 6 | 7 | export const ManageContributors = () => { 8 | const contributors = useContributors(); 9 | if (isEmpty(contributors)) return null; 10 | 11 | return ( 12 |
13 |

These people can contribute to this todo list :

14 |
    15 | {contributors.map((contributor) => ( 16 |
  • 20 | {contributor.shortName} 21 | 22 | {contributor.email} 23 | 24 | 25 | {" "} 26 | •{" "} 27 | {contributor.role === "contributor" ? ( 28 | 29 | ) : ( 30 | "Owner" 31 | )} 32 | 33 |
  • 34 | ))} 35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /app/todolist/Sharing/RevokeAccess.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoListContributorDto } from "shared/client"; 2 | import React from "react"; 3 | import { useFetcher } from "@remix-run/react"; 4 | import classNames from "classnames"; 5 | import { useTodoListInfo } from "front/todolist/state"; 6 | import { PlainButton } from "front/ui/Button"; 7 | 8 | export type RevokeAccessProps = { contributor: TodoListContributorDto }; 9 | export const RevokeAccess = ({ contributor }: RevokeAccessProps) => { 10 | const revokeAccessFetcher = useFetcher(); 11 | const { id } = useTodoListInfo(); 12 | const isBusy = revokeAccessFetcher.state !== "idle"; 13 | 14 | const revoke = () => { 15 | const formData = new FormData(); 16 | formData.append("contributorId", contributor.id); 17 | revokeAccessFetcher.submit(formData, { 18 | method: "post", 19 | action: `/l/${id}/revoke-access`, 20 | }); 21 | }; 22 | 23 | return ( 24 | 31 | {isBusy ? "Revoking..." : "Revoke"} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/todolist/Sharing/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import { PlusIcon } from "@radix-ui/react-icons"; 4 | import { ShareTodoListDialogContent } from "front/todolist/Sharing/ShareTodoListDialogContent"; 5 | import { Dialog } from "front/ui/Dialog"; 6 | import { useIsTodoListStale } from "front/todolist/state"; 7 | 8 | export const ShareButton = () => { 9 | const stale = useIsTodoListStale(); 10 | 11 | return ( 12 | 13 | 14 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /app/todolist/Sharing/ShareTodoListDialogContent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ShareTodoListForm } from "front/todolist/Sharing/ShareTodoListForm"; 3 | import { ManageContributors } from "front/todolist/Sharing/ManageContributors"; 4 | 5 | export const ShareTodoListDialogContent = () => { 6 | return ( 7 |
8 | 9 |
10 | 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /app/todolist/Sharing/ShareTodoListForm.tsx: -------------------------------------------------------------------------------- 1 | import type { FloatingLabelInputRef } from "front/ui/FloatingLabelInput"; 2 | import React, { useEffect, useRef } from "react"; 3 | import { useFetcher } from "@remix-run/react"; 4 | import { UpdateIcon } from "@radix-ui/react-icons"; 5 | import { FloatingLabelInput } from "front/ui/FloatingLabelInput"; 6 | import { useTodoListInfo } from "front/todolist/state"; 7 | import { ButtonPrimary } from "front/ui/Button"; 8 | 9 | type ErrorReponse = { 10 | error: true; 11 | message: string; 12 | }; 13 | 14 | export const ShareTodoListForm = () => { 15 | const grantAccessFetcher = useFetcher(); 16 | const inputRef = useRef(null); 17 | const { id } = useTodoListInfo(); 18 | const isBusy = grantAccessFetcher.state !== "idle"; 19 | 20 | useEffect(() => { 21 | if (grantAccessFetcher.type !== "done") return; 22 | if (grantAccessFetcher.data?.error) return; 23 | 24 | inputRef.current?.clear(); 25 | }, [grantAccessFetcher, grantAccessFetcher.type]); 26 | 27 | return ( 28 | 33 |

Add someone else

34 | 35 | 42 | 43 | 44 | {isBusy ? : "Share"} 45 | 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/EditTodoTitle.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import classNames from "classnames"; 4 | import { useFetcher } from "@remix-run/react"; 5 | import { useIsTodoListStale, useTodoListInfo } from "front/todolist/state"; 6 | import { EditableContent } from "front/ui/EditableContent"; 7 | 8 | export type EditTodoTitleProps = { todo: TodoDto }; 9 | 10 | export const EditTodoTitle = ({ todo }: EditTodoTitleProps) => { 11 | const editTodoFetcher = useFetcher(); 12 | const { id } = useTodoListInfo(); 13 | const stale = useIsTodoListStale(); 14 | 15 | return ( 16 | 20 | 26 | 31 | {todo.title} 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/AddTag.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | 3 | import React, { useEffect, useRef } from "react"; 4 | import { useFetcher } from "@remix-run/react"; 5 | import { Popover } from "front/ui/Popover"; 6 | import { useTodoListInfo } from "front/todolist/state"; 7 | 8 | export type AddTagProps = { 9 | todo: TodoDto; 10 | }; 11 | 12 | export const AddTag = ({ todo }: AddTagProps) => { 13 | const { id } = useTodoListInfo(); 14 | const addTagFetcher = useFetcher(); 15 | const inputRef = useRef(null); 16 | 17 | useEffect(() => { 18 | if (addTagFetcher.type !== "done") return; 19 | 20 | const input = inputRef.current; 21 | if (input == null) return; 22 | input.value = ""; 23 | }, [addTagFetcher.type]); 24 | 25 | if (todo.tags.length === 3) return null; 26 | 27 | return ( 28 | 29 | 33 | 41 | 42 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/DeleteTodo.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { useFetcher } from "@remix-run/react"; 4 | import { CrossCircledIcon } from "@radix-ui/react-icons"; 5 | import { PlainButton } from "front/ui/Button"; 6 | import { useTodoListInfo } from "front/todolist/state"; 7 | 8 | export type DeleteTodoProps = { 9 | todo: TodoDto; 10 | }; 11 | 12 | export const DeleteTodo = ({ todo }: DeleteTodoProps) => { 13 | const deleteTodo = useFetcher(); 14 | const { id } = useTodoListInfo(); 15 | const isArchiving = deleteTodo.state === "submitting"; 16 | 17 | return ( 18 | 24 | 30 | Delete 31 | 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/SelectableTag.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import classNames from "classnames"; 4 | import { useFetcher } from "@remix-run/react"; 5 | import { Popover } from "front/ui/Popover"; 6 | import { PlainButton } from "front/ui/Button"; 7 | import { TodoTag } from "front/todolist/TodoTag"; 8 | 9 | export type SelectableTagProps = { 10 | tag: string; 11 | todoListId: string; 12 | todo: TodoDto; 13 | }; 14 | 15 | export const SelectableTag = ({ 16 | tag, 17 | todoListId, 18 | todo, 19 | }: SelectableTagProps) => { 20 | const addTagFetcher = useFetcher(); 21 | const disabled = todo.tags.length === 3; 22 | 23 | return ( 24 | 25 | 30 | 31 | 32 | 38 | {tag} 39 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/SelectedTag.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { useFetcher } from "@remix-run/react"; 4 | import { DotFilledIcon } from "@radix-ui/react-icons"; 5 | import { Popover } from "front/ui/Popover"; 6 | import { PlainButton } from "front/ui/Button"; 7 | import { TodoTag } from "front/todolist/TodoTag"; 8 | 9 | export type SelectedTagProps = { 10 | tag: string; 11 | todoListId: string; 12 | todo: TodoDto; 13 | }; 14 | export const SelectedTag = ({ tag, todoListId, todo }: SelectedTagProps) => { 15 | const removeTagFetcher = useFetcher(); 16 | 17 | return ( 18 | 19 |
20 | 21 |
22 | 26 | 27 | 28 | {tag} 29 | 30 | 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/SendToBottom.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React, { useEffect } from "react"; 3 | import { DoubleArrowDownIcon } from "@radix-ui/react-icons"; 4 | import { useFetcher } from "@remix-run/react"; 5 | import { PlainButton } from "front/ui/Button"; 6 | import { useAllDoingTodos, useTodoListInfo } from "front/todolist/state"; 7 | 8 | export type SendToBottomProps = { 9 | todo: TodoDto; 10 | onDone: () => void; 11 | }; 12 | 13 | export const SendToBottom = ({ todo, onDone }: SendToBottomProps) => { 14 | const sendToBottomFetcher = useFetcher(); 15 | const { id } = useTodoListInfo(); 16 | const doingTodos = useAllDoingTodos(); 17 | 18 | useEffect(() => { 19 | if (sendToBottomFetcher.type !== "done") return; 20 | 21 | onDone(); 22 | }, [onDone, sendToBottomFetcher.type]); 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | Send to bottom 30 | 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/SendToTop.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React, { useEffect } from "react"; 3 | import { DoubleArrowUpIcon } from "@radix-ui/react-icons"; 4 | import { useFetcher } from "@remix-run/react"; 5 | import { PlainButton } from "front/ui/Button"; 6 | import { useTodoListInfo } from "front/todolist/state"; 7 | 8 | export type SendToTopProps = { 9 | todo: TodoDto; 10 | onDone: () => void; 11 | }; 12 | 13 | export const SendToTop = ({ todo, onDone }: SendToTopProps) => { 14 | const sendToTopFetcher = useFetcher(); 15 | const { id } = useTodoListInfo(); 16 | 17 | useEffect(() => { 18 | if (sendToTopFetcher.type !== "done") return; 19 | 20 | onDone(); 21 | }, [onDone, sendToTopFetcher.type]); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | Send to top 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/TagsList.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { useTodoListInfo } from "front/todolist/state"; 4 | import { SelectableTag } from "front/todolist/TodoItem/Popover/SelectableTag"; 5 | import { SelectedTag } from "front/todolist/TodoItem/Popover/SelectedTag"; 6 | 7 | export const TagsList = ({ todo }: { todo: TodoDto }) => { 8 | const isTaggedWith = (tagToCheck: string) => todo.tags.includes(tagToCheck); 9 | const { id, tags } = useTodoListInfo(); 10 | 11 | return ( 12 |
    13 | {tags.map((tag) => ( 14 |
  • 15 | {isTaggedWith(tag) ? ( 16 | 17 | ) : ( 18 | 19 | )} 20 |
  • 21 | ))} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/TodoActions.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { Popover } from "front/ui/Popover"; 4 | import { SendToTop } from "front/todolist/TodoItem/Popover/SendToTop"; 5 | import { SendToBottom } from "front/todolist/TodoItem/Popover/SendToBottom"; 6 | 7 | export type TodoActionsProps = { 8 | todo: TodoDto; 9 | onAction: () => void; 10 | }; 11 | export const TodoActions = ({ todo, onAction }: TodoActionsProps) => { 12 | if (todo.isDone) return null; 13 | 14 | return ( 15 | <> 16 | Actions 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/TodoDangerZone.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { Popover } from "front/ui/Popover"; 4 | import { DeleteTodo } from "front/todolist/TodoItem/Popover/DeleteTodo"; 5 | 6 | export type TodoDangerZoneProps = { todo: TodoDto }; 7 | export const TodoDangerZone = ({ todo }: TodoDangerZoneProps) => ( 8 | <> 9 | 10 | Danger zone 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/TodoPopoverContent.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React, { useRef } from "react"; 3 | import { Popover } from "front/ui/Popover"; 4 | import { TodoTags } from "front/todolist/TodoItem/Popover/TodoTags"; 5 | import { TodoActions } from "front/todolist/TodoItem/Popover/TodoActions"; 6 | import { TodoDangerZone } from "front/todolist/TodoItem/Popover/TodoDangerZone"; 7 | 8 | export type TodoPopoverContentProps = { 9 | todo: TodoDto; 10 | }; 11 | 12 | export const TodoPopoverContent = ({ todo }: TodoPopoverContentProps) => { 13 | const closeButtonRef = useRef(null); 14 | const closePopOver = () => closeButtonRef.current?.click(); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/Popover/TodoTags.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { Popover } from "front/ui/Popover"; 4 | import { TagsList } from "front/todolist/TodoItem/Popover/TagsList"; 5 | import { AddTag } from "front/todolist/TodoItem/Popover/AddTag"; 6 | 7 | export type TodoTagsProps = { todo: TodoDto }; 8 | export const TodoTags = ({ todo }: TodoTagsProps) => ( 9 | <> 10 | Tags 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/ReorderableTodoItem.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoItemProps } from "./TodoItem"; 2 | import { TodoItem } from "./TodoItem"; 3 | 4 | import React from "react"; 5 | import { DragHandleHorizontalIcon } from "@radix-ui/react-icons"; 6 | import classNames from "classnames"; 7 | import { useReorderTodo } from "front/todolist/TodoItem/useReorderTodo"; 8 | 9 | export type ReorderableTodoItemProps = TodoItemProps & { 10 | index: number; 11 | enabled: boolean; 12 | onPreviewMove: (todoId: string, newIndex: number) => void; 13 | }; 14 | 15 | export const ReorderableTodoItem = ({ 16 | index, 17 | onPreviewMove, 18 | enabled, 19 | ...props 20 | }: ReorderableTodoItemProps) => { 21 | const { ref, preview, isDragging } = useReorderTodo( 22 | props.todo, 23 | index, 24 | onPreviewMove 25 | ); 26 | 27 | return ( 28 |
29 | 37 | 38 | {enabled && ( 39 |
43 | 44 |
45 | )} 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/TodoPopoverMenu.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import { DotsVerticalIcon } from "@radix-ui/react-icons"; 4 | import { Popover } from "front/ui/Popover"; 5 | import { PlainButton } from "front/ui/Button"; 6 | import { useIsTodoListStale } from "front/todolist/state"; 7 | import { TodoPopoverContent } from "front/todolist/TodoItem/Popover/TodoPopoverContent"; 8 | 9 | export type TodoPopoverMenuProps = { todo: TodoDto }; 10 | 11 | export const TodoPopoverMenu = ({ todo }: TodoPopoverMenuProps) => { 12 | const stale = useIsTodoListStale(); 13 | 14 | if (stale) return null; 15 | 16 | return ( 17 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/TodoTags.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | import React from "react"; 3 | import classNames from "classnames"; 4 | import { TodoTag } from "front/todolist/TodoTag"; 5 | 6 | export type TodoTagsProps = { todo: TodoDto }; 7 | export const TodoTags = ({ todo }: TodoTagsProps) => ( 8 |
    14 | {todo.tags.map((tag) => ( 15 |
  • 16 | {tag} 17 |
  • 18 | ))} 19 |
20 | ); 21 | -------------------------------------------------------------------------------- /app/todolist/TodoItem/useReorderTodo.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | 3 | import { useFetcher } from "@remix-run/react"; 4 | import { useDrag, useDrop } from "react-dnd"; 5 | import { useTodoListInfo } from "front/todolist/state"; 6 | 7 | interface DragItem { 8 | todoId: string; 9 | } 10 | 11 | interface DropResult { 12 | newIndex: number; 13 | } 14 | 15 | interface CollectedProps { 16 | isDragging: boolean; 17 | } 18 | 19 | const DragTypeTodo = Symbol("todo"); 20 | 21 | export const useReorderTodo = ( 22 | todo: TodoDto, 23 | currentIndex: number, 24 | onPreviewMove: (todoId: string, newIndex: number) => void 25 | ) => { 26 | const { id } = useTodoListInfo(); 27 | const todosOrder = useFetcher(); 28 | 29 | const moveTodo = (newIndex: number) => { 30 | const formData = new FormData(); 31 | formData.append("todoId", todo.id); 32 | formData.append("newIndex", newIndex.toString()); 33 | 34 | todosOrder.submit(formData, { 35 | method: "put", 36 | action: `/l/${id}/reorder-todo`, 37 | }); 38 | }; 39 | 40 | const [{ isDragging }, drag, preview] = useDrag< 41 | DragItem, 42 | DropResult, 43 | CollectedProps 44 | >({ 45 | type: DragTypeTodo, 46 | item: { todoId: todo.id }, 47 | collect: (monitor) => ({ 48 | isDragging: monitor.isDragging(), 49 | }), 50 | end: (item, monitor) => { 51 | const dropResult = monitor.getDropResult(); 52 | if (dropResult?.newIndex != null) moveTodo(dropResult.newIndex); 53 | }, 54 | }); 55 | 56 | const [, drop] = useDrop({ 57 | accept: DragTypeTodo, 58 | hover: (item) => { 59 | onPreviewMove(item.todoId, currentIndex); 60 | }, 61 | drop: () => ({ 62 | newIndex: currentIndex, 63 | }), 64 | }); 65 | 66 | return { 67 | isDragging, 68 | ref: (node: HTMLDivElement | null) => drag(drop(node)), 69 | preview, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /app/todolist/TodoList/AddTodoForm.tsx: -------------------------------------------------------------------------------- 1 | import type { AddTodoErrorDto } from "shared/client"; 2 | import type { FloatingLabelInputRef } from "front/ui/FloatingLabelInput"; 3 | import { useEffect, useRef } from "react"; 4 | import { useFetcher } from "@remix-run/react"; 5 | import { FloatingLabelInput } from "front/ui/FloatingLabelInput"; 6 | import { ButtonPrimary } from "front/ui/Button"; 7 | import { useIsTodoListStale } from "front/todolist/state"; 8 | 9 | type ActionData = { 10 | errors?: AddTodoErrorDto; 11 | }; 12 | 13 | export const AddTodoForm = () => { 14 | const { ref, addTodo } = useAddTodoForm(); 15 | const stale = useIsTodoListStale(); 16 | const disabled = stale || addTodo.state !== "idle"; 17 | 18 | return ( 19 | 24 | 31 | 32 | 33 | Add 34 | 35 | 36 | ); 37 | }; 38 | 39 | function useAddTodoForm() { 40 | const addTodo = useFetcher(); 41 | const isSubmitting = useRef(false); 42 | const floatingLabelInputRef = useRef(null); 43 | 44 | useEffect(() => { 45 | if (addTodo.state === "submitting") { 46 | isSubmitting.current = true; 47 | return; 48 | } 49 | 50 | if (isSubmitting.current && addTodo.state === "idle") { 51 | floatingLabelInputRef.current?.focus(); 52 | isSubmitting.current = false; 53 | return; 54 | } 55 | }, [addTodo.state]); 56 | 57 | useEffect(() => { 58 | if (addTodo.type === "done") { 59 | floatingLabelInputRef.current?.clear(); 60 | } 61 | }, [addTodo.type]); 62 | 63 | return { ref: floatingLabelInputRef, addTodo }; 64 | } 65 | -------------------------------------------------------------------------------- /app/todolist/TodoList/TagFilters.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import { isEmpty } from "fp-ts/Array"; 4 | import { PlainButton } from "front/ui/Button"; 5 | import { TodoTag } from "front/todolist/TodoTag"; 6 | import { useFilter, useTodoListInfo } from "front/todolist/state"; 7 | 8 | export const TagFilters = () => { 9 | const { active, clear, deselect, isSelected, select } = useFilter(); 10 | const { tags } = useTodoListInfo(); 11 | 12 | if (isEmpty(tags)) return null; 13 | 14 | return ( 15 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /app/todolist/TodoList/TodoListCompletion.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Tooltip } from "front/ui/Tooltip"; 3 | import { useTodoListInfo } from "front/todolist/state"; 4 | 5 | export type TodoListCompletionProps = { 6 | className?: string; 7 | }; 8 | 9 | export const TodoListCompletion = ({ className }: TodoListCompletionProps) => { 10 | const { completion } = useTodoListInfo(); 11 | 12 | return ( 13 | 14 | 15 |
18 |

This todo list is ${completion}% complete.

19 | 20 |
24 |
25 | 26 | 27 | 28 | 29 | {completion}% complete 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /app/todolist/TodoList/TodoListEditableTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useFetcher } from "@remix-run/react"; 3 | import { EditableContent } from "front/ui/EditableContent"; 4 | import { PageTitle } from "front/ui/PageTitle"; 5 | import { useTodoListInfo, useIsTodoListStale } from "front/todolist/state"; 6 | 7 | export const TodoListEditableTitle = () => { 8 | const { id, title } = useTodoListInfo(); 9 | const stale = useIsTodoListStale(); 10 | const updateTodoListFetcher = useFetcher(); 11 | 12 | return ( 13 | 14 | 20 | {title} 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/todolist/TodoList/TodoListHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { displayDate } from "front/Date"; 3 | import { AddTodoForm } from "front/todolist/TodoList/AddTodoForm"; 4 | import { ContributorPinsMenu } from "front/todolist/Sharing/ContributorPinsMenu"; 5 | import { TodoListEditableTitle } from "front/todolist/TodoList/TodoListEditableTitle"; 6 | import { TodoListCompletion } from "front/todolist/TodoList/TodoListCompletion"; 7 | import { useTodoListInfo } from "front/todolist/state"; 8 | 9 | export const TodoListHeader = () => { 10 | const { createdAt, isOwner } = useTodoListInfo(); 11 | 12 | return ( 13 |
14 |
15 | 16 | 17 |
18 | 19 | 20 | 21 |

↳ Created {displayDate(createdAt)}

22 | 23 | 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /app/todolist/TodoList/Todos.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | 3 | import React from "react"; 4 | import { isEmpty } from "fp-ts/Array"; 5 | import { EmptyMessage } from "front/ui/EmptyMessage"; 6 | 7 | interface TodosProps { 8 | title: React.ReactNode; 9 | todos: TodoDto[]; 10 | emptyMessage: string; 11 | renderTodo: (todoItem: TodoDto, index: number) => React.ReactNode; 12 | } 13 | 14 | export const Todos = ({ 15 | title, 16 | todos, 17 | emptyMessage, 18 | renderTodo, 19 | }: TodosProps) => ( 20 |
21 | {title} 22 | 23 | {isEmpty(todos) ? ( 24 | {emptyMessage} 25 | ) : ( 26 |
    27 | {todos.map((todo, index) => ( 28 |
  1. {renderTodo(todo, index)}
  2. 29 | ))} 30 |
31 | )} 32 |
33 | ); 34 | -------------------------------------------------------------------------------- /app/todolist/TodoList/TodosHeading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type TodosHeadingProps = { title: string; filterLabel: string | number }; 4 | 5 | export const TodosHeading = ({ title, filterLabel }: TodosHeadingProps) => ( 6 |

7 | {title} ({filterLabel}) 8 |

9 | ); 10 | -------------------------------------------------------------------------------- /app/todolist/TodoListOutdatedMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "@remix-run/react"; 3 | import { useIsTodoListStale } from "front/todolist/state"; 4 | 5 | export const TodoListOutdatedMessage = () => { 6 | const stale = useIsTodoListStale(); 7 | 8 | if (!stale) return null; 9 | 10 | return ( 11 |
12 |

13 | This todo list has been modified.{" "} 14 | 19 | Click to refresh! 20 | 21 |

22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/todolist/TodoTag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import { colorFromString } from "front/palette"; 4 | 5 | export type TodoTagProps = { children: string; className?: string }; 6 | 7 | export const TodoTag = ({ children, className }: TodoTagProps) => ( 8 | 15 | {children} 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /app/todolist/useTodosOrderPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { TodoDto } from "shared/client"; 2 | 3 | import { moveArrayItem } from "shared/lib"; 4 | import { useEffect, useState } from "react"; 5 | import { useTodos } from "front/todolist/state"; 6 | 7 | interface TodoOrderPreview { 8 | todoId: string; 9 | newIndex: number; 10 | } 11 | 12 | export function useTodosOrderPreview() { 13 | const { doingTodos, completedTodos } = useTodos(); 14 | const [todoOrderPreview, setTodoOrderPreview] = 15 | useState(null); 16 | const reorderForPreview = (todoId: string, newIndex: number) => 17 | setTodoOrderPreview({ todoId, newIndex }); 18 | 19 | useEffect(() => { 20 | setTodoOrderPreview(null); 21 | }, [doingTodos, completedTodos]); 22 | 23 | const sortForPreview = (todos: TodoDto[]) => 24 | sortTodos(todos, todoOrderPreview); 25 | 26 | return { reorderForPreview, sortForPreview }; 27 | } 28 | 29 | function sortTodos(todos: TodoDto[], orderPreview: TodoOrderPreview | null) { 30 | if (orderPreview == null) return todos; 31 | 32 | const currentIndex = todos.findIndex( 33 | (todo) => todo.id === orderPreview.todoId 34 | ); 35 | 36 | if (currentIndex === -1) return todos; 37 | return moveArrayItem(todos, currentIndex, orderPreview.newIndex); 38 | } 39 | -------------------------------------------------------------------------------- /app/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonHTMLAttributes } from "react"; 2 | 3 | import classNames from "classnames"; 4 | import { forwardRef } from "react"; 5 | 6 | type ButtonProps = ButtonHTMLAttributes; 7 | 8 | export const PlainButton = forwardRef( 9 | function PlainButton(props, ref) { 10 | const { children, className, type = "button", ...buttonProps } = props; 11 | 12 | return ( 13 | 21 | ); 22 | } 23 | ); 24 | 25 | export const Button = ({ 26 | children, 27 | className, 28 | type = "button", 29 | ...props 30 | }: ButtonProps) => ( 31 | 41 | ); 42 | 43 | const CLASSES_COLORED_BUTTONS = 44 | "rounded-2xl py-3 px-4 sm:py-4 sm:px-6 shadow transition"; 45 | 46 | export const ButtonPrimary = ({ 47 | children, 48 | className, 49 | ...props 50 | }: ButtonProps) => ( 51 | 61 | ); 62 | 63 | export const ButtonSecondary = ({ 64 | children, 65 | className, 66 | ...props 67 | }: ButtonProps) => ( 68 | 78 | ); 79 | -------------------------------------------------------------------------------- /app/ui/CheckboxOption.tsx: -------------------------------------------------------------------------------- 1 | import type { CheckedState } from "@radix-ui/react-checkbox"; 2 | import React, { useState } from "react"; 3 | import classNames from "classnames"; 4 | import { CheckIcon } from "@radix-ui/react-icons"; 5 | import * as Checkbox from "@radix-ui/react-checkbox"; 6 | 7 | interface CheckboxOptionProps { 8 | id: string; 9 | checked: boolean; 10 | label: React.ReactNode; 11 | disabled?: boolean; 12 | } 13 | 14 | export const CheckboxOption = ({ 15 | id, 16 | checked: initialChecked, 17 | label, 18 | disabled, 19 | }: CheckboxOptionProps) => { 20 | const [checked, setChecked] = useState(initialChecked); 21 | 22 | return ( 23 |
24 | 25 | 44 | 45 | {checked && ( 46 | 47 | )} 48 | 49 | 50 | 51 | 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /app/ui/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React from "react"; 3 | import * as BaseDialog from "@radix-ui/react-dialog"; 4 | import { Cross1Icon } from "@radix-ui/react-icons"; 5 | import classNames from "classnames"; 6 | 7 | type ContentProps = { 8 | title: string; 9 | children: ReactNode; 10 | }; 11 | const Content = ({ children, title }: ContentProps) => ( 12 | 13 | 14 | 20 | 21 | {title} 22 | 23 | 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | ); 31 | 32 | export const Dialog = { 33 | ...BaseDialog, 34 | Content, 35 | }; 36 | -------------------------------------------------------------------------------- /app/ui/EditableContent.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from "react"; 2 | import React, { useEffect, useState } from "react"; 3 | import { EditableContentEditionMode } from "front/ui/EditableContentEditionMode"; 4 | import { EditableContentDisplayMode } from "front/ui/EditableContentDisplayMode"; 5 | 6 | export interface EditableContentProps { 7 | children: ReactElement<{ className?: string }>; 8 | initialValue: string; 9 | inputName: string; 10 | disabled?: boolean; 11 | inputClassName?: string; 12 | } 13 | 14 | export const EditableContent = ({ 15 | children, 16 | initialValue, 17 | inputName, 18 | disabled, 19 | inputClassName, 20 | }: EditableContentProps) => { 21 | const { isEditionMode, switchToDisplayMode, switchToEditionMode } = 22 | useEditableContent(initialValue); 23 | 24 | if (isEditionMode && !disabled) 25 | return ( 26 | 32 | ); 33 | 34 | return ( 35 | 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | function useEditableContent(initialValue: string) { 45 | const [isEditionMode, setEditionMode] = useState(false); 46 | 47 | useEffect(() => { 48 | setEditionMode(false); 49 | }, [initialValue]); 50 | 51 | return { 52 | isEditionMode, 53 | switchToEditionMode: () => setEditionMode(true), 54 | switchToDisplayMode: () => setEditionMode(false), 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /app/ui/EditableContentDisplayMode.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | import React from "react"; 4 | import { Pencil1Icon } from "@radix-ui/react-icons"; 5 | import { Button } from "front/ui/Button"; 6 | 7 | export interface EditableContentDisplayModeProps { 8 | children: ReactNode; 9 | readonly?: boolean; 10 | onEdit: () => void; 11 | } 12 | 13 | export const EditableContentDisplayMode = ({ 14 | children, 15 | readonly, 16 | onEdit, 17 | }: EditableContentDisplayModeProps) => ( 18 |
19 |
{children}
20 | {!readonly && ( 21 | 28 | )} 29 |
30 | ); 31 | -------------------------------------------------------------------------------- /app/ui/EditableContentEditionMode.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { Button } from "front/ui/Button"; 3 | import classNames from "classnames"; 4 | import { CheckIcon, Cross1Icon } from "@radix-ui/react-icons"; 5 | 6 | export interface EditableContentEditionModeProps { 7 | inputName: string; 8 | initialValue: string; 9 | onCancel: () => void; 10 | inputClassName?: string; 11 | } 12 | 13 | export const EditableContentEditionMode = ({ 14 | inputName, 15 | initialValue, 16 | onCancel, 17 | inputClassName, 18 | }: EditableContentEditionModeProps) => { 19 | const inputRef = useRef(null); 20 | const [size, setSize] = useState(computeSize(initialValue)); 21 | 22 | const onChange = (e: React.ChangeEvent) => 23 | setSize(computeSize(e.target.value)); 24 | 25 | return ( 26 |
27 | 41 | 44 | 47 |
48 | ); 49 | }; 50 | 51 | function computeSize(content: string) { 52 | return Math.max(10, content.length); 53 | } 54 | -------------------------------------------------------------------------------- /app/ui/EmptyMessage.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React from "react"; 3 | import classNames from "classnames"; 4 | 5 | export type EmptyMessageProps = { children: ReactNode; className?: string }; 6 | export const EmptyMessage = ({ children, className }: EmptyMessageProps) => ( 7 |

13 | {children} 14 |

15 | ); 16 | -------------------------------------------------------------------------------- /app/ui/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | 4 | type PageTitleProps = { 5 | children: React.ReactNode; 6 | className?: string; 7 | }; 8 | 9 | export const PageTitle = ({ children, className }: PageTitleProps) => ( 10 |

11 | {children} 12 |

13 | ); 14 | -------------------------------------------------------------------------------- /app/ui/Popover.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import type { PopoverContentProps } from "@radix-ui/react-popover"; 3 | import React from "react"; 4 | import * as BasePopover from "@radix-ui/react-popover"; 5 | import classNames from "classnames"; 6 | 7 | export type ContentProps = PopoverContentProps; 8 | export const Content = ({ children, ...props }: ContentProps) => ( 9 | 13 | {children} 14 | 15 | ); 16 | 17 | export type ItemProps = { children: ReactNode; className?: string }; 18 | export const Item = ({ children, className }: ItemProps) => ( 19 |
{children}
20 | ); 21 | 22 | export type LabelProps = { children: ReactNode; className?: string }; 23 | export const SectionTitle = ({ children, className }: LabelProps) => ( 24 |

25 | {children} 26 |

27 | ); 28 | 29 | export const Separator = () => ( 30 |
35 | ); 36 | 37 | export const Arrow = () => ; 38 | 39 | export const Popover = { 40 | ...BasePopover, 41 | Content, 42 | Item, 43 | SectionTitle, 44 | Separator, 45 | Arrow, 46 | }; 47 | -------------------------------------------------------------------------------- /app/ui/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "@remix-run/react"; 3 | import { SkipToContent } from "front/ui/SkipToContent"; 4 | 5 | export const RootLayout = ({ 6 | children, 7 | authenticated, 8 | }: React.PropsWithChildren<{ authenticated: boolean }>) => ( 9 | <> 10 | 11 |
12 |
13 |
14 | 15 | Todos 20 | 21 | 22 | {authenticated && Logout} 23 |
24 |
25 | 26 |
27 | {children} 28 |
29 |
30 | 31 | ); 32 | -------------------------------------------------------------------------------- /app/ui/SkipToContent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SkipToContent = () => ( 4 | 8 | Skip to content 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /app/ui/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import type { TooltipContentProps } from "@radix-ui/react-tooltip"; 2 | import * as BaseTooltip from "@radix-ui/react-tooltip"; 3 | import classNames from "classnames"; 4 | 5 | const Arrow = () => ( 6 | 7 | ); 8 | 9 | const Content = ({ children, className, ...props }: TooltipContentProps) => ( 10 | 18 | {children} 19 | 20 | ); 21 | 22 | export const Tooltip = { 23 | ...BaseTooltip, 24 | Arrow, 25 | Content, 26 | }; 27 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3333", 3 | "viewportHeight": 1080, 4 | "viewportWidth": 1920, 5 | "projectId": "rq91tt" 6 | } 7 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/integration/managing-a-todo-list.js: -------------------------------------------------------------------------------- 1 | describe("The user can manage a todo list", () => { 2 | beforeEach(() => { 3 | cy.exec("yarn e2e:clean"); 4 | cy.exec("yarn e2e:seed"); 5 | }); 6 | 7 | it("creates a new todo list and allows adding todos", () => { 8 | cy.visit("/"); 9 | 10 | givenALoggedInUser(); 11 | theyCanCreateANewTodoList(); 12 | theyCanAddATodo("Beer"); 13 | theyCanAddATodo("Pizza"); 14 | theyCanAddATodo("Chocolate"); 15 | theyCanCompleteTheTodo("Pizza"); 16 | theyCanUncompleteTheTodo("Pizza"); 17 | }); 18 | }); 19 | 20 | function givenALoggedInUser() { 21 | cy.findByLabelText("Email address").click().type("john.doe@example.com"); 22 | cy.findByLabelText("Password").click().type("azertyuiop"); 23 | cy.findByRole("button", { name: "Login" }).click(); 24 | } 25 | 26 | function theyCanCreateANewTodoList() { 27 | cy.findByLabelText("Add a new todo list").click().type("Things to buy"); 28 | cy.findByRole("button", { name: "Add" }).click(); 29 | } 30 | 31 | function theyCanAddATodo(todo) { 32 | cy.intercept("GET", "/l/**").as("todosLoaded"); 33 | cy.findByLabelText("What needs to be done?").click().type(todo); 34 | cy.findByRole("button", { name: "Add" }).click(); 35 | cy.wait("@todosLoaded"); 36 | cy.findByText(`${todo} (click to toggle)`).should("be.visible"); 37 | } 38 | 39 | function theyCanCompleteTheTodo(todo) { 40 | cy.findByText(/^Things to do/) 41 | .parent() 42 | .within(() => { 43 | cy.findByText(`${todo} (click to toggle)`).click({ force: true }); 44 | }); 45 | } 46 | 47 | function theyCanUncompleteTheTodo(todo) { 48 | cy.findByText(/^Things done/) 49 | .parent() 50 | .within(() => { 51 | cy.findByText(`${todo} (click to toggle)`).click({ force: true }); 52 | }); 53 | cy.wait(500); 54 | cy.findByText(/^Things to do/) 55 | .parent() 56 | .within(() => { 57 | cy.findByText(`${todo} (click to toggle)`).should("exist"); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | import "@testing-library/cypress/add-commands"; 27 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /doc/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/doc/cover.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Use postgres/example user/password credentials 2 | version: "3.1" 3 | 4 | services: 5 | database: 6 | image: postgres 7 | restart: always 8 | ports: 9 | - "6060:5432" 10 | environment: 11 | PGDATA: ./data 12 | POSTGRES_USER: user 13 | POSTGRES_PASSWORD: password 14 | POSTGRES_DB: todolistmanager-dev 15 | 16 | database-integration: 17 | image: postgres 18 | restart: always 19 | ports: 20 | - "6070:5432" 21 | environment: 22 | PGDATA: ./data 23 | POSTGRES_USER: user 24 | POSTGRES_PASSWORD: password 25 | POSTGRES_DB: todolistmanager-integration 26 | 27 | database-e2e: 28 | image: postgres 29 | restart: always 30 | ports: 31 | - "6080:5432" 32 | environment: 33 | PGDATA: ./data 34 | POSTGRES_USER: user 35 | POSTGRES_PASSWORD: password 36 | POSTGRES_DB: todolistmanager-e2e 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | moduleNameMapper: { 6 | "front/(.*)$": "/app/$1", 7 | "todo-list-manager$": "/src/todo-list-manager", 8 | infrastructure$: "/src/infrastructure", 9 | "shared/(.*)$": "/src/shared/$1", 10 | }, 11 | testMatch: ["**/__tests__/**/*.test.ts?(x)"], 12 | }; 13 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } -------------------------------------------------------------------------------- /prisma/migrations/20220507222731_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Todo" ( 3 | "id" UUID NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL, 5 | "title" TEXT NOT NULL, 6 | "isComplete" BOOLEAN NOT NULL, 7 | "todoListId" UUID NOT NULL, 8 | "ownerId" UUID NOT NULL, 9 | 10 | CONSTRAINT "Todo_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "TodoList" ( 15 | "id" UUID NOT NULL, 16 | "createdAt" TIMESTAMP(3) NOT NULL, 17 | "title" TEXT NOT NULL, 18 | "ownerId" UUID NOT NULL, 19 | 20 | CONSTRAINT "TodoList_pkey" PRIMARY KEY ("id") 21 | ); 22 | 23 | -- CreateTable 24 | CREATE TABLE "Account" ( 25 | "id" UUID NOT NULL, 26 | "email" TEXT NOT NULL, 27 | "hash" TEXT NOT NULL, 28 | 29 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 30 | ); 31 | 32 | -- AddForeignKey 33 | ALTER TABLE "Todo" ADD CONSTRAINT "Todo_todoListId_fkey" FOREIGN KEY ("todoListId") REFERENCES "TodoList"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 34 | -------------------------------------------------------------------------------- /prisma/migrations/20220508101228_unique_email/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[email]` on the table `Account` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Account_email_key" ON "Account"("email"); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20220508112709_add_todos_order/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `todosOrder` to the `TodoList` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "TodoList" ADD COLUMN "todosOrder" JSONB NOT NULL; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20220518071633_add_todo_tags/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Todo" ADD COLUMN "tags" JSONB NOT NULL DEFAULT '[]'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20220521090745_add_account_verification/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Account" ADD COLUMN "verificationToken" TEXT, 3 | ADD COLUMN "verified" BOOLEAN NOT NULL DEFAULT true; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20220521090857_remove_default_verification_status/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Account" ALTER COLUMN "verified" DROP DEFAULT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20220522080328_add_password_reset_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Account" ADD COLUMN "passwordResetExpiration" TIMESTAMP(3), 3 | ADD COLUMN "passwordResetToken" TEXT; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20220526195155_add_todo_list_permissions/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "TodoListPermission" ( 3 | "todoListId" UUID NOT NULL, 4 | "ownerId" UUID NOT NULL 5 | ); 6 | 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "TodoListPermission_todoListId_key" ON "TodoListPermission"("todoListId"); 9 | 10 | INSERT INTO "TodoListPermission" ("todoListId", "ownerId") 11 | SELECT "id", "ownerId" FROM "TodoList"; -------------------------------------------------------------------------------- /prisma/migrations/20220527112419_remove_owner_id_columns/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `ownerId` on the `Todo` table. All the data in the column will be lost. 5 | - You are about to drop the column `ownerId` on the `TodoList` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "Todo" DROP COLUMN "ownerId"; 10 | 11 | -- AlterTable 12 | ALTER TABLE "TodoList" DROP COLUMN "ownerId"; 13 | -------------------------------------------------------------------------------- /prisma/migrations/20220527210827_add_collaborator_permission/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "TodoListPermission" ADD COLUMN "collaboratorsIds" JSONB NOT NULL DEFAULT '[]'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20220604180009_add_completion_date/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Todo" ADD COLUMN "completedAt" TIMESTAMP(3); 3 | UPDATE "Todo" SET "completedAt" = now() WHERE "isComplete" IS true; -------------------------------------------------------------------------------- /prisma/migrations/20220604185050_remove_complete_todos_from_order/migration.sql: -------------------------------------------------------------------------------- 1 | UPDATE "TodoList" SET "todosOrder" = "UpdatedOrder"."newTodoOrder" 2 | FROM ( 3 | SELECT DoingTodos."todoListId", jsonb_agg(DoingTodos."todoId") AS "newTodoOrder" FROM ( 4 | SELECT TL.id as "todoListId", TRIM('"' FROM "todoOrderId"::text) AS "todoId" 5 | FROM "TodoList" TL, jsonb_array_elements(TL."todosOrder") WITH ORDINALITY arr("todoOrderId", position) 6 | WHERE TRIM('"'FROM "todoOrderId"::text)::uuid IN ( 7 | SELECT id FROM "Todo" WHERE "isComplete" = false 8 | ) 9 | ORDER BY "todoListId", position 10 | ) as DoingTodos 11 | GROUP BY DoingTodos."todoListId" 12 | ) AS "UpdatedOrder" 13 | WHERE "TodoList".id = "UpdatedOrder"."todoListId"; -------------------------------------------------------------------------------- /prisma/migrations/20220607205640_model_renaming/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Todo" RENAME COLUMN "completedAt" TO "doneAt"; 3 | ALTER TABLE "Todo" RENAME COLUMN "isComplete" TO "isDone"; 4 | 5 | -- AlterTable 6 | ALTER TABLE "TodoListPermission" RENAME COLUMN "collaboratorsIds" TO "contributorsIds"; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20220611114123_add_todo_list_events/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "TodoListEvent" ( 3 | "id" UUID NOT NULL, 4 | "todoListId" UUID NOT NULL, 5 | "publishedAt" TIMESTAMP(3) NOT NULL, 6 | "event" JSONB NOT NULL, 7 | 8 | CONSTRAINT "TodoListEvent_pkey" PRIMARY KEY ("id") 9 | ); 10 | -------------------------------------------------------------------------------- /prisma/migrations/20220611115953_index_todo_list_id_in_events/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "TodoListEvent_todoListId_idx" ON "TodoListEvent"("todoListId"); 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["interactiveTransactions", "filterJson"] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_URL") 9 | } 10 | 11 | model Todo { 12 | id String @id @db.Uuid 13 | createdAt DateTime 14 | title String 15 | isDone Boolean 16 | TodoList TodoList @relation(fields: [todoListId], references: [id]) 17 | todoListId String @db.Uuid 18 | doneAt DateTime? 19 | tags Json @default("[]") 20 | } 21 | 22 | model TodoList { 23 | id String @id @db.Uuid 24 | createdAt DateTime 25 | title String 26 | todos Todo[] 27 | todosOrder Json 28 | } 29 | 30 | model Account { 31 | id String @id @db.Uuid 32 | email String @unique 33 | hash String 34 | verified Boolean 35 | verificationToken String? 36 | passwordResetToken String? 37 | passwordResetExpiration DateTime? 38 | } 39 | 40 | model TodoListPermission { 41 | todoListId String @unique @db.Uuid 42 | ownerId String @db.Uuid 43 | contributorsIds Json @default("[]") 44 | } 45 | 46 | model TodoListEvent { 47 | id String @id @db.Uuid 48 | todoListId String @db.Uuid 49 | publishedAt DateTime 50 | event Json 51 | 52 | @@index([todoListId]) 53 | } 54 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | async function doSeed() { 4 | const prisma = new PrismaClient(); 5 | 6 | await prisma.account.create({ 7 | data: { 8 | id: "051cef90-ee3a-4046-a9dd-f5cdc303d073", 9 | email: "john.doe@example.com", 10 | hash: "$2b$10$5Z4G6eRXFw2KqEArn1eXNOlNGOZXQXcyZ2IkXYLcDhWNKfqyVJQkS", // Password is azertyuiop :) 11 | verified: true, 12 | }, 13 | }); 14 | } 15 | 16 | doSeed().catch((err) => { 17 | console.error(err); 18 | process.exit(1); 19 | }); 20 | -------------------------------------------------------------------------------- /public/assets/fonts/Metropolis-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/fonts/Metropolis-Regular.woff2 -------------------------------------------------------------------------------- /public/assets/fonts/Metropolis-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/fonts/Metropolis-SemiBold.woff2 -------------------------------------------------------------------------------- /public/assets/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets/images/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/images/favicon-256x256.png -------------------------------------------------------------------------------- /public/assets/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets/images/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/images/favicon-96x96.png -------------------------------------------------------------------------------- /public/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoinechalifour/remix-hexagonal-architecture/df94d98bcccf5b4d87aa5792ac8642e7b3816f5d/public/assets/images/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | browserBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "build", 9 | devServerPort: 8002, 10 | serverDependenciesToBundle: [ 11 | // react-dnd and its dependencies are bundled as ESM 12 | "react-dnd", 13 | "dnd-core", 14 | "@react-dnd/invariant", 15 | "@react-dnd/shallowequal", 16 | "@react-dnd/asap", 17 | "react-dnd-html5-backend", 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/authentication/__tests__/domain/ForgotPassword.test.ts: -------------------------------------------------------------------------------- 1 | import { GenerateTestId } from "shared/id"; 2 | import { FixedClock } from "shared/time"; 3 | import { CollectEvents } from "shared/events"; 4 | import { PasswordForgotten } from "../../domain/event/PasswordForgotten"; 5 | import { ForgotPassword } from "../../usecase/ForgotPassword"; 6 | import { Accounts } from "../../domain/Accounts"; 7 | import { AccountsInMemory } from "./fakes/AccountsInMemory"; 8 | import { anAccountForgotPassword, aVerifiedAccount } from "./builders/Account"; 9 | 10 | jest.mock("uuid", () => ({ 11 | v4: () => "e775b0c1-7622-40df-a329-95f83b260c80", 12 | })); 13 | 14 | let clock: FixedClock; 15 | let accounts: Accounts; 16 | let events: CollectEvents; 17 | let forgotPassword: ForgotPassword; 18 | 19 | beforeEach(() => { 20 | const generateId = new GenerateTestId("passwordResetCode"); 21 | clock = new FixedClock(new Date("2022-05-22T12:00:00.000Z")); 22 | accounts = new AccountsInMemory(); 23 | events = new CollectEvents(); 24 | forgotPassword = new ForgotPassword(accounts, generateId, clock, events); 25 | }); 26 | 27 | it("generates the password reset token", async () => { 28 | // Arrange 29 | const theEmail = "john.doe@example.com"; 30 | const theAccount = aVerifiedAccount().forEmail(theEmail).build(); 31 | await accounts.save(theAccount); 32 | 33 | // Act 34 | await forgotPassword.execute("John.Doe@example.COM"); 35 | 36 | // Arrange 37 | expect(await accounts.accountForgotPasswordOfEmail(theEmail)).toEqual( 38 | anAccountForgotPassword() 39 | .forEmail(theEmail) 40 | .withPasswordResetToken("passwordResetCode/1") 41 | .withPasswordResetExpiration(new Date("2022-05-29T12:00:00.000Z")) 42 | .build() 43 | ); 44 | expect(events.collected()).toEqual([ 45 | new PasswordForgotten( 46 | "john.doe@example.com", 47 | "passwordResetCode/1", 48 | clock.now() 49 | ), 50 | ]); 51 | }); 52 | -------------------------------------------------------------------------------- /src/authentication/__tests__/domain/LoginFlow.test.ts: -------------------------------------------------------------------------------- 1 | import { LoginFlow } from "../../usecase/LoginFlow"; 2 | import { InvalidCredentialsError } from "../../domain/error/InvalidCredentialsError"; 3 | import { AccountsInMemory } from "./fakes/AccountsInMemory"; 4 | import { FakePasswordHasher } from "./fakes/FakePasswordHasher"; 5 | import { aVerifiedAccount } from "./builders/Account"; 6 | 7 | describe("LoginFlow", () => { 8 | let passwordHasher: FakePasswordHasher; 9 | let accounts: AccountsInMemory; 10 | let loginFlow: LoginFlow; 11 | 12 | beforeEach(() => { 13 | passwordHasher = new FakePasswordHasher(); 14 | accounts = new AccountsInMemory(); 15 | loginFlow = new LoginFlow(accounts, passwordHasher); 16 | }); 17 | 18 | it("returns an error when the account cannot be found", async () => { 19 | // Act 20 | const promise = loginFlow.execute("user-does-exist@example.com", "azerty"); 21 | 22 | // Assert 23 | await expect(promise).rejects.toThrow(InvalidCredentialsError); 24 | }); 25 | 26 | it("returns an error when the password is invalid", async () => { 27 | // Arrange 28 | await accounts.save( 29 | aVerifiedAccount() 30 | .forEmail("jane.doe@example.com") 31 | .usingPassword("jane_password") 32 | .build() 33 | ); 34 | 35 | // Act 36 | const promise = loginFlow.execute("jane.doe@example.com", "wrong_password"); 37 | 38 | // Assert 39 | await expect(promise).rejects.toThrow(InvalidCredentialsError); 40 | }); 41 | 42 | it("returns the account id when credentials are valid", async () => { 43 | // Arrange 44 | const theAccount = aVerifiedAccount() 45 | .forEmail("jane.doe@example.com") 46 | .usingPassword("jane_password") 47 | .build(); 48 | await accounts.save(theAccount); 49 | 50 | // Act 51 | const result = await loginFlow.execute( 52 | "Jane.Doe@example.COM", 53 | "jane_password" 54 | ); 55 | 56 | // Assert 57 | expect(result).toEqual(theAccount.id); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/authentication/__tests__/domain/VerifyAccount.test.ts: -------------------------------------------------------------------------------- 1 | import { VerifyAccount } from "../../usecase/VerifyAccount"; 2 | import { AccountsInMemory } from "./fakes/AccountsInMemory"; 3 | import { aUnverifiedAccount, aVerifiedAccount } from "./builders/Account"; 4 | import { InvalidVerificationTokenError } from "../../domain/error/InvalidVerificationTokenError"; 5 | 6 | describe("Verifying an account", () => { 7 | it("verifies the account when the token is valid", async () => { 8 | // Arrange 9 | const accounts = new AccountsInMemory(); 10 | const verifyAccount = new VerifyAccount(accounts); 11 | const theAccount = aUnverifiedAccount() 12 | .forEmail("john.doe@example.com") 13 | .withToken("john-token") 14 | .build(); 15 | await accounts.save(theAccount); 16 | 17 | // Act 18 | const accountId = await verifyAccount.execute( 19 | "JOHN.DOE@EXAMPLE.COM", 20 | theAccount.verificationToken 21 | ); 22 | 23 | // Assert 24 | expect(await accounts.verifiedAccountOfEmail(theAccount.email)).toEqual( 25 | aVerifiedAccount().forEmail(theAccount.email).build() 26 | ); 27 | expect(accountId).toEqual(theAccount.id); 28 | }); 29 | 30 | it("throws an error when the token is invalid", async () => { 31 | // Arrange 32 | const accounts = new AccountsInMemory(); 33 | const verifyAccount = new VerifyAccount(accounts); 34 | const theAccount = aUnverifiedAccount() 35 | .forEmail("john.doe@example.com") 36 | .withToken("john-token") 37 | .build(); 38 | await accounts.save(theAccount); 39 | 40 | // Act 41 | const result = verifyAccount.execute( 42 | theAccount.email, 43 | "some invalid token" 44 | ); 45 | 46 | // Assert 47 | await expect(result).rejects.toEqual( 48 | new InvalidVerificationTokenError("some invalid token") 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/authentication/__tests__/domain/fakes/FakePasswordHasher.ts: -------------------------------------------------------------------------------- 1 | import { PasswordHasher } from "../../../domain/PasswordHasher"; 2 | 3 | export class FakePasswordHasher implements PasswordHasher { 4 | async hash(password: string): Promise { 5 | return password; 6 | } 7 | 8 | async verify(password: string, hash: string): Promise { 9 | return password === hash; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/authentication/application/AuthenticationEventsConsumer.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { Inject, Injectable } from "@nestjs/common"; 3 | import { OnEvent } from "@nestjs/event-emitter"; 4 | import { MAILER, Mailer } from "shared/mail"; 5 | import { UserRegistered } from "../domain/event/UserRegistered"; 6 | import { PasswordForgotten } from "../domain/event/PasswordForgotten"; 7 | import { PasswordChanged } from "../domain/event/PasswordChanged"; 8 | 9 | @Injectable() 10 | export class AuthenticationEventsConsumer { 11 | private readonly baseUrl: string; 12 | 13 | constructor( 14 | @Inject(MAILER) 15 | private readonly mailer: Mailer 16 | ) { 17 | assert(process.env.BASE_URL, "no base url configured"); 18 | this.baseUrl = process.env.BASE_URL; 19 | } 20 | 21 | @OnEvent(UserRegistered.TYPE) 22 | async onUserRegistered(event: UserRegistered) { 23 | await this.mailer.send({ 24 | to: event.email, 25 | templateId: "d-5a6d53b34cb0463bb1d7dc17c06a1aca", 26 | data: { 27 | verify_account_url: `${this.baseUrl}/verify-account?email=${event.email}&token=${event.verificationToken}`, 28 | }, 29 | }); 30 | } 31 | 32 | @OnEvent(PasswordForgotten.TYPE) 33 | async onPasswordForgotten(event: PasswordForgotten) { 34 | await this.mailer.send({ 35 | to: event.email, 36 | templateId: "d-3ded8d05f2bf47c0a74e7246dd997242", 37 | data: { 38 | reset_password_url: `${this.baseUrl}/reset-password?email=${event.email}&token=${event.passwordResetToken}`, 39 | }, 40 | }); 41 | } 42 | 43 | @OnEvent(PasswordChanged.TYPE) 44 | async onPasswordChanged(event: PasswordChanged) { 45 | await this.mailer.send({ 46 | to: event.email, 47 | templateId: "d-6a6964c87a234750a1aec4cc4c342f46", 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/authentication/domain/Accounts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AccountForgotPassword, 3 | UnverifiedAccount, 4 | VerifiedAccount, 5 | } from "./Account"; 6 | 7 | export interface Accounts { 8 | verifiedAccountOfEmail(email: string): Promise; 9 | unverifiedAccountOfEmail(email: string): Promise; 10 | accountForgotPasswordOfEmail(email: string): Promise; 11 | save( 12 | account: UnverifiedAccount | VerifiedAccount | AccountForgotPassword 13 | ): Promise; 14 | } 15 | -------------------------------------------------------------------------------- /src/authentication/domain/Authenticator.ts: -------------------------------------------------------------------------------- 1 | export interface CurrentUser { 2 | id: string; 3 | sessionId: string; 4 | } 5 | 6 | export interface Authenticator { 7 | isAuthenticated(): Promise; 8 | currentUser(): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/FetchAuthenticationStatus.ts: -------------------------------------------------------------------------------- 1 | export type AuthenticationStatusDto = { 2 | authenticated: boolean; 3 | }; 4 | 5 | export interface FetchAuthenticationStatus { 6 | run(): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/authentication/domain/PasswordHasher.ts: -------------------------------------------------------------------------------- 1 | export interface PasswordHasher { 2 | hash(password: string): Promise; 3 | verify(password: string, hash: string): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /src/authentication/domain/error/AccountAlreadyVerifiedError.ts: -------------------------------------------------------------------------------- 1 | export class AccountAlreadyVerifiedError extends Error { 2 | constructor(email: string) { 3 | super(`Account ${email} is already verified`); 4 | } 5 | 6 | static is(err: unknown): err is AccountAlreadyVerifiedError { 7 | return err instanceof AccountAlreadyVerifiedError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/error/AccountNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class AccountNotFoundError extends Error { 2 | constructor(email: string) { 3 | super(`Account ${email} was not found`); 4 | } 5 | 6 | static is(err: unknown): err is AccountNotFoundError { 7 | return err instanceof AccountNotFoundError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/error/AccountNotVerifiedError.ts: -------------------------------------------------------------------------------- 1 | export class AccountNotVerifiedError extends Error { 2 | constructor(email: string) { 3 | super(`Account ${email} is not verified`); 4 | } 5 | 6 | static is(err: unknown): err is AccountNotVerifiedError { 7 | return err instanceof AccountNotVerifiedError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/error/EmailAlreadyInUseError.ts: -------------------------------------------------------------------------------- 1 | export class EmailAlreadyInUseError extends Error { 2 | constructor(private readonly email: string) { 3 | super(`Email ${email} is already in use`); 4 | } 5 | 6 | static is(err: unknown): err is EmailAlreadyInUseError { 7 | return err instanceof EmailAlreadyInUseError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/error/InvalidCredentialsError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidCredentialsError extends Error { 2 | static is(err: unknown): err is InvalidCredentialsError { 3 | return err instanceof InvalidCredentialsError; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/authentication/domain/error/InvalidPasswordResetTokenError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidPasswordResetTokenError extends Error { 2 | constructor(token: string) { 3 | super(`Invalid password reset token ${token}`); 4 | } 5 | 6 | static is(err: unknown): err is InvalidPasswordResetTokenError { 7 | return err instanceof InvalidPasswordResetTokenError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/error/InvalidVerificationTokenError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidVerificationTokenError extends Error { 2 | constructor(token: string) { 3 | super(`Token ${token} is invalid`); 4 | } 5 | 6 | static is(err: unknown): err is InvalidVerificationTokenError { 7 | return err instanceof InvalidVerificationTokenError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/error/PasswordResetTokenExpiredError.ts: -------------------------------------------------------------------------------- 1 | export class PasswordResetTokenExpiredError extends Error { 2 | constructor(token: string) { 3 | super(`Password reset token ${token} is expired`); 4 | } 5 | 6 | static is(err: unknown): err is PasswordResetTokenExpiredError { 7 | return err instanceof PasswordResetTokenExpiredError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/event/PasswordChanged.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "shared/events"; 2 | 3 | export class PasswordChanged extends Event { 4 | static TYPE = "user.password-changed"; 5 | 6 | constructor(public readonly email: string, publishedAt: Date) { 7 | super(PasswordChanged.TYPE, publishedAt); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/authentication/domain/event/PasswordForgotten.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "shared/events"; 2 | 3 | export class PasswordForgotten extends Event { 4 | static TYPE = "user.password-forgotten"; 5 | 6 | constructor( 7 | public readonly email: string, 8 | public readonly passwordResetToken: string, 9 | publishedAt: Date 10 | ) { 11 | super(PasswordForgotten.TYPE, publishedAt); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/authentication/domain/event/UserRegistered.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "shared/events"; 2 | 3 | export class UserRegistered extends Event { 4 | static TYPE = "user.registered"; 5 | 6 | constructor( 7 | public readonly email: string, 8 | public readonly verificationToken: string, 9 | publishedAt: Date 10 | ) { 11 | super(UserRegistered.TYPE, publishedAt); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/authentication/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./application/AuthenticationApplicationService"; 2 | export * from "./application/AuthenticationEventsConsumer"; 3 | export * from "./domain/Authenticator"; 4 | export * from "./domain/FetchAuthenticationStatus"; 5 | export * from "./domain/error/AccountNotFoundError"; 6 | export * from "./domain/error/AccountAlreadyVerifiedError"; 7 | export * from "./domain/error/InvalidPasswordResetTokenError"; 8 | export * from "./domain/error/InvalidVerificationTokenError"; 9 | export * from "./domain/error/PasswordResetTokenExpiredError"; 10 | export * from "./infrastructure/AccountDatabaseRepository"; 11 | export * from "./infrastructure/FetchAuthenticationStatusSessionQuery"; 12 | export * from "./infrastructure/BCryptPasswordHasher"; 13 | -------------------------------------------------------------------------------- /src/authentication/infrastructure/BCryptPasswordHasher.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import bcrypt from "bcrypt"; 3 | import { PasswordHasher } from "../domain/PasswordHasher"; 4 | 5 | @Injectable() 6 | export class BCryptPasswordHasher implements PasswordHasher { 7 | hash(password: string): Promise { 8 | return bcrypt.hash(password, 10); 9 | } 10 | 11 | verify(password: string, hash: string): Promise { 12 | return bcrypt.compare(password, hash); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/authentication/infrastructure/FetchAuthenticationStatusSessionQuery.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { AUTHENTICATOR } from "../../keys"; 3 | import { Authenticator } from "../domain/Authenticator"; 4 | import { 5 | AuthenticationStatusDto, 6 | FetchAuthenticationStatus, 7 | } from "../domain/FetchAuthenticationStatus"; 8 | 9 | @Injectable() 10 | export class FetchAuthenticationStatusSessionQuery 11 | implements FetchAuthenticationStatus 12 | { 13 | constructor( 14 | @Inject(AUTHENTICATOR) 15 | private readonly authenticator: Authenticator 16 | ) {} 17 | 18 | async run(): Promise { 19 | return { 20 | authenticated: await this.authenticator.isAuthenticated(), 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/authentication/usecase/ForgotPassword.ts: -------------------------------------------------------------------------------- 1 | import type { GenerateId } from "shared/id"; 2 | import type { Events } from "shared/events"; 3 | import type { Clock } from "shared/time"; 4 | import type { Accounts } from "../domain/Accounts"; 5 | import { generateResetPasswordToken } from "../domain/Account"; 6 | import { PasswordForgotten } from "../domain/event/PasswordForgotten"; 7 | 8 | export class ForgotPassword { 9 | constructor( 10 | private readonly accounts: Accounts, 11 | private readonly generateId: GenerateId, 12 | private readonly clock: Clock, 13 | private readonly events: Events 14 | ) {} 15 | 16 | async execute(email: string) { 17 | const account = generateResetPasswordToken( 18 | await this.accounts.verifiedAccountOfEmail(email), 19 | this.generateId, 20 | this.clock 21 | ); 22 | await this.accounts.save(account); 23 | await this.events.publish( 24 | new PasswordForgotten( 25 | account.email, 26 | account.passwordResetToken, 27 | this.clock.now() 28 | ) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/authentication/usecase/LoginFlow.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from "../domain/Accounts"; 2 | import { PasswordHasher } from "../domain/PasswordHasher"; 3 | import { InvalidCredentialsError } from "../domain/error/InvalidCredentialsError"; 4 | 5 | export class LoginFlow { 6 | constructor( 7 | private readonly accounts: Accounts, 8 | private readonly passwordHasher: PasswordHasher 9 | ) {} 10 | 11 | async execute(email: string, password: string): Promise { 12 | const account = await this.accounts.verifiedAccountOfEmail(email); 13 | const isValidCredentials = await this.passwordHasher.verify( 14 | password, 15 | account.hash 16 | ); 17 | 18 | if (!isValidCredentials) throw new InvalidCredentialsError(); 19 | return account.id; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/authentication/usecase/RegisterFlow.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { GenerateId } from "shared/id"; 3 | import type { Events } from "shared/events"; 4 | import type { Accounts } from "../domain/Accounts"; 5 | import type { PasswordHasher } from "../domain/PasswordHasher"; 6 | import { register } from "../domain/Account"; 7 | import { UserRegistered } from "../domain/event/UserRegistered"; 8 | 9 | export class RegisterFlow { 10 | constructor( 11 | private readonly credentials: Accounts, 12 | private readonly generateId: GenerateId, 13 | private readonly passwordHasher: PasswordHasher, 14 | private readonly clock: Clock, 15 | private readonly events: Events 16 | ) {} 17 | 18 | async execute(username: string, password: string) { 19 | const account = await register( 20 | username, 21 | password, 22 | this.generateId, 23 | this.passwordHasher 24 | ); 25 | 26 | await this.credentials.save(account); 27 | await this.events.publish( 28 | new UserRegistered( 29 | account.email, 30 | account.verificationToken, 31 | this.clock.now() 32 | ) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/authentication/usecase/ResetPassword.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { Accounts } from "../domain/Accounts"; 4 | import type { PasswordHasher } from "../domain/PasswordHasher"; 5 | import { resetPassword } from "../domain/Account"; 6 | import { PasswordChanged } from "../domain/event/PasswordChanged"; 7 | 8 | export class ResetPassword { 9 | constructor( 10 | private readonly accounts: Accounts, 11 | private readonly passwordHasher: PasswordHasher, 12 | private readonly clock: Clock, 13 | private readonly events: Events 14 | ) {} 15 | 16 | async execute(email: string, token: string, newPassword: string) { 17 | email = email.toLowerCase(); 18 | const account = await this.accounts.accountForgotPasswordOfEmail(email); 19 | 20 | await this.accounts.save( 21 | await resetPassword( 22 | account, 23 | token, 24 | newPassword, 25 | this.passwordHasher, 26 | this.clock 27 | ) 28 | ); 29 | await this.events.publish(new PasswordChanged(email, this.clock.now())); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/authentication/usecase/VerifyAccount.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from "../domain/Accounts"; 2 | import { verify } from "../domain/Account"; 3 | 4 | export class VerifyAccount { 5 | constructor(private readonly accounts: Accounts) {} 6 | 7 | async execute(email: string, token: string) { 8 | const account = await this.accounts.unverifiedAccountOfEmail(email); 9 | await this.accounts.save(verify(account, token)); 10 | return account.id; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/keys.ts: -------------------------------------------------------------------------------- 1 | export const PRISMA = Symbol("prisma"); 2 | export const AUTHENTICATOR = Symbol("authenticator"); 3 | export const SESSION_CONFIG = Symbol("sessionConfig"); 4 | export const REMIX_HANDLER = Symbol("remixHandler"); 5 | export const ACTIONS_CLASS = Symbol("actionsClass"); 6 | export const LOADERS_CLASS = Symbol("loadersClass"); 7 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { NestFactory } from "@nestjs/core"; 3 | import { ApplicationModule } from "web"; 4 | 5 | const session = { 6 | name: "__session", 7 | maxAge: 60 * 60 * 24, 8 | httpOnly: true, 9 | sameSite: "strict" as const, 10 | secrets: [process.env.SESSION_SECRET!], 11 | }; 12 | 13 | async function bootstrap() { 14 | const app = await NestFactory.create( 15 | ApplicationModule.register({ 16 | session, 17 | remixHandlerPath: path.join(__dirname, "../../build"), 18 | }), 19 | { 20 | bodyParser: false, 21 | } 22 | ); 23 | 24 | return app.listen(process.env.PORT ?? 3000); 25 | } 26 | 27 | bootstrap(); 28 | -------------------------------------------------------------------------------- /src/shared/client/Authentication.ts: -------------------------------------------------------------------------------- 1 | export type AuthenticationErrorDto = { 2 | error: string | null; 3 | }; 4 | -------------------------------------------------------------------------------- /src/shared/client/HomePage.ts: -------------------------------------------------------------------------------- 1 | export interface TodoListSummaryDto { 2 | id: string; 3 | title: string; 4 | createdAt: string; 5 | lastUpdatedAt: string | null; 6 | numberOfTodos: number; 7 | } 8 | 9 | export interface TodoListPermissionDto { 10 | permissions: { 11 | archive: boolean; 12 | leave: boolean; 13 | }; 14 | } 15 | 16 | export type TodoListSummaryWithPermissionDto = TodoListSummaryDto & 17 | TodoListPermissionDto; 18 | 19 | export interface HomePageDto { 20 | totalNumberOfDoingTodos: number; 21 | todoListsOwned: TodoListSummaryWithPermissionDto[]; 22 | todoListsContributed: TodoListSummaryWithPermissionDto[]; 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/client/Todo.ts: -------------------------------------------------------------------------------- 1 | export interface DoneTodos { 2 | id: string; 3 | title: string; 4 | isDone: true; 5 | createdAt: string; 6 | tags: string[]; 7 | } 8 | 9 | export interface DoingTodoDto { 10 | id: string; 11 | title: string; 12 | isDone: false; 13 | createdAt: string; 14 | tags: string[]; 15 | } 16 | 17 | export type TodoDto = DoneTodos | DoingTodoDto; 18 | 19 | export interface AddTodoErrorDto { 20 | todoTitle?: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/client/TodoList.ts: -------------------------------------------------------------------------------- 1 | import type { DoneTodos, DoingTodoDto } from "./Todo"; 2 | 3 | export interface TodoListDetailsDto { 4 | id: string; 5 | version: string; 6 | title: string; 7 | createdAt: string; 8 | doingTodos: DoingTodoDto[]; 9 | doneTodos: DoneTodos[]; 10 | tags: string[]; 11 | } 12 | 13 | export interface TodoListContributorDto { 14 | id: string; 15 | email: string; 16 | shortName: string; 17 | role: "owner" | "contributor"; 18 | } 19 | 20 | export interface TodoListPageDto { 21 | isOwner: boolean; 22 | todoList: TodoListDetailsDto; 23 | completion: number; 24 | contributors: TodoListContributorDto[]; 25 | } 26 | 27 | export interface AddTodoListErrorDto { 28 | title?: string; 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TodoList"; 2 | export * from "./Authentication"; 3 | export * from "./HomePage"; 4 | export * from "./Todo"; 5 | -------------------------------------------------------------------------------- /src/shared/database/Prisma.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from "@nestjs/common"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | @Injectable() 5 | export class Prisma extends PrismaClient implements OnModuleInit { 6 | onModuleInit() { 7 | return this.$connect(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/database/PrismaRepository.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; 3 | import { Inject } from "@nestjs/common"; 4 | import { PRISMA } from "../../keys"; 5 | 6 | export type PrismaQueryRunner = Omit< 7 | PrismaClient, 8 | "$connect" | "$disconnect" | "$on" | "$transaction" | "$use" 9 | >; 10 | 11 | export abstract class PrismaRepository { 12 | constructor(@Inject(PRISMA) protected readonly prisma: PrismaQueryRunner) {} 13 | 14 | protected isUniqueConstraintFailed( 15 | e: unknown 16 | ): e is PrismaClientKnownRequestError { 17 | return e instanceof PrismaClientKnownRequestError && e.code === "P2002"; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/shared/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Prisma"; 2 | export * from "./PrismaRepository"; 3 | -------------------------------------------------------------------------------- /src/shared/events/CollectEvents.ts: -------------------------------------------------------------------------------- 1 | import { Events } from "./Events"; 2 | import { Event } from "./Event"; 3 | 4 | export class CollectEvents implements Events { 5 | private events: Event[] = []; 6 | 7 | async publish(event: Event): Promise { 8 | this.events.push(event); 9 | } 10 | 11 | collected() { 12 | return this.events; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/events/Event.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from "uuid"; 2 | 3 | export class Event { 4 | public readonly id = uuid(); 5 | 6 | constructor( 7 | public readonly type: string, 8 | public readonly publishedAt: Date 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/events/Events.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./Event"; 2 | 3 | export interface Events { 4 | publish(event: T): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/events/NestEvents.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { EventEmitter2 } from "@nestjs/event-emitter"; 3 | import { Events } from "./Events"; 4 | import { Event } from "./Event"; 5 | 6 | @Injectable() 7 | export class NestEvents implements Events { 8 | constructor(private readonly eventEmitter: EventEmitter2) {} 9 | 10 | async publish(event: Event) { 11 | this.eventEmitter.emit(event.type, event); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Event"; 2 | export * from "./Events"; 3 | export * from "./NestEvents"; 4 | export * from "./CollectEvents"; 5 | -------------------------------------------------------------------------------- /src/shared/id/GenerateId.ts: -------------------------------------------------------------------------------- 1 | export interface GenerateId { 2 | generate(): string; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/id/GenerateTestId.ts: -------------------------------------------------------------------------------- 1 | import type { GenerateId } from "shared/id"; 2 | 3 | export class GenerateTestId implements GenerateId { 4 | private count = 0; 5 | 6 | constructor(private prefix: string) {} 7 | 8 | generate(): string { 9 | this.count += 1; 10 | 11 | return `${this.prefix}/${this.count}`; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/id/GenerateUUID.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { v4 as uuid } from "uuid"; 3 | import { GenerateId } from "./GenerateId"; 4 | 5 | @Injectable() 6 | export class GenerateUUID implements GenerateId { 7 | generate(): string { 8 | return uuid(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/id/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GenerateId"; 2 | export * from "./GenerateUUID"; 3 | export * from "./GenerateTestId"; 4 | -------------------------------------------------------------------------------- /src/shared/lib/array.ts: -------------------------------------------------------------------------------- 1 | export function moveArrayItem( 2 | source: T[], 3 | currentIndex: number, 4 | newIndex: number 5 | ) { 6 | const copy = [...source]; 7 | const element = copy[currentIndex]; 8 | copy.splice(currentIndex, 1); 9 | copy.splice(newIndex, 0, element); 10 | 11 | return copy; 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./array"; 2 | -------------------------------------------------------------------------------- /src/shared/mail/FakeMailer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { Mail } from "./Mail"; 3 | import { Mailer } from "./Mailer"; 4 | 5 | @Injectable() 6 | export class FakeMailer implements Mailer { 7 | async send(mail: Mail): Promise { 8 | console.log(mail); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/mail/Mail.ts: -------------------------------------------------------------------------------- 1 | export interface Mail { 2 | to: string; 3 | templateId: string; 4 | data?: Record; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/mail/Mailer.ts: -------------------------------------------------------------------------------- 1 | import { Mail } from "./Mail"; 2 | 3 | export const MAILER = Symbol("MAILER"); 4 | 5 | export interface Mailer { 6 | send(mail: Mail): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/mail/SendGridMailer.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { Injectable } from "@nestjs/common"; 3 | import sgMail from "@sendgrid/mail"; 4 | import { Mail } from "./Mail"; 5 | import { Mailer } from "./Mailer"; 6 | 7 | @Injectable() 8 | export class SendGridMailer implements Mailer { 9 | private readonly sender: string; 10 | 11 | constructor() { 12 | assert(process.env.SENDGRID_API_KEY, "No api key configured"); 13 | assert(process.env.SENDGRID_SENDER, "No sender configured"); 14 | 15 | sgMail.setApiKey(process.env.SENDGRID_API_KEY!); 16 | this.sender = process.env.SENDGRID_SENDER; 17 | } 18 | 19 | async send(mail: Mail): Promise { 20 | try { 21 | await sgMail.send({ 22 | to: mail.to, // Change to your recipient 23 | from: this.sender, // Change to your verified sender 24 | templateId: mail.templateId, 25 | dynamicTemplateData: mail.data, 26 | hideWarnings: true, 27 | }); 28 | } catch (error) { 29 | console.error(error); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/mail/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FakeMailer"; 2 | export * from "./Mailer"; 3 | export * from "./SendGridMailer"; 4 | -------------------------------------------------------------------------------- /src/shared/time/Clock.ts: -------------------------------------------------------------------------------- 1 | export interface Clock { 2 | now(): Date; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/time/FixedClock.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "./Clock"; 2 | 3 | export class FixedClock implements Clock { 4 | constructor(private fixedTime = new Date("2022-01-05T12:00:00.000Z")) {} 5 | 6 | now(): Date { 7 | return this.fixedTime; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/time/RealClock.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import type { Clock } from "./Clock"; 3 | 4 | @Injectable() 5 | export class RealClock implements Clock { 6 | now(): Date { 7 | return new Date(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/time/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Clock"; 2 | export * from "./RealClock"; 3 | export * from "./FixedClock"; 4 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/persistence/TodoDatabaseRepository.test.ts: -------------------------------------------------------------------------------- 1 | import type { Todos } from "../../domain/Todos"; 2 | 3 | import { Clock, FixedClock } from "shared/time"; 4 | import { PrismaClient } from "@prisma/client"; 5 | import { 6 | TodoDatabaseRepository, 7 | TodoListDatabaseRepository, 8 | } from "todo-list-manager"; 9 | import { markAsDoing, markAsDone } from "../../domain/Todo"; 10 | import { TodoLists } from "../../domain/TodoLists"; 11 | import { aTodo } from "../usecase/builders/Todo"; 12 | import { aTodoList } from "../usecase/builders/TodoList"; 13 | import { 14 | configureTestingDatabaseEnvironment, 15 | prepareDatabase, 16 | } from "./database"; 17 | 18 | let todos: Todos; 19 | let todoLists: TodoLists; 20 | let clock: Clock; 21 | let prisma: PrismaClient; 22 | 23 | beforeAll(() => configureTestingDatabaseEnvironment()); 24 | 25 | beforeEach(async () => { 26 | await prepareDatabase(); 27 | 28 | clock = new FixedClock(); 29 | prisma = new PrismaClient(); 30 | todos = new TodoDatabaseRepository(prisma); 31 | todoLists = new TodoListDatabaseRepository(prisma); 32 | }); 33 | 34 | afterEach(() => prisma.$disconnect()); 35 | 36 | it("persists and retrieves todos", async () => { 37 | const theTodoId = "eb7531dd-0e2b-47b6-9bca-47182995f3ab"; 38 | const todoList = aTodoList().build(); 39 | await todoLists.save(todoList); 40 | 41 | // Persist todos 42 | let todo = aTodo().withId(theTodoId).ofTodoList(todoList.id).build(); 43 | await todos.save(todo); 44 | expect(await todos.ofId(theTodoId)).toEqual(todo); 45 | 46 | // Updates todos 47 | todo = markAsDone(todo, clock); 48 | await todos.save(todo); 49 | expect(await todos.ofId(theTodoId)).toEqual(todo); 50 | 51 | // Removes todos 52 | await todos.remove(theTodoId); 53 | await expect(todos.ofId(theTodoId)).rejects.toThrow( 54 | new Error("No Todo found") 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/persistence/TodoListPermissionsDatabaseRepository.test.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListPermissions } from "../../domain/TodoListPermissions"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import { TodoListPermissionsDatabaseRepository } from "todo-list-manager"; 4 | import { grantAccess } from "../../domain/TodoListPermission"; 5 | import { aTodoListPermission } from "../usecase/builders/TodoListPermission"; 6 | import { 7 | configureTestingDatabaseEnvironment, 8 | prepareDatabase, 9 | } from "./database"; 10 | 11 | let prisma: PrismaClient; 12 | let todoListPermissions: TodoListPermissions; 13 | 14 | beforeAll(() => configureTestingDatabaseEnvironment()); 15 | 16 | beforeEach(async () => { 17 | await prepareDatabase(); 18 | 19 | prisma = new PrismaClient(); 20 | todoListPermissions = new TodoListPermissionsDatabaseRepository(prisma); 21 | }); 22 | 23 | it("persists and retrieves todo list permissions", async () => { 24 | // Arrange 25 | const theTodoListId = "22693e37-1c98-40c5-841f-40c53f11d86b"; 26 | const theOwnerId = "592c26c0-3144-4ef0-b1ba-fa748e6ba2e3"; 27 | const todoListPermission = aTodoListPermission() 28 | .forTodoList(theTodoListId) 29 | .forOwner(theOwnerId) 30 | .withContributors( 31 | "b3bb9c18-1d45-4590-9f2e-870c9deebe06", 32 | "b53fe547-437c-4fed-8824-797bfe0305c6" 33 | ) 34 | .build(); 35 | 36 | // Persist 37 | await todoListPermissions.save(todoListPermission); 38 | 39 | expect(await todoListPermissions.ofTodoList(theTodoListId)).toEqual( 40 | todoListPermission 41 | ); 42 | 43 | // By owner 44 | expect(await todoListPermissions.ofContributor(theOwnerId)).toEqual([ 45 | todoListPermission, 46 | ]); 47 | 48 | // Update, by contributor 49 | const newContributorId = "03c02ef9-d429-4d5a-bde7-6cc0da1d0912"; 50 | const newPermissions = grantAccess(todoListPermission, newContributorId); 51 | await todoListPermissions.save(newPermissions); 52 | 53 | expect(await todoListPermissions.ofContributor(newContributorId)).toEqual([ 54 | newPermissions, 55 | ]); 56 | }); 57 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/persistence/database.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | export async function prepareDatabase() { 4 | execSync("yarn integration:clean"); 5 | } 6 | 7 | export function configureTestingDatabaseEnvironment() { 8 | process.env.DATABASE_URL = 9 | "postgresql://user:password@localhost:6070/todolistmanager-integration"; 10 | } 11 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/builders/Todo.ts: -------------------------------------------------------------------------------- 1 | import type { Todo } from "../../../domain/Todo"; 2 | 3 | export interface TodoBuilder { 4 | todo: Todo; 5 | withId(id: string): TodoBuilder; 6 | withTitle(title: string): TodoBuilder; 7 | ofTodoList(todoListId: string): TodoBuilder; 8 | taggedAs(...tags: string[]): TodoBuilder; 9 | completed({ at }: { at: Date }): TodoBuilder; 10 | uncompleted(): TodoBuilder; 11 | build(): Todo; 12 | } 13 | 14 | export const aTodo = (): TodoBuilder => ({ 15 | todo: { 16 | id: "9d3dd87b-c570-4271-92eb-f4d6ca011ffa", 17 | createdAt: new Date("2022-01-15T12:00:00.000Z"), 18 | title: "Buy beers", 19 | todoListId: "3608ffe6-c397-416d-8b49-a17340c5fde4", 20 | isDone: false, 21 | doneAt: null, 22 | tags: [], 23 | }, 24 | withId(id: string): TodoBuilder { 25 | this.todo.id = id; 26 | return this; 27 | }, 28 | ofTodoList(todoListId: string): TodoBuilder { 29 | this.todo.todoListId = todoListId; 30 | return this; 31 | }, 32 | withTitle(title: string): TodoBuilder { 33 | this.todo.title = title; 34 | return this; 35 | }, 36 | taggedAs(...tags): TodoBuilder { 37 | this.todo.tags = tags; 38 | return this; 39 | }, 40 | completed({ at }): TodoBuilder { 41 | this.todo.isDone = true; 42 | this.todo.doneAt = at; 43 | return this; 44 | }, 45 | uncompleted(): TodoBuilder { 46 | this.todo.isDone = false; 47 | return this; 48 | }, 49 | build(): Todo { 50 | return this.todo; 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/builders/TodoList.ts: -------------------------------------------------------------------------------- 1 | import type { TodoList } from "../../../domain/TodoList"; 2 | import { TodoId } from "../../../domain/Todo"; 3 | 4 | export interface TodoListBuilder { 5 | todoList: TodoList; 6 | withId(id: string): TodoListBuilder; 7 | withTitle(title: string): TodoListBuilder; 8 | withTodosOrder(...todoIds: TodoId[]): TodoListBuilder; 9 | build(): TodoList; 10 | } 11 | 12 | export const aTodoList = (): TodoListBuilder => ({ 13 | todoList: { 14 | id: "6885f4fc-dc8e-46ef-bbbd-141de0db6c9c", 15 | title: "A sample todo list", 16 | createdAt: new Date().toISOString(), 17 | todosOrder: [], 18 | }, 19 | withId(id: string) { 20 | this.todoList.id = id; 21 | return this; 22 | }, 23 | withTitle(title: string): TodoListBuilder { 24 | this.todoList.title = title; 25 | return this; 26 | }, 27 | withTodosOrder(...todoIds: TodoId[]): TodoListBuilder { 28 | this.todoList.todosOrder = todoIds; 29 | return this; 30 | }, 31 | build() { 32 | return this.todoList; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/builders/TodoListPermission.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListPermission } from "../../../domain/TodoListPermission"; 2 | import type { TodoListId } from "../../../domain/TodoList"; 3 | import type { OwnerId } from "../../../domain/OwnerId"; 4 | import type { ContributorId } from "../../../domain/ContributorId"; 5 | 6 | export interface TodoListPermissionBuilder { 7 | todoListPermission: TodoListPermission; 8 | forTodoList(todoListId: TodoListId): TodoListPermissionBuilder; 9 | forOwner(ownerId: OwnerId): TodoListPermissionBuilder; 10 | withContributors( 11 | ...contributorsIds: ContributorId[] 12 | ): TodoListPermissionBuilder; 13 | withNewContributors( 14 | ...contributorsIds: ContributorId[] 15 | ): TodoListPermissionBuilder; 16 | build(): TodoListPermission; 17 | } 18 | 19 | export const aTodoListPermission = (): TodoListPermissionBuilder => ({ 20 | todoListPermission: { 21 | todoListId: "todoList/1", 22 | ownerId: "owner/1", 23 | contributorsIds: [], 24 | }, 25 | forTodoList(todoListId: TodoListId): TodoListPermissionBuilder { 26 | this.todoListPermission.todoListId = todoListId; 27 | return this; 28 | }, 29 | forOwner(ownerId: OwnerId): TodoListPermissionBuilder { 30 | this.todoListPermission.ownerId = ownerId; 31 | return this; 32 | }, 33 | withContributors( 34 | ...contributorsIds: ContributorId[] 35 | ): TodoListPermissionBuilder { 36 | this.todoListPermission.contributorsIds = contributorsIds; 37 | return this; 38 | }, 39 | withNewContributors(...contributorsIds: ContributorId[]) { 40 | this.todoListPermission.contributorsIds.push(...contributorsIds); 41 | return this; 42 | }, 43 | build(): TodoListPermission { 44 | return this.todoListPermission; 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/fakes/ContributorsInMemory.ts: -------------------------------------------------------------------------------- 1 | import type { Contributors } from "../../../domain/Contributors"; 2 | import type { ContributorId } from "../../../domain/ContributorId"; 3 | import type { Contributor } from "../../../domain/Contributor"; 4 | 5 | export class ContributorsInMemory implements Contributors { 6 | private database = new Map(); 7 | private databaseByEmail = new Map(); 8 | 9 | addTestContributor(contributor: Contributor) { 10 | this.database.set(contributor.id, contributor); 11 | this.databaseByEmail.set(contributor.email, contributor); 12 | } 13 | 14 | async ofEmail(email: string): Promise { 15 | const contributor = this.databaseByEmail.get(email); 16 | if (contributor == null) throw new Error("oups"); 17 | 18 | return contributor; 19 | } 20 | 21 | async ofIds(contributorsIds: string[]): Promise { 22 | throw new Error("Method not implemented."); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/fakes/TodoListPermissionsInMemory.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListPermissions } from "../../../domain/TodoListPermissions"; 2 | import type { TodoListId } from "../../../domain/TodoList"; 3 | import type { TodoListPermission } from "../../../domain/TodoListPermission"; 4 | import type { ContributorId } from "../../../domain/ContributorId"; 5 | 6 | export class TodoListPermissionsInMemory implements TodoListPermissions { 7 | private __database = new Map(); 8 | 9 | async ofTodoList(todoListId: TodoListId): Promise { 10 | const todoListPermission = this.__database.get(todoListId); 11 | if (!todoListPermission) 12 | throw new Error(`Not permissions found for todolist ${todoListId}`); 13 | 14 | return todoListPermission; 15 | } 16 | 17 | async ofContributor( 18 | contributorId: ContributorId 19 | ): Promise { 20 | return [...this.__database.values()].filter( 21 | (permission) => 22 | permission.ownerId === contributorId || 23 | permission.contributorsIds.includes(contributorId) 24 | ); 25 | } 26 | 27 | async save(todoListPermission: TodoListPermission): Promise { 28 | this.__database.set(todoListPermission.todoListId, todoListPermission); 29 | } 30 | 31 | async remove(todoListPermission: TodoListPermission): Promise { 32 | this.__database.delete(todoListPermission.todoListId); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/fakes/TodoListQueryInMemory.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListDetailsDto, TodoListSummaryDto } from "shared/client"; 2 | import type { TodoListQuery } from "../../../domain/TodoListQuery"; 3 | import type { TodoListId } from "../../../domain/TodoList"; 4 | 5 | export class TodoListQueryInMemory implements TodoListQuery { 6 | private todoListDetailsDatabase = new Map(); 7 | private todoListSummariesDatabase = new Map(); 8 | 9 | withTodoListDetails(...todoListDetails: TodoListDetailsDto[]) { 10 | for (let todoListDetail of todoListDetails) { 11 | this.todoListDetailsDatabase.set(todoListDetail.id, todoListDetail); 12 | } 13 | } 14 | 15 | withTodoListSummary(...todoListSummaries: TodoListSummaryDto[]) { 16 | for (let todoListSummary of todoListSummaries) { 17 | this.todoListSummariesDatabase.set(todoListSummary.id, todoListSummary); 18 | } 19 | } 20 | 21 | async detailsOfTodoList(todoListId: TodoListId): Promise { 22 | const todoListDetailsDto = this.todoListDetailsDatabase.get(todoListId); 23 | 24 | if (todoListDetailsDto == null) 25 | throw new Error(`Todo list ${todoListId} was not found`); 26 | 27 | return todoListDetailsDto; 28 | } 29 | 30 | async summaryOfTodoLists( 31 | todoListsIds: TodoListId[] 32 | ): Promise { 33 | return todoListsIds 34 | .map((todoListId) => this.todoListSummariesDatabase.get(todoListId)) 35 | .filter((item): item is TodoListSummaryDto => item != null); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/fakes/TodoListsInMemory.ts: -------------------------------------------------------------------------------- 1 | import type { TodoLists } from "../../../domain/TodoLists"; 2 | import type { TodoList, TodoListId } from "../../../domain/TodoList"; 3 | 4 | export class TodoListsInMemory implements TodoLists { 5 | private __database = new Map(); 6 | 7 | async ofId(todoListId: TodoListId): Promise { 8 | const todoList = this.__database.get(todoListId); 9 | if (todoList) return Promise.resolve(todoList); 10 | 11 | throw new Error(`Todolist ${todoListId} not found`); 12 | } 13 | 14 | async remove(todoListId: string): Promise { 15 | this.__database.delete(todoListId); 16 | 17 | return Promise.resolve(); 18 | } 19 | 20 | async save(todoList: TodoList): Promise { 21 | this.__database.set(todoList.id, todoList); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/todo-list-manager/__tests__/usecase/fakes/TodosInMemory.ts: -------------------------------------------------------------------------------- 1 | import type { Todos } from "../../../domain/Todos"; 2 | import type { Todo, TodoId } from "../../../domain/Todo"; 3 | import type { TodoListId } from "../../../domain/TodoList"; 4 | 5 | export class TodosInMemory implements Todos { 6 | private __database = new Map(); 7 | 8 | ofId(todoId: TodoId): Promise { 9 | const todo = this.__database.get(todoId); 10 | 11 | if (!todo) throw new Error(`Todo ${todoId} not found`); 12 | 13 | return Promise.resolve(todo); 14 | } 15 | 16 | ofTodoList(todoListId: TodoListId): Promise { 17 | const todos = [...this.__database.values()].filter( 18 | (todo) => todo.todoListId === todoListId 19 | ); 20 | 21 | return Promise.resolve(todos); 22 | } 23 | 24 | remove(todoId: string): Promise { 25 | this.__database.delete(todoId); 26 | 27 | return Promise.resolve(); 28 | } 29 | 30 | save(todo: Todo): Promise { 31 | this.__database.set(todo.id, todo); 32 | 33 | return Promise.resolve(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/Contributor.ts: -------------------------------------------------------------------------------- 1 | import { ContributorId } from "./ContributorId"; 2 | 3 | export type Contributor = { 4 | id: ContributorId; 5 | email: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/ContributorId.ts: -------------------------------------------------------------------------------- 1 | export type ContributorId = string; 2 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/Contributors.ts: -------------------------------------------------------------------------------- 1 | import type { Contributor } from "./Contributor"; 2 | import type { ContributorId } from "./ContributorId"; 3 | 4 | export interface Contributors { 5 | ofIds(contributorsIds: ContributorId[]): Promise; 6 | ofEmail(email: string): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/OwnerId.ts: -------------------------------------------------------------------------------- 1 | export type OwnerId = string; 2 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/Todo.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { TodoListId } from "./TodoList"; 3 | 4 | export type TodoId = string; 5 | 6 | export type Todo = { 7 | id: TodoId; 8 | title: string; 9 | isDone: boolean; 10 | createdAt: Date; 11 | doneAt: Date | null; 12 | todoListId: TodoListId; 13 | tags: string[]; 14 | }; 15 | 16 | export const markAsDoing = (todo: Todo): Todo => ({ 17 | ...todo, 18 | isDone: false, 19 | doneAt: null, 20 | }); 21 | 22 | export const markAsDone = (todo: Todo, clock: Clock): Todo => ({ 23 | ...todo, 24 | isDone: true, 25 | doneAt: clock.now(), 26 | }); 27 | 28 | export const updateTitle = (todo: Todo, newTitle: string): Todo => ({ 29 | ...todo, 30 | title: newTitle, 31 | }); 32 | 33 | export const hasTag = (todo: Todo, tagToCheck: string): boolean => 34 | todo.tags.includes(tagToCheck); 35 | 36 | export const addTag = (todo: Todo, tagToAdd: string): Todo => { 37 | if (todo.tags.includes(tagToAdd)) return todo; 38 | if (todo.tags.length === 3) 39 | throw new Error("Todos can only have at most 3 tags"); 40 | 41 | return { 42 | ...todo, 43 | tags: [...todo.tags, tagToAdd], 44 | }; 45 | }; 46 | 47 | export const removeTag = (todo: Todo, tagToRemove: string): Todo => ({ 48 | ...todo, 49 | tags: todo.tags.filter((tag) => tag !== tagToRemove), 50 | }); 51 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/TodoListPermissions.ts: -------------------------------------------------------------------------------- 1 | import { TodoListPermission } from "./TodoListPermission"; 2 | import { TodoListId } from "./TodoList"; 3 | import { ContributorId } from "./ContributorId"; 4 | 5 | export interface TodoListPermissions { 6 | ofTodoList(todoListId: TodoListId): Promise; 7 | ofContributor(contributorId: ContributorId): Promise; 8 | save(todoListPermission: TodoListPermission): Promise; 9 | remove(todoListPermission: TodoListPermission): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/TodoListQuery.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListDetailsDto, TodoListSummaryDto } from "shared/client"; 2 | import type { TodoListId } from "./TodoList"; 3 | 4 | export interface TodoListQuery { 5 | detailsOfTodoList(todoListId: TodoListId): Promise; 6 | summaryOfTodoLists(todoListsIds: TodoListId[]): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/TodoLists.ts: -------------------------------------------------------------------------------- 1 | import type { TodoList, TodoListId } from "./TodoList"; 2 | 3 | export interface TodoLists { 4 | ofId(todoListId: TodoListId): Promise; 5 | save(todoList: TodoList): Promise; 6 | remove(todoListId: TodoListId): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/Todos.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListId } from "./TodoList"; 2 | import type { Todo, TodoId } from "./Todo"; 3 | 4 | export interface Todos { 5 | ofId(todoId: TodoId): Promise; 6 | ofTodoList(todoListId: TodoListId): Promise; 7 | save(todo: Todo): Promise; 8 | remove(todoId: string): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/error/ContributorNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class ContributorNotFoundError extends Error { 2 | constructor(email: string) { 3 | super(`Contributor ${email} was not found`); 4 | } 5 | 6 | static is(err: unknown): err is ContributorNotFoundError { 7 | return err instanceof ContributorNotFoundError; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/error/TodoListNotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { TodoListId } from "../TodoList"; 2 | 3 | export class TodoListNotFoundError extends Error { 4 | constructor(todoListId: TodoListId) { 5 | super(`TodoList ${todoListId} was not found`); 6 | } 7 | 8 | static is(err: unknown): err is TodoListNotFoundError { 9 | return err instanceof TodoListNotFoundError; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/error/TodoListPermissionDeniedError.ts: -------------------------------------------------------------------------------- 1 | import type { TodoListId } from "../TodoList"; 2 | import type { ContributorId } from "../ContributorId"; 3 | 4 | export class TodoListPermissionDeniedError extends Error { 5 | constructor(todoListId: TodoListId, contributorId: ContributorId) { 6 | super( 7 | `Access to TodoList ${todoListId} denied for contributor ${contributorId}` 8 | ); 9 | } 10 | 11 | static is(err: unknown): err is TodoListPermissionDeniedError { 12 | return err instanceof TodoListPermissionDeniedError; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TagAddedToTodo.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TagAddedToTodo extends TodoListEvent { 4 | static TYPE = "todo.tagAdded"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly todoId: string, 10 | public readonly tag: string, 11 | publishedAt: Date 12 | ) { 13 | super(TagAddedToTodo.TYPE, todoListId, contributorId, publishedAt); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TagRemovedFromTodo.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TagRemovedFromTodo extends TodoListEvent { 4 | static TYPE = "todo.tagRemoved"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly todoId: string, 10 | public readonly tag: string, 11 | publishedAt: Date 12 | ) { 13 | super(TagRemovedFromTodo.TYPE, todoListId, contributorId, publishedAt); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoAdded.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoAdded extends TodoListEvent { 4 | static TYPE = "todo.added"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly todoId: string, 10 | publishedAt: Date 11 | ) { 12 | super(TodoAdded.TYPE, todoListId, contributorId, publishedAt); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoCompletionChanged.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoCompletionChanged extends TodoListEvent { 4 | static TYPE = "todo.completionChanged"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly todoId: string, 10 | public readonly completion: "doing" | "done", 11 | publishedAt: Date 12 | ) { 13 | super(TodoCompletionChanged.TYPE, todoListId, contributorId, publishedAt); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoDeleted.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoDeleted extends TodoListEvent { 4 | static TYPE = "todo.deleted"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly todoId: string, 10 | public readonly todoTitle: string, 11 | publishedAt: Date 12 | ) { 13 | super(TodoDeleted.TYPE, todoListId, contributorId, publishedAt); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoListAccessGranted.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoListAccessGranted extends TodoListEvent { 4 | static TYPE = "todoList.accessGranted"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly newContributorId: string, 10 | publishedAt: Date 11 | ) { 12 | super(TodoListAccessGranted.TYPE, todoListId, contributorId, publishedAt); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoListAccessRevoked.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoListAccessRevoked extends TodoListEvent { 4 | static TYPE = "todoList.accessRevoked"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly previousContributorId: string, 10 | publishedAt: Date 11 | ) { 12 | super(TodoListAccessRevoked.TYPE, todoListId, contributorId, publishedAt); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoListCreated.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoListCreated extends TodoListEvent { 4 | static TYPE = "todoList.created"; 5 | 6 | constructor(todoListId: string, contributorId: string, publishedAt: Date) { 7 | super(TodoListCreated.TYPE, todoListId, contributorId, publishedAt); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoListEvent.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "shared/events"; 2 | 3 | export abstract class TodoListEvent extends Event { 4 | protected constructor( 5 | type: string, 6 | public readonly todoListId: string, 7 | public readonly contributorId: string, 8 | publishedAt: Date 9 | ) { 10 | super(type, publishedAt); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoListUpdated.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export type Changes = Record; 4 | 5 | export class TodoListUpdated extends TodoListEvent { 6 | static TYPE = "todoList.updated"; 7 | 8 | constructor( 9 | todoListId: string, 10 | contributorId: string, 11 | public readonly changes: Changes, 12 | publishedAt: Date 13 | ) { 14 | super(TodoListUpdated.TYPE, todoListId, contributorId, publishedAt); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoReordered.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export class TodoReordered extends TodoListEvent { 4 | static TYPE = "todo.reordered"; 5 | 6 | constructor( 7 | todoListId: string, 8 | contributorId: string, 9 | public readonly todoId: string, 10 | public readonly previousOrder: number, 11 | public readonly newOrder: number, 12 | publishedAt: Date 13 | ) { 14 | super(TodoReordered.TYPE, todoListId, contributorId, publishedAt); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/todo-list-manager/domain/event/TodoUpdated.ts: -------------------------------------------------------------------------------- 1 | import { TodoListEvent } from "./TodoListEvent"; 2 | 3 | export type Changes = Record; 4 | 5 | export class TodoUpdated extends TodoListEvent { 6 | static TYPE = "todo.updated"; 7 | 8 | constructor( 9 | todoListId: string, 10 | contributorId: string, 11 | public readonly todoId: string, 12 | public readonly changes: Changes, 13 | publishedAt: Date 14 | ) { 15 | super(TodoUpdated.TYPE, todoListId, contributorId, publishedAt); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/todo-list-manager/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./application/TodoListApplicationService"; 2 | export * from "./application/TodoApplicationService"; 3 | export * from "./domain/error/TodoListNotFoundError"; 4 | export * from "./domain/error/TodoListPermissionDeniedError"; 5 | export * from "./domain/error/ContributorNotFoundError"; 6 | export * from "./infrastructure/TodoListDatabaseRepository"; 7 | export * from "./infrastructure/TodoDatabaseRepository"; 8 | export * from "./infrastructure/TodoListPermissionsDatabaseRepository"; 9 | export * from "./infrastructure/TodoListDatabaseQuery"; 10 | export * from "./infrastructure/ContributorsAdapter"; 11 | -------------------------------------------------------------------------------- /src/todo-list-manager/infrastructure/ContributorsAdapter.ts: -------------------------------------------------------------------------------- 1 | import type { Contributors } from "../domain/Contributors"; 2 | import type { Contributor } from "../domain/Contributor"; 3 | import type { Account as AccountRow } from "@prisma/client"; 4 | import { Injectable } from "@nestjs/common"; 5 | import { PrismaRepository } from "shared/database"; 6 | import { ContributorNotFoundError } from "../domain/error/ContributorNotFoundError"; 7 | 8 | @Injectable() 9 | export class ContributorsAdapter 10 | extends PrismaRepository 11 | implements Contributors 12 | { 13 | async ofIds(contributorsIds: string[]): Promise { 14 | const rows = await this.prisma.account.findMany({ 15 | where: { 16 | id: { in: contributorsIds }, 17 | }, 18 | }); 19 | 20 | return rows.map(toContributor); 21 | } 22 | 23 | async ofEmail(email: string): Promise { 24 | const row = await this.prisma.account.findFirst({ 25 | where: { email }, 26 | }); 27 | 28 | if (row == null) throw new ContributorNotFoundError(email); 29 | 30 | return toContributor(row); 31 | } 32 | } 33 | 34 | function toContributor(account: AccountRow) { 35 | return { 36 | id: account.id, 37 | email: account.email, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/todo-list-manager/infrastructure/TodoDatabaseRepository.ts: -------------------------------------------------------------------------------- 1 | import type { Todos } from "../domain/Todos"; 2 | import type { TodoListId } from "../domain/TodoList"; 3 | import type { Todo, TodoId } from "../domain/Todo"; 4 | import { Injectable } from "@nestjs/common"; 5 | import { PrismaRepository } from "shared/database"; 6 | 7 | @Injectable() 8 | export class TodoDatabaseRepository extends PrismaRepository implements Todos { 9 | async ofId(todoId: TodoId): Promise { 10 | const row = await this.prisma.todo.findFirst({ 11 | where: { id: todoId }, 12 | rejectOnNotFound: true, 13 | }); 14 | 15 | return { 16 | id: row.id, 17 | title: row.title, 18 | isDone: row.isDone, 19 | createdAt: row.createdAt, 20 | doneAt: row.doneAt, 21 | todoListId: row.todoListId, 22 | tags: row.tags as string[], 23 | }; 24 | } 25 | 26 | async ofTodoList(todoListId: TodoListId): Promise { 27 | const todos = await this.prisma.todo.findMany({ 28 | where: { todoListId }, 29 | }); 30 | 31 | return todos.map((row) => ({ 32 | id: row.id, 33 | title: row.title, 34 | isDone: row.isDone, 35 | doneAt: row.doneAt, 36 | createdAt: row.createdAt, 37 | todoListId, 38 | tags: row.tags as string[], 39 | })); 40 | } 41 | 42 | async save(todo: Todo): Promise { 43 | await this.prisma.todo.upsert({ 44 | where: { id: todo.id }, 45 | update: { 46 | isDone: todo.isDone, 47 | doneAt: todo.doneAt, 48 | title: todo.title, 49 | tags: todo.tags, 50 | }, 51 | create: { 52 | id: todo.id, 53 | title: todo.title, 54 | isDone: todo.isDone, 55 | doneAt: todo.doneAt, 56 | createdAt: new Date(todo.createdAt), 57 | todoListId: todo.todoListId, 58 | tags: todo.tags, 59 | }, 60 | }); 61 | } 62 | 63 | async remove(todoId: string): Promise { 64 | await this.prisma.todo.deleteMany({ 65 | where: { id: todoId }, 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/todo-list-manager/infrastructure/TodoListDatabaseRepository.ts: -------------------------------------------------------------------------------- 1 | import type { TodoList, TodoListId } from "../domain/TodoList"; 2 | import type { TodoLists } from "../domain/TodoLists"; 3 | import { Injectable } from "@nestjs/common"; 4 | import { PrismaRepository } from "shared/database"; 5 | import { TodoListNotFoundError } from "../domain/error/TodoListNotFoundError"; 6 | 7 | @Injectable() 8 | export class TodoListDatabaseRepository 9 | extends PrismaRepository 10 | implements TodoLists 11 | { 12 | async ofId(todoListId: TodoListId): Promise { 13 | const row = await this.prisma.todoList.findFirst({ 14 | where: { id: todoListId }, 15 | }); 16 | 17 | if (!row) throw new TodoListNotFoundError(todoListId); 18 | 19 | return { 20 | id: row.id, 21 | createdAt: row.createdAt.toISOString(), 22 | title: row.title, 23 | todosOrder: row.todosOrder as string[], 24 | }; 25 | } 26 | 27 | async save(todoList: TodoList): Promise { 28 | await this.prisma.todoList.upsert({ 29 | where: { id: todoList.id }, 30 | update: { 31 | title: todoList.title, 32 | todosOrder: todoList.todosOrder, 33 | }, 34 | create: { 35 | id: todoList.id, 36 | title: todoList.title, 37 | createdAt: new Date(todoList.createdAt), 38 | todosOrder: todoList.todosOrder, 39 | }, 40 | }); 41 | } 42 | 43 | async remove(todoListId: TodoListId): Promise { 44 | await this.prisma.todo.deleteMany({ 45 | where: { todoListId }, 46 | }); 47 | await this.prisma.todoList.deleteMany({ 48 | where: { id: todoListId }, 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/todo-list-manager/infrastructure/TodoListEventDatabaseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PrismaRepository } from "shared/database"; 3 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 4 | 5 | @Injectable() 6 | export class TodoListEventDatabaseRepository extends PrismaRepository { 7 | async save(event: TodoListEvent) { 8 | await this.prisma.todoListEvent.create({ 9 | data: { 10 | id: event.id, 11 | todoListId: event.todoListId, 12 | publishedAt: event.publishedAt, 13 | event: JSON.stringify(event), 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/todo-list-manager/infrastructure/TodoListEvents.ts: -------------------------------------------------------------------------------- 1 | import { Events, NestEvents } from "shared/events"; 2 | import { Injectable } from "@nestjs/common"; 3 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 4 | import { TodoListEventDatabaseRepository } from "./TodoListEventDatabaseRepository"; 5 | 6 | @Injectable() 7 | export class TodoListEvents implements Events { 8 | constructor( 9 | private readonly events: NestEvents, 10 | private readonly todoListEventDatabaseRepository: TodoListEventDatabaseRepository 11 | ) {} 12 | 13 | async publish(event: TodoListEvent): Promise { 14 | await this.todoListEventDatabaseRepository.save(event); 15 | await this.events.publish(event); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/AddTagToTodo.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { ContributorId } from "../domain/ContributorId"; 4 | import type { Todos } from "../domain/Todos"; 5 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 6 | import type { TodoListId } from "../domain/TodoList"; 7 | import { addTag, hasTag, TodoId } from "../domain/Todo"; 8 | import { canEditTodoList } from "../domain/TodoListPermission"; 9 | import { TagAddedToTodo } from "../domain/event/TagAddedToTodo"; 10 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 11 | 12 | export class AddTagToTodo { 13 | constructor( 14 | private readonly todos: Todos, 15 | private readonly todoListPermissions: TodoListPermissions, 16 | private readonly clock: Clock, 17 | private readonly events: Events 18 | ) {} 19 | 20 | async execute( 21 | todoListId: TodoListId, 22 | todoId: TodoId, 23 | contributorId: ContributorId, 24 | tag: string 25 | ) { 26 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 27 | canEditTodoList(permission, contributorId); 28 | 29 | const todo = await this.todos.ofId(todoId); 30 | if (hasTag(todo, tag)) return; 31 | 32 | await this.todos.save(addTag(todo, tag)); 33 | await this.events.publish( 34 | new TagAddedToTodo( 35 | todoListId, 36 | contributorId, 37 | todoId, 38 | tag, 39 | this.clock.now() 40 | ) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/AddTodoToTodoList.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { ContributorId } from "../domain/ContributorId"; 4 | import type { GenerateId } from "shared/id"; 5 | import type { Todos } from "../domain/Todos"; 6 | import type { TodoLists } from "../domain/TodoLists"; 7 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 8 | import { addTodo, TodoListId } from "../domain/TodoList"; 9 | import { canEditTodoList } from "../domain/TodoListPermission"; 10 | import { TodoAdded } from "../domain/event/TodoAdded"; 11 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 12 | 13 | export class AddTodoToTodoList { 14 | constructor( 15 | private readonly todos: Todos, 16 | private readonly todoLists: TodoLists, 17 | private readonly todoListPermissions: TodoListPermissions, 18 | private readonly generateId: GenerateId, 19 | private readonly clock: Clock, 20 | private readonly events: Events 21 | ) {} 22 | 23 | async execute( 24 | todoListId: TodoListId, 25 | title: string, 26 | contributorId: ContributorId 27 | ) { 28 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 29 | canEditTodoList(permission, contributorId); 30 | 31 | const todoList = await this.todoLists.ofId(todoListId); 32 | const [updatedTodoList, addedTodo] = addTodo( 33 | todoList, 34 | title, 35 | this.generateId, 36 | this.clock 37 | ); 38 | 39 | await Promise.all([ 40 | this.todoLists.save(updatedTodoList), 41 | this.todos.save(addedTodo), 42 | ]); 43 | 44 | await this.events.publish( 45 | new TodoAdded(todoListId, contributorId, addedTodo.id, this.clock.now()) 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/ArchiveTodoList.ts: -------------------------------------------------------------------------------- 1 | import type { TodoLists } from "../domain/TodoLists"; 2 | import type { TodoListId } from "../domain/TodoList"; 3 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 4 | import type { ContributorId } from "../domain/ContributorId"; 5 | import { canArchiveTodoList } from "../domain/TodoListPermission"; 6 | 7 | export class ArchiveTodoList { 8 | constructor( 9 | private readonly todoLists: TodoLists, 10 | private readonly todoListPermissions: TodoListPermissions 11 | ) {} 12 | 13 | async execute(todoListId: TodoListId, contributorId: ContributorId) { 14 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 15 | canArchiveTodoList(permission, contributorId); 16 | 17 | await this.todoListPermissions.remove(permission); 18 | await this.todoLists.remove(todoListId); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/CreateTodoList.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { GenerateId } from "shared/id"; 3 | import type { Events } from "shared/events"; 4 | import type { TodoLists } from "../domain/TodoLists"; 5 | import type { OwnerId } from "../domain/OwnerId"; 6 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 7 | import { createTotoList } from "../domain/TodoList"; 8 | import { createPermissions } from "../domain/TodoListPermission"; 9 | import { TodoListCreated } from "../domain/event/TodoListCreated"; 10 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 11 | 12 | export class CreateTodoList { 13 | constructor( 14 | private readonly todoLists: TodoLists, 15 | private readonly todoListPermissions: TodoListPermissions, 16 | private readonly generateId: GenerateId, 17 | private readonly clock: Clock, 18 | private readonly events: Events 19 | ) {} 20 | 21 | async execute(title: string, ownerId: OwnerId) { 22 | const todoList = createTotoList(title, this.generateId, this.clock); 23 | const todoListPermissions = createPermissions(todoList, ownerId); 24 | 25 | await this.todoLists.save(todoList); 26 | await this.todoListPermissions.save(todoListPermissions); 27 | await this.events.publish( 28 | new TodoListCreated(todoList.id, ownerId, this.clock.now()) 29 | ); 30 | 31 | return todoList.id; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/DeleteTodoFromTodoList.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { ContributorId } from "../domain/ContributorId"; 4 | import type { TodoId } from "../domain/Todo"; 5 | import type { Todos } from "../domain/Todos"; 6 | import type { TodoLists } from "../domain/TodoLists"; 7 | import type { TodoListId } from "../domain/TodoList"; 8 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 9 | import { removeTodoFromOrder } from "../domain/TodoList"; 10 | import { canEditTodoList } from "../domain/TodoListPermission"; 11 | import { TodoDeleted } from "../domain/event/TodoDeleted"; 12 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 13 | 14 | export class DeleteTodoFromTodoList { 15 | constructor( 16 | private readonly todoLists: TodoLists, 17 | private readonly todoListPermissions: TodoListPermissions, 18 | private readonly todos: Todos, 19 | private readonly clock: Clock, 20 | private readonly events: Events 21 | ) {} 22 | 23 | async execute( 24 | todoListId: TodoListId, 25 | todoId: TodoId, 26 | contributorId: ContributorId 27 | ) { 28 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 29 | canEditTodoList(permission, contributorId); 30 | 31 | const [todoList, todo] = await Promise.all([ 32 | this.todoLists.ofId(todoListId), 33 | this.todos.ofId(todoId), 34 | ]); 35 | 36 | await Promise.all([ 37 | this.todoLists.save(removeTodoFromOrder(todoList, todoId)), 38 | this.todos.remove(todoId), 39 | ]); 40 | 41 | await this.events.publish( 42 | new TodoDeleted( 43 | todoListId, 44 | contributorId, 45 | todo.id, 46 | todo.title, 47 | this.clock.now() 48 | ) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/GrantAccess.ts: -------------------------------------------------------------------------------- 1 | import type { Events } from "shared/events"; 2 | import type { Clock } from "shared/time"; 3 | import type { TodoListId } from "../domain/TodoList"; 4 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 5 | import type { Contributors } from "../domain/Contributors"; 6 | import type { ContributorId } from "../domain/ContributorId"; 7 | import { 8 | canShareTodoList, 9 | isContributor, 10 | grantAccess, 11 | } from "../domain/TodoListPermission"; 12 | import { TodoListAccessGranted } from "../domain/event/TodoListAccessGranted"; 13 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 14 | 15 | export class GrantAccess { 16 | constructor( 17 | private readonly todoListPermissions: TodoListPermissions, 18 | private readonly contributors: Contributors, 19 | private readonly clock: Clock, 20 | private readonly events: Events 21 | ) {} 22 | 23 | async execute( 24 | todoListId: TodoListId, 25 | contributorId: ContributorId, 26 | newContributorEmail: string 27 | ) { 28 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 29 | canShareTodoList(permission, contributorId); 30 | 31 | const contributorToGrantAccess = await this.contributors.ofEmail( 32 | newContributorEmail 33 | ); 34 | 35 | if (!isContributor(permission, contributorToGrantAccess.id)) { 36 | await this.todoListPermissions.save( 37 | grantAccess(permission, contributorToGrantAccess.id) 38 | ); 39 | 40 | await this.events.publish( 41 | new TodoListAccessGranted( 42 | todoListId, 43 | contributorId, 44 | contributorToGrantAccess.id, 45 | this.clock.now() 46 | ) 47 | ); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/RemoveTagFromTodo.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { ContributorId } from "../domain/ContributorId"; 4 | import type { Todos } from "../domain/Todos"; 5 | import type { TodoListId } from "../domain/TodoList"; 6 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 7 | import { TodoId, removeTag, hasTag } from "../domain/Todo"; 8 | import { canEditTodoList } from "../domain/TodoListPermission"; 9 | import { TagRemovedFromTodo } from "../domain/event/TagRemovedFromTodo"; 10 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 11 | 12 | export class RemoveTagFromTodo { 13 | constructor( 14 | private readonly todos: Todos, 15 | private readonly todoListPermissions: TodoListPermissions, 16 | private readonly clock: Clock, 17 | private readonly events: Events 18 | ) {} 19 | 20 | async execute( 21 | todoListId: TodoListId, 22 | todoId: TodoId, 23 | contributorId: ContributorId, 24 | tag: string 25 | ) { 26 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 27 | canEditTodoList(permission, contributorId); 28 | 29 | const todo = await this.todos.ofId(todoId); 30 | if (!hasTag(todo, tag)) return; 31 | 32 | await this.todos.save(removeTag(todo, tag)); 33 | await this.events.publish( 34 | new TagRemovedFromTodo( 35 | todoListId, 36 | contributorId, 37 | todoId, 38 | tag, 39 | this.clock.now() 40 | ) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/ReorderTodo.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { TodoLists } from "../domain/TodoLists"; 4 | import type { TodoListId } from "../domain/TodoList"; 5 | import type { TodoId } from "../domain/Todo"; 6 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 7 | import type { ContributorId } from "../domain/ContributorId"; 8 | import { currentTodoOrder, reorderTodoInTodoList } from "../domain/TodoList"; 9 | import { canEditTodoList } from "../domain/TodoListPermission"; 10 | import { TodoReordered } from "../domain/event/TodoReordered"; 11 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 12 | 13 | export class ReorderTodo { 14 | constructor( 15 | private readonly todoLists: TodoLists, 16 | private readonly todoListPermissions: TodoListPermissions, 17 | private readonly clock: Clock, 18 | private readonly events: Events 19 | ) {} 20 | 21 | async execute( 22 | todoListId: TodoListId, 23 | contributorId: ContributorId, 24 | todoToReorderId: TodoId, 25 | newIndex: number 26 | ) { 27 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 28 | canEditTodoList(permission, contributorId); 29 | 30 | const todoList = await this.todoLists.ofId(todoListId); 31 | const previousOrder = currentTodoOrder(todoList, todoToReorderId); 32 | 33 | await this.todoLists.save( 34 | reorderTodoInTodoList(todoList, todoToReorderId, newIndex) 35 | ); 36 | await this.events.publish( 37 | new TodoReordered( 38 | todoListId, 39 | contributorId, 40 | todoToReorderId, 41 | previousOrder, 42 | newIndex, 43 | this.clock.now() 44 | ) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/RevokeAccess.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 4 | import type { TodoListId } from "../domain/TodoList"; 5 | import type { ContributorId } from "../domain/ContributorId"; 6 | import { canShareTodoList, revokeAccess } from "../domain/TodoListPermission"; 7 | import { TodoListAccessRevoked } from "../domain/event/TodoListAccessRevoked"; 8 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 9 | 10 | export class RevokeAccess { 11 | constructor( 12 | private readonly todoListPermissions: TodoListPermissions, 13 | private readonly clock: Clock, 14 | private readonly events: Events 15 | ) {} 16 | 17 | async execute( 18 | todoListId: TodoListId, 19 | ownerId: ContributorId, 20 | contributorToRevokeAccess: ContributorId 21 | ) { 22 | const permissions = await this.todoListPermissions.ofTodoList(todoListId); 23 | canShareTodoList(permissions, ownerId); 24 | 25 | await this.todoListPermissions.save( 26 | revokeAccess(permissions, contributorToRevokeAccess) 27 | ); 28 | await this.events.publish( 29 | new TodoListAccessRevoked( 30 | todoListId, 31 | ownerId, 32 | contributorToRevokeAccess, 33 | this.clock.now() 34 | ) 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/UpdateTodoListTitle.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { ContributorId } from "../domain/ContributorId"; 4 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 5 | import type { TodoLists } from "../domain/TodoLists"; 6 | import { updateTodoListTitle, TodoListId } from "../domain/TodoList"; 7 | import { canEditTodoList } from "../domain/TodoListPermission"; 8 | import { TodoListUpdated } from "../domain/event/TodoListUpdated"; 9 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 10 | 11 | export class UpdateTodoListTitle { 12 | constructor( 13 | private readonly todoLists: TodoLists, 14 | private readonly todoListPermissions: TodoListPermissions, 15 | private readonly clock: Clock, 16 | private readonly events: Events 17 | ) {} 18 | 19 | async execute( 20 | todoListId: TodoListId, 21 | title: string, 22 | contributorId: ContributorId 23 | ) { 24 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 25 | canEditTodoList(permission, contributorId); 26 | 27 | const todoList = await this.todoLists.ofId(todoListId); 28 | const previousTitle = todoList.title; 29 | await this.todoLists.save(updateTodoListTitle(todoList, title)); 30 | await this.events.publish( 31 | new TodoListUpdated( 32 | todoListId, 33 | contributorId, 34 | { 35 | title: { previous: previousTitle, current: title }, 36 | }, 37 | this.clock.now() 38 | ) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/UpdateTodoTitle.ts: -------------------------------------------------------------------------------- 1 | import type { Clock } from "shared/time"; 2 | import type { Events } from "shared/events"; 3 | import type { Todos } from "../domain/Todos"; 4 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 5 | import type { ContributorId } from "../domain/ContributorId"; 6 | import type { TodoListId } from "../domain/TodoList"; 7 | import { updateTitle, TodoId } from "../domain/Todo"; 8 | import { canEditTodoList } from "../domain/TodoListPermission"; 9 | import { TodoUpdated } from "../domain/event/TodoUpdated"; 10 | import { TodoListEvent } from "../domain/event/TodoListEvent"; 11 | 12 | export class UpdateTodoTitle { 13 | constructor( 14 | private readonly todos: Todos, 15 | private readonly todoListPermissions: TodoListPermissions, 16 | private readonly clock: Clock, 17 | private readonly events: Events 18 | ) {} 19 | 20 | async execute( 21 | todoListId: TodoListId, 22 | todoId: TodoId, 23 | title: string, 24 | contributorId: ContributorId 25 | ) { 26 | const permission = await this.todoListPermissions.ofTodoList(todoListId); 27 | canEditTodoList(permission, contributorId); 28 | 29 | const todo = await this.todos.ofId(todoId); 30 | const previousTitle = todo.title; 31 | await this.todos.save(updateTitle(todo, title)); 32 | await this.events.publish( 33 | new TodoUpdated( 34 | todoListId, 35 | contributorId, 36 | todoId, 37 | { 38 | title: { previous: previousTitle, current: title }, 39 | }, 40 | this.clock.now() 41 | ) 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/todo-list-manager/usecase/ViewHomePage.ts: -------------------------------------------------------------------------------- 1 | import type { HomePageDto, TodoListSummaryDto } from "shared/client"; 2 | import type { ContributorId } from "../domain/ContributorId"; 3 | import type { TodoListPermissions } from "../domain/TodoListPermissions"; 4 | import type { TodoListQuery } from "../domain/TodoListQuery"; 5 | import partition from "lodash.partition"; 6 | import { 7 | getTodoListId, 8 | TodoListPermission, 9 | } from "../domain/TodoListPermission"; 10 | 11 | export class ViewHomePage { 12 | constructor( 13 | private readonly todoListPermissions: TodoListPermissions, 14 | private readonly todoListQuery: TodoListQuery 15 | ) {} 16 | 17 | async execute(contributorId: ContributorId): Promise { 18 | const [permissionsOwner, permissionsContributor] = partition( 19 | await this.todoListPermissions.ofContributor(contributorId), 20 | (permission) => permission.ownerId === contributorId 21 | ); 22 | 23 | const [todoListsOwned, todoListsContributed] = await Promise.all([ 24 | this.todoListOfPermission(permissionsOwner), 25 | this.todoListOfPermission(permissionsContributor), 26 | ]); 27 | 28 | return { 29 | todoListsOwned: todoListsOwned.map((todoList) => ({ 30 | ...todoList, 31 | permissions: { archive: true, leave: false }, 32 | })), 33 | todoListsContributed: todoListsContributed.map((todoList) => ({ 34 | ...todoList, 35 | permissions: { archive: false, leave: true }, 36 | })), 37 | totalNumberOfDoingTodos: this.totalNumberOfDoingTodos([ 38 | ...todoListsOwned, 39 | ...todoListsContributed, 40 | ]), 41 | }; 42 | } 43 | 44 | private totalNumberOfDoingTodos(todoListsSummaries: TodoListSummaryDto[]) { 45 | return todoListsSummaries.reduce( 46 | (count, summary) => count + summary.numberOfTodos, 47 | 0 48 | ); 49 | } 50 | 51 | private todoListOfPermission(permissions: TodoListPermission[]) { 52 | return this.todoListQuery.summaryOfTodoLists( 53 | permissions.map(getTodoListId) 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/web/authenticator/Authenticated.ts: -------------------------------------------------------------------------------- 1 | import { DataFunctionArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | import { Inject } from "@nestjs/common"; 4 | import { AUTHENTICATOR } from "../../keys"; 5 | import { SessionAuthenticator } from "./SessionAuthenticator"; 6 | 7 | const authenticatorKey = Symbol("authenticator"); 8 | 9 | export const Authenticated = (): MethodDecorator => { 10 | const injectAuthenticator = Inject(AUTHENTICATOR); 11 | 12 | return ( 13 | target: Object, 14 | propertyKey: string | symbol, 15 | descriptor: PropertyDescriptor 16 | ) => { 17 | injectAuthenticator(target, authenticatorKey); 18 | const originalMethod = descriptor.value; 19 | 20 | descriptor.value = async function (args: DataFunctionArgs) { 21 | const authenticator: SessionAuthenticator = (this as any)[ 22 | authenticatorKey 23 | ]; 24 | const isAuthenticated = await authenticator.isAuthenticated(); 25 | 26 | if (!isAuthenticated) throw redirect("/login"); 27 | return originalMethod.apply(this, [args]); 28 | }; 29 | 30 | return descriptor; 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/web/authenticator/SessionAuthenticator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { CurrentUser, Authenticator } from "authentication"; 3 | import { SessionManager } from "./SessionManager"; 4 | 5 | @Injectable() 6 | export class SessionAuthenticator implements Authenticator { 7 | constructor(private readonly sessionManager: SessionManager) {} 8 | 9 | async isAuthenticated() { 10 | const session = await this.sessionManager.get(); 11 | return session.has("userId") && session.has("sessionId"); 12 | } 13 | 14 | async currentUser(): Promise { 15 | const session = await this.sessionManager.get(); 16 | return { 17 | id: session.get("userId"), 18 | sessionId: session.get("sessionId"), 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/web/authenticator/SessionManager.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import { Inject, Injectable, Scope } from "@nestjs/common"; 3 | import { REQUEST } from "@nestjs/core"; 4 | import { 5 | createCookieSessionStorage, 6 | Session, 7 | SessionIdStorageStrategy, 8 | } from "@remix-run/node"; 9 | import { SESSION_CONFIG } from "../../keys"; 10 | 11 | @Injectable({ scope: Scope.REQUEST }) 12 | export class SessionManager { 13 | private storage; 14 | 15 | constructor( 16 | @Inject(SESSION_CONFIG) 17 | private readonly options: SessionIdStorageStrategy["cookie"], 18 | @Inject(REQUEST) 19 | private readonly request: Request 20 | ) { 21 | this.storage = createCookieSessionStorage({ cookie: options }); 22 | } 23 | 24 | get() { 25 | return this.storage.getSession(this.request.headers.cookie); 26 | } 27 | 28 | commit(session: Session) { 29 | return this.storage.commitSession(session); 30 | } 31 | 32 | async destroy() { 33 | const session = await this.storage.getSession(this.request.headers.cookie); 34 | return this.storage.destroySession(session); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/web/controllers/TodoListEventsConsumer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { Subject } from "rxjs"; 3 | import { OnEvent } from "@nestjs/event-emitter"; 4 | import { Event } from "shared/events"; 5 | import { TodoListUpdated } from "../../todo-list-manager/domain/event/TodoListUpdated"; 6 | import { TodoUpdated } from "../../todo-list-manager/domain/event/TodoUpdated"; 7 | import { TodoReordered } from "../../todo-list-manager/domain/event/TodoReordered"; 8 | import { TodoListAccessGranted } from "../../todo-list-manager/domain/event/TodoListAccessGranted"; 9 | import { TodoDeleted } from "../../todo-list-manager/domain/event/TodoDeleted"; 10 | import { TodoCompletionChanged } from "../../todo-list-manager/domain/event/TodoCompletionChanged"; 11 | import { TodoAdded } from "../../todo-list-manager/domain/event/TodoAdded"; 12 | import { TagAddedToTodo } from "../../todo-list-manager/domain/event/TagAddedToTodo"; 13 | import { TagRemovedFromTodo } from "../../todo-list-manager/domain/event/TagRemovedFromTodo"; 14 | import { TodoListAccessRevoked } from "../../todo-list-manager/domain/event/TodoListAccessRevoked"; 15 | 16 | @Injectable() 17 | export class TodoListEventsConsumer { 18 | private subject = new Subject(); 19 | 20 | @OnEvent("todoList.*", { async: true }) 21 | handeTodoListEvents( 22 | event: TodoListUpdated | TodoListAccessGranted | TodoListAccessRevoked 23 | ) { 24 | this.subject.next(event); 25 | } 26 | 27 | @OnEvent("todo.*", { async: true }) 28 | handleTodoEvents( 29 | event: 30 | | TodoUpdated 31 | | TodoReordered 32 | | TodoAdded 33 | | TodoCompletionChanged 34 | | TodoDeleted 35 | | TagAddedToTodo 36 | | TagRemovedFromTodo 37 | ) { 38 | this.subject.next(event); 39 | } 40 | 41 | get events() { 42 | return this.subject.asObservable(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/web/controllers/TodoListEventsController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Sse, Param } from "@nestjs/common"; 2 | import { delay, filter, interval, map, merge } from "rxjs"; 3 | import { TodoListEventsConsumer } from "./TodoListEventsConsumer"; 4 | 5 | @Controller("events/l") 6 | export class TodoListEventsController { 7 | constructor(private readonly todoListEvents: TodoListEventsConsumer) {} 8 | 9 | @Sse("/:todoListId") 10 | async getEvents(@Param("todoListId") todoListId: string) { 11 | const heartbeat$ = interval(30_000).pipe( 12 | map(() => ({ type: "heartbeat", data: "_" })) 13 | ); 14 | 15 | const updates$ = this.todoListEvents.events.pipe( 16 | delay(100), 17 | filter((event) => event.todoListId === todoListId), 18 | map((event) => ({ type: "update", data: event.id } as MessageEvent)) 19 | ); 20 | 21 | return merge(heartbeat$, updates$); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/web/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ApplicationModule"; 2 | export * from "./remix/RemixAppContext"; 3 | -------------------------------------------------------------------------------- /src/web/remix/RemixAppContext.ts: -------------------------------------------------------------------------------- 1 | import { Actions } from "./Actions"; 2 | import { Loaders } from "./Loaders"; 3 | import { MakeRemixContext } from "./types"; 4 | 5 | export type RemixAppContext = MakeRemixContext; 6 | -------------------------------------------------------------------------------- /src/web/remix/RemixController.ts: -------------------------------------------------------------------------------- 1 | import { All, Body, Controller, Inject, Next, Req, Res } from "@nestjs/common"; 2 | import { createRequestHandler } from "@remix-run/express"; 3 | import { NextFunction, Request, Response } from "express"; 4 | import { ACTIONS_CLASS, LOADERS_CLASS, REMIX_HANDLER } from "../../keys"; 5 | 6 | @Controller("/") 7 | export class RemixController { 8 | constructor( 9 | @Inject(REMIX_HANDLER) private readonly remixHandlerPath: string, 10 | @Inject(ACTIONS_CLASS) private readonly actions: unknown, 11 | @Inject(LOADERS_CLASS) private readonly loaders: unknown 12 | ) {} 13 | 14 | @All("*") 15 | handler( 16 | @Req() request: Request, 17 | @Res() response: Response, 18 | @Next() next: NextFunction, 19 | @Body() body: any 20 | ) { 21 | if (this.isStaticAsset(request)) return next(); 22 | this.purgeRequireCacheInDev(); 23 | 24 | return createRequestHandler({ 25 | // `remix build` and `remix dev` output files to a build directory, you need 26 | // to pass that build to the request handler 27 | build: require(this.remixHandlerPath), 28 | 29 | // return anything you want here to be available as `context` in your 30 | // loaders and actions. This is where you can bridge the gap between Remix 31 | // and your server 32 | getLoadContext: () => ({ 33 | actions: this.actions, 34 | loaders: this.loaders, 35 | }), 36 | })(request, response, next); 37 | } 38 | 39 | private purgeRequireCacheInDev() { 40 | if (process.env.NODE_ENV === "production") return; 41 | 42 | for (let key in require.cache) { 43 | if (key.startsWith(this.remixHandlerPath)) { 44 | delete require.cache[key]; 45 | } 46 | } 47 | } 48 | 49 | private isStaticAsset(request: Request) { 50 | return /^\/(build|assets)\//gi.test(request.url); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/web/remix/decorators/Body.ts: -------------------------------------------------------------------------------- 1 | export const METADATA_BODY = Symbol("body"); 2 | 3 | export const Body = 4 | (): ParameterDecorator => (target, propertyKey, parameterIndex) => { 5 | Reflect.defineMetadata(METADATA_BODY, parameterIndex, target, propertyKey); 6 | }; 7 | -------------------------------------------------------------------------------- /src/web/remix/decorators/MapError.ts: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | 3 | type ErrorCasting = { 4 | is(err: unknown): err is T; 5 | }; 6 | type ErrorOptions = { 7 | status: number; 8 | message?: string; 9 | }; 10 | type ErrorMapping = [ErrorCasting, ErrorOptions]; 11 | 12 | export const MapErrorThrowing = 13 | (mapping: ErrorMapping[]): MethodDecorator => 14 | (target, propertyKey, descriptor: PropertyDescriptor) => { 15 | const originalMethod = descriptor.value; 16 | 17 | descriptor.value = async function (...args: any[]) { 18 | try { 19 | return await originalMethod.apply(this, args); 20 | } catch (err) { 21 | for (let [CustomErrorConstructor, options] of mapping) { 22 | if (CustomErrorConstructor.is(err)) 23 | throw json( 24 | { error: true, message: options.message ?? err.message }, 25 | { 26 | status: options.status, 27 | } 28 | ); 29 | } 30 | throw err; 31 | } 32 | }; 33 | }; 34 | 35 | export const MapErrorReturning = 36 | (mapping: ErrorMapping[]): MethodDecorator => 37 | (target, propertyKey, descriptor: PropertyDescriptor) => { 38 | const originalMethod = descriptor.value; 39 | 40 | descriptor.value = async function (...args: any[]) { 41 | try { 42 | return await originalMethod.apply(this, args); 43 | } catch (err) { 44 | for (let [CustomErrorConstructor, options] of mapping) { 45 | if (CustomErrorConstructor.is(err)) 46 | return json( 47 | { error: true, message: options.message ?? err.message }, 48 | { 49 | status: options.status, 50 | } 51 | ); 52 | } 53 | throw err; 54 | } 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /src/web/remix/decorators/Params.ts: -------------------------------------------------------------------------------- 1 | export const METADATA_PARAMS = Symbol("params"); 2 | export const Params = 3 | (): ParameterDecorator => (target, propertyKey, parameterIndex) => { 4 | Reflect.defineMetadata( 5 | METADATA_PARAMS, 6 | parameterIndex, 7 | target, 8 | propertyKey 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/web/remix/decorators/Query.ts: -------------------------------------------------------------------------------- 1 | export const METADATA_QUERY = Symbol("query"); 2 | 3 | export const Query = 4 | (): ParameterDecorator => (target, propertyKey, parameterIndex) => { 5 | Reflect.defineMetadata(METADATA_QUERY, parameterIndex, target, propertyKey); 6 | }; 7 | -------------------------------------------------------------------------------- /src/web/remix/dtos/AddTagToTodo.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class AddTagToTodoParams { 4 | @IsString() 5 | todoId!: string; 6 | 7 | @IsString() 8 | todoListId!: string; 9 | } 10 | 11 | export class AddTagToTodoBody { 12 | @IsString() 13 | @MinLength(1) 14 | @MaxLength(15) 15 | tag!: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/web/remix/dtos/AddTodo.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, MaxLength } from "class-validator"; 2 | 3 | export class AddTodoBody { 4 | @IsString() 5 | @IsNotEmpty({ message: "The title of your todo is required." }) 6 | @MaxLength(50, { 7 | message: "The title of your todo is limited to 50 characters.", 8 | }) 9 | todoTitle!: string; 10 | } 11 | 12 | export class AddTodoParams { 13 | @IsString() 14 | todoListId!: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/web/remix/dtos/ArchiveTodoList.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | 3 | export class ArchiveTodoListParams { 4 | @IsString() 5 | todoListId!: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/web/remix/dtos/CreateTodoList.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, MaxLength } from "class-validator"; 2 | 3 | export class CreateTodoListBody { 4 | @IsString() 5 | @IsNotEmpty({ message: "The title of your todo list is required." }) 6 | @MaxLength(50, { 7 | message: "The title of your todo list is limited to 50 characters.", 8 | }) 9 | title!: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/web/remix/dtos/DeleteTodoFromTodoList.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | 3 | export class DeleteTodoFromTodoListParams { 4 | @IsString() 5 | todoId!: string; 6 | 7 | @IsString() 8 | todoListId!: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/web/remix/dtos/FetchTodoList.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | 3 | export class FetchTodoListParams { 4 | @IsString() 5 | todoListId!: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/web/remix/dtos/ForgotPassword.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail } from "class-validator"; 2 | 3 | export class ForgotPasswordBody { 4 | @IsEmail() 5 | email!: string; 6 | } -------------------------------------------------------------------------------- /src/web/remix/dtos/GrantAccess.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | 3 | export class GrantAccessParams { 4 | @IsString() 5 | todoListId!: string; 6 | } 7 | 8 | export class GrantAccessBody { 9 | @IsString() 10 | email!: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/web/remix/dtos/Login.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class LoginBody { 4 | @IsEmail(undefined, { message: "This email doesn't seem to be valid." }) 5 | email!: string; 6 | 7 | @IsString() 8 | @MinLength(8, { message: "Your password must be at least 4 characters" }) 9 | @MaxLength(64, { message: "Your password must be at most 64 characters" }) 10 | password!: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/web/remix/dtos/MarkTodo.ts: -------------------------------------------------------------------------------- 1 | import { IsIn, IsString } from "class-validator"; 2 | 3 | export class MarkTodoParams { 4 | @IsString() 5 | todoId!: string; 6 | 7 | @IsString() 8 | todoListId!: string; 9 | } 10 | 11 | export class MarkTodoBody { 12 | @IsString() 13 | @IsIn(["on", "off"]) 14 | isChecked!: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/web/remix/dtos/Register.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class RegisterBody { 4 | @IsEmail(undefined, { message: "This email doesn't seem to be valid." }) 5 | email!: string; 6 | 7 | @IsString() 8 | @MinLength(8, { message: "Your password must be at least 4 characters" }) 9 | @MaxLength(64, { message: "Your password must be at most 64 characters" }) 10 | password!: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/web/remix/dtos/RemoveTagFromTodo.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class RemoveTagFromTodoParams { 4 | @IsString() 5 | todoId!: string; 6 | 7 | @IsString() 8 | todoListId!: string; 9 | } 10 | 11 | export class RemoveTagFromTodoBody { 12 | @IsString() 13 | @MinLength(1) 14 | @MaxLength(15) 15 | tag!: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/web/remix/dtos/ReorderTodo.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsString, Min } from "class-validator"; 2 | import { Type } from "class-transformer"; 3 | 4 | export class ReorderTodoParams { 5 | @IsString() 6 | todoListId!: string; 7 | } 8 | 9 | export class ReorderTodoBody { 10 | @IsString() 11 | todoId!: string; 12 | 13 | @Type(() => Number) 14 | @IsInt() 15 | @Min(0) 16 | newIndex!: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/web/remix/dtos/ResetPassword.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class ResetPasswordBody { 4 | @IsEmail() 5 | email!: string; 6 | 7 | @IsString() 8 | token!: string; 9 | 10 | @IsString() 11 | @MinLength(8, { message: "Your password must be at least 4 characters" }) 12 | @MaxLength(64, { message: "Your password must be at most 64 characters" }) 13 | password!: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/web/remix/dtos/RevokeAccess.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | 3 | export class RevokeAccessParams { 4 | @IsString() 5 | todoListId!: string; 6 | } 7 | 8 | export class RevokeAccessBody { 9 | @IsString() 10 | contributorId!: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/web/remix/dtos/UpdateTodoListTitle.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class UpdateTodoListTitleBody { 4 | @IsString() 5 | @MinLength(1) 6 | @MaxLength(50) 7 | title!: string; 8 | } 9 | 10 | export class UpdateTodoListTitleParams { 11 | @IsString() 12 | todoListId!: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/web/remix/dtos/UpdateTodoTitle.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MaxLength, MinLength } from "class-validator"; 2 | 3 | export class UpdateTodoTitleParams { 4 | @IsString() 5 | todoId!: string; 6 | 7 | @IsString() 8 | todoListId!: string; 9 | } 10 | 11 | export class UpdateTodoTitleBody { 12 | @IsString() 13 | @MinLength(1) 14 | @MaxLength(50) 15 | title!: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/web/remix/dtos/VerifyAccount.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from "class-validator"; 2 | 3 | export class VerifyAccountQuery { 4 | @IsEmail() 5 | email!: string; 6 | 7 | @IsString() 8 | token!: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/web/remix/types.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | 3 | export type MakeRemixContext = { 4 | actions: { 5 | [key in keyof Actions]: ActionFunction; 6 | }; 7 | loaders: { 8 | [key in keyof Loaders]: LoaderFunction; 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /styles/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: Metropolis; 7 | src: url("/assets/fonts/Metropolis-Regular.woff2") format("woff2"); 8 | } 9 | 10 | @font-face { 11 | font-family: Metropolis; 12 | font-weight: 600; 13 | src: url("/assets/fonts/Metropolis-SemiBold.woff2") format("woff2"); 14 | } 15 | 16 | @layer base { 17 | *:focus-visible { 18 | @apply outline-2 outline-light outline-dashed outline-offset-4; 19 | } 20 | 21 | body { 22 | @apply text-light; 23 | @apply font-metropolis; 24 | } 25 | 26 | * { 27 | box-sizing: border-box; 28 | } 29 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./app/**/*.{js,ts,jsx,tsx}"], 3 | theme: { 4 | extend: { 5 | fontFamily: { 6 | metropolis: [ 7 | "Metropolis", 8 | "-apple-system", 9 | "Segoe UI", 10 | "Helvetica Neue", 11 | "Helvetica", 12 | "Roboto", 13 | "Arial", 14 | "sans-serif", 15 | "system-ui", 16 | "Apple Color Emoji", 17 | "Segoe UI Emoji", 18 | ], 19 | }, 20 | colors: { 21 | darker: "hsl(235, 19%, 12%)", 22 | dark: "hsl(240, 15%, 17%)", 23 | faded: "hsl(240,14%,28%)", 24 | lighter: "hsl(0, 0%, 100%)", 25 | light: "hsl(0, 0%, 64%)", 26 | inverse: "hsl(235, 19%, 12%)", 27 | "primary-darker": "hsl(240,100%,17%)", 28 | primary: "hsl(240, 67%, 64%)", 29 | "primary-lighter": "hsl(240, 25%, 64%)", 30 | "danger-darker": "hsl(345,96%,19%)", 31 | danger: "hsl(345, 86%, 56%)", 32 | "danger-lighter": "hsl(345, 86%, 80%)", 33 | }, 34 | keyframes: { 35 | "scale-in": { 36 | "0%": { opacity: 0, transform: "scaleX(0.5)" }, 37 | "100%": { opacity: 1, transform: "scaleX(1)" }, 38 | }, 39 | enter: { 40 | "0%": { opacity: 0 }, 41 | "100%": { opacity: 1 }, 42 | }, 43 | }, 44 | animation: { 45 | "scale-in": "scale-in 0.1s ease-in-out forwards", 46 | enter: "enter 0.2s ease-in-out forwards", 47 | }, 48 | }, 49 | }, 50 | plugins: [], 51 | }; 52 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts", "app"], 4 | "compilerOptions": { 5 | "noEmit": false 6 | } 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "target": "ES2019", 10 | "strict": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "front/*": ["./app/*"], 14 | "remix-nest-adapter": ["./src/remix-nest-adapter"], 15 | "web": ["./src/web"], 16 | "shared/database": ["./src/shared/database"], 17 | "shared/mail": ["./src/shared/mail"], 18 | "shared/time": ["./src/shared/time"], 19 | "shared/events": ["./src/shared/events"], 20 | "shared/id": ["./src/shared/id"], 21 | "shared/lib": ["./src/shared/lib"], 22 | "shared/client": ["./src/shared/client"], 23 | "infrastructure": ["./src/infrastructure"], 24 | "authentication": ["./src/authentication"], 25 | "todo-list-manager": ["./src/todo-list-manager"] 26 | }, 27 | "skipLibCheck": true, 28 | "incremental": true, 29 | "outDir": "./dist", 30 | "module": "CommonJS", 31 | "experimentalDecorators": true, 32 | "emitDecoratorMetadata": true, 33 | "allowJs": false, 34 | "forceConsistentCasingInFileNames": true, 35 | "isolatedModules": true, 36 | "noEmit": true 37 | } 38 | } 39 | --------------------------------------------------------------------------------