├── .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 | -
27 |
28 |
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 | - {renderTodo(todo, index)}
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 |
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 |
--------------------------------------------------------------------------------