├── .gitignore ├── .husky └── pre-commit ├── README.md ├── backend ├── .gitignore ├── .prettierrc ├── Dockerfile ├── Dockerfile.prod ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── server.ts ├── src │ ├── app.ts │ ├── config │ │ └── index.ts │ ├── loaders │ │ ├── index.ts │ │ ├── mongoose.ts │ │ └── ngrok.ts │ ├── modules │ │ ├── auth │ │ │ ├── dto │ │ │ │ └── user.dto.ts │ │ │ ├── routers │ │ │ │ └── index.ts │ │ │ ├── services │ │ │ │ ├── email │ │ │ │ │ ├── _index.ts │ │ │ │ │ ├── abstract-mail-factory.interface.ts │ │ │ │ │ ├── adapters │ │ │ │ │ │ ├── _index.ts │ │ │ │ │ │ ├── mailgun.adapter.ts │ │ │ │ │ │ └── sendgrid.adapter.ts │ │ │ │ │ ├── email-verification.service.ts │ │ │ │ │ ├── factories │ │ │ │ │ │ ├── _index.ts │ │ │ │ │ │ ├── christmas-mail.factory.ts │ │ │ │ │ │ ├── halloween-mail.factory.ts │ │ │ │ │ │ └── simple-mail.factory.ts │ │ │ │ │ ├── mail-adapter.interface.ts │ │ │ │ │ └── providers │ │ │ │ │ │ ├── _index.ts │ │ │ │ │ │ ├── mailgun.provider.ts │ │ │ │ │ │ └── sendgrid.provider.ts │ │ │ │ ├── index.ts │ │ │ │ └── login.service.ts │ │ │ └── usecases │ │ │ │ ├── google-auth │ │ │ │ ├── google-auth.controller.ts │ │ │ │ ├── google-auth.errors.ts │ │ │ │ ├── google-auth.usecase.ts │ │ │ │ └── index.ts │ │ │ │ ├── login │ │ │ │ ├── index.ts │ │ │ │ ├── login.controller.ts │ │ │ │ ├── login.dto.ts │ │ │ │ ├── login.errors.ts │ │ │ │ └── login.usecase.ts │ │ │ │ ├── logout │ │ │ │ ├── index.ts │ │ │ │ └── logout.controller.ts │ │ │ │ ├── sing-up │ │ │ │ ├── index.ts │ │ │ │ ├── signup.controller.ts │ │ │ │ ├── signup.dto.ts │ │ │ │ ├── signup.errors.ts │ │ │ │ └── signup.usecase.ts │ │ │ │ └── verify-email │ │ │ │ ├── _index.ts │ │ │ │ ├── verify-email.controller.ts │ │ │ │ ├── verify-email.dto.ts │ │ │ │ ├── verify-email.errors.ts │ │ │ │ └── verify-email.usecase.ts │ │ ├── files │ │ │ ├── router.ts │ │ │ ├── services │ │ │ │ ├── _index.ts │ │ │ │ └── image-resize.service.ts │ │ │ └── usecases │ │ │ │ ├── get-image │ │ │ │ ├── _index.ts │ │ │ │ ├── get-image.controller.ts │ │ │ │ └── get-image.usecase.ts │ │ │ │ └── upload-image │ │ │ │ ├── _index.ts │ │ │ │ ├── upload-image.controller.ts │ │ │ │ ├── upload-image.errors.ts │ │ │ │ ├── upload-image.request.ts │ │ │ │ ├── upload-image.response.ts │ │ │ │ └── upload-image.usecase.ts │ │ ├── integrations │ │ │ ├── google │ │ │ │ ├── router.ts │ │ │ │ ├── services │ │ │ │ │ ├── google-drive.service.ts │ │ │ │ │ └── index.ts │ │ │ │ └── usecases │ │ │ │ │ └── get-oauth-consent-screen │ │ │ │ │ ├── get-oauth-consent-screen.controller.ts │ │ │ │ │ └── index.ts │ │ │ ├── router.ts │ │ │ └── slack │ │ │ │ ├── enums │ │ │ │ └── slack-event.enum.ts │ │ │ │ ├── middleware │ │ │ │ └── verification-challenge.function.ts │ │ │ │ ├── routers │ │ │ │ └── index.ts │ │ │ │ └── usecases │ │ │ │ ├── add-to-slack │ │ │ │ ├── add-to-slack.controller.ts │ │ │ │ ├── add-to-slack.errors.ts │ │ │ │ ├── add-to-slack.usecase.ts │ │ │ │ └── index.ts │ │ │ │ ├── remove-from-slack │ │ │ │ ├── index.ts │ │ │ │ ├── remove-from-slack.controller.ts │ │ │ │ ├── remove-from-slack.errors.ts │ │ │ │ └── remove-from-slack.usecase.ts │ │ │ │ └── slack-event-received │ │ │ │ ├── index.ts │ │ │ │ ├── slack-event-received.controller.ts │ │ │ │ ├── slack-event-received.dto.ts │ │ │ │ ├── slack-event-received.usecase.ts │ │ │ │ └── slack-event-recieved.errors.ts │ │ └── sync │ │ │ ├── domain │ │ │ ├── dtos │ │ │ │ ├── change.dto.ts │ │ │ │ └── task.dto.ts │ │ │ └── values │ │ │ │ └── change.ts │ │ │ ├── routers │ │ │ └── index.ts │ │ │ └── usecases │ │ │ ├── get-changes │ │ │ ├── _index.ts │ │ │ ├── get-changes.controller.ts │ │ │ ├── get-changes.dto.ts │ │ │ ├── get-changes.errors.ts │ │ │ └── get-changes.usecase.ts │ │ │ ├── release-client-id │ │ │ ├── index.ts │ │ │ ├── release-client-id.controller.ts │ │ │ ├── release-client-id.dto.ts │ │ │ ├── release-client-id.errors.ts │ │ │ └── release-client-id.usecase.ts │ │ │ └── task │ │ │ ├── create │ │ │ ├── create-task.controller.ts │ │ │ ├── create-task.dto.ts │ │ │ ├── create-task.errors.ts │ │ │ ├── create-task.usecase.ts │ │ │ └── index.ts │ │ │ ├── delete │ │ │ ├── delete-task.controller.ts │ │ │ ├── delete-task.dto.ts │ │ │ ├── delete-task.erros.ts │ │ │ ├── delete-task.usecase.ts │ │ │ └── index.ts │ │ │ └── update │ │ │ ├── index.ts │ │ │ ├── update-task.controller.ts │ │ │ ├── update-task.dto.ts │ │ │ ├── update-task.errors.ts │ │ │ └── update-task.usecase.ts │ └── shared │ │ ├── core │ │ ├── UseCase.ts │ │ ├── app-error.ts │ │ ├── domain-error.ts │ │ ├── guard.ts │ │ ├── result.ts │ │ ├── service-error.ts │ │ └── use-case-error.ts │ │ ├── domain │ │ ├── AggregateRoot.ts │ │ ├── Entity.ts │ │ ├── Identifier.ts │ │ ├── UniqueEntityID.ts │ │ ├── ValueObject.ts │ │ ├── events │ │ │ ├── DomainEvents.ts │ │ │ └── IDomainEvent.ts │ │ ├── models │ │ │ ├── actions.ts │ │ │ ├── client.ts │ │ │ ├── slack-oauth-access.ts │ │ │ ├── task.ts │ │ │ └── user.ts │ │ └── values │ │ │ └── user │ │ │ ├── user-email.ts │ │ │ └── verification-token.ts │ │ ├── infra │ │ ├── auth │ │ │ ├── google.strategy.ts │ │ │ ├── index.ts │ │ │ ├── isAuthenticated.middleware.ts │ │ │ └── jwt.strategy.ts │ │ ├── database │ │ │ └── mongodb │ │ │ │ ├── action.model.ts │ │ │ │ ├── client.model.ts │ │ │ │ ├── index.ts │ │ │ │ ├── slack-oauth-access.model.ts │ │ │ │ ├── task.model.ts │ │ │ │ ├── user.model.ts │ │ │ │ └── verification-token.model.ts │ │ ├── http │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── dtos │ │ │ │ └── api-errors.dto.ts │ │ │ ├── models │ │ │ │ └── base-controller.ts │ │ │ └── utils │ │ │ │ └── middleware.ts │ │ └── integrations │ │ │ └── slack │ │ │ ├── index.ts │ │ │ └── slack.service.ts │ │ ├── mappers │ │ ├── action.mapper.ts │ │ ├── change.mapper.ts │ │ ├── client.mapper.ts │ │ ├── slack-oauth-access.mapper.ts │ │ ├── task.mapper.ts │ │ └── user.mapper.ts │ │ ├── repo │ │ ├── action.repo.ts │ │ ├── client.repo.ts │ │ ├── slack-oauth-access.repo.ts │ │ ├── task-repo.service.ts │ │ └── user.repo.ts │ │ └── types │ │ └── error.d.ts └── tsconfig.json ├── db ├── Dockerfile └── init-db.d │ └── seed.js ├── development.yml ├── docker-compose.yml ├── frontend ├── .browserslistrc ├── .gitignore ├── .prettierrc ├── .stylelintrc.json ├── Dockerfile ├── angular.json ├── eslint.config.mjs ├── ngsw-config.json ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.ts │ │ ├── desktop-app │ │ │ ├── components │ │ │ │ └── screens │ │ │ │ │ ├── dt-home-screen │ │ │ │ │ ├── dt-home-screen.component.html │ │ │ │ │ ├── dt-home-screen.component.scss │ │ │ │ │ └── dt-home-screencomponent.ts │ │ │ │ │ └── dt-profile-screen │ │ │ │ │ ├── dt-profile-screen.component.html │ │ │ │ │ ├── dt-profile-screen.component.scss │ │ │ │ │ └── dt-profile-screen.component.ts │ │ │ ├── desktop-app.component.ts │ │ │ └── desktop-app.routing.ts │ │ ├── mobile-app │ │ │ ├── components │ │ │ │ ├── common │ │ │ │ │ └── task-tiles-panel │ │ │ │ │ │ ├── task-tile │ │ │ │ │ │ ├── task-tile.actions.ts │ │ │ │ │ │ ├── task-tile.component.html │ │ │ │ │ │ ├── task-tile.component.scss │ │ │ │ │ │ └── task-tile.component.ts │ │ │ │ │ │ ├── task-tiles-panel.component.html │ │ │ │ │ │ ├── task-tiles-panel.component.scss │ │ │ │ │ │ └── task-tiles-panel.component.ts │ │ │ │ └── screens │ │ │ │ │ ├── mb-home-screen │ │ │ │ │ ├── mb-home-bottom-panel │ │ │ │ │ │ ├── mb-home-bottom-panel.actions.ts │ │ │ │ │ │ ├── mb-home-bottom-panel.component.html │ │ │ │ │ │ ├── mb-home-bottom-panel.component.scss │ │ │ │ │ │ └── mb-home-bottom-panel.component.ts │ │ │ │ │ ├── mb-home-screen.component.html │ │ │ │ │ ├── mb-home-screen.component.scss │ │ │ │ │ └── mb-home-screen.component.ts │ │ │ │ │ ├── mb-login-screen │ │ │ │ │ ├── mb-login-screen.actions.ts │ │ │ │ │ ├── mb-login-screen.component.html │ │ │ │ │ ├── mb-login-screen.component.scss │ │ │ │ │ ├── mb-login-screen.component.ts │ │ │ │ │ └── mb-login-screen.state.ts │ │ │ │ │ ├── mb-profile-screen │ │ │ │ │ ├── add-to-slack-btn │ │ │ │ │ │ ├── add-to-slack-btn.component.html │ │ │ │ │ │ ├── add-to-slack-btn.component.scss │ │ │ │ │ │ └── add-to-slack-btn.component.ts │ │ │ │ │ ├── integrations │ │ │ │ │ │ ├── mb-integration.interface.ts │ │ │ │ │ │ ├── mb-integrations.component.html │ │ │ │ │ │ ├── mb-integrations.component.scss │ │ │ │ │ │ └── mb-integrations.component.ts │ │ │ │ │ ├── mb-profile-screen.actions.ts │ │ │ │ │ ├── mb-profile-screen.component.html │ │ │ │ │ ├── mb-profile-screen.component.scss │ │ │ │ │ └── mb-profile-screen.component.ts │ │ │ │ │ ├── mb-signup-screen │ │ │ │ │ ├── mb-signup-screen.actions.ts │ │ │ │ │ ├── mb-signup-screen.component.html │ │ │ │ │ ├── mb-signup-screen.component.scss │ │ │ │ │ ├── mb-signup-screen.component.ts │ │ │ │ │ └── mb-signup-screen.state.ts │ │ │ │ │ ├── mb-sync-screen │ │ │ │ │ ├── mb-sync-screen.actions.ts │ │ │ │ │ ├── mb-sync-screen.component.html │ │ │ │ │ ├── mb-sync-screen.component.scss │ │ │ │ │ └── mb-sync-screen.component.ts │ │ │ │ │ └── mb-task-screen │ │ │ │ │ ├── mb-task-bottom-panel │ │ │ │ │ ├── mb-task-bottom-panel.component.html │ │ │ │ │ ├── mb-task-bottom-panel.component.scss │ │ │ │ │ └── mb-task-bottom-panel.component.ts │ │ │ │ │ ├── mb-task-edit │ │ │ │ │ ├── mb-task-edit.component.html │ │ │ │ │ ├── mb-task-edit.component.interface.ts │ │ │ │ │ ├── mb-task-edit.component.scss │ │ │ │ │ └── mb-task-edit.component.ts │ │ │ │ │ ├── mb-task-screen.actions.ts │ │ │ │ │ ├── mb-task-screen.component.html │ │ │ │ │ ├── mb-task-screen.component.scss │ │ │ │ │ ├── mb-task-screen.component.ts │ │ │ │ │ ├── mb-task-screen.state.ts │ │ │ │ │ ├── mb-task-side-menu │ │ │ │ │ ├── mb-task-side-menu-item │ │ │ │ │ │ ├── mb-task-side-menu-item.component.html │ │ │ │ │ │ ├── mb-task-side-menu-item.component.scss │ │ │ │ │ │ ├── mb-task-side-menu-item.component.ts │ │ │ │ │ │ └── mb-task-side-menu-item.interface.ts │ │ │ │ │ ├── mb-task-side-menu.component.html │ │ │ │ │ ├── mb-task-side-menu.component.scss │ │ │ │ │ └── mb-task-side-menu.component.ts │ │ │ │ │ ├── mb-task-top-panel │ │ │ │ │ ├── mb-task-top-panel.component.html │ │ │ │ │ ├── mb-task-top-panel.component.scss │ │ │ │ │ └── mb-task-top-panel.component.ts │ │ │ │ │ └── mb-task-view │ │ │ │ │ ├── mb-task-view.component.html │ │ │ │ │ ├── mb-task-view.component.scss │ │ │ │ │ └── mb-task-view.component.ts │ │ │ ├── mobile-app.component.ts │ │ │ ├── mobile-app.routing.ts │ │ │ └── mobile-app.state.ts │ │ └── shared │ │ │ ├── components │ │ │ ├── redirects │ │ │ │ ├── google │ │ │ │ │ └── google-auth-redirect │ │ │ │ │ │ ├── google-auth-redirect.actions.ts │ │ │ │ │ │ ├── google-auth-redirect.component.html │ │ │ │ │ │ ├── google-auth-redirect.component.scss │ │ │ │ │ │ ├── google-auth-redirect.component.ts │ │ │ │ │ │ └── google-auth-redirect.state.ts │ │ │ │ ├── slack │ │ │ │ │ └── add-to-slack-redirect │ │ │ │ │ │ ├── add-to-slack-redirect.actions.ts │ │ │ │ │ │ ├── add-to-slack-redirect.component.html │ │ │ │ │ │ ├── add-to-slack-redirect.component.scss │ │ │ │ │ │ ├── add-to-slack-redirect.component.ts │ │ │ │ │ │ └── add-to-slack-redirect.state.ts │ │ │ │ └── styles.scss │ │ │ └── ui-elements │ │ │ │ ├── sign-in-with-google-btn │ │ │ │ ├── sign-in-with-google-btn.component.html │ │ │ │ ├── sign-in-with-google-btn.component.scss │ │ │ │ └── sign-in-with-google-btn.component.ts │ │ │ │ ├── speech-recorder │ │ │ │ ├── mic-icon │ │ │ │ │ ├── mic-icon.component.html │ │ │ │ │ ├── mic-icon.component.scss │ │ │ │ │ └── mic-icon.component.ts │ │ │ │ ├── voice-recorder.component.html │ │ │ │ ├── voice-recorder.component.scss │ │ │ │ └── voice-recorder.component.ts │ │ │ │ ├── spinner │ │ │ │ ├── spinner.component.html │ │ │ │ ├── spinner.component.scss │ │ │ │ └── spinner.component.ts │ │ │ │ └── user-avatar │ │ │ │ ├── user-avatar.component.html │ │ │ │ ├── user-avatar.component.scss │ │ │ │ ├── user-avatar.component.ts │ │ │ │ └── user-avatar.interface.ts │ │ │ ├── constants │ │ │ └── api-endpoints.const.ts │ │ │ ├── dto │ │ │ ├── change.dto.ts │ │ │ ├── index.ts │ │ │ ├── tag.dto.ts │ │ │ ├── task.dto.ts │ │ │ └── user.dto.ts │ │ │ ├── forms │ │ │ └── types │ │ │ │ └── form-controls-of.ts │ │ │ ├── helpers │ │ │ └── convert-object-to-url-params.function.ts │ │ │ ├── mappers │ │ │ ├── change.mapper.ts │ │ │ ├── index.ts │ │ │ ├── tag.mapper.ts │ │ │ ├── task.mapper.ts │ │ │ └── user.mapper.ts │ │ │ ├── models │ │ │ ├── change.model.ts │ │ │ ├── index.ts │ │ │ ├── tag.model.ts │ │ │ ├── task.model.ts │ │ │ └── user.model.ts │ │ │ ├── services │ │ │ ├── api │ │ │ │ ├── auth.actions.ts │ │ │ │ ├── auth.service.ts │ │ │ │ ├── client-changes.service.ts │ │ │ │ ├── client-id.service.ts │ │ │ │ ├── image-uploader.service.ts │ │ │ │ ├── server-changes.actions.ts │ │ │ │ ├── server-changes.service.ts │ │ │ │ └── speech-to-text.service.ts │ │ │ ├── auth │ │ │ │ └── auth-guard.service.ts │ │ │ ├── device-detector │ │ │ │ └── device-detector.service.ts │ │ │ ├── infrastructure │ │ │ │ └── http-interceptor.service.ts │ │ │ ├── integrations │ │ │ │ ├── google-api.actions.ts │ │ │ │ ├── google-api.service.ts │ │ │ │ ├── slack.api.actions.ts │ │ │ │ └── slack.service.ts │ │ │ ├── pwa │ │ │ │ ├── device-camera.service.ts │ │ │ │ ├── pwa.service.ts │ │ │ │ └── voice-recorder.service.ts │ │ │ └── utility │ │ │ │ ├── dialog.service.ts │ │ │ │ ├── image-optimizer.service.ts │ │ │ │ └── window-ref.service.ts │ │ │ └── state │ │ │ ├── app.actions.ts │ │ │ ├── app.state.ts │ │ │ ├── sync.action.ts │ │ │ ├── sync.state.ts │ │ │ ├── tasks.state.ts │ │ │ └── user.state.ts │ ├── assets │ │ ├── .gitkeep │ │ ├── icons │ │ │ ├── icon-128x128.png │ │ │ ├── icon-144x144.png │ │ │ ├── icon-152x152.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-384x384.png │ │ │ ├── icon-512x512.png │ │ │ ├── icon-72x72.png │ │ │ └── icon-96x96.png │ │ ├── screenshots │ │ │ └── 375_667.png │ │ └── ui │ │ │ ├── icons │ │ │ ├── cancel-cross-icon-gray.png │ │ │ ├── cancel-cross-icon-red.png │ │ │ ├── checkmark-green-dark.png │ │ │ ├── checkmark-green.png │ │ │ ├── disconnect-svgrepo-com.svg │ │ │ ├── edit.png │ │ │ ├── google-logo.png │ │ │ ├── home-icon.png │ │ │ ├── home-icon_light.png │ │ │ ├── no-connection.png │ │ │ ├── no-synchronize.png │ │ │ ├── options.png │ │ │ ├── options_light.png │ │ │ ├── plus-icon-btn.png │ │ │ ├── plus-icon-btn_light.png │ │ │ ├── rubbish.png │ │ │ └── slack-mark.svg │ │ │ └── main │ │ │ └── ba_logo.png │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── manifest.webmanifest │ ├── polyfills.ts │ ├── scss │ │ ├── _breakpoints.scss │ │ ├── _button.scss │ │ ├── _colors.scss │ │ ├── _constants.scss │ │ ├── _input.scss │ │ ├── _mat-fileld.scss │ │ └── index.scss │ └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── nginx ├── Dockerfile ├── default.conf └── prod.conf ├── package-lock.json ├── package.json └── production.yml /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | nginx/ssl/* 3 | 4 | # evirament variables 5 | .env 6 | 7 | node_modules/ 8 | 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | npx lint-staged 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # # Angular Clean Architecture 2 | # Youtube Channel: https://www.youtube.com/@kirillushakov-webmobiledev6785 3 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # local test ssl 2 | /ssl 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # variables 8 | .env 9 | 10 | #compiled ts -> js 11 | /dist -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "printWidth": 100, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --verbose 8 | RUN npm install -g nodemon 9 | 10 | COPY . . 11 | 12 | CMD npm run dev 13 | -------------------------------------------------------------------------------- /backend/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --verbose 8 | RUN npm install -g ts-node@latest 9 | RUN npm install typescript -g 10 | 11 | COPY . . 12 | 13 | RUN tsc 14 | 15 | CMD ["node", "./dist/server.js"] 16 | -------------------------------------------------------------------------------- /backend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslintPluginTs from '@typescript-eslint/eslint-plugin'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | import securityPlugin from 'eslint-plugin-security'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | 6 | export default [ 7 | { 8 | files: ['**/*.ts'], 9 | languageOptions: { 10 | parser: tsParser, 11 | ecmaVersion: 'latest', 12 | sourceType: 'module', 13 | }, 14 | plugins: { 15 | '@typescript-eslint': eslintPluginTs, 16 | security: securityPlugin, 17 | }, 18 | rules: { 19 | ...eslintPluginTs.configs.recommended.rules, 20 | ...securityPlugin.configs.recommended.rules, 21 | ...prettierConfig.rules, 22 | 23 | '@typescript-eslint/no-namespace': 'off', 24 | 'arrow-body-style': 'off', 25 | 'prefer-arrow-callback': 'off', 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /backend/server.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import https from 'https'; 3 | import fs from 'fs'; 4 | import { app } from './src/app.js'; 5 | 6 | dotenv.config(); 7 | 8 | const port = process.env.PORT; 9 | const crtPath = process.env.CRT_PATH; 10 | const keyPath = process.env.KEY_PATH; 11 | const caBandlePath = process.env.CA_BANDLE_PATH; 12 | 13 | const httpsOptions: https.ServerOptions = { 14 | cert: fs.readFileSync(crtPath), 15 | key: fs.readFileSync(keyPath), 16 | }; 17 | 18 | if (caBandlePath) { 19 | httpsOptions.ca = fs.readFileSync(caBandlePath); 20 | } 21 | 22 | app.set('port', port); 23 | const server = https.createServer(httpsOptions, app); 24 | server.listen(port); 25 | -------------------------------------------------------------------------------- /backend/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | 6 | const REQUIRED_ENV_VARS = ['FILES_UPLOAD_PATH']; 7 | 8 | REQUIRED_ENV_VARS.forEach(name => { 9 | if (!process.env[name]) { 10 | throw new Error(`Missing required env variable: ${name}`); 11 | } 12 | }); 13 | 14 | const baseUploadPath = process.env.FILES_UPLOAD_PATH!; 15 | 16 | export const config = { 17 | paths: { 18 | uploadTempDir: path.resolve(baseUploadPath, 'uploads'), 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/loaders/index.ts: -------------------------------------------------------------------------------- 1 | import * as mongooseLoader from './mongoose.js'; 2 | //import * as ngrokLoader from './ngrok'; 3 | 4 | export async function bootstrap(client: string): Promise { 5 | try { 6 | await mongooseLoader.connectToDb(client); 7 | //await ngrokLoader.establishIngress(); 8 | } catch (err) { 9 | console.log(err); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/loaders/mongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Mongoose } from "mongoose"; 2 | 3 | const DB_PORT = process.env.DB_PORT; 4 | const DB_CONTAINER = process.env.DB_CONTAINER 5 | const DB_USER = process.env.DB_USER; 6 | const DB_PASSWORD = process.env.DB_PASSWORD; 7 | 8 | const DB_URI = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_CONTAINER}:${DB_PORT}/ba?authSource=admin`; 9 | 10 | export function connectToDb(client):Promise { 11 | return new Promise((resolve, reject) => { 12 | mongoose.connect(DB_URI).then((connection) => { 13 | console.log(`Database connection established successfully for Client: ${client}`); 14 | resolve(connection); 15 | }).catch((err) => { 16 | console.log(`Error occurred while connection with DB for for Client: ${client}`); 17 | console.log("DB_URI: " + DB_URI); 18 | reject(err); 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/loaders/ngrok.ts: -------------------------------------------------------------------------------- 1 | import ngrok, { Listener } from '@ngrok/ngrok'; 2 | 3 | export function establishIngress() { 4 | return new Promise((resolve, reject) => { 5 | // Establish connectivity 6 | ngrok 7 | .forward({ 8 | domain: process.env.NGROK_STATIC_DOMAIN, 9 | addr: process.env.NGROK_FORWARD_PATH, 10 | authtoken_from_env: true, 11 | verify_webhook_provider: 'slack', 12 | verify_webhook_secret: process.env.SLACK_SIGNING_SECRET, 13 | }) 14 | .then((listener: Listener) => { 15 | console.log(`Ingress established at: ${listener.url()}`); 16 | ngrok.consoleLog(); 17 | resolve(); 18 | }) 19 | .catch((err) => { 20 | console.log( 21 | `An error occurred while establishing of the ngrok ingress.` 22 | ); 23 | reject(err); 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/auth/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | export type UserDto = { 2 | firstName: string; 3 | lastName: string; 4 | email: string; 5 | userId: string; 6 | }; 7 | -------------------------------------------------------------------------------- /backend/src/modules/auth/routers/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { loginController } from '../usecases/login/index.js'; 3 | import { logoutController } from '../usecases/logout/index.js'; 4 | import { signupController } from '../usecases/sing-up/index.js'; 5 | import { verifyEmailController } from '../usecases/verify-email/_index.js'; 6 | 7 | const authRouter: Router = Router(); 8 | 9 | authRouter.post('/signup', (req, res, next) => 10 | signupController.execute(req, res, next) 11 | ); 12 | authRouter.post('/login', (req, res, next) => 13 | loginController.execute(req, res, next) 14 | ); 15 | authRouter.get('/verify-email', (req, res, next) => 16 | verifyEmailController.execute(req, res, next) 17 | ); 18 | authRouter.delete('/logout', (req, res, next) => 19 | logoutController.execute(req, res, next) 20 | ); 21 | 22 | export { authRouter }; 23 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/_index.ts: -------------------------------------------------------------------------------- 1 | import { sendgridMailAdapter, mailgunAdapter } from './adapters/_index.js'; 2 | import { 3 | simpleMailFactory, 4 | christmasMailFactory, 5 | halloweenMailFactory, 6 | } from './factories/_index.js'; 7 | import { EmailVerificationService } from './email-verification.service.js'; 8 | import { IAbstractMailFactory } from './abstract-mail-factory.interface.js'; 9 | 10 | let mailAdapter; 11 | switch (process.env.MAIL_API) { 12 | case 'MAILGUN': 13 | mailAdapter = mailgunAdapter; 14 | case 'SENDGRID': 15 | mailAdapter = sendgridMailAdapter; 16 | 17 | // other adapters can be added here 18 | // .... 19 | 20 | default: 21 | mailAdapter = mailgunAdapter; 22 | } 23 | 24 | let mailFactory: IAbstractMailFactory; 25 | 26 | if (new Date().getMonth() === 11 && new Date().getDate() === 25) { 27 | mailFactory = christmasMailFactory; 28 | } else if (new Date().getMonth() === 9 && new Date().getDate() === 31) { 29 | mailFactory = halloweenMailFactory; 30 | } else { 31 | mailFactory = simpleMailFactory; 32 | } 33 | 34 | const emailVerificationService = new EmailVerificationService( 35 | mailAdapter, 36 | mailFactory 37 | ); 38 | 39 | export { emailVerificationService }; 40 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/abstract-mail-factory.interface.ts: -------------------------------------------------------------------------------- 1 | import { UserDocument } from '../../../../shared/infra/database/mongodb/user.model.js'; 2 | 3 | export interface IAbstractMailFactory { 4 | verificationEmail(user: UserDocument, link: string): string; 5 | restorePasswordEmail(user: UserDocument, link: string): string; 6 | notificationEmail(user: UserDocument, notification: any): string; 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/adapters/_index.ts: -------------------------------------------------------------------------------- 1 | import { SendgridMailAdapter } from './sendgrid.adapter.js'; 2 | import { mailgunProvider, sendgridMailProvider } from '../providers/_index.js'; 3 | import { MailgunAdapter } from './mailgun.adapter.js'; 4 | 5 | const sendgridMailAdapter: SendgridMailAdapter = new SendgridMailAdapter( 6 | sendgridMailProvider 7 | ); 8 | const mailgunAdapter: MailgunAdapter = new MailgunAdapter(mailgunProvider); 9 | 10 | export { sendgridMailAdapter, mailgunAdapter }; 11 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/adapters/mailgun.adapter.ts: -------------------------------------------------------------------------------- 1 | import { IMailgunClient } from 'mailgun.js/Interfaces'; 2 | import { IMailAdapter } from '../mail-adapter.interface.js'; 3 | 4 | export class MailgunAdapter implements IMailAdapter { 5 | private provider: IMailgunClient; 6 | 7 | constructor(mailgunProvider: IMailgunClient) { 8 | this.provider = mailgunProvider; 9 | } 10 | 11 | public async sendEmail( 12 | to: string, 13 | from: string, 14 | subject: string, 15 | html: string 16 | ): Promise { 17 | try { 18 | // 👇 Create email with MailGun API (provider is MailgunClient) 19 | const result = await this.provider.messages.create( 20 | process.env.EMAIL_DOMAIN, 21 | { 22 | from: from, 23 | to: [to], 24 | subject: subject, 25 | html: html, 26 | } 27 | ); 28 | console.log(result); 29 | } catch (err) { 30 | console.error(err); 31 | throw err; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/adapters/sendgrid.adapter.ts: -------------------------------------------------------------------------------- 1 | import { MailDataRequired, MailService } from '@sendgrid/mail'; 2 | import { IMailAdapter } from '../mail-adapter.interface.js'; 3 | 4 | export class SendgridMailAdapter implements IMailAdapter { 5 | private _provider: MailService; 6 | 7 | constructor(sendgridMailProvider: MailService) { 8 | this._provider = sendgridMailProvider; 9 | } 10 | 11 | public async sendEmail( 12 | to: string, 13 | from: string, 14 | subject: string, 15 | html: string 16 | ): Promise { 17 | let mail: MailDataRequired = { 18 | from, 19 | subject, 20 | to, 21 | html, 22 | }; 23 | 24 | try { 25 | await this._provider.send(mail); 26 | } catch (error) { 27 | // TODO: log error here 28 | const { message, code, response } = error; 29 | const { headers, body } = response; 30 | console.error(body); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/factories/_index.ts: -------------------------------------------------------------------------------- 1 | import { SimpleMailFactory } from './simple-mail.factory.js'; 2 | import { HalloweenMailFactory } from './halloween-mail.factory.js'; 3 | import { ChristmasMailFactory } from './christmas-mail.factory.js'; 4 | 5 | const simpleMailFactory = new SimpleMailFactory(); 6 | const halloweenMailFactory = new HalloweenMailFactory(); 7 | const christmasMailFactory = new ChristmasMailFactory(); 8 | 9 | export { simpleMailFactory, halloweenMailFactory, christmasMailFactory }; 10 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/factories/christmas-mail.factory.ts: -------------------------------------------------------------------------------- 1 | import { UserDocument } from '../../../../../shared/infra/database/mongodb/user.model.js'; 2 | import { IAbstractMailFactory } from '../abstract-mail-factory.interface.js'; 3 | 4 | export class ChristmasMailFactory implements IAbstractMailFactory { 5 | constructor() {} 6 | 7 | verificationEmail(user, link: string): string { 8 | const html = ` 9 |

Happy Christmas, ${user.firstName} ${user.lastName},

10 |
11 |

Please click on the following link to verify your account.

12 |
13 |

If you did not request this, please ignore this email.

`; 14 | 15 | return html; 16 | } 17 | 18 | restorePasswordEmail(user, link: string): string { 19 | // TODO 20 | const html = ''; 21 | return html; 22 | } 23 | 24 | notificationEmail(user: UserDocument, notification: any): string { 25 | // TODO 26 | const html = ''; 27 | return html; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/factories/halloween-mail.factory.ts: -------------------------------------------------------------------------------- 1 | import { UserDocument } from '../../../../../shared/infra/database/mongodb/user.model.js'; 2 | import { IAbstractMailFactory } from '../abstract-mail-factory.interface.js'; 3 | 4 | export class HalloweenMailFactory implements IAbstractMailFactory { 5 | constructor() {} 6 | 7 | verificationEmail(user, link: string): string { 8 | const html = ` 9 |

Eat, drink and be scary, ${user.firstName} ${user.lastName},

10 |
11 |

Please click on the following link to verify your account.

12 |
13 |

If you did not request this, please ignore this email.

`; 14 | 15 | return html; 16 | } 17 | 18 | restorePasswordEmail(user, link: string): string { 19 | // TODO 20 | const html = ''; 21 | return html; 22 | } 23 | 24 | notificationEmail(user: UserDocument, notification: any): string { 25 | // TODO 26 | const html = ''; 27 | return html; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/factories/simple-mail.factory.ts: -------------------------------------------------------------------------------- 1 | import { UserDocument } from '../../../../../shared/infra/database/mongodb/user.model.js'; 2 | import { IAbstractMailFactory } from '../abstract-mail-factory.interface.js'; 3 | 4 | export class SimpleMailFactory implements IAbstractMailFactory { 5 | constructor() {} 6 | 7 | verificationEmail(user, link: string): string { 8 | const html = ` 9 |

Hi ${user.firstName} ${user.lastName},

10 |
11 |

Please click on the following link to verify your account.

12 |
13 |

If you did not request this, please ignore this email.

`; 14 | 15 | return html; 16 | } 17 | 18 | restorePasswordEmail(user, link: string): string { 19 | // TODO 20 | const html = ''; 21 | return html; 22 | } 23 | 24 | // 'any' type has to be replaced on INotificationData type in future 25 | notificationEmail(user: UserDocument, notification: any): string { 26 | // TODO 27 | const html = ''; 28 | return html; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/mail-adapter.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IMailAdapter { 2 | sendEmail: (to, from, subject, html) => Promise; 3 | } 4 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/providers/_index.ts: -------------------------------------------------------------------------------- 1 | import { sendgridMailProvider } from './sendgrid.provider.js'; 2 | import { mailgunProvider } from './mailgun.provider.js'; 3 | 4 | export { sendgridMailProvider, mailgunProvider }; 5 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/providers/mailgun.provider.ts: -------------------------------------------------------------------------------- 1 | import FormData from 'form-data'; 2 | import Mailgun from 'mailgun.js'; 3 | 4 | const mailgun = new Mailgun(FormData); 5 | 6 | const mailgunProvider = mailgun.client({ 7 | username: 'api', 8 | key: process.env.MAILGUN_API_KEY as string, 9 | }); 10 | 11 | export { mailgunProvider }; 12 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/email/providers/sendgrid.provider.ts: -------------------------------------------------------------------------------- 1 | import { MailService } from '@sendgrid/mail'; 2 | 3 | const sendgridApiKey = process.env.SENDGRID_API_KEY; 4 | const sendgridMailProvider: MailService = new MailService(); 5 | sendgridMailProvider.setApiKey(sendgridApiKey); 6 | 7 | export { sendgridMailProvider }; 8 | -------------------------------------------------------------------------------- /backend/src/modules/auth/services/index.ts: -------------------------------------------------------------------------------- 1 | import { LoginService } from './login.service.js'; 2 | 3 | const loginService = new LoginService(); 4 | 5 | export { loginService }; 6 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/google-auth/google-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { 3 | BaseController, 4 | EHttpStatus, 5 | } from '../../../../shared/infra/http/models/base-controller.js'; 6 | import { GoogleAuthUsecase } from './google-auth.usecase.js'; 7 | 8 | export class GoogleAuthController extends BaseController { 9 | private usecase: GoogleAuthUsecase; 10 | 11 | constructor(usecase: GoogleAuthUsecase) { 12 | super(); 13 | this.usecase = usecase; 14 | } 15 | 16 | protected async executeImpl(req: Request, res: Response, next?: NextFunction): Promise { 17 | try { 18 | const result = await this.usecase.execute({ 19 | context: { req, res, next }, 20 | }); 21 | if (result.isSuccess) { 22 | BaseController.jsonResponse(res, EHttpStatus.Ok, result.getValue()); 23 | } else { 24 | const error = result.error; 25 | BaseController.jsonResponse(res, error.httpCode, { 26 | name: error.name, 27 | message: error.message, 28 | }); 29 | } 30 | } catch (err) { 31 | this.fail(res, err.toString()); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/google-auth/google-auth.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | enum EGoogleAuthUsecaseError { 6 | EmailAlreadyInUse = 'GOOGLE_AUTH_USECASE__EMAIL_ALREADY_IN_USE', 7 | } 8 | 9 | type EGoogleAuthError = EGoogleAuthUsecaseError; 10 | 11 | export class GoogleAuthError extends UseCaseError {} 12 | 13 | export namespace GoogleAuthErrors { 14 | export class EmailAlreadyInUse extends Result { 15 | constructor(email: string) { 16 | super( 17 | false, 18 | new GoogleAuthError( 19 | EGoogleAuthUsecaseError.EmailAlreadyInUse, 20 | `Cannot create user with this email. The email ${email} already exists`, 21 | EHttpStatus.Conflict, 22 | ), 23 | ); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/google-auth/index.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import { GoogleAuthController } from './google-auth.controller.js'; 3 | import { GoogleAuthUsecase } from './google-auth.usecase.js'; 4 | import { loginService } from '../../services/index.js'; 5 | import { UserRepo } from '../../../../shared/repo/user.repo.js'; 6 | import { models } from '../../../../shared/infra/database/mongodb/index.js'; 7 | 8 | const userRepo = new UserRepo(models); 9 | const googleAuthUsecase = new GoogleAuthUsecase( 10 | passport, 11 | userRepo, 12 | loginService 13 | ); 14 | const googleAuthController = new GoogleAuthController(googleAuthUsecase); 15 | 16 | export { googleAuthController }; 17 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/login/index.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import { loginService } from '../../services/index.js'; 3 | import { LoginController } from './login.controller.js'; 4 | import { LoginUsecase } from './login.usecase.js'; 5 | 6 | const loginUsecase = new LoginUsecase(passport, loginService); 7 | const loginController = new LoginController(loginUsecase); 8 | 9 | export { loginController }; 10 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/login/login.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { 3 | BaseController, 4 | EHttpStatus, 5 | } from '../../../../shared/infra/http/models/base-controller.js'; 6 | import { LoginUsecase } from './login.usecase.js'; 7 | 8 | export class LoginController extends BaseController { 9 | private loginUsecase: LoginUsecase; 10 | 11 | constructor(loginUsecase: LoginUsecase) { 12 | super(); 13 | this.loginUsecase = loginUsecase; 14 | } 15 | 16 | protected async executeImpl(req: Request, res: Response, next?: NextFunction): Promise { 17 | try { 18 | const result = await this.loginUsecase.execute({ 19 | context: { req, res, next }, 20 | }); 21 | if (result.isSuccess) { 22 | BaseController.jsonResponse(res, EHttpStatus.Ok, result.getValue()); 23 | } else { 24 | const error = result.error; 25 | BaseController.jsonResponse(res, error.httpCode, { 26 | name: error.name, 27 | message: error.message, 28 | }); 29 | } 30 | } catch (err) { 31 | this.fail(res, err.toString()); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/login/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserDto } from '../../dto/user.dto.js'; 2 | 3 | export interface LoginResponseDTO { 4 | user: UserDto; 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/login/login.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | type ELoginError = ELoginUsecaseError; 6 | export class LoginError extends UseCaseError {} 7 | 8 | export enum ELoginUsecaseError { 9 | LoginFailed = 'LOGIN_USECASE_ERROR__AUTHENTICATION_FAILED', 10 | UserAccountNotVerified = 'LOGIN_USECASE_ERROR__ACCOUNT_NOT_VERIFIED', 11 | } 12 | 13 | export namespace LoginError { 14 | export class LoginFailed extends Result { 15 | constructor() { 16 | super( 17 | false, 18 | new LoginError( 19 | ELoginUsecaseError.LoginFailed, 20 | 'Authorization failed!', 21 | EHttpStatus.BadRequest, 22 | ), 23 | ); 24 | } 25 | } 26 | 27 | export class UserAccountNotVerified extends Result { 28 | constructor() { 29 | super( 30 | false, 31 | new LoginError( 32 | ELoginUsecaseError.UserAccountNotVerified, 33 | 'User account not verified!', 34 | EHttpStatus.BadRequest, 35 | ), 36 | ); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/logout/index.ts: -------------------------------------------------------------------------------- 1 | import { LogoutController } from './logout.controller.js'; 2 | 3 | const logoutController = new LogoutController(); 4 | 5 | export { logoutController }; 6 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/logout/logout.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { BaseController } from '../../../../shared/infra/http/models/base-controller.js'; 3 | 4 | export class LogoutController extends BaseController { 5 | protected async executeImpl( 6 | req: Request, 7 | res: Response, 8 | next?: NextFunction 9 | ): Promise { 10 | try { 11 | if (process.env.AUTHENTICATION_STRATEGY === 'SESSION') { 12 | if (req.isAuthenticated()) { 13 | req.logout((err: any) => { 14 | console.log(err); 15 | }); 16 | } 17 | } 18 | 19 | if (process.env.AUTHENTICATION_STRATEGY === 'JWT') { 20 | res.clearCookie('jwt'); 21 | } 22 | return this.ok(res); 23 | } catch (err) { 24 | return this.fail(res, err.toString()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/sing-up/index.ts: -------------------------------------------------------------------------------- 1 | import { SignupController } from './signup.controller.js'; 2 | import { SignUp } from './signup.usecase.js'; 3 | import { UserRepo } from '../../../../shared/repo/user.repo.js'; 4 | import { models } from '../../../../shared/infra/database/mongodb/index.js'; 5 | import { emailVerificationService } from '../../services/email/_index.js'; 6 | 7 | const userRepo: UserRepo = new UserRepo(models); 8 | const signup: SignUp = new SignUp(userRepo, emailVerificationService); 9 | const signupController = new SignupController(signup); 10 | 11 | export { signupController }; 12 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/sing-up/signup.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { BaseController } from '../../../../shared/infra/http/models/base-controller.js'; 3 | import { SignUpRequestDTO } from './signup.dto.js'; 4 | import { SignUp } from './signup.usecase.js'; 5 | 6 | export class SignupController extends BaseController { 7 | private _useCase: SignUp; 8 | 9 | constructor(useCase: SignUp) { 10 | super(); 11 | this._useCase = useCase; 12 | } 13 | 14 | protected async executeImpl(req: Request, res: Response): Promise { 15 | let dto: SignUpRequestDTO = req.body as SignUpRequestDTO; 16 | 17 | // TODO data validation and sanitize has to be here 18 | // (https://www.npmjs.com/package/validator, https://www.npmjs.com/package/dompurify) 19 | 20 | try { 21 | const result = await this._useCase.execute({ dto }); 22 | 23 | if (result.isFailure) { 24 | const error = result.error; 25 | 26 | BaseController.jsonResponse(res, error.httpCode, { 27 | name: error.name, 28 | message: error.message, 29 | }); 30 | } else { 31 | this.ok(res, result.getValue()); 32 | } 33 | } catch (err) { 34 | this.fail(res, err); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/sing-up/signup.dto.ts: -------------------------------------------------------------------------------- 1 | export interface SignUpRequestDTO { 2 | email: string; 3 | firstName: string; 4 | lastName: string; 5 | password: string; 6 | } 7 | 8 | export interface SignUpResponseDTO { 9 | email: string; 10 | firstName: string; 11 | lastName: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/verify-email/_index.ts: -------------------------------------------------------------------------------- 1 | import { models } from '../../../../shared/infra/database/mongodb/index.js'; 2 | import { UserRepo } from '../../../../shared/repo/user.repo.js'; 3 | import { VerifyEmailController } from './verify-email.controller.js'; 4 | import { VerifyEmailUseCase } from './verify-email.usecase.js'; 5 | 6 | const userRepo: UserRepo = new UserRepo(models); 7 | const verifyEmail: VerifyEmailUseCase = new VerifyEmailUseCase(userRepo); 8 | const verifyEmailController = new VerifyEmailController(verifyEmail); 9 | 10 | export { verifyEmailController }; 11 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/verify-email/verify-email.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { BaseController } from '../../../../shared/infra/http/models/base-controller.js'; 3 | import { VerifyEmailRequestDTO } from './verify-email.dto.js'; 4 | import { VerifyEmailUseCase } from './verify-email.usecase.js'; 5 | 6 | export class VerifyEmailController extends BaseController { 7 | private _useCase: VerifyEmailUseCase; 8 | 9 | constructor(useCase: VerifyEmailUseCase) { 10 | super(); 11 | this._useCase = useCase; 12 | } 13 | 14 | protected async executeImpl(req: Request, res: Response): Promise { 15 | let dto: VerifyEmailRequestDTO = { 16 | token: req.query.token as string, 17 | }; 18 | 19 | try { 20 | const result = await this._useCase.execute({ dto }); 21 | 22 | if (result.isSuccess) { 23 | this.ok(res, result.getValue()); 24 | } else { 25 | const error = result.error; 26 | 27 | BaseController.jsonResponse(res, error.httpCode, { 28 | name: error.name, 29 | message: error.message, 30 | }); 31 | } 32 | } catch (error) { 33 | this.fail(res, error.toString()); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/verify-email/verify-email.dto.ts: -------------------------------------------------------------------------------- 1 | export interface VerifyEmailRequestDTO { 2 | token: string; 3 | } 4 | 5 | export interface IVerifyEmailResponceDTO { 6 | email: string; 7 | firstName: string; 8 | lastName: string; 9 | verified: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/modules/auth/usecases/verify-email/verify-email.errors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from '../../../../shared/core/use-case-error.js'; 2 | 3 | export enum EVerifyEmailUsecaseError { 4 | GivenTokenDoesNotExist = 'VERIFY_EMAIL_ERROR__GIVEN_TOKEN_DOES_NOT_EXIST', 5 | VerificationFailed = 'VERIFY_EMAIL_ERROR__VERIFICATION_FAILED', 6 | } 7 | 8 | type EVerifyEmail = EVerifyEmailUsecaseError; 9 | export class VerifyEmailError extends UseCaseError {} 10 | -------------------------------------------------------------------------------- /backend/src/modules/files/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import multer from 'multer'; 3 | import { uploadImageController } from './usecases/upload-image/_index.js'; 4 | import { getImageController } from './usecases/get-image/_index.js'; 5 | 6 | const filesRouter: Router = Router(); 7 | 8 | const uploader: multer.Multer = multer({ 9 | dest: process.env.FILES_UPLOAD_PATH + '/tmp/', 10 | }); 11 | 12 | filesRouter.post('/image', uploader.single('file'), (req, res, next) => 13 | uploadImageController.execute(req, res, next) 14 | ); 15 | 16 | filesRouter.get('/image/:file', (req, res, next) => 17 | getImageController.execute(req, res, next) 18 | ); 19 | 20 | export { filesRouter }; 21 | -------------------------------------------------------------------------------- /backend/src/modules/files/services/_index.ts: -------------------------------------------------------------------------------- 1 | import { ImageResizeService } from './image-resize.service.js'; 2 | 3 | const imageResizeService = new ImageResizeService(); 4 | 5 | export { imageResizeService }; -------------------------------------------------------------------------------- /backend/src/modules/files/usecases/get-image/_index.ts: -------------------------------------------------------------------------------- 1 | import { GetImageController } from './get-image.controller.js'; 2 | import { GetImageUsecase } from './get-image.usecase.js'; 3 | import { googleDriveService } from './../../../integrations/google/services/index.js'; 4 | import { imageResizeService } from '../../services/_index.js'; 5 | 6 | const getImageUsecase = new GetImageUsecase(googleDriveService, imageResizeService); 7 | const getImageController = new GetImageController(getImageUsecase); 8 | 9 | export { getImageController }; 10 | -------------------------------------------------------------------------------- /backend/src/modules/files/usecases/upload-image/_index.ts: -------------------------------------------------------------------------------- 1 | import { UploadImageController } from './upload-image.controller.js'; 2 | import { UploadImageUsecase } from './upload-image.usecase.js'; 3 | import { googleDriveService } from './../../../integrations/google/services/index.js'; 4 | 5 | const uploadImageUsecase = new UploadImageUsecase(googleDriveService); 6 | const uploadImageController = new UploadImageController(uploadImageUsecase); 7 | export { uploadImageController }; 8 | -------------------------------------------------------------------------------- /backend/src/modules/files/usecases/upload-image/upload-image.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | export class UploadImageError extends UseCaseError {} 6 | 7 | enum UploadImageErrorCode { 8 | NotSupportedType = 'UPLOAD_IMAGE_ERROR_CODE__NOT_SUPPORTED_TYPE', 9 | UploadToGoogleDriveFailed = 'UPLOAD_IMAGE_ERROR_CODE__UPLOAD_TO_GOOGLE_DRIVE_FAILED', 10 | } 11 | 12 | type UploadImageErrorCodes = UploadImageErrorCode; 13 | 14 | export namespace UploadImageErrors { 15 | export class NotSupportedTypeError extends Result { 16 | constructor(type: string) { 17 | super( 18 | false, 19 | new UploadImageError( 20 | UploadImageErrorCode.NotSupportedType, 21 | `The file type "${type}" doesn't supported`, 22 | EHttpStatus.BadRequest, 23 | ), 24 | ); 25 | } 26 | } 27 | 28 | export class UploadToGoogleDriveFailed extends Result { 29 | constructor() { 30 | super( 31 | false, 32 | new UseCaseError( 33 | UploadImageErrorCode.UploadToGoogleDriveFailed, 34 | `Uploading file to Google Drive failed`, 35 | EHttpStatus.BadGateway, 36 | ), 37 | ); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/modules/files/usecases/upload-image/upload-image.request.ts: -------------------------------------------------------------------------------- 1 | export type UploadImageRequest = { 2 | file: Express.Multer.File; 3 | }; 4 | -------------------------------------------------------------------------------- /backend/src/modules/files/usecases/upload-image/upload-image.response.ts: -------------------------------------------------------------------------------- 1 | export type UploadImageResponse = { 2 | fileId: string; 3 | extension: string; 4 | }; 5 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/google/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { getOAuthConsentScreenController } from './usecases/get-oauth-consent-screen/index.js'; 3 | import { googleAuthController } from '../../auth/usecases/google-auth/index.js'; 4 | const googleRouter: Router = Router(); 5 | 6 | googleRouter.get('/oauth-consent-screen', (req, res) => 7 | getOAuthConsentScreenController.execute(req, res) 8 | ); 9 | 10 | googleRouter.get('/auth', (req, res, next) => 11 | googleAuthController.execute(req, res, next) 12 | ); 13 | 14 | export { googleRouter }; 15 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/google/services/index.ts: -------------------------------------------------------------------------------- 1 | import { google } from 'googleapis'; 2 | import { GoogleDriveService } from './google-drive.service.js'; 3 | import { OAuth2Client } from 'googleapis-common'; 4 | 5 | const oAuth2Client: OAuth2Client = new google.auth.OAuth2( 6 | process.env.GOOGLE_CLIENT_ID, 7 | process.env.GOOGLE_CLIENT_SECRET, 8 | process.env.GOOGLE_OAUTH_CALLBACK 9 | ); 10 | 11 | const googleDriveService = new GoogleDriveService(oAuth2Client); 12 | 13 | export { googleDriveService }; 14 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/google/usecases/get-oauth-consent-screen/get-oauth-consent-screen.controller.ts: -------------------------------------------------------------------------------- 1 | import { PassportStatic } from 'passport'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { BaseController } from '../../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | export class GetOAuthConsentScreenController extends BaseController { 6 | private _passport: PassportStatic; 7 | 8 | constructor(passport: PassportStatic) { 9 | super(); 10 | this._passport = passport; 11 | } 12 | 13 | protected async executeImpl( 14 | req: Request, 15 | res: Response, 16 | next?: NextFunction 17 | ): Promise { 18 | try { 19 | this._passport.authenticate('google', { 20 | scope: [ 21 | 'profile', 22 | 'email', 23 | 'https://www.googleapis.com/auth/drive.appdata', 24 | ], 25 | accessType: 'offline', 26 | })(req, res, next); 27 | } catch (err) { 28 | return this.fail(res, err.toString()); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/google/usecases/get-oauth-consent-screen/index.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import { GetOAuthConsentScreenController } from './get-oauth-consent-screen.controller.js'; 3 | 4 | const getOAuthConsentScreenController = new GetOAuthConsentScreenController( 5 | passport 6 | ); 7 | 8 | export { getOAuthConsentScreenController }; 9 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { slackRouter } from './slack/routers/index.js'; 3 | import { googleRouter } from './google/router.js'; 4 | 5 | const integrationsRouter: Router = Router(); 6 | 7 | integrationsRouter.use('/slack', slackRouter); 8 | integrationsRouter.use('/google', googleRouter); 9 | 10 | export { integrationsRouter }; 11 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/enums/slack-event.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ESlackEventType { 2 | AppUninstalled = 'app_uninstalled', 3 | AppHomeOpened = 'app_home_opened', 4 | MemberLeftChannel = 'member_left_channel', 5 | //...others slack events from https://api.slack.com/events 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/routers/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { addToSlackController } from '../usecases/add-to-slack/index.js'; 3 | import { removeFromSlackController } from '../usecases/remove-from-slack/index.js'; 4 | import { slackEventRecivedController } from '../usecases/slack-event-received/index.js'; 5 | import { verificationChallenge } from '../middleware/verification-challenge.function.js'; 6 | import { isAuthenticated } from '../../../../shared/infra/auth/index.js'; 7 | 8 | const slackRouter: Router = Router(); 9 | 10 | slackRouter.post('/install', isAuthenticated, (req, res) => 11 | addToSlackController.execute(req, res) 12 | ); 13 | 14 | slackRouter.delete('/install', isAuthenticated, (req, res) => 15 | removeFromSlackController.execute(req, res) 16 | ); 17 | 18 | slackRouter.post('/event-recived', verificationChallenge(), (req, res) => 19 | slackEventRecivedController.execute(req, res) 20 | ); 21 | 22 | export { slackRouter }; 23 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/add-to-slack/add-to-slack.errors.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from '../../../../../shared/core/use-case-error.js'; 2 | 3 | export class AddToSlackError extends UseCaseError {} 4 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/add-to-slack/index.ts: -------------------------------------------------------------------------------- 1 | import { WebClient } from '@slack/web-api'; 2 | import { AddToSlackController } from './add-to-slack.controller.js'; 3 | import { AddToSlackUsecase } from './add-to-slack.usecase.js'; 4 | import { models } from '../../../../../shared/infra/database/mongodb/index.js'; 5 | import { SlackOAuthAccessRepo } from '../../../../../shared/repo/slack-oauth-access.repo.js'; 6 | 7 | const webClient = new WebClient(); 8 | const slackOAuthAccessRepo = new SlackOAuthAccessRepo(models); 9 | const addToSlackUsecase = new AddToSlackUsecase( 10 | webClient, 11 | slackOAuthAccessRepo 12 | ); 13 | 14 | const addToSlackController = new AddToSlackController(addToSlackUsecase); 15 | 16 | export { addToSlackController }; 17 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/remove-from-slack/index.ts: -------------------------------------------------------------------------------- 1 | import { WebClient } from '@slack/web-api'; 2 | import { models } from '../../../../../shared/infra/database/mongodb/index.js'; 3 | import { SlackOAuthAccessRepo } from '../../../../../shared/repo/slack-oauth-access.repo.js'; 4 | import { RemoveFromSlackUsecase } from './remove-from-slack.usecase.js'; 5 | import { RemoveFromSlackController } from './remove-from-slack.controller.js'; 6 | 7 | const webClient = new WebClient(); 8 | 9 | const slackOAuthAccessRepo = new SlackOAuthAccessRepo(models); 10 | 11 | const removeFromSlackUsecase = new RemoveFromSlackUsecase( 12 | webClient, 13 | slackOAuthAccessRepo 14 | ); 15 | 16 | const removeFromSlackController = new RemoveFromSlackController( 17 | removeFromSlackUsecase 18 | ); 19 | 20 | export { removeFromSlackController }; 21 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/remove-from-slack/remove-from-slack.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { 3 | BaseController, 4 | EHttpStatus, 5 | } from '../../../../../shared/infra/http/models/base-controller.js'; 6 | 7 | import { RemoveFromSlackUsecase, RemoveFromSlackResponse } from './remove-from-slack.usecase.js'; 8 | import { UserPersistent } from '../../../../../shared/domain/models/user.js'; 9 | 10 | export class RemoveFromSlackController extends BaseController { 11 | private _useCase: RemoveFromSlackUsecase; 12 | 13 | constructor(useCase: RemoveFromSlackUsecase) { 14 | super(); 15 | this._useCase = useCase; 16 | } 17 | 18 | protected async executeImpl(req: Request, res: Response): Promise { 19 | const loggedUser: UserPersistent = req.user as UserPersistent; 20 | const userId = loggedUser._id; 21 | 22 | try { 23 | const addToSlackResult: RemoveFromSlackResponse = await this._useCase.execute({ 24 | userId, 25 | }); 26 | 27 | if (addToSlackResult.isSuccess) { 28 | BaseController.jsonResponse(res, EHttpStatus.Ok); 29 | } else { 30 | const error = addToSlackResult.error; 31 | BaseController.jsonResponse(res, error.httpCode, { 32 | name: error.name, 33 | message: error.message, 34 | }); 35 | } 36 | } catch (err) { 37 | this.fail(res, err.toString()); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/remove-from-slack/remove-from-slack.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | export class RemoveFromSlackError extends UseCaseError {} 6 | 7 | enum RemoveFromSlackErrorCode { 8 | SlackOAuthAccessNotFound = 'REMOVE_FROM_SLACK_ERROR_CODE__SLACK_OAUTH_ACCESS_NOT_FOUND', 9 | } 10 | 11 | type RemoveFromSlackErrorCodes = RemoveFromSlackErrorCode; 12 | 13 | export namespace RemoveFromSlackErrors { 14 | export class SlackOAuthAccessNotFound extends Result { 15 | constructor() { 16 | super( 17 | false, 18 | new RemoveFromSlackError( 19 | RemoveFromSlackErrorCode.SlackOAuthAccessNotFound, 20 | 'Slack OAuth Access Token Not Found', 21 | EHttpStatus.NotFound, 22 | ), 23 | ); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/slack-event-received/index.ts: -------------------------------------------------------------------------------- 1 | import { SlackEventReceivedController } from './slack-event-received.controller.js'; 2 | import { models } from '../../../../../shared/infra/database/mongodb/index.js'; 3 | import { SlackOAuthAccessRepo } from '../../../../../shared/repo/slack-oauth-access.repo.js'; 4 | import { SlackEventReceivedUsecase } from './slack-event-received.usecase.js'; 5 | 6 | const slackOAuthAccessRepo = new SlackOAuthAccessRepo(models); 7 | const slackEventReceivedUsecase = new SlackEventReceivedUsecase( 8 | slackOAuthAccessRepo 9 | ); 10 | const slackEventRecivedController = new SlackEventReceivedController( 11 | slackEventReceivedUsecase 12 | ); 13 | 14 | export { slackEventRecivedController }; 15 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/slack-event-received/slack-event-received.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { 3 | BaseController, 4 | EHttpStatus, 5 | } from '../../../../../shared/infra/http/models/base-controller.js'; 6 | import { 7 | SlackEventReceivedUsecase, 8 | SlackEventReceivedResponse, 9 | } from './slack-event-received.usecase.js'; 10 | import { SlackEventReceivedReqestDTO } from './slack-event-received.dto.js'; 11 | 12 | export class SlackEventReceivedController extends BaseController { 13 | private _useCase: SlackEventReceivedUsecase; 14 | 15 | constructor(useCase: SlackEventReceivedUsecase) { 16 | super(); 17 | this._useCase = useCase; 18 | } 19 | protected async executeImpl(req: Request, res: Response): Promise { 20 | const dto: SlackEventReceivedReqestDTO = req.body; 21 | try { 22 | const slackEventReceivedResult: SlackEventReceivedResponse = await this._useCase.execute({ 23 | dto: dto, 24 | }); 25 | 26 | if (slackEventReceivedResult.isSuccess) { 27 | BaseController.jsonResponse(res, EHttpStatus.Ok); 28 | } else { 29 | const error = slackEventReceivedResult.error; 30 | BaseController.jsonResponse(res, error.httpCode, { 31 | name: error.name, 32 | message: error.message, 33 | }); 34 | } 35 | } catch (err) { 36 | this.fail(res, err.toString()); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/slack-event-received/slack-event-received.dto.ts: -------------------------------------------------------------------------------- 1 | import { ESlackEventType } from '../../enums/slack-event.enum.js'; 2 | 3 | /** 4 | * These are just the event types and properties that I use in my code. 5 | * the full list here https://api.slack.com/events 6 | */ 7 | export interface SlackEvent { 8 | type: ESlackEventType; 9 | } 10 | 11 | export interface AppUninstalledSlackEvent extends SlackEvent {} 12 | 13 | export interface MemberLeftChannelSlackEvent extends SlackEvent { 14 | channel: string; 15 | user: string; 16 | } 17 | 18 | export interface AppHomeOpenedSlackEvent extends SlackEvent { 19 | event_ts: number; 20 | user: string; 21 | } 22 | 23 | export interface SlackEventReceivedReqestDTO { 24 | team_id: string; 25 | event: SlackEvent; 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/integrations/slack/usecases/slack-event-received/slack-event-recieved.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | export class SlackEventReceivedError extends UseCaseError {} 6 | 7 | enum SlackEventReceivedErrorCode { 8 | SlackEventTypeNotSupported = 'SLACK_EVENT_TYPE_NOT_SUPPORTED', 9 | SlackOAuthAccessNotFound = 'SLACK_OAUTH_ACCESS_NOT_FOUND', 10 | } 11 | 12 | type SlackEventReceivedErrorCodes = SlackEventReceivedErrorCode; 13 | 14 | export namespace SlackEventReceivedErrors { 15 | export class SlackEventTypeNotSupported extends Result { 16 | constructor(eventType: string) { 17 | super( 18 | false, 19 | new SlackEventReceivedError( 20 | SlackEventReceivedErrorCode.SlackEventTypeNotSupported, 21 | `Slack Event Type ${eventType} Is Not Supported`, 22 | EHttpStatus.BadRequest, 23 | ), 24 | ); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/modules/sync/domain/dtos/change.dto.ts: -------------------------------------------------------------------------------- 1 | import { TaskDTO } from './task.dto.js'; 2 | 3 | export interface IChangeableObjectDTO { 4 | id: string; 5 | modifiedAt: string; 6 | } 7 | 8 | type DeletedEntityDTO = IChangeableObjectDTO; 9 | 10 | export type ChangeableModelDTO = TaskDTO | DeletedEntityDTO; 11 | 12 | export interface ChangeDTO { 13 | entity: string; 14 | action: string; 15 | object: ChangeableModelDTO; 16 | entityId: string; 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/modules/sync/domain/dtos/task.dto.ts: -------------------------------------------------------------------------------- 1 | import { ITaskProps } from '../../../../shared/domain/models/task.js'; 2 | 3 | // For this moment Task DTO interface is identical Task Persistent interface 4 | export interface TaskDTO extends ITaskProps { 5 | id: string; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/modules/sync/domain/values/change.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '../../../../shared/domain/ValueObject.js'; 2 | import { ChangeableModelDTO } from '../dtos/change.dto.js'; 3 | 4 | export enum EChangedEntity { 5 | Task = 'CHANGED_ENTITY_TASK', 6 | Tag = 'CHANGED_ENTITY_TAG', 7 | // more types will be here 8 | } 9 | 10 | export enum EChangeAction { 11 | Created = 'CHANGE_ACTION_CREATED', 12 | Updated = 'CHANGE_ACTION_UPDATED', 13 | Deleted = 'CHANGE_ACTION_DELETED', 14 | } 15 | 16 | export interface IChangeProps { 17 | entity: EChangedEntity; 18 | action: EChangeAction; 19 | object?: ChangeableModelDTO; 20 | modifiedAt?: string; 21 | } 22 | 23 | export class Change extends ValueObject { 24 | constructor(props: IChangeProps) { 25 | super(props); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/modules/sync/routers/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { createTaskController } from '../usecases/task/create/index.js'; 3 | import { updateTaskController } from '../usecases/task/update/index.js'; 4 | import { deleteTaskController } from '../usecases/task/delete/index.js'; 5 | import { releaseClientIdController } from '../usecases/release-client-id/index.js'; 6 | import { getChnagesController } from '../usecases/get-changes/_index.js'; 7 | 8 | const syncRouter: Router = Router(); 9 | 10 | syncRouter.post('/task', (req, res) => createTaskController.execute(req, res)); 11 | syncRouter.patch('/task', (req, res) => updateTaskController.execute(req, res)); 12 | syncRouter.delete('/task/:taskId', (req, res) => 13 | deleteTaskController.execute(req, res) 14 | ); 15 | 16 | syncRouter.get('/release-client-id', (req, res) => 17 | releaseClientIdController.execute(req, res) 18 | ); 19 | syncRouter.get('/changes', (req, res) => 20 | getChnagesController.execute(req, res) 21 | ); 22 | 23 | export { syncRouter }; 24 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/get-changes/_index.ts: -------------------------------------------------------------------------------- 1 | import { GetChnagesController } from './get-changes.controller.js'; 2 | import { GetChangesUC } from './get-changes.usecase.js'; 3 | import { models } from '../../../../shared/infra/database/mongodb/index.js'; 4 | import { ClientRepo } from '../../../../shared/repo/client.repo.js'; 5 | import { TaskRepoService } from '../../../../shared/repo/task-repo.service.js'; 6 | import { ActionRepo } from '../../../../shared/repo/action.repo.js'; 7 | 8 | const taskRepoService = new TaskRepoService(models); 9 | const clientRepo = new ClientRepo(models); 10 | const actionRepo = new ActionRepo(models); 11 | const getChangesUC = new GetChangesUC(clientRepo, taskRepoService, actionRepo); 12 | const getChnagesController = new GetChnagesController(getChangesUC); 13 | 14 | export { getChnagesController }; 15 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/get-changes/get-changes.dto.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDTO } from '../../domain/dtos/change.dto.js'; 2 | 3 | export interface IGetChangesRequestDTO { 4 | userId: string; 5 | clientId: string; 6 | } 7 | 8 | export interface IGetChangesResponseDTO { 9 | changes: ChangeDTO[]; 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/get-changes/get-changes.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | export class GoogleAuthError extends UseCaseError {} 6 | 7 | export enum GetChangesErrorCode { 8 | ClientNotFound = 'GET_CHANGES_ERROR_CODE__CLIENT_NOT_FOUND', 9 | } 10 | 11 | type GetChangesErrorCodes = GetChangesErrorCode; 12 | 13 | export namespace GetChangesErrors { 14 | export class ClientNotFoundError extends Result { 15 | constructor(userId: string, clientId: string) { 16 | super( 17 | false, 18 | new GoogleAuthError( 19 | GetChangesErrorCode.ClientNotFound, 20 | `The client with id = "${clientId}" for user ${userId} dosn't exists`, 21 | EHttpStatus.BadRequest, 22 | ), 23 | ); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/release-client-id/index.ts: -------------------------------------------------------------------------------- 1 | import { ReleaseClientIdController } from './release-client-id.controller.js'; 2 | import { ReleaseClientId } from './release-client-id.usecase.js'; 3 | import { models } from '../../../../shared/infra/database/mongodb/index.js'; 4 | import { ClientRepo } from '../../../../shared/repo/client.repo.js'; 5 | import { UserRepo } from '../../../../shared/repo/user.repo.js'; 6 | 7 | const clientRepo: ClientRepo = new ClientRepo(models); 8 | const userRepo: UserRepo = new UserRepo(models); 9 | const releaseClientId = new ReleaseClientId(clientRepo, userRepo); 10 | const releaseClientIdController = new ReleaseClientIdController( 11 | releaseClientId 12 | ); 13 | 14 | export { releaseClientIdController }; 15 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/release-client-id/release-client-id.dto.ts: -------------------------------------------------------------------------------- 1 | export interface IReleaseClientIdRequestDTO { 2 | userId: string; 3 | // TODO: potentially have here type of device: `deviceId entityObject` or `deviceId entity String` 4 | } 5 | 6 | export interface IReleaseClientIdResponseDTO { 7 | clientId: string; 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/release-client-id/release-client-id.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../shared/core/Result.js'; 2 | import { UseCaseError } from '../../../../shared/core/use-case-error.js'; 3 | import { EHttpStatus } from '../../../../shared/infra/http/models/base-controller.js'; 4 | 5 | export class ReleaseClientIdError extends UseCaseError {} 6 | 7 | enum EReleaseClientIdUsecaseError { 8 | UserDoesNotExist = 'RELEASE_CLIENT_ID_USECASE_ERROR__USER_DOES_NOT_EXIST', 9 | } 10 | 11 | type EReleaseClientIdError = EReleaseClientIdUsecaseError; 12 | 13 | export namespace ReleaseClientIdErrors { 14 | export class UserDoesNotExist extends Result { 15 | constructor(userId: string) { 16 | super( 17 | false, 18 | new ReleaseClientIdError( 19 | EReleaseClientIdUsecaseError.UserDoesNotExist, 20 | `The user with id = "${userId}" dosn't exists`, 21 | EHttpStatus.NotFound, 22 | ), 23 | ); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/create/create-task.dto.ts: -------------------------------------------------------------------------------- 1 | export interface CreateTaskRequestDTO { 2 | id: string; 3 | type: string; 4 | title: string; 5 | status: string; 6 | imageUri?: string; 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/create/create-task.errors.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from '../../../../../shared/core/domain-error.js'; 2 | import { Result } from '../../../../../shared/core/Result.js'; 3 | import { UseCaseError } from '../../../../../shared/core/use-case-error.js'; 4 | import { ETaskError, Task } from '../../../../../shared/domain/models/task.js'; 5 | import { EHttpStatus } from '../../../../../shared/infra/http/models/base-controller.js'; 6 | 7 | type ECreateTaskError = ETaskError; 8 | export class CreateTaskError extends UseCaseError {} 9 | 10 | export namespace CreateTaskErrors { 11 | export class DataInvalid extends Result { 12 | constructor(error: DomainError) { 13 | super(false, new CreateTaskError(error.code, error.message, EHttpStatus.BadRequest)); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/create/index.ts: -------------------------------------------------------------------------------- 1 | import { CreateTaskController } from './create-task.controller.js'; 2 | import { CreateTask } from './create-task.usecase.js'; 3 | import { TaskRepoService } from '../../../../../shared/repo/task-repo.service.js'; 4 | import { models } from '../../../../../shared/infra/database/mongodb/index.js'; 5 | import { slackService } from '../../../../../shared/infra/integrations/slack/index.js'; 6 | 7 | const taskRepoService = new TaskRepoService(models); 8 | const createTask = new CreateTask(taskRepoService, slackService); 9 | const createTaskController = new CreateTaskController(createTask); 10 | 11 | export { createTaskController }; 12 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/delete/delete-task.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { UserPersistent } from '../../../../../shared/domain/models/user.js'; 3 | import { BaseController } from '../../../../../shared/infra/http/models/base-controller.js'; 4 | import { DeleteTaskRequestDTO } from './delete-task.dto.js'; 5 | import { DeleteTaskUsecase } from './delete-task.usecase.js'; 6 | 7 | export class DeleteTaskController extends BaseController { 8 | private _useCase: DeleteTaskUsecase; 9 | 10 | constructor(useCase: DeleteTaskUsecase) { 11 | super(); 12 | this._useCase = useCase; 13 | } 14 | 15 | protected async executeImpl(req: Request, res: Response): Promise { 16 | const loggedUser: UserPersistent = req.user as UserPersistent; 17 | const userId = loggedUser._id; 18 | 19 | const deleteTaskDto: DeleteTaskRequestDTO = { id: req.params.taskId }; 20 | 21 | try { 22 | const result = await this._useCase.execute({ 23 | userId: userId, 24 | dto: deleteTaskDto, 25 | }); 26 | if (result.isSuccess) { 27 | this.ok(res); 28 | } else { 29 | switch (result.error) { 30 | default: 31 | this.fail(res, result.error); 32 | } 33 | } 34 | } catch (err) { 35 | // TODO: handle unexpected error proper way 36 | // TICKET: https://brainas.atlassian.net/browse/BA-222 37 | console.error(err); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/delete/delete-task.dto.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteTaskRequestDTO { 2 | id: string; 3 | } 4 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/delete/delete-task.erros.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseError } from '../../../../../shared/core/use-case-error.js'; 2 | 3 | export class DeleteTaskError extends UseCaseError {} 4 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/delete/index.ts: -------------------------------------------------------------------------------- 1 | import { TaskRepoService } from '../../../../../shared/repo/task-repo.service.js'; 2 | import { DeleteTaskController } from './delete-task.controller.js'; 3 | import { DeleteTaskUsecase } from './delete-task.usecase.js'; 4 | import { models } from '../../../../../shared/infra/database/mongodb/index.js'; 5 | import { ActionRepo } from '../../../../../shared/repo/action.repo.js'; 6 | 7 | const taskRepoService = new TaskRepoService(models); 8 | const actionRepo: ActionRepo = new ActionRepo(models); 9 | const deleteTask = new DeleteTaskUsecase(taskRepoService, actionRepo); 10 | const deleteTaskController = new DeleteTaskController(deleteTask); 11 | 12 | export { deleteTaskController }; 13 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/update/index.ts: -------------------------------------------------------------------------------- 1 | import { models } from '../../../../../shared/infra/database/mongodb/index.js'; 2 | import { TaskRepoService } from '../../../../../shared/repo/task-repo.service.js'; 3 | import { UpdateTaskController } from './update-task.controller.js'; 4 | import { UpdateTask } from './update-task.usecase.js'; 5 | 6 | const taskRepoService = new TaskRepoService(models); 7 | const updateTask = new UpdateTask(taskRepoService); 8 | const updateTaskController = new UpdateTaskController(updateTask); 9 | 10 | export { updateTaskController }; 11 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/update/update-task.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { UserPersistent } from '../../../../../shared/domain/models/user.js'; 3 | import { BaseController } from '../../../../../shared/infra/http/models/base-controller.js'; 4 | import { UpdateTask } from './update-task.usecase.js'; 5 | 6 | export class UpdateTaskController extends BaseController { 7 | private _useCase: UpdateTask; 8 | 9 | constructor(useCase: UpdateTask) { 10 | super(); 11 | this._useCase = useCase; 12 | } 13 | 14 | protected async executeImpl(req: Request, res: Response): Promise { 15 | const loggedUser: UserPersistent = req.user as UserPersistent; 16 | const userId = loggedUser._id; 17 | 18 | const taskDto = req.body.changeableObjectDto; 19 | 20 | try { 21 | const result = await this._useCase.execute({ 22 | userId: userId, 23 | dto: taskDto, 24 | }); 25 | 26 | if (result.isSuccess) { 27 | this.ok(res); 28 | } else { 29 | const error = result.error; 30 | 31 | BaseController.jsonResponse(res, error.httpCode, { 32 | name: error.name, 33 | message: error.message, 34 | }); 35 | } 36 | } catch (err) { 37 | this.fail(res, err.toString()); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/update/update-task.dto.ts: -------------------------------------------------------------------------------- 1 | export interface IUpdateTaskRequestDTO { 2 | userId: string, 3 | id: string 4 | type: string, 5 | title: string, 6 | status: string, 7 | imageUri?: string, 8 | createdAt: string, 9 | modifiedAt: string, 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/update/update-task.errors.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../../../shared/core/Result.js'; 2 | import { ServiceError } from '../../../../../shared/core/service-error.js'; 3 | import { UseCaseError } from '../../../../../shared/core/use-case-error.js'; 4 | import { EHttpStatus } from '../../../../../shared/infra/http/models/base-controller.js'; 5 | import { ETaskRepoServiceError } from '../../../../../shared/repo/task-repo.service.js'; 6 | 7 | type EUpdateTaskError = ETaskRepoServiceError; 8 | export class UpdateTaskError extends UseCaseError {} 9 | 10 | export namespace UpdateTaskErrors { 11 | export class TaskNotFoundError extends Result { 12 | constructor(error: ServiceError) { 13 | super(false, new UpdateTaskError(error.code, error.message, EHttpStatus.NotFound)); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/modules/sync/usecases/task/update/update-task.usecase.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '../../../../../shared/core/UseCase.js'; 2 | import { Result } from '../../../../../shared/core/Result.js'; 3 | import { IUpdateTaskRequestDTO } from './update-task.dto.js'; 4 | import { TaskRepoService } from '../../../../../shared/repo/task-repo.service.js'; 5 | import { UpdateTaskError, UpdateTaskErrors } from './update-task.errors.js'; 6 | 7 | type Request = { 8 | userId: string; 9 | dto: IUpdateTaskRequestDTO; 10 | }; 11 | 12 | type Response = Result; 13 | 14 | export class UpdateTask implements UseCase> { 15 | constructor(private readonly taskRepoService: TaskRepoService) {} 16 | public async execute(req: Request): Promise { 17 | const userId = req.userId; 18 | const taskDto: IUpdateTaskRequestDTO = req.dto; 19 | 20 | const taskOrError = await this.taskRepoService.getUserTaskById(userId, taskDto.id); 21 | 22 | if (taskOrError.isFailure) { 23 | return new UpdateTaskErrors.TaskNotFoundError(taskOrError.error); 24 | } 25 | 26 | const task = taskOrError.getValue(); 27 | task.update({ 28 | ...taskDto, 29 | }); 30 | 31 | await this.taskRepoService.save(task); 32 | 33 | return Result.ok(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/shared/core/UseCase.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../domain/models/user.js'; 2 | 3 | export interface UseCase { 4 | execute(request?: IRequest, user?: User): Promise | IResponse; 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/shared/core/app-error.ts: -------------------------------------------------------------------------------- 1 | export class AppError extends Error { 2 | public readonly code: U; 3 | 4 | constructor(message: string, code: U, options?: ErrorOptions) { 5 | super(message, options); 6 | this.code = code; 7 | 8 | // Fix the prototype chain (important if you compile to ES5) 9 | Object.setPrototypeOf(this, new.target.prototype); 10 | 11 | this.name = this.constructor.name; 12 | 13 | // V8-only: capture a cleaner stack trace 14 | if (typeof Error.captureStackTrace === 'function') { 15 | Error.captureStackTrace(this, new.target); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/shared/core/domain-error.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from './app-error.js'; 2 | 3 | export class DomainError extends AppError { 4 | constructor( 5 | code: U, 6 | message: string, 7 | public readonly object?: T, 8 | ) { 9 | super(message, code); 10 | this.object = object ? Object.freeze(object) : undefined; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/shared/core/guard.ts: -------------------------------------------------------------------------------- 1 | export class Guard { 2 | public static notNullOrUndefined(argument: any): boolean { 3 | if (argument === null || argument === undefined) { 4 | return false; 5 | } else { 6 | return true; 7 | } 8 | } 9 | 10 | public static notEmptyString(argument: string): boolean { 11 | if (typeof argument === 'string' && argument.trim().length) { 12 | return true; 13 | } else { 14 | return false; 15 | } 16 | } 17 | 18 | public static textLengthAtLeast(text: string, minLength: number) { 19 | if (typeof text === 'string' && text.trim().length >= minLength) 20 | return true; 21 | return false; 22 | } 23 | public static textLengthAtMost(text: string, maxLength: number) { 24 | if (typeof text === 'string' && text.trim().length <= maxLength) 25 | return true; 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/shared/core/result.ts: -------------------------------------------------------------------------------- 1 | export class Result { 2 | public isSuccess: boolean; 3 | public isFailure: boolean; 4 | public _error: E; 5 | private _value: T; 6 | 7 | public constructor(isSuccess: boolean, error?: E, value?: T) { 8 | if (isSuccess && error) { 9 | throw new Error('InvalidOperation: A result cannot be successful and contain an error'); 10 | } 11 | if (!isSuccess && !error) { 12 | throw new Error('InvalidOperation: A failing result needs to contain an error message'); 13 | } 14 | 15 | this.isSuccess = isSuccess; 16 | this.isFailure = !isSuccess; 17 | this._error = error; 18 | this._value = value; 19 | 20 | Object.freeze(this); 21 | } 22 | 23 | public getValue(): T { 24 | if (!this.isSuccess) { 25 | console.log(this._error); 26 | throw new Error("Can't get the value of an error result. Use 'error' instead."); 27 | } 28 | 29 | return this._value; 30 | } 31 | 32 | get error(): E { 33 | return this._error as E; 34 | } 35 | 36 | public static ok(value?: U): Result { 37 | return new Result(true, null, value); 38 | } 39 | 40 | public static fail(error: E): Result { 41 | return new Result(false, error); 42 | } 43 | 44 | public static combine(results: Result[]): Result { 45 | for (let result of results) { 46 | if (result.isFailure) return result; 47 | } 48 | return Result.ok(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/shared/core/service-error.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from './app-error.js'; 2 | 3 | export class ServiceError extends AppError { 4 | constructor( 5 | message: string, 6 | code: U, 7 | public readonly error?: unknown, 8 | public readonly details?: Record, 9 | ) { 10 | super(message, code, { 11 | cause: error instanceof Error ? error : undefined, 12 | }); 13 | this.details = details ? Object.freeze(details) : undefined; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/shared/core/use-case-error.ts: -------------------------------------------------------------------------------- 1 | import { EHttpStatus } from '../infra/http/models/base-controller.js'; 2 | import { AppError } from './app-error.js'; 3 | 4 | export class UseCaseError extends AppError { 5 | constructor( 6 | code: U, 7 | message: string, 8 | public readonly httpCode: EHttpStatus, 9 | ) { 10 | super(message, code); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/shared/domain/AggregateRoot.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from './Entity.js'; 2 | import { IDomainEvent } from './events/IDomainEvent.js'; 3 | import { DomainEvents } from './events/DomainEvents.js'; 4 | import { UniqueEntityID } from './UniqueEntityID.js'; 5 | 6 | export abstract class AggregateRoot extends Entity { 7 | private _domainEvents: IDomainEvent[] = []; 8 | 9 | get id(): UniqueEntityID { 10 | return this._id; 11 | } 12 | 13 | get domainEvents(): IDomainEvent[] { 14 | return this._domainEvents; 15 | } 16 | 17 | protected addDomainEvent(domainEvent: IDomainEvent): void { 18 | // Add the domain event to this aggregate's list of domain events 19 | this._domainEvents.push(domainEvent); 20 | // Add this aggregate instance to the domain event's list of aggregates who's 21 | // events it eventually needs to dispatch. 22 | DomainEvents.markAggregateForDispatch(this); 23 | // Log the domain event 24 | this.logDomainEventAdded(domainEvent); 25 | } 26 | 27 | public clearEvents(): void { 28 | this._domainEvents.splice(0, this._domainEvents.length); 29 | } 30 | 31 | private logDomainEventAdded(domainEvent: IDomainEvent): void { 32 | const thisClass = Reflect.getPrototypeOf(this); 33 | const domainEventClass = Reflect.getPrototypeOf(domainEvent); 34 | console.info( 35 | `[Domain Event Created]:`, 36 | thisClass.constructor.name, 37 | '==>', 38 | domainEventClass.constructor.name 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/shared/domain/Entity.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from './UniqueEntityID.js'; 2 | 3 | const isEntity = (v: any): v is Entity => { 4 | return v instanceof Entity; 5 | }; 6 | 7 | export abstract class Entity { 8 | protected readonly _id: UniqueEntityID; 9 | public readonly props: T; 10 | 11 | constructor(props: T, id?: UniqueEntityID) { 12 | this._id = id ? id : new UniqueEntityID(); 13 | this.props = props; 14 | } 15 | 16 | public equals(object?: Entity): boolean { 17 | if (object == null || object == undefined) { 18 | return false; 19 | } 20 | 21 | if (this === object) { 22 | return true; 23 | } 24 | 25 | if (!isEntity(object)) { 26 | return false; 27 | } 28 | 29 | return this._id.equals(object._id); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/shared/domain/Identifier.ts: -------------------------------------------------------------------------------- 1 | export class Identifier { 2 | constructor(private value: T) { 3 | this.value = value; 4 | } 5 | 6 | equals (id?: Identifier): boolean { 7 | if (id === null || id === undefined) { 8 | return false; 9 | } 10 | if (!(id instanceof this.constructor)) { 11 | return false; 12 | } 13 | return id.toValue() === this.value; 14 | } 15 | 16 | toString () { 17 | return String(this.value); 18 | } 19 | 20 | toValue (): T { 21 | return this.value; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/shared/domain/UniqueEntityID.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Identifier } from './Identifier.js'; 3 | 4 | export class UniqueEntityID extends Identifier { 5 | constructor(id?: string | number) { 6 | super(id ? id : new mongoose.Types.ObjectId().toHexString()); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/shared/domain/ValueObject.ts: -------------------------------------------------------------------------------- 1 | interface IValueObjectProps { 2 | [index: string]: any; 3 | } 4 | 5 | export abstract class ValueObject { 6 | public props: T; 7 | 8 | constructor (props: T) { 9 | this.props = { 10 | ...props 11 | }; 12 | } 13 | 14 | public equals (vObj?: ValueObject) : boolean { 15 | if (vObj === null || vObj === undefined) { 16 | return false; 17 | } 18 | if (vObj.props === undefined) { 19 | return false; 20 | } 21 | return JSON.stringify(this.props) === JSON.stringify(vObj.props); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/shared/domain/events/DomainEvents.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '../AggregateRoot.js'; 2 | import { UniqueEntityID } from '../UniqueEntityID.js'; 3 | 4 | export class DomainEvents { 5 | private static markedAggregates: AggregateRoot[] = []; 6 | 7 | public static markAggregateForDispatch(aggregate: AggregateRoot): void { 8 | const aggregateFound = !!this.findMarkedAggregateByID(aggregate.id); 9 | 10 | if (!aggregateFound) { 11 | this.markedAggregates.push(aggregate); 12 | } 13 | } 14 | 15 | private static findMarkedAggregateByID( 16 | id: UniqueEntityID 17 | ): AggregateRoot { 18 | let found: AggregateRoot = null; 19 | for (let aggregate of this.markedAggregates) { 20 | if (aggregate.id.equals(id)) { 21 | found = aggregate; 22 | } 23 | } 24 | 25 | return found; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/shared/domain/events/IDomainEvent.ts: -------------------------------------------------------------------------------- 1 | import { UniqueEntityID } from '../UniqueEntityID.js'; 2 | 3 | export interface IDomainEvent { 4 | dateTimeOccurred: Date; 5 | getAggregateId(): UniqueEntityID; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/shared/domain/models/actions.ts: -------------------------------------------------------------------------------- 1 | import { EActionType } from '../../infra/database/mongodb/action.model.js'; 2 | import { ValueObject } from '../ValueObject.js'; 3 | 4 | export interface IActionProps { 5 | userId: string; 6 | type: EActionType; 7 | occurredAt: Date; 8 | entityId: string; 9 | } 10 | 11 | export class Action extends ValueObject { 12 | get userId(): string { 13 | return this.props.userId; 14 | } 15 | 16 | get occurredAt(): Date { 17 | return this.props.occurredAt; 18 | } 19 | 20 | get type(): EActionType { 21 | return this.props.type; 22 | } 23 | 24 | get entityId(): string { 25 | return this.props.entityId; 26 | } 27 | 28 | private constructor(props: IActionProps) { 29 | super(props); 30 | } 31 | 32 | public static create(props: IActionProps): Action { 33 | const action = new Action(props); 34 | 35 | return action; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/shared/domain/models/client.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '../AggregateRoot.js'; 2 | import { UniqueEntityID } from '../UniqueEntityID.js'; 3 | import { Result } from '../../core/Result.js'; 4 | 5 | export interface IClientProps { 6 | userId: string; 7 | syncTime: Date; 8 | } 9 | 10 | export class Client extends AggregateRoot { 11 | get id(): UniqueEntityID { 12 | return this._id; 13 | } 14 | 15 | get userId(): string { 16 | return this.props.userId; 17 | } 18 | 19 | get syncTime(): Date { 20 | return this.props.syncTime; 21 | } 22 | 23 | public static create( 24 | props: IClientProps, 25 | id?: UniqueEntityID 26 | ): Result { 27 | const client = new Client(props, id); 28 | 29 | return Result.ok(client); 30 | } 31 | 32 | public updateSyncTime(time: Date): void { 33 | this.props.syncTime = time; 34 | } 35 | 36 | private constructor(props: IClientProps, id?: UniqueEntityID) { 37 | super(props, id); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/shared/domain/values/user/user-email.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '../../../core/Result.js'; 2 | import { ValueObject } from '../../ValueObject.js'; 3 | 4 | export interface UserEmailProps { 5 | value: string; 6 | } 7 | 8 | export class UserEmail extends ValueObject { 9 | get value(): string { 10 | return this.props.value; 11 | } 12 | 13 | private constructor(props: UserEmailProps) { 14 | super(props); 15 | } 16 | 17 | private static isValidEmail(email: string) { 18 | var re = 19 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 20 | return re.test(email); 21 | } 22 | 23 | private static format(email: string): string { 24 | return email.trim().toLowerCase(); 25 | } 26 | 27 | public static create(email: string): Result { 28 | if (!this.isValidEmail(email)) { 29 | return Result.fail('Email address not valid'); 30 | } else { 31 | return Result.ok(new UserEmail({ value: this.format(email) })); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/shared/domain/values/user/verification-token.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Result } from '../../../core/Result.js'; 3 | import { ValueObject } from '../../ValueObject.js'; 4 | 5 | export interface IVerificationTokenProps { 6 | userId: mongoose.Types.ObjectId; 7 | token: string; 8 | createdAt: Date; 9 | } 10 | 11 | export class VerificationToken extends ValueObject { 12 | get value(): IVerificationTokenProps { 13 | return this.props; 14 | } 15 | 16 | get userId(): mongoose.Types.ObjectId { 17 | return this.props.userId; 18 | } 19 | 20 | get token(): string { 21 | return this.props.token; 22 | } 23 | 24 | get createdAt(): Date { 25 | return this.props.createdAt; 26 | } 27 | 28 | private constructor(props: IVerificationTokenProps) { 29 | super(props); 30 | } 31 | 32 | public static create( 33 | props?: IVerificationTokenProps 34 | ): Result { 35 | return Result.ok(new VerificationToken(props)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/shared/infra/auth/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Strategy as GoogleStrategy, 3 | Profile, 4 | VerifyCallback, 5 | } from 'passport-google-oauth20'; 6 | 7 | export const googleStrategy = new GoogleStrategy( 8 | { 9 | clientID: process.env.GOOGLE_CLIENT_ID, 10 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 11 | callbackURL: process.env.GOOGLE_OAUTH_CALLBACK, 12 | }, 13 | async function ( 14 | accessToken: string, 15 | refreshToken: string, 16 | profile: Profile, 17 | done: VerifyCallback 18 | ) { 19 | try { 20 | if (profile) { 21 | const tokens = { accessToken, refreshToken }; 22 | return done(null, { profile, tokens }); 23 | } else { 24 | return done(null, false); 25 | } 26 | } catch (err) { 27 | return done(err, false); 28 | } 29 | } 30 | ); 31 | 32 | export type GoogleAuthTokens = { 33 | accessToken: string; 34 | refreshToken: string; 35 | }; 36 | 37 | export type GoogleProfileWithTokens = { 38 | profile: Profile; 39 | tokens: GoogleAuthTokens; 40 | }; 41 | -------------------------------------------------------------------------------- /backend/src/shared/infra/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { jwtStrategy } from './jwt.strategy.js'; 2 | import { googleStrategy } from './google.strategy.js'; 3 | import { isAuthenticated } from './isAuthenticated.middleware.js'; 4 | 5 | export { isAuthenticated, jwtStrategy, googleStrategy }; 6 | -------------------------------------------------------------------------------- /backend/src/shared/infra/auth/isAuthenticated.middleware.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import { EHttpStatus } from '../http/models/base-controller.js'; 3 | import { IApiErrorDto } from '../http/dtos/api-errors.dto.js'; 4 | 5 | export function isAuthenticated(req, res, next) { 6 | const NOT_AUTHENTICATED_ERROR = 'USER_NOT_AUTHENTICATED'; 7 | 8 | switch (process.env.AUTHENTICATION_STRATEGY) { 9 | case 'JWT': 10 | passport.authenticate('jwt', { session: false }, async (error, user) => { 11 | if (error || !user) { 12 | const RESPONSE_CODE = EHttpStatus.Unauthorized; 13 | const RESPONSE_ERROR_MESSAGE = 'User not authenticated'; 14 | 15 | const errorDto: IApiErrorDto = { 16 | name: NOT_AUTHENTICATED_ERROR, 17 | message: RESPONSE_ERROR_MESSAGE, 18 | }; 19 | 20 | return res.status(RESPONSE_CODE).send(errorDto); 21 | } 22 | req.user = user; 23 | next(); 24 | })(req, res, next); 25 | break; 26 | 27 | case 'SESSION': 28 | if (req.isAuthenticated()) { 29 | return next(); 30 | } else { 31 | const errorDto: IApiErrorDto = { 32 | name: NOT_AUTHENTICATED_ERROR, 33 | message: 'User not authenticated', 34 | }; 35 | return res.status(401).send(errorDto); 36 | } 37 | default: 38 | throw new Error(`Not Supported Auth Strategy: ${process.env.AUTHENTICATION_STRATEGY}`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/shared/infra/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy as JwtStrategy } from 'passport-jwt'; 2 | import UserModel from '../database/mongodb/user.model.js'; 3 | 4 | interface IJwtTokenPayload { 5 | user: { 6 | firstName: string; 7 | lastName: string; 8 | email: string; 9 | userId: string; 10 | }; 11 | iat: number; 12 | exp: number; 13 | } 14 | 15 | /** 16 | * Custom extractor to get jwt from cookies 17 | **/ 18 | const cookieExtractor = function (req) { 19 | var token = null; 20 | if (req && req.cookies) { 21 | token = req.cookies['jwt']; 22 | } 23 | return token; 24 | }; 25 | 26 | const opts = { 27 | jwtFromRequest: cookieExtractor, 28 | secretOrKey: process.env.JWT_SECRET, 29 | }; 30 | 31 | export const jwtStrategy = new JwtStrategy(opts, async function ( 32 | jwtPayload: IJwtTokenPayload, 33 | done 34 | ) { 35 | try { 36 | const user = await UserModel.findOne({ _id: jwtPayload.user.userId }); 37 | if (user) { 38 | return done(null, user); 39 | } else { 40 | return done(null, false); 41 | } 42 | } catch (err) { 43 | return done(err, false); 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /backend/src/shared/infra/database/mongodb/action.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose'; 2 | 3 | export enum EActionType { 4 | TaskDeleted = 'ACTION_TYPE_TASK_DELETED', 5 | } 6 | 7 | export interface IActionPersistent { 8 | userId: string; 9 | type: EActionType; 10 | occurredAt: Date; 11 | entityId: string; 12 | } 13 | 14 | export interface ActionDocument extends IActionPersistent, Document {} 15 | 16 | const ActionSchema = new Schema({ 17 | userId: { type: String, require: true }, 18 | type: { type: String, require: true }, 19 | occurredAt: { type: Date, require: true }, 20 | entityId: { type: String, require: true }, 21 | }); 22 | 23 | const ActionModel = mongoose.model('Action', ActionSchema); 24 | 25 | export default ActionModel; 26 | -------------------------------------------------------------------------------- /backend/src/shared/infra/database/mongodb/client.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose'; 2 | 3 | export interface IClientPersistent { 4 | _id?: string; 5 | userId: string; 6 | syncTime: Date; 7 | } 8 | 9 | export interface ClientDocument extends IClientPersistent, Document {} 10 | 11 | const ClientSchema = new Schema({ 12 | userId: { type: String, require: true }, 13 | syncTime: { type: Date, default: null }, 14 | }); 15 | 16 | const ClientModel = mongoose.model('Client', ClientSchema); 17 | 18 | export default ClientModel; 19 | -------------------------------------------------------------------------------- /backend/src/shared/infra/database/mongodb/index.ts: -------------------------------------------------------------------------------- 1 | import { Model, PassportLocalModel } from 'mongoose'; 2 | import TaskModel, { TaskDocument } from './task.model.js'; 3 | import UserModel, { UserDocument } from './user.model.js'; 4 | import ClientModel, { ClientDocument } from './client.model.js'; 5 | import ActionModel, { ActionDocument } from './action.model.js'; 6 | import VerificationTokenModel, { 7 | VerificationTokenDocument, 8 | } from './verification-token.model.js'; 9 | import SlackOAuthAccessModel, { 10 | ISlackOAuthAccessDocument, 11 | } from './slack-oauth-access.model.js'; 12 | 13 | export interface IDbModels { 14 | TaskModel: Model; 15 | UserModel: PassportLocalModel; 16 | VerificationTokenModel: Model; 17 | ClientModel: Model; 18 | SlackOAuthAccessModel: Model; 19 | ActionModel: Model; 20 | } 21 | 22 | export const models: IDbModels = { 23 | TaskModel, 24 | UserModel, 25 | VerificationTokenModel, 26 | ClientModel, 27 | SlackOAuthAccessModel, 28 | ActionModel, 29 | }; 30 | -------------------------------------------------------------------------------- /backend/src/shared/infra/database/mongodb/slack-oauth-access.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose'; 2 | import { ISlackOAuthAccessPresitant } from '../../../domain/models/slack-oauth-access.js'; 3 | 4 | export interface ISlackOAuthAccessDocument 5 | extends ISlackOAuthAccessPresitant, 6 | Document {} 7 | 8 | const SlackOAuthAccessSchema = new Schema({ 9 | _id: { type: String, require: true }, 10 | userId: { type: String, require: true }, 11 | accessToken: { type: String, require: true }, 12 | authedUserId: { type: String, require: true }, 13 | slackBotUserId: { type: String, require: true }, 14 | teamId: { type: String, require: true }, 15 | }); 16 | 17 | const SlackOAuthAccessModel = mongoose.model( 18 | 'SlackOAuthAccess', 19 | SlackOAuthAccessSchema 20 | ); 21 | 22 | export default SlackOAuthAccessModel; 23 | -------------------------------------------------------------------------------- /backend/src/shared/infra/database/mongodb/task.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose'; 2 | import { TaskPresitant } from '../../../domain/models/task.js'; 3 | 4 | export interface TaskDocument extends TaskPresitant, Document {} 5 | 6 | const TaskSchema = new Schema({ 7 | _id: { type: String, require: true }, 8 | userId: { type: String, require: true }, 9 | type: { type: String, require: true }, // #TODO need to be a special TYPE 10 | title: { type: String, require: true }, 11 | status: { type: String, require: true }, // #TODO need to be a special TYPE 12 | imageUri: { type: String, require: false }, 13 | createdAt: { type: Date, require: true }, 14 | modifiedAt: { type: Date, require: true }, 15 | }); 16 | 17 | const TaskModel = mongoose.model('Task', TaskSchema); 18 | 19 | export default TaskModel; 20 | -------------------------------------------------------------------------------- /backend/src/shared/infra/database/mongodb/verification-token.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose'; 2 | 3 | export interface VerificationTokenDocument extends Document { 4 | userId: mongoose.Types.ObjectId; 5 | token: string; 6 | createdAt: Date; 7 | } 8 | 9 | const verificationTokenSchema = new Schema( 10 | { 11 | userId: { 12 | type: mongoose.Types.ObjectId, 13 | required: true, 14 | ref: 'User', 15 | }, 16 | token: { 17 | type: String, 18 | required: true, 19 | }, 20 | createdAt: { 21 | type: Date, 22 | required: true, 23 | default: Date.now, 24 | expires: 30 * 24 * 60, // 30 days 25 | }, 26 | }, 27 | { timestamps: true } 28 | ); 29 | 30 | const VerificationTokenModel = mongoose.model( 31 | 'VerificationToken', 32 | verificationTokenSchema 33 | ); 34 | 35 | export default VerificationTokenModel; 36 | -------------------------------------------------------------------------------- /backend/src/shared/infra/http/api/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { syncRouter } from '../../../../modules/sync/routers/index.js'; 3 | import { authRouter } from '../../../../modules/auth/routers/index.js'; 4 | import { integrationsRouter } from '../../../../modules/integrations/router.js'; 5 | import { isAuthenticated } from '../../auth/index.js'; 6 | import { filesRouter } from '../../../../modules/files/router.js'; 7 | 8 | const apiRouters = express.Router(); 9 | 10 | apiRouters.get('/', (req, res) => { 11 | return res.json({ message: 'BA backend up!' }); 12 | }); 13 | 14 | apiRouters.use('/api/auth', authRouter); 15 | apiRouters.use('/api/sync', isAuthenticated, syncRouter); 16 | apiRouters.use('/api/integrations', integrationsRouter); 17 | apiRouters.use('/api/files/', isAuthenticated, filesRouter); 18 | 19 | export { apiRouters }; 20 | -------------------------------------------------------------------------------- /backend/src/shared/infra/http/dtos/api-errors.dto.ts: -------------------------------------------------------------------------------- 1 | export interface IApiErrorDto { 2 | name: string; 3 | message: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/shared/infra/http/utils/middleware.ts: -------------------------------------------------------------------------------- 1 | export function serialize(obj) { 2 | var str = []; 3 | for (var p in obj) 4 | if (obj.hasOwnProperty(p)) { 5 | str.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p])); 6 | } 7 | return str.join('&'); 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/shared/infra/integrations/slack/index.ts: -------------------------------------------------------------------------------- 1 | import { SlackService } from './slack.service.js'; 2 | import { models } from '../../../../shared/infra/database/mongodb/index.js'; 3 | import { SlackOAuthAccessRepo } from '../../../repo/slack-oauth-access.repo.js'; 4 | 5 | const slackOAuthAccessRepo = new SlackOAuthAccessRepo(models); 6 | 7 | const slackService = new SlackService(slackOAuthAccessRepo); 8 | 9 | export { slackService }; 10 | -------------------------------------------------------------------------------- /backend/src/shared/mappers/action.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../domain/models/actions.js'; 2 | import { IActionPersistent } from '../infra/database/mongodb/action.model.js'; 3 | 4 | export class ActionMapper { 5 | public static toDomain(raw: IActionPersistent): Action { 6 | const action = Action.create({ 7 | userId: raw.userId, 8 | type: raw.type, 9 | occurredAt: raw.occurredAt, 10 | entityId: raw.entityId, 11 | }); 12 | 13 | return action; 14 | } 15 | 16 | public static toPersistence(action: Action): IActionPersistent { 17 | return { 18 | userId: action.userId, 19 | type: action.type, 20 | occurredAt: action.occurredAt, 21 | entityId: action.entityId, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/shared/mappers/change.mapper.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDTO } from '../../modules/sync/domain/dtos/change.dto.js'; 2 | import { Change } from '../../modules/sync/domain/values/change.js'; 3 | 4 | export class ChangeMapper { 5 | public static toDTO(change: Change): ChangeDTO { 6 | return { 7 | entity: change.props.entity, 8 | action: change.props.action, 9 | object: change.props.object, 10 | } as ChangeDTO; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/shared/mappers/client.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../domain/models/client.js'; 2 | import { IClientPersistent } from '../infra/database/mongodb/client.model.js'; 3 | import { UniqueEntityID } from '../domain/UniqueEntityID.js'; 4 | 5 | export class ClientMapper { 6 | public static toDomain(raw: IClientPersistent): Client { 7 | const clientOrError = Client.create( 8 | { 9 | userId: raw.userId, 10 | syncTime: raw.syncTime, 11 | }, 12 | new UniqueEntityID(raw._id) 13 | ); 14 | 15 | clientOrError.isFailure ? console.log(clientOrError.error) : ''; 16 | 17 | return clientOrError.isSuccess ? clientOrError.getValue() : null; 18 | } 19 | 20 | public static toPersistence(client: Client): IClientPersistent { 21 | return { 22 | _id: client.id.toString(), 23 | userId: client.userId, 24 | syncTime: client.syncTime, 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/shared/mappers/slack-oauth-access.mapper.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from '../core/domain-error.js'; 2 | import { UniqueEntityID } from '../domain/UniqueEntityID.js'; 3 | import { 4 | CreateSlackOAuthAccessResult, 5 | ISlackOAuthAccessPresitant, 6 | SlackOAuthAccess, 7 | } from '../domain/models/slack-oauth-access.js'; 8 | 9 | export class SlackOAuthAccessMapper { 10 | public static toDomain( 11 | raw: ISlackOAuthAccessPresitant, 12 | ): SlackOAuthAccess | DomainError { 13 | const slackOAuthAccessOrError: CreateSlackOAuthAccessResult = SlackOAuthAccess.create( 14 | { 15 | userId: raw.userId, 16 | accessToken: raw.accessToken, 17 | authedUserId: raw.authedUserId, 18 | slackBotUserId: raw.slackBotUserId, 19 | teamId: raw.teamId, 20 | }, 21 | new UniqueEntityID(raw._id), 22 | ); 23 | 24 | if (slackOAuthAccessOrError.isFailure) { 25 | console.log(slackOAuthAccessOrError.error); 26 | } 27 | 28 | return slackOAuthAccessOrError.isSuccess ? slackOAuthAccessOrError.getValue() : null; 29 | } 30 | 31 | public static toPersistence(slackOAuthAccess: SlackOAuthAccess): ISlackOAuthAccessPresitant { 32 | return { 33 | _id: slackOAuthAccess.id.toString(), 34 | userId: slackOAuthAccess.userId, 35 | accessToken: slackOAuthAccess.accessToken, 36 | authedUserId: slackOAuthAccess.authedUserId, 37 | slackBotUserId: slackOAuthAccess.slackBotUserId, 38 | teamId: slackOAuthAccess.teamId, 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/shared/mappers/task.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Task, TaskPresitant } from '../domain/models/task.js'; 2 | import { TaskDTO } from '../../modules/sync/domain/dtos/task.dto.js'; 3 | import { UniqueEntityID } from '../domain/UniqueEntityID.js'; 4 | import { DomainError } from '../core/domain-error.js'; 5 | 6 | export class TaskMapper { 7 | public static toDomain(raw: TaskPresitant): Task | DomainError { 8 | const { userId, type, title, status, imageUri, _id } = raw; 9 | 10 | const taskProps = { userId, type, title, status, imageUri }; 11 | const taskId = new UniqueEntityID(_id); 12 | 13 | const taskOrError = Task.create(taskProps, taskId); 14 | 15 | if (taskOrError.isFailure) { 16 | console.log(taskOrError.error); 17 | } 18 | 19 | return taskOrError.isSuccess ? taskOrError.getValue() : null; 20 | } 21 | 22 | public static toPersistence(task: Task): TaskPresitant { 23 | const { id, userId, type, title, status, imageUri, createdAt, modifiedAt } = task; 24 | 25 | return { 26 | _id: id.toString(), 27 | userId, 28 | type, 29 | title, 30 | status, 31 | imageUri, 32 | createdAt, 33 | modifiedAt, 34 | }; 35 | } 36 | 37 | public static toDTO(task: Task): TaskDTO { 38 | // For this moment DTO object is identical to Presistan object 39 | // so we can use same method (except _id-> id) 40 | const taskPresitant = TaskMapper.toPersistence(task) as TaskPresitant; 41 | const taskDto = { ...taskPresitant, id: taskPresitant._id }; 42 | return taskDto; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/shared/repo/action.repo.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../domain/models/actions.js'; 2 | import { IDbModels } from '../infra/database/mongodb/index.js'; 3 | import { 4 | ActionDocument, 5 | EActionType, 6 | IActionPersistent, 7 | } from '../infra/database/mongodb/action.model.js'; 8 | import { ActionMapper } from '../mappers/action.mapper.js'; 9 | 10 | export class ActionRepo { 11 | private _models: IDbModels; 12 | 13 | constructor(models: IDbModels) { 14 | this._models = models; 15 | } 16 | 17 | public async create(action: Action): Promise { 18 | const actionData: IActionPersistent = ActionMapper.toPersistence(action); 19 | 20 | const ActionModel = this._models.ActionModel; 21 | 22 | const newAction = await ActionModel.create(actionData); 23 | 24 | return newAction; 25 | } 26 | 27 | public async getActionsOccurredSince( 28 | userId: string, 29 | time: Date = null, 30 | actionType: EActionType = null 31 | ): Promise { 32 | const actionModel = this._models.ActionModel; 33 | let actionDocuments: ActionDocument[]; 34 | 35 | const filter = { userId: userId }; 36 | if (time) { 37 | filter['occurredAt'] = { $gte: time }; 38 | } 39 | if (actionType) { 40 | filter['type'] = actionType; 41 | } 42 | 43 | actionDocuments = await actionModel.find(filter); 44 | return actionDocuments.map((a) => ActionMapper.toDomain(a)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/shared/types/error.d.ts: -------------------------------------------------------------------------------- 1 | export {}; // ← this makes the file an external module 2 | 3 | declare global { 4 | interface ErrorConstructor { 5 | /** 6 | * V8-only: capture a cleaner stack without the constructor frame itself. 7 | * @param target The object on which to assign the .stack property. 8 | * @param constructorOpt The constructor to omit from the stack trace. 9 | */ 10 | captureStackTrace(target: object, constructorOpt?: { new (...args: any[]): any }): void; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["server.ts", "src/app.ts"], 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "esModuleInterop": true, 6 | "target": "es2022", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "resolveJsonModule": true 11 | }, 12 | "lib": ["es2022"], 13 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mongo:latest 2 | 3 | COPY ./init-db.d/seed.js /docker-entrypoint-initdb.d 4 | -------------------------------------------------------------------------------- /db/init-db.d/seed.js: -------------------------------------------------------------------------------- 1 | db.init.drop(); 2 | db.init.insert({ inti: true }); 3 | 4 | const MONGO_INITDB_ROOT_USERNAME=process.env.MONGO_INITDB_ROOT_USERNAME 5 | const MONGO_INITDB_ROOT_PASSWORD=process.env.MONGO_INITDB_ROOT_PASSWORD 6 | const MONGO_INITDB_DATABASE=process.env.MONGO_INITDB_DATABASE 7 | 8 | db.createUser( 9 | { 10 | user: MONGO_INITDB_ROOT_USERNAME, 11 | pwd: MONGO_INITDB_ROOT_PASSWORD, 12 | roles: [ 13 | { 14 | role: "readWrite", 15 | db: MONGO_INITDB_DATABASE 16 | } 17 | ] 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /development.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | backend-test: 5 | container_name: backend_test 6 | build: 7 | context: ./backend 8 | ports: 9 | - '4443:4443' 10 | environment: 11 | - PORT=4443 12 | - CRT_PATH=./ssl/server.crt 13 | - KEY_PATH=./ssl/server.key 14 | - DB_PORT=27018 15 | - DB_USER=${DB_USER} 16 | - DB_CONTAINER=db-test 17 | - DB_PASSWORD=${DB_PASSWORD} 18 | - SESSION_SECRET=${SESSION_SECRET} 19 | - EMAIL_DOMAIN=${EMAIL_DOMAIN} 20 | - SENDGRID_API_KEY=${SENDGRID_API_KEY} 21 | - MAILGUN_API_KEY=${MAILGUN_API_KEY} 22 | - MAIL_API=${MAIL_API} 23 | 24 | 25 | backend: 26 | environment: 27 | - SSL_CERT_FILE=${SSL_CERT_FILE} 28 | -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # Pact consumer contracts 11 | /pacts 12 | 13 | # dependencies 14 | /node_modules 15 | 16 | #logs 17 | /logs 18 | debug.log 19 | 20 | # profiling files 21 | chrome-profiler-events*.json 22 | speed-measure-plugin*.json 23 | 24 | # IDEs and editors 25 | /.idea 26 | .project 27 | .classpath 28 | .c9/ 29 | *.launch 30 | .settings/ 31 | *.sublime-workspace 32 | 33 | # IDE - VSCode 34 | .vscode/* 35 | !.vscode/settings.json 36 | !.vscode/tasks.json 37 | !.vscode/launch.json 38 | !.vscode/extensions.json 39 | .history/* 40 | 41 | # misc 42 | /.angular/cache 43 | /.sass-cache 44 | /connect.lock 45 | /coverage 46 | /libpeerconnection.log 47 | npm-debug.log 48 | yarn-error.log 49 | testem.log 50 | /typings 51 | 52 | # System Files 53 | .DS_Store 54 | Thumbs.db 55 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "printWidth": 100, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "auto" 10 | } -------------------------------------------------------------------------------- /frontend/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "stylelint-prettier" 4 | ], 5 | "extends": [ 6 | "stylelint-config-standard-scss", 7 | "stylelint-config-prettier-scss" 8 | ], 9 | "rules": { 10 | "prettier/prettier": true, 11 | "selector-class-pattern": null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | COPY . /usr/src/app 4 | 5 | WORKDIR /usr/src/app 6 | 7 | RUN npm install --verbose 8 | 9 | CMD npm run $BUILD_MODE 10 | -------------------------------------------------------------------------------- /frontend/ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": [ 10 | "/favicon.ico", 11 | "/index.html", 12 | "/manifest.webmanifest", 13 | "/*.css", 14 | "/*.js" 15 | ] 16 | } 17 | }, 18 | { 19 | "name": "assets", 20 | "installMode": "prefetch", 21 | "resources": { 22 | "files": [ 23 | "/assets/ui/**", 24 | "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" 25 | ] 26 | } 27 | }, 28 | { 29 | "name": "apiImages", 30 | "resources": { 31 | "urls": ["/api/files/image/*"] 32 | } 33 | }, 34 | { 35 | "name": "material-icons", 36 | "installMode": "prefetch", 37 | "resources": { 38 | "urls": [ 39 | "https://fonts.googleapis.com/icon?family=Material+Icons" 40 | ] 41 | } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | /* Not yet styles here */ 2 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/components/screens/dt-home-screen/dt-home-screen.component.html: -------------------------------------------------------------------------------- 1 |

Desktop Home Screen Will be Here

2 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/components/screens/dt-home-screen/dt-home-screen.component.scss: -------------------------------------------------------------------------------- 1 | /* Not yet styles here */ 2 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/components/screens/dt-home-screen/dt-home-screencomponent.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ba-dt-home-screen', 5 | templateUrl: './dt-home-screen.component.html', 6 | styleUrls: ['./dt-home-screen.component.scss'], 7 | standalone: false, 8 | }) 9 | export class DtHomeScreenComponent {} 10 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/components/screens/dt-profile-screen/dt-profile-screen.component.html: -------------------------------------------------------------------------------- 1 |

dt-profile-screen works!

2 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/components/screens/dt-profile-screen/dt-profile-screen.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/app/desktop-app/components/screens/dt-profile-screen/dt-profile-screen.component.scss -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/components/screens/dt-profile-screen/dt-profile-screen.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-dt-profile-screen', 5 | templateUrl: './dt-profile-screen.component.html', 6 | styleUrls: ['./dt-profile-screen.component.scss'], 7 | standalone: false, 8 | }) 9 | export class DtProfileScreenComponent {} 10 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/desktop-app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterModule } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'desktop-mobile', 6 | imports: [RouterModule], 7 | template: ``, 8 | }) 9 | export class DesktopAppComponent {} 10 | -------------------------------------------------------------------------------- /frontend/src/app/desktop-app/desktop-app.routing.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { DtHomeScreenComponent } from './components/screens/dt-home-screen/dt-home-screencomponent'; 3 | import { DesktopAppComponent } from './desktop-app.component'; 4 | 5 | export const desktopRoutes: Routes = [ 6 | { 7 | path: '', 8 | component: DesktopAppComponent, 9 | children: [ 10 | { path: '', redirectTo: 'home', pathMatch: 'full' }, 11 | { path: 'home', component: DtHomeScreenComponent }, 12 | ], 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/common/task-tiles-panel/task-tile/task-tile.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace MbTaskTileAction { 2 | export class Clicked { 3 | static readonly type = '[MbTaskView] Tile Clicked'; 4 | 5 | constructor(public taskId: string | number) {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/common/task-tiles-panel/task-tile/task-tile.component.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ task.title }}
3 |
4 | @if (isLoading()) { 5 | 6 | } 7 | 8 |
9 |
10 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/common/task-tiles-panel/task-tile/task-tile.component.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use 'src/scss/colors'; 3 | 4 | :host { 5 | background-color: map.get(colors.$colors-green, default); 6 | display: flex; 7 | justify-content: center; 8 | height: auto; 9 | } 10 | 11 | .task-tile-inner { 12 | padding: .75rem; 13 | } 14 | 15 | .task-tile-title { 16 | text-align: center; 17 | word-break: break-word; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/common/task-tiles-panel/task-tiles-panel.component.html: -------------------------------------------------------------------------------- 1 | @for (column of columns; track column.index) { 2 |
3 | @for (task of column.tasks; track task.id) { 4 |
5 | 6 |
7 | } 8 |
9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/common/task-tiles-panel/task-tiles-panel.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: grid; 3 | flex-wrap: nowrap; 4 | column-gap: 5px; 5 | grid-template-columns: repeat(3, 1fr); 6 | } 7 | 8 | .tiles-column { 9 | display: flex; 10 | flex-direction: column; 11 | row-gap: 5px; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/common/task-tiles-panel/task-tiles-panel.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Task } from 'src/app/shared/models/task.model'; 3 | import { TaskTileComponent } from './task-tile/task-tile.component'; 4 | 5 | @Component({ 6 | selector: 'ba-task-tiles-panel', 7 | templateUrl: './task-tiles-panel.component.html', 8 | styleUrls: ['./task-tiles-panel.component.scss'], 9 | imports: [TaskTileComponent], 10 | }) 11 | export class TaskTilesPanelComponent { 12 | @Input() set tasks(tasks: Array) { 13 | this.columns = []; 14 | 15 | for (let k = 0; k < this.noOfColumns; k++) { 16 | const column: { index: number; tasks: Task[] } = { 17 | index: k + 1, 18 | tasks: [], 19 | }; 20 | for (let i = k; i < tasks.length; i += this.noOfColumns) { 21 | column.tasks.push(tasks[i]); 22 | } 23 | console.log('K: ' + k); 24 | this.columns = [...this.columns, column]; 25 | } 26 | } 27 | 28 | readonly noOfColumns = 3; 29 | 30 | columns: { index: number; tasks: Task[] }[] = []; 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-home-screen/mb-home-bottom-panel/mb-home-bottom-panel.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace MbHomeBottomPanelAction { 2 | export class CreateTask { 3 | static readonly type = '[MbBottomPanel] Create Task'; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-home-screen/mb-home-bottom-panel/mb-home-bottom-panel.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-home-screen/mb-home-bottom-panel/mb-home-bottom-panel.component.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @import "src/scss/colors"; 3 | 4 | :host { 5 | background-color: map.get($colors-gray-palette, 600); 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | height: 100%; 10 | } 11 | 12 | .panel-button { 13 | height: 2.2rem; 14 | width: 2.2rem; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-home-screen/mb-home-bottom-panel/mb-home-bottom-panel.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Store } from '@ngxs/store'; 3 | import { MbHomeBottomPanelAction } from './mb-home-bottom-panel.actions'; 4 | 5 | @Component({ 6 | selector: 'ba-mb-home-bottom-panel', 7 | templateUrl: './mb-home-bottom-panel.component.html', 8 | styleUrls: ['./mb-home-bottom-panel.component.scss'], 9 | }) 10 | export class MbHomeBottomPanelComponent { 11 | constructor(private store: Store) {} 12 | 13 | createTask() { 14 | this.store.dispatch(MbHomeBottomPanelAction.CreateTask); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-home-screen/mb-home-screen.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-home-screen/mb-home-screen.component.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @import 'src/scss/colors'; 3 | 4 | :host { 5 | display: flex; 6 | height: 100%; 7 | flex-direction: column; 8 | overflow: hidden; 9 | } 10 | 11 | .top-panel-holder { 12 | background-color: map.get($colors-gray-palette, 600); 13 | display: flex; 14 | height: 7vh; 15 | padding: 0 5px; 16 | 17 | .top-panel-holder-left, 18 | .top-panel-holder-right { 19 | display: flex; 20 | flex: 1; 21 | 22 | > * { 23 | margin-left: 10px; 24 | } 25 | } 26 | 27 | .top-panel-holder-right { 28 | justify-content: end; 29 | } 30 | 31 | .avatar-holder { 32 | align-self: center; 33 | height: 6vh; 34 | width: 6vh; 35 | } 36 | } 37 | 38 | .main-panel-holder { 39 | background: $color-main-bg; 40 | flex: 1; 41 | overflow: hidden; 42 | overflow-y: scroll; 43 | } 44 | 45 | .bottom-panel-holder { 46 | height: 7vh; 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-login-screen/mb-login-screen.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace MbLoginScreenAction { 2 | export class Opened { 3 | static readonly type = '[MbLoginScreen] Opened'; 4 | } 5 | export class FieldValuesChanged { 6 | static readonly type = '[MbLoginScreen] Field Values Changed'; 7 | } 8 | 9 | export class LoginUser { 10 | static readonly type = '[MbLoginScreen] Login User'; 11 | 12 | constructor( 13 | public email: string, 14 | public password: string, 15 | ) {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-login-screen/mb-login-screen.component.scss: -------------------------------------------------------------------------------- 1 | @use 'src/scss/colors'; 2 | 3 | :host { 4 | display: flex; 5 | height: 100%; 6 | flex-direction: column; 7 | overflow: hidden; 8 | background-color: colors.$color-panel-bg; 9 | justify-content: center; 10 | } 11 | 12 | .logo-holder { 13 | display: flex; 14 | height: 30%; 15 | 16 | .logo-image { 17 | height: 50%; 18 | margin: auto; 19 | } 20 | } 21 | 22 | .login-form-holder { 23 | height: 70%; 24 | padding: 15px; 25 | } 26 | 27 | .login-form { 28 | display: flex; 29 | flex-direction: column; 30 | } 31 | 32 | .signup-link-holder { 33 | display: flex; 34 | justify-content: center; 35 | margin-bottom: 10px; 36 | text-align: center; 37 | } 38 | 39 | .signup-link { 40 | color: blue; 41 | cursor: pointer; 42 | display: flex; 43 | font-size: 14px; 44 | text-decoration: underline; 45 | } 46 | 47 | .auth-failed-msg { 48 | color: colors.$dark-warn; 49 | text-align: center; 50 | margin-top: 8px; 51 | } 52 | 53 | .google-btn-holder { 54 | display: flex; 55 | margin-top: 1.5rem; 56 | justify-content: center; 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-login-screen/mb-login-screen.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { State, Action, StateContext, Selector } from '@ngxs/store'; 3 | import { MbLoginScreenAction } from './mb-login-screen.actions'; 4 | import { AuthAPIAction } from 'src/app/shared/services/api/auth.actions'; 5 | 6 | interface IMbLoginScreenStateModel { 7 | authErrMessage: string; 8 | } 9 | 10 | @State({ 11 | name: 'mbLoginScreenState', 12 | defaults: { authErrMessage: null }, 13 | }) 14 | @Injectable() 15 | export class MbLoginScreenState { 16 | @Selector() 17 | static authError(state: IMbLoginScreenStateModel): string { 18 | return state.authErrMessage; 19 | } 20 | 21 | @Action(AuthAPIAction.UserAuthFailed) 22 | authFailed(ctx: StateContext, { message }) { 23 | ctx.patchState({ 24 | authErrMessage: message, 25 | }); 26 | } 27 | 28 | @Action(MbLoginScreenAction.Opened) 29 | @Action(MbLoginScreenAction.FieldValuesChanged) 30 | removeErrMessage(ctx: StateContext) { 31 | ctx.patchState({ 32 | authErrMessage: null, 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/add-to-slack-btn/add-to-slack-btn.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/app/mobile-app/components/screens/mb-profile-screen/add-to-slack-btn/add-to-slack-btn.component.scss -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/add-to-slack-btn/add-to-slack-btn.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { environment as env } from '../../../../../../environments/environment'; 3 | 4 | @Component({ 5 | selector: 'app-add-to-slack-btn', 6 | templateUrl: './add-to-slack-btn.component.html', 7 | styleUrls: ['./add-to-slack-btn.component.scss'], 8 | }) 9 | export class AddToSlackBtnComponent { 10 | readonly INSTALL_PATH = 'https://slack.com/oauth/v2/authorize'; 11 | readonly REDIRECT_URI = 'https://brainas.net/integrations/slack/install'; 12 | readonly SCOPES = [ 13 | 'chat:write', 14 | 'channels:read', 15 | 'groups:read', 16 | 'mpim:read', 17 | 'im:read', 18 | 'channels:manage', 19 | 'groups:write', 20 | 'im:write', 21 | 'mpim:write', 22 | 'channels:join', 23 | ]; 24 | readonly CLIENT_ID = env.slackAppClientId; 25 | 26 | get scopes() { 27 | return this.SCOPES.toString(); 28 | } 29 | 30 | get installURI() { 31 | return `${this.INSTALL_PATH}?scope=${this.scopes}&redirect_uri=${this.REDIRECT_URI}&client_id=${this.CLIENT_ID}`; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/integrations/mb-integration.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IMbIntegrationsComponentConfig { 2 | styleClass: 'default' | 'custom'; // others 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/integrations/mb-integrations.component.html: -------------------------------------------------------------------------------- 1 |
2 | integrations 3 |
4 |
5 |
6 | 7 | @if (isAddedToSlack()) { 8 |
installed into slack workspace
9 | } 10 | @if (!isAddedToSlack()) { 11 |
add to slack
12 | } 13 |
14 | 15 |
16 | @if (!isAddedToSlack()) { 17 | 18 | } 19 | @if (isAddedToSlack()) { 20 |
21 | remove from slack 22 | 23 |
24 | } 25 |
26 |
27 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/integrations/mb-integrations.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostBinding, Input, Signal } from '@angular/core'; 2 | import { Store } from '@ngxs/store'; 3 | import { UserState } from 'src/app/shared/state/user.state'; 4 | import { MbProfileScreenAction } from '../mb-profile-screen.actions'; 5 | import { IMbIntegrationsComponentConfig } from './mb-integration.interface'; 6 | import { AddToSlackBtnComponent } from '../add-to-slack-btn/add-to-slack-btn.component'; 7 | import { CommonModule } from '@angular/common'; 8 | 9 | @Component({ 10 | selector: 'ba-mb-integrations', 11 | templateUrl: './mb-integrations.component.html', 12 | styleUrls: ['./mb-integrations.component.scss'], 13 | imports: [CommonModule, AddToSlackBtnComponent], 14 | }) 15 | export class MbIntegrationsComponent { 16 | @HostBinding('class') get hostClass() { 17 | return this.config.styleClass; 18 | } 19 | @Input() config: IMbIntegrationsComponentConfig = { styleClass: 'default' }; 20 | isAddedToSlack: Signal = this._store.selectSignal(UserState.isAddedToSlack); 21 | 22 | constructor(private _store: Store) {} 23 | removeFromSlack() { 24 | this._store.dispatch(MbProfileScreenAction.RemoveFromSlack); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/mb-profile-screen.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace MbProfileScreenAction { 2 | export class Logout { 3 | static readonly type = '[MbProfileScreen] Logout'; 4 | } 5 | 6 | export class RemoveFromSlack { 7 | static readonly type = '[MbProfileScreen] Remove From Slack'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-profile-screen/mb-profile-screen.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | arrow_back 5 | 6 |
7 |
8 |
9 | 27 | 28 | 29 |
30 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-signup-screen/mb-signup-screen.actions.ts: -------------------------------------------------------------------------------- 1 | import { SignUpRequestDTO } from 'src/app/shared/services/api/auth.service'; 2 | 3 | export namespace MbSignupScreenAction { 4 | export class SignupUser { 5 | static readonly type = '[MbSignupScreenAction] Signup User'; 6 | 7 | constructor(public dto: SignUpRequestDTO) {} 8 | } 9 | 10 | export class Closed { 11 | static readonly type = '[MbSignupScreenAction] Closed'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-signup-screen/mb-signup-screen.component.scss: -------------------------------------------------------------------------------- 1 | @import "src/scss/colors"; 2 | 3 | :host { 4 | display: flex; 5 | height: 100%; 6 | flex-direction: column; 7 | overflow: hidden; 8 | background-color: $color-panel-bg; 9 | justify-content: center; 10 | } 11 | 12 | .logo-holder { 13 | display: flex; 14 | height: 30%; 15 | 16 | .logo-image { 17 | height: 50%; 18 | margin: auto; 19 | } 20 | } 21 | 22 | .login-form-holder { 23 | height: 70%; 24 | padding: 15px; 25 | } 26 | 27 | .login-form { 28 | display: flex; 29 | flex-direction: column; 30 | } 31 | 32 | .login-link-holder { 33 | display: flex; 34 | justify-content: center; 35 | margin-bottom: 25px; 36 | text-align: center; 37 | } 38 | 39 | .login-link { 40 | color: blue; 41 | cursor: pointer; 42 | display: flex; 43 | font-size: 18px; 44 | text-decoration: underline; 45 | } 46 | 47 | .name-row { 48 | display: flex; 49 | } 50 | 51 | .first-name { 52 | margin-right: 10px; 53 | } 54 | 55 | .mb-signup__success-block { 56 | text-align: center; 57 | color: white; 58 | height: 70%; 59 | font-size: 18px; 60 | padding: 15px; 61 | line-height: 30px; 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-sync-screen/mb-sync-screen.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace MbSyncScreenAction { 2 | export class Relogin { 3 | static readonly type = '[MbSyncScreen] Relogin User'; 4 | 5 | constructor(public password: string) {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-sync-screen/mb-sync-screen.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | arrow_back 5 | 6 |
7 |
8 |
9 |
Please enter your password to synchronize with server
10 | 29 |
30 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-sync-screen/mb-sync-screen.component.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @import "src/scss/colors"; 3 | @import "src/scss/button"; 4 | 5 | :host { 6 | background: $color-main-bg; 7 | display: flex; 8 | height: 100%; 9 | flex-direction: column; 10 | overflow: hidden; 11 | color: white; 12 | } 13 | 14 | .top-panel-holder { 15 | background-color: map.get($colors-gray-palette, 600); 16 | display: flex; 17 | height: 7vh; 18 | padding: 0 5px; 19 | align-items: center; 20 | 21 | > .icon-holder { 22 | display: flex; 23 | padding: 0.5rem 1rem; 24 | } 25 | 26 | & > .icon-holder > mat-icon { 27 | transform: scale(2); 28 | } 29 | } 30 | 31 | .main-panel-holder { 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: center; 35 | padding: 2rem 1rem; 36 | 37 | .message { 38 | text-align: center; 39 | margin-bottom: 36px; 40 | font-size: 24px; 41 | } 42 | 43 | .login-form { 44 | text-align: center; 45 | } 46 | 47 | .password-field { 48 | margin-bottom: 24px; 49 | } 50 | 51 | .sync-button-holder { 52 | font-size: 1.5rem; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-sync-screen/mb-sync-screen.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { 3 | UntypedFormControl, 4 | UntypedFormGroup, 5 | Validators, 6 | ReactiveFormsModule, 7 | } from '@angular/forms'; 8 | import { Store } from '@ngxs/store'; 9 | import { MbSyncScreenAction } from './mb-sync-screen.actions'; 10 | import { MatFormFieldModule } from '@angular/material/form-field'; 11 | import { MatIconModule } from '@angular/material/icon'; 12 | import { RouterModule } from '@angular/router'; 13 | import { CommonModule } from '@angular/common'; 14 | 15 | @Component({ 16 | selector: 'ba-mb-sync-screen', 17 | templateUrl: './mb-sync-screen.component.html', 18 | styleUrls: ['./mb-sync-screen.component.scss'], 19 | imports: [CommonModule, RouterModule, ReactiveFormsModule, MatFormFieldModule, MatIconModule], 20 | }) 21 | export class MbSyncScreenComponent { 22 | syncForm = new UntypedFormGroup({ 23 | password: new UntypedFormControl('', Validators.required), 24 | }); 25 | 26 | constructor(private store: Store) {} 27 | 28 | submitPassword(): void { 29 | if (this.syncForm.invalid) return; 30 | this.store.dispatch(new MbSyncScreenAction.Relogin(this.syncForm.value.password)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-bottom-panel/mb-task-bottom-panel.component.html: -------------------------------------------------------------------------------- 1 | @if (mode() === ETaskViewMode.Create || mode() === ETaskViewMode.Edit) { 2 |
3 |
Cancel
4 | @let isEditFormValid = isEditFormValid$ | async; 5 | 14 |
15 | } 16 | 17 | @if (mode() === ETaskViewMode.View) { 18 |
19 | 20 |
21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-bottom-panel/mb-task-bottom-panel.component.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @import "src/scss/colors"; 3 | 4 | .mb-task-ok-panel { 5 | background-color: $color-panel-bg; 6 | display: flex; 7 | height: 100%; 8 | 9 | .cancel-task-btn { 10 | background: url("/assets/ui/icons/cancel-cross-icon-gray.png") center / auto 11 | 85% no-repeat; 12 | display: flex; 13 | width: 50%; 14 | } 15 | 16 | .create-task-btn { 17 | background: url("/assets/ui/icons/checkmark-green.png") center / auto 85% 18 | no-repeat; 19 | display: flex; 20 | opacity: 0.4; 21 | width: 50%; 22 | 23 | &.enabled { 24 | opacity: 1; 25 | } 26 | } 27 | } 28 | 29 | .go-home-btn { 30 | background-color: map.get($colors-gray-palette, 600); 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | height: 100%; 35 | 36 | & > img { 37 | height: 2.2rem; 38 | width: 2.2rem; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-bottom-panel/mb-task-bottom-panel.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, Input, Signal } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { Observable } from 'rxjs'; 4 | import { Store } from '@ngxs/store'; 5 | import { MbTaskScreenAction } from 'src/app/mobile-app/components/screens/mb-task-screen/mb-task-screen.actions'; 6 | import { 7 | ETaskViewMode, 8 | MbTaskScreenState, 9 | } from 'src/app/mobile-app/components/screens/mb-task-screen/mb-task-screen.state'; 10 | 11 | @Component({ 12 | selector: 'ba-mb-task-bottom-panel', 13 | templateUrl: './mb-task-bottom-panel.component.html', 14 | styleUrls: ['./mb-task-bottom-panel.component.scss'], 15 | imports: [CommonModule], 16 | }) 17 | export class MbTaskBottomPanelComponent { 18 | @Input() enabled: boolean; 19 | 20 | mode: Signal = this.store.selectSignal(MbTaskScreenState.mode); 21 | 22 | isEditFormValid$: Observable = inject(Store).select(MbTaskScreenState.isEditFormValid); 23 | 24 | ETaskViewMode = ETaskViewMode; 25 | 26 | constructor(private store: Store) {} 27 | 28 | ngOnInit() {} 29 | 30 | cancelChanges(): void { 31 | this.store.dispatch(MbTaskScreenAction.CancelButtonPressed); 32 | } 33 | 34 | applyChanges(): void { 35 | this.store.dispatch(MbTaskScreenAction.ApplyButtonPressed); 36 | } 37 | 38 | goHome() { 39 | this.store.dispatch(MbTaskScreenAction.HomeButtonPressed); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-edit/mb-task-edit.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 13 | 14 | 24 | 25 | Task title is required 26 | 27 | @if (imageUri$ | async) { 28 |
29 | 30 |
31 | } @else { 32 |
33 | 39 | image 40 | 41 |
42 | } 43 |
44 |
45 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-edit/mb-task-edit.component.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ITaskEditFormData { 2 | title?: string; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-edit/mb-task-edit.component.scss: -------------------------------------------------------------------------------- 1 | .title-input-holder { 2 | width: 100%; 3 | } 4 | 5 | .take-a-picture-icon-holder { 6 | text-align: center; 7 | 8 | $picture-icon-size: 6rem; 9 | > mat-icon { 10 | color: white; 11 | font-size: $picture-icon-size; 12 | height: $picture-icon-size; 13 | width: $picture-icon-size; 14 | } 15 | } 16 | 17 | .mic-icon-button { 18 | border: none; 19 | background: none; 20 | } 21 | 22 | .mic-icon { 23 | font-size: 2rem; 24 | cursor: pointer; 25 | width: auto; 26 | height: auto; 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-screen.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 |
7 | @let mode = mode$ | async; 8 | @if (mode === ETaskViewMode.Create || mode === ETaskViewMode.Edit) { 9 | 10 | } 11 | @if (mode === ETaskViewMode.View) { 12 | 13 | } 14 |
15 |
16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-screen.component.scss: -------------------------------------------------------------------------------- 1 | @use 'src/scss/constants'; 2 | @use 'src/scss/colors'; 3 | 4 | :host { 5 | display: flex; 6 | height: 100%; 7 | flex-direction: column; 8 | overflow: hidden; 9 | } 10 | 11 | .task-view-container, 12 | .task-view-sidemenu, 13 | .task-view-content { 14 | display: flex; 15 | flex-direction: column; 16 | height: 100%; 17 | } 18 | 19 | .task-view-sidemenu { 20 | background-color: #27272b; 21 | border-left: 1px solid white; 22 | width: 75%; 23 | } 24 | 25 | .top-panel-holder { 26 | height: 42px; 27 | background: bisque; 28 | } 29 | 30 | .main-panel-holder { 31 | background: colors.$color-main-bg; 32 | flex: 1 0 auto; 33 | padding: constants.$main-padding; 34 | } 35 | 36 | .bottom-panel-holder { 37 | flex-shrink: 0; 38 | height: 42px; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-side-menu/mb-task-side-menu-item/mb-task-side-menu-item.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{ optionItem().label }} 4 |
5 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-side-menu/mb-task-side-menu-item/mb-task-side-menu-item.component.scss: -------------------------------------------------------------------------------- 1 | .sidemenu-item { 2 | color: white; 3 | display: flex; 4 | align-items: center; 5 | font-size: 1.5rem; 6 | line-height: 2.375rem; 7 | margin-bottom: 1rem; 8 | } 9 | 10 | .sidemenu-item-icon { 11 | width: 1.875rem; 12 | margin-right: 1.5rem; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-side-menu/mb-task-side-menu-item/mb-task-side-menu-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input } from '@angular/core'; 2 | import { ITaskSideMenuOptionItem } from './mb-task-side-menu-item.interface'; 3 | 4 | @Component({ 5 | selector: 'ba-mb-task-side-menu-item', 6 | imports: [], 7 | templateUrl: './mb-task-side-menu-item.component.html', 8 | styleUrl: './mb-task-side-menu-item.component.scss', 9 | }) 10 | export class MbTaskSideMenuItemComponent { 11 | optionItem = input.required(); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-side-menu/mb-task-side-menu-item/mb-task-side-menu-item.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ITaskSideMenuOptionItem { 2 | label: string; 3 | icon: string; 4 | callback: () => void; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-side-menu/mb-task-side-menu.component.html: -------------------------------------------------------------------------------- 1 | @for (optionItem of optionItems; track optionItem.label) { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-side-menu/mb-task-side-menu.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | padding: 2.75rem 1.75rem; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-top-panel/mb-task-top-panel.component.html: -------------------------------------------------------------------------------- 1 | @if (showCompleteTaskBtn$ | async) { 2 |
3 | } 4 | 5 | @if (showToggleOptionsBtn$ | async) { 6 |
7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-top-panel/mb-task-top-panel.component.scss: -------------------------------------------------------------------------------- 1 | @use 'src/scss/constants'; 2 | @use 'src/scss/colors'; 3 | 4 | :host { 5 | align-items: center; 6 | background-color: colors.$color-panel-bg; 7 | display: flex; 8 | justify-content: flex-end; 9 | height: 100%; 10 | padding: 0 constants.$main-padding; 11 | } 12 | 13 | .top-panel-btn { 14 | display: flex; 15 | height: 30px; 16 | width: 30px; 17 | 18 | &.done { 19 | background: url('/assets/ui/icons/checkmark-green.png') center / auto 85% 20 | no-repeat; 21 | display: flex; 22 | } 23 | 24 | &.options { 25 | background: url('/assets/ui/icons/options.png') center / auto 85% no-repeat; 26 | display: flex; 27 | } 28 | 29 | &:not(:last-child) { 30 | margin-right: 10px; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-top-panel/mb-task-top-panel.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { Store } from '@ngxs/store'; 3 | import { MbTaskScreenAction } from '../mb-task-screen.actions'; 4 | import { Observable } from 'rxjs'; 5 | import { MbTaskScreenState } from '../mb-task-screen.state'; 6 | import { CommonModule } from '@angular/common'; 7 | 8 | @Component({ 9 | selector: 'ba-mb-task-top-panel', 10 | imports: [CommonModule], 11 | templateUrl: './mb-task-top-panel.component.html', 12 | styleUrl: './mb-task-top-panel.component.scss', 13 | }) 14 | export class MbTaskTopPanelComponent { 15 | showCompleteTaskBtn$: Observable = inject(Store).select( 16 | MbTaskScreenState.showCompleteTaskBtn, 17 | ); 18 | showToggleOptionsBtn$: Observable = inject(Store).select( 19 | MbTaskScreenState.showToggleOptionsBtn, 20 | ); 21 | 22 | constructor(private readonly store: Store) {} 23 | 24 | completeTask() { 25 | this.store.dispatch(MbTaskScreenAction.CompleteTaskOptionSelected); 26 | } 27 | 28 | toggleMenu() { 29 | this.store.dispatch(MbTaskScreenAction.SideMenuToggle); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-view/mb-task-view.component.html: -------------------------------------------------------------------------------- 1 | @let task = task$ | async; 2 |
3 | {{ task.title }} 4 |
5 | @if (task?.imageUri) { 6 |
7 | 8 |
9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-view/mb-task-view.component.scss: -------------------------------------------------------------------------------- 1 | @import 'src/scss/colors'; 2 | 3 | .title-holder { 4 | text-align: center; 5 | color: $color-main-text; 6 | font-size: 20px; 7 | font-style: italic; 8 | } 9 | 10 | .task-picture-holder { 11 | display: flex; 12 | justify-content: center; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/components/screens/mb-task-screen/mb-task-view/mb-task-view.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, inject } from '@angular/core'; 3 | import { Store } from '@ngxs/store'; 4 | import { Observable } from 'rxjs'; 5 | import { MbTaskScreenState } from '../mb-task-screen.state'; 6 | import { Task } from 'src/app/shared/models/task.model'; 7 | 8 | @Component({ 9 | selector: 'ba-mb-task-view', 10 | imports: [CommonModule], 11 | templateUrl: './mb-task-view.component.html', 12 | styleUrl: './mb-task-view.component.scss', 13 | }) 14 | export class MbTaskViewComponent { 15 | task$: Observable = inject(Store).select(MbTaskScreenState.task); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/app/mobile-app/mobile-app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterModule } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-mobile', 6 | template: ``, 7 | imports: [RouterModule], 8 | }) 9 | export class MobileAppComponent {} 10 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/google/google-auth-redirect/google-auth-redirect.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace GoogleAuthRedirectScreenAction { 2 | export class Opened { 3 | static readonly type = '[LoginWithGoogleRedirectScreen] Opened'; 4 | 5 | constructor(public readonly code: string) {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/google/google-auth-redirect/google-auth-redirect.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
Logging...
5 | @if (selectors.errorOccurred()) { 6 |
7 | @if (selectors.errorMessage()) { 8 | 9 | {{ selectors.errorMessage() }} 10 | 11 | } @else { 12 | 13 | Something went wrong :(
14 | Try again later 15 |
16 | } 17 |
18 | ← go to login 19 |
20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/google/google-auth-redirect/google-auth-redirect.component.scss: -------------------------------------------------------------------------------- 1 | @import './../../styles.scss'; 2 | 3 | :host { 4 | @extend .redirectPage; 5 | } 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/google/google-auth-redirect/google-auth-redirect.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { createSelectMap, Store } from '@ngxs/store'; 3 | import { GoogleAuthRedirectScreenAction } from './google-auth-redirect.actions'; 4 | import { AppAction } from 'src/app/shared/state/app.actions'; 5 | import { GoogleAuthRedirectScreenState } from './google-auth-redirect.state'; 6 | import { CommonModule } from '@angular/common'; 7 | import { ActivatedRoute } from '@angular/router'; 8 | 9 | @Component({ 10 | selector: 'ba-google-auth-redirect', 11 | templateUrl: './google-auth-redirect.component.html', 12 | styleUrls: ['./google-auth-redirect.component.scss'], 13 | imports: [CommonModule], 14 | }) 15 | export class GoogleAuthRedirectScreenComponent { 16 | selectors = createSelectMap({ 17 | isLogging: GoogleAuthRedirectScreenState.isLogging, 18 | errorOccurred: GoogleAuthRedirectScreenState.errorOccurred, 19 | errorMessage: GoogleAuthRedirectScreenState.errorMessage, 20 | }); 21 | 22 | constructor( 23 | private readonly _activatedRoute: ActivatedRoute, 24 | private readonly _store: Store, 25 | ) {} 26 | 27 | ngOnInit() { 28 | const code: string = this._activatedRoute.snapshot.queryParamMap.get('code'); 29 | this._store.dispatch(new GoogleAuthRedirectScreenAction.Opened(code)); 30 | } 31 | 32 | goToLoginScreen() { 33 | this._store.dispatch(AppAction.NavigateToLoginScreen); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/slack/add-to-slack-redirect/add-to-slack-redirect.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace AddToSlackRedirectScreenAction { 2 | export class Opened { 3 | static readonly type = '[AddToSlackRedirectScreen] Opened'; 4 | 5 | constructor(public readonly code: string) {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/slack/add-to-slack-redirect/add-to-slack-redirect.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | @if (isInstalling()) { 5 |
Installing...
6 | } 7 | 8 | @if (errorOccurred()) { 9 |
10 | 11 | Something went wrong :(
12 | Try again later 13 |
14 |
15 | ← go to profile 16 |
17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/slack/add-to-slack-redirect/add-to-slack-redirect.component.scss: -------------------------------------------------------------------------------- 1 | @import './../../styles.scss'; 2 | 3 | :host { 4 | @extend .redirectPage; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/slack/add-to-slack-redirect/add-to-slack-redirect.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Signal } from '@angular/core'; 2 | import { Select, Store } from '@ngxs/store'; 3 | import { Observable, from } from 'rxjs'; 4 | import { AppAction } from 'src/app/shared/state/app.actions'; 5 | import { AddToSlackRedirectScreenState } from './add-to-slack-redirect.state'; 6 | import { AddToSlackRedirectScreenAction } from './add-to-slack-redirect.actions'; 7 | import { ActivatedRoute } from '@angular/router'; 8 | 9 | @Component({ 10 | selector: 'app-add-to-slack-redirect', 11 | templateUrl: './add-to-slack-redirect.component.html', 12 | styleUrls: ['./add-to-slack-redirect.component.scss'], 13 | standalone: false, 14 | }) 15 | export class AddToSlackRedirectComponent { 16 | isInstalling: Signal = this._store.selectSignal( 17 | AddToSlackRedirectScreenState.isInstalling, 18 | ); 19 | errorOccurred: Signal = this._store.selectSignal( 20 | AddToSlackRedirectScreenState.errorOccurred, 21 | ); 22 | 23 | constructor( 24 | private readonly _activatedRoute: ActivatedRoute, 25 | private readonly _store: Store, 26 | ) {} 27 | 28 | ngOnInit() { 29 | const code: string = this._activatedRoute.snapshot.queryParamMap.get('code'); 30 | this._store.dispatch(new AddToSlackRedirectScreenAction.Opened(code)); 31 | } 32 | 33 | goToProfileScreen() { 34 | this._store.dispatch(AppAction.NavigateToProfileScreen); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/redirects/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use 'src/scss/colors'; 3 | @use 'src/scss/button'; 4 | 5 | .redirectPage { 6 | 7 | background: colors.$color-main-bg; 8 | display: flex; 9 | height: 100%; 10 | flex-direction: column; 11 | overflow: hidden; 12 | color: white; 13 | 14 | 15 | .in-progress-text { 16 | margin: auto; 17 | font-size: 2rem; 18 | } 19 | 20 | .error-holder { 21 | margin: 1rem auto; 22 | font-size: 1.2rem; 23 | text-align: center; 24 | } 25 | 26 | .error-text { 27 | color: colors.$dark-warn; 28 | } 29 | 30 | .error-back { 31 | display: block; 32 | margin-top: 2rem; 33 | font-size: 1.7rem; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/sign-in-with-google-btn/sign-in-with-google-btn.component.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/sign-in-with-google-btn/sign-in-with-google-btn.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ba-sign-in-with-google-btn', 5 | templateUrl: './sign-in-with-google-btn.component.html', 6 | styleUrls: ['./sign-in-with-google-btn.component.scss'], 7 | }) 8 | export class SignInWithGoogleBtnComponent { 9 | readonly pathToConsentScreen = `/api/integrations/google/oauth-consent-screen?ngsw-bypass=1`; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/speech-recorder/mic-icon/mic-icon.component.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/speech-recorder/mic-icon/mic-icon.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | height: 100%; 4 | 5 | svg { 6 | width: 100%; 7 | height: 100%; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/speech-recorder/mic-icon/mic-icon.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ba-mic-icon', 5 | imports: [], 6 | templateUrl: './mic-icon.component.html', 7 | styleUrl: './mic-icon.component.scss', 8 | }) 9 | export class MicIconComponent { 10 | @Input() color!: string; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/speech-recorder/voice-recorder.component.html: -------------------------------------------------------------------------------- 1 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 25 | 26 |
33 | 34 |
35 |
36 |
37 | 40 | 43 |
44 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/spinner/spinner.component.html: -------------------------------------------------------------------------------- 1 |
2 | Loading 3 |
4 |
5 |
6 |
7 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/spinner/spinner.component.scss: -------------------------------------------------------------------------------- 1 | *, *::after, *::before { 2 | box-sizing: border-box; 3 | } 4 | 5 | .spinner { 6 | width: 100%; 7 | aspect-ratio: 1 / 1; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | font-size: 2rem; 12 | overflow: hidden; 13 | position: relative; 14 | animation: text-color 2s ease-in-out infinite alternate; 15 | } 16 | 17 | .spinner-sector { 18 | position: absolute; 19 | width: 100%; 20 | height: 100%; 21 | border-radius: 50%; 22 | border: 15px solid transparent; 23 | mix-blend-mode: overlay; 24 | animation: rotate var(--duration) var(--timing) infinite; 25 | pointer-events: none; 26 | } 27 | 28 | .spinner-sector-red { 29 | border-top-color: rosybrown; 30 | --duration: 1.5s; 31 | --timing: ease-in; 32 | } 33 | 34 | .spinner-sector-blue { 35 | border-left-color: lightblue; 36 | --duration: 2s; 37 | --timing: ease-in; 38 | } 39 | 40 | .spinner-sector-green { 41 | border-right-color: lightgreen; 42 | --duration: 2.5s; 43 | --timing: ease-out; 44 | } 45 | 46 | @keyframes rotate { 47 | 0% { 48 | transform: rotate(0); 49 | } 50 | 51 | 100% { 52 | transform: rotate(360deg); 53 | } 54 | } 55 | 56 | @keyframes text-color { 57 | 0% { 58 | color: rgba(0, 0, 0, 1); 59 | } 60 | 61 | 50% { 62 | color: rgba(0, 0, 0, .5); 63 | } 64 | 65 | 100% { 66 | color: rgba(0, 0, 0, .1); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/spinner/spinner.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, ViewChild } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'ba-spinner', 5 | imports: [], 6 | templateUrl: './spinner.component.html', 7 | styleUrl: './spinner.component.scss', 8 | }) 9 | export class SpinnerComponent { 10 | @ViewChild('spinner', { static: true }) spinnerRef!: ElementRef; 11 | 12 | getNativeElement(): HTMLElement { 13 | return this.spinnerRef.nativeElement; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/user-avatar/user-avatar.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/user-avatar/user-avatar.component.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @import "src/scss/colors"; 3 | 4 | :host { 5 | align-items: center; 6 | border-radius: 50%; 7 | display: flex; 8 | height: 100%; 9 | justify-content: center; 10 | overflow: hidden; 11 | width: 100%; 12 | } 13 | 14 | .user-avatar { 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | .first-letter { 20 | align-items: center; 21 | background-color: $color-avarat; 22 | background-size: contain; 23 | color: map.get($colors-gray-palette, 600); 24 | display: flex; 25 | width: 100%; 26 | height: 100%; 27 | justify-content: center; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/user-avatar/user-avatar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild, ElementRef, Input } from '@angular/core'; 2 | import { IUserAvatarInputData } from './user-avatar.interface'; 3 | 4 | @Component({ 5 | selector: 'ba-user-avatar', 6 | templateUrl: './user-avatar.component.html', 7 | styleUrls: ['./user-avatar.component.scss'], 8 | }) 9 | export class UserAvatarComponent { 10 | @Input() data: IUserAvatarInputData; 11 | @ViewChild('avatarHolder') avatarHolderRef: ElementRef; 12 | 13 | constructor(private _el: ElementRef) {} 14 | 15 | ngAfterViewInit(): void { 16 | this.setAvatarProperties(); 17 | this.fitLetterSizeToCont(); 18 | } 19 | 20 | private fitLetterSizeToCont() { 21 | if (this.avatarHolderRef) { 22 | const elHeight = this._el.nativeElement.offsetHeight; 23 | this.avatarHolderRef.nativeElement.style.fontSize = elHeight * 0.75 + 'px'; 24 | } 25 | } 26 | 27 | private setAvatarProperties() { 28 | if (this.data.photoUrl) { 29 | this.avatarHolderRef.nativeElement.style.backgroundImage = `url(${this.data.photoUrl})`; 30 | } else { 31 | this.avatarHolderRef.nativeElement.innerHTML = this.data.firstLetter; 32 | if (this.data.color) { 33 | this.avatarHolderRef.nativeElement.style.background = this.data.color; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/app/shared/components/ui-elements/user-avatar/user-avatar.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IUserAvatarInputData { 2 | firstLetter: string; 3 | color?: string; 4 | photoUrl?: string; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/app/shared/constants/api-endpoints.const.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = `/api/`; 2 | 3 | export const API_ENDPOINTS = { 4 | AUTH: { 5 | LOGIN: BASE_URL + 'auth/login', 6 | LOGOUT: BASE_URL + 'auth/logout', 7 | SIGNUP: BASE_URL + 'auth/signup', 8 | VERIFY_EMAIL: BASE_URL + 'auth/verify-email', 9 | }, 10 | SYNC: { 11 | RELEASE_CLIENTID: BASE_URL + 'sync/release-client-id', 12 | CHANGES: BASE_URL + 'sync/changes', 13 | TASK: BASE_URL + 'sync/task', 14 | TAG: BASE_URL + 'sync/tag', 15 | }, 16 | INTEGRATIONS: { 17 | SLACK: BASE_URL + 'integrations/slack/install', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/app/shared/dto/change.dto.ts: -------------------------------------------------------------------------------- 1 | import { EChangeAction, EChangedEntity } from '../models'; 2 | 3 | export interface ChangeableObjectDTO { 4 | id: string; 5 | modifiedAt: string; 6 | } 7 | export interface ChangeDTO { 8 | entity: EChangedEntity; 9 | action: EChangeAction; 10 | object: ChangeableObjectDTO; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/app/shared/dto/index.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDTO, ChangeableObjectDTO } from './change.dto'; 2 | import { TaskDTO } from './task.dto'; 3 | import { UserDto } from './user.dto'; 4 | import { TagDTO } from './tag.dto'; 5 | 6 | export { ChangeableObjectDTO, ChangeDTO, TaskDTO, UserDto, TagDTO }; 7 | -------------------------------------------------------------------------------- /frontend/src/app/shared/dto/tag.dto.ts: -------------------------------------------------------------------------------- 1 | export interface TagDTO { 2 | id: string; 3 | isCategory: boolean; 4 | name: string; 5 | color: string; 6 | createdAt: string; 7 | modifiedAt: string; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/shared/dto/task.dto.ts: -------------------------------------------------------------------------------- 1 | export interface TaskDTO { 2 | id: string; 3 | userId: string; 4 | type: string; 5 | title: string; 6 | status: string; 7 | imageUri?: string; 8 | createdAt: string; 9 | modifiedAt: string; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/app/shared/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | export interface UserDto { 2 | firstName: string; 3 | lastName: string; 4 | email: string; 5 | userId: string; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/app/shared/forms/types/form-controls-of.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from '@angular/forms'; 2 | 3 | export type FormControlsOf = { 4 | [K in keyof T]: FormControl; 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/app/shared/helpers/convert-object-to-url-params.function.ts: -------------------------------------------------------------------------------- 1 | export function convertObjectToUrlParams(payloadObject): string { 2 | const payload = new URLSearchParams(); 3 | for (const key in payloadObject) { 4 | payload.set(key, payloadObject[key]); 5 | } 6 | 7 | return payload.toString(); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/shared/mappers/index.ts: -------------------------------------------------------------------------------- 1 | import { ChangeMapper } from './change.mapper'; 2 | import { TagMapper } from './tag.mapper'; 3 | import { TasksMapper } from './task.mapper'; 4 | import { UserMapper } from './user.mapper'; 5 | 6 | export { ChangeMapper, TagMapper, TasksMapper, UserMapper }; 7 | -------------------------------------------------------------------------------- /frontend/src/app/shared/mappers/tag.mapper.ts: -------------------------------------------------------------------------------- 1 | import { TagDTO } from '../dto'; 2 | import { ETagType, Tag } from '../models'; 3 | 4 | export class TagMapper { 5 | public static toModel(tagDto: TagDTO): Tag { 6 | return { 7 | id: tagDto.id, 8 | type: tagDto.isCategory ? ETagType.CATEGORY : ETagType.REGULAR, 9 | name: tagDto.name, 10 | color: tagDto.color, 11 | createdAt: tagDto.createdAt, 12 | modifiedAt: tagDto.modifiedAt, 13 | } as Tag; 14 | } 15 | 16 | public static toDto(tag: Tag): TagDTO { 17 | return { 18 | id: tag.id, 19 | isCategory: tag.type === ETagType.CATEGORY ? true : false, 20 | name: tag.name, 21 | color: tag.color, 22 | createdAt: tag.createdAt, 23 | modifiedAt: tag.modifiedAt, 24 | } as TagDTO; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/app/shared/mappers/task.mapper.ts: -------------------------------------------------------------------------------- 1 | import { TaskDTO } from '../dto/task.dto'; 2 | import { Task, ETaskStatus, ETaskType } from '../models/task.model'; 3 | 4 | export class TasksMapper { 5 | public static toModel(taskDto: TaskDTO): Task { 6 | return { 7 | id: taskDto.id, 8 | userId: taskDto.userId, 9 | type: taskDto.type as ETaskType, 10 | title: taskDto.title, 11 | status: taskDto.status as ETaskStatus, 12 | imageUri: taskDto.imageUri, 13 | createdAt: taskDto.createdAt, 14 | modifiedAt: taskDto.modifiedAt, 15 | } as Task; 16 | } 17 | 18 | public static toDto(task: Task): TaskDTO { 19 | if (!task) { 20 | throw new Error( 21 | 'An error occurred while converting the task to a DTO: the passed task object is NULL or undefined.', 22 | ); 23 | } 24 | 25 | return { 26 | id: task.id, 27 | userId: task.userId, 28 | type: task.type, 29 | title: task.title, 30 | status: task.status, 31 | imageUri: task.imageUri, 32 | createdAt: task.createdAt, 33 | modifiedAt: task.modifiedAt, 34 | } as TaskDTO; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/app/shared/mappers/user.mapper.ts: -------------------------------------------------------------------------------- 1 | import { UserDto } from '../dto/user.dto'; 2 | import { User } from '../models/user.model'; 3 | 4 | export class UserMapper { 5 | public static toModel(userDto: UserDto): User { 6 | return { 7 | firstName: userDto.firstName, 8 | lastName: userDto.lastName, 9 | email: userDto.email, 10 | userId: userDto.userId, 11 | } as User; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/shared/models/change.model.ts: -------------------------------------------------------------------------------- 1 | import { Task } from './task.model'; 2 | import { Tag } from './tag.model'; 3 | 4 | export enum EChangedEntity { 5 | Task = 'CHANGED_ENTITY_TASK', 6 | Tag = 'CHANGED_ENTITY_TAG', 7 | // more types will be here 8 | } 9 | 10 | export enum EChangeAction { 11 | Created = 'CHANGE_ACTION_CREATED', 12 | Updated = 'CHANGE_ACTION_UPDATED', 13 | Deleted = 'CHANGE_ACTION_DELETED', 14 | } 15 | 16 | export type Change = { 17 | entity: EChangedEntity; 18 | action: EChangeAction; 19 | modifiedAt?: string; 20 | object?: IChangeableObject; 21 | }; 22 | 23 | export interface IChangeableObject { 24 | id: string; 25 | modifiedAt: string; 26 | } 27 | 28 | export type ChangeableObject = (Task | Tag) & IChangeableObject; 29 | -------------------------------------------------------------------------------- /frontend/src/app/shared/models/index.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.model'; 2 | import { Task, ETaskStatus, ETaskType } from './task.model'; 3 | import { Change, ChangeableObject, EChangedEntity, EChangeAction } from './change.model'; 4 | import { Tag, ETagType } from './tag.model'; 5 | 6 | export { 7 | User, 8 | Task, 9 | ETaskStatus, 10 | ETaskType, 11 | Tag, 12 | ETagType, 13 | Change, 14 | EChangedEntity, 15 | EChangeAction, 16 | ChangeableObject, 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/app/shared/models/tag.model.ts: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | id: string; 3 | type: ETagType; 4 | name: string; 5 | color: string; 6 | createdAt: string; 7 | modifiedAt: string; 8 | }; 9 | 10 | export enum ETagType { 11 | REGULAR = 'REGULAR_TAG', 12 | CATEGORY = 'CATEGORY_TAG', 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/app/shared/models/task.model.ts: -------------------------------------------------------------------------------- 1 | export enum ETaskStatus { 2 | Todo = 'TASK_STATUS_TODO', 3 | Done = 'TASK_STATUS_DONE', 4 | Cancel = 'TASK_STATUS_CANCEL', 5 | } 6 | 7 | export enum ETaskType { 8 | Basic = 'TASK_TYPE_BASIC', 9 | // TODO: More statuses will be here 10 | } 11 | 12 | export type Task = { 13 | id: string; 14 | userId: string; 15 | type: ETaskType; 16 | title: string; 17 | imageUri?: string; 18 | status: ETaskStatus; 19 | createdAt: string; 20 | modifiedAt: string; 21 | }; 22 | 23 | export const defaultTask = { 24 | id: null, 25 | userId: null, 26 | type: ETaskType.Basic, 27 | status: ETaskStatus.Todo, 28 | title: '', 29 | createdAt: null, 30 | modifiedAt: null, 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/src/app/shared/models/user.model.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | firstName: string; 3 | lastName: string; 4 | email: string; 5 | userId: string; 6 | googleId: string; 7 | googleRefreshToken?: string; 8 | googleAccessToken?: string; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/api/auth.actions.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../models'; 2 | 3 | export namespace AuthAPIAction { 4 | export class UserLoggedIn { 5 | static readonly type = '[Auth API] Uer Logged In'; 6 | 7 | constructor(public userData: User) {} 8 | } 9 | 10 | export class UserAuthFailed { 11 | static readonly type = '[Auth API] User Auth Failed'; 12 | 13 | constructor(public message: string) {} 14 | } 15 | 16 | export class UserLoggedOut { 17 | static readonly type = '[Auth API] User Logged Out'; 18 | } 19 | 20 | export class UserLogoutFailed { 21 | static readonly type = '[Auth API] User Loggout Failed'; 22 | 23 | constructor(public userData: User) {} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/api/client-id.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { API_ENDPOINTS } from '../../constants/api-endpoints.const'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { Observable, map } from 'rxjs'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class ClientIdService { 10 | public static readonly SYNC_INTERVAL: number = 30 * 1000; // 30 secs 11 | public static readonly FAIL_MESSAGE = 'Synchronization Failed :('; 12 | 13 | constructor(private http: HttpClient) {} 14 | 15 | public releaseClientId(): Observable { 16 | return this.http 17 | .get(API_ENDPOINTS.SYNC.RELEASE_CLIENTID) 18 | .pipe(map((res: IReleaseClientIdResponseDTO) => res.clientId)); 19 | } 20 | } 21 | 22 | export interface IReleaseClientIdResponseDTO { 23 | clientId: string; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/api/server-changes.actions.ts: -------------------------------------------------------------------------------- 1 | import { Change } from '../../models'; 2 | 3 | export namespace SyncServiceAPIAction { 4 | export class ServerChangesLoaded { 5 | static readonly type = '[Sync Service API] Changes Loaded'; 6 | 7 | constructor(public changes: Change[]) {} 8 | } 9 | export class ServerChangesLoadingFailed { 10 | static readonly type = '[Sync Service API] Changes Loading Failed'; 11 | } 12 | 13 | export class LocalChangeWasSynchronized { 14 | static readonly type = '[Sync Service API] Local Change Was Synchronized'; 15 | 16 | constructor(public change: Change) {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/api/server-changes.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { API_ENDPOINTS } from '../../constants/api-endpoints.const'; 3 | import { ChangeDTO } from '../../dto/change.dto'; 4 | import { HttpClient } from '@angular/common/http'; 5 | import { convertObjectToUrlParams } from '../../helpers/convert-object-to-url-params.function'; 6 | import { Observable, map } from 'rxjs'; 7 | import { ChangeMapper } from '../../mappers'; 8 | import { Change } from '../../models'; 9 | 10 | interface GetChangesResponceDTO { 11 | changes: ChangeDTO[]; 12 | } 13 | @Injectable({ 14 | providedIn: 'root', 15 | }) 16 | export class ServerChangesService { 17 | constructor(private http: HttpClient) {} 18 | 19 | public fetch(clientId: string): Observable { 20 | const queryString: string = convertObjectToUrlParams({ 21 | clientId, 22 | }); 23 | return this.http 24 | .get(`${API_ENDPOINTS.SYNC.CHANGES}?${queryString}`) 25 | .pipe( 26 | map(response => { 27 | const changeDTOs: ChangeDTO[] = response.changes; 28 | const changes: Array = changeDTOs.map(dto => ChangeMapper.toModel(dto)); 29 | return changes; 30 | }), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/api/speech-to-text.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import type { HttpClient } from '@angular/common/http'; 3 | import type { Observable } from 'rxjs'; 4 | import { delay, of } from 'rxjs'; 5 | import { environment } from 'src/environments/environment'; 6 | 7 | export interface SpeechToTextResponse { 8 | transcript: string; 9 | } 10 | 11 | @Injectable({ providedIn: 'root' }) 12 | export class SpeechToTextService { 13 | private readonly VOICE_DECODE_API_ENDPOINT = `${environment.baseUrl}/ai/speech-to-text`; 14 | private readonly useFakeResponse = true; // toggle this to enable/disable fake response 15 | constructor(private http: HttpClient) {} 16 | 17 | uploadAudio(blob: Blob): Observable<{ transcript: string }> { 18 | if (this.useFakeResponse) { 19 | // Return fake transcript with some artificial delay to simulate network latency 20 | const fakeResponse = { transcript: 'This is a fake transcript for testing.' }; 21 | return of(fakeResponse).pipe(delay(500)); // 500ms delay 22 | } 23 | 24 | const formData = new FormData(); 25 | formData.append('file', blob, 'voice.webm'); 26 | 27 | return this.http.post<{ transcript: string }>(this.VOICE_DECODE_API_ENDPOINT, formData); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/auth/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; 3 | import { Store } from '@ngxs/store'; 4 | import { Observable } from 'rxjs'; 5 | import { UserState } from '../../state/user.state'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class AuthGuardService { 11 | constructor( 12 | private router: Router, 13 | private store: Store, 14 | ) {} 15 | 16 | canActivate( 17 | route: ActivatedRouteSnapshot, 18 | state: RouterStateSnapshot, 19 | ): Observable | Promise | boolean { 20 | return this.redirectIfNotLoggedIn(); 21 | } 22 | 23 | canActivateChild( 24 | childRoute: ActivatedRouteSnapshot, 25 | state: RouterStateSnapshot, 26 | ): Observable | Promise | boolean { 27 | return this.redirectIfNotLoggedIn(); 28 | } 29 | 30 | private redirectIfNotLoggedIn(): boolean { 31 | if (!this.isLoggedIn()) { 32 | this.router.navigateByUrl('login'); 33 | return false; 34 | } else { 35 | return true; 36 | } 37 | } 38 | 39 | private isLoggedIn(): boolean { 40 | return this.store.selectSnapshot(UserState.isLoggedIn); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/device-detector/device-detector.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { WindowRefService } from 'src/app/shared/services/utility/window-ref.service'; 3 | 4 | @Injectable({ 5 | providedIn: 'root', 6 | }) 7 | export class DeviceDetectorService { 8 | static readonly MOBILE_DEVICE_SIZE = 768; 9 | 10 | constructor(private windowRef: WindowRefService) {} 11 | 12 | public isMobile(): boolean { 13 | return this.windowRef.nativeWindow.innerWidth <= DeviceDetectorService.MOBILE_DEVICE_SIZE; 14 | } 15 | 16 | public isDesktop(): boolean { 17 | return this.windowRef.nativeWindow.innerWidth > DeviceDetectorService.MOBILE_DEVICE_SIZE; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/infrastructure/http-interceptor.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 3 | import { Observable, throwError } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { Router } from '@angular/router'; 6 | import { Store } from '@ngxs/store'; 7 | import { AppAction } from '../../state/app.actions'; 8 | 9 | @Injectable() 10 | export class HttpInterceptorService implements HttpInterceptor { 11 | constructor( 12 | public router: Router, 13 | private _store: Store, 14 | ) {} 15 | 16 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 17 | return next.handle(req).pipe( 18 | catchError(error => { 19 | if (error.status === 401) { 20 | this._store.dispatch(AppAction.UserNotAuthenticated); 21 | } 22 | if (error.status === 500) { 23 | // TICKET: https://brainas.atlassian.net/browse/BA-135 24 | // TODO: Log Unexpected Error On Client and notify user 25 | console.log('Unexpected Error'); 26 | console.log(error); 27 | } 28 | return throwError(error); 29 | }), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/integrations/google-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../models'; 2 | 3 | export namespace GoogleAPIAction { 4 | export class UserAuthenticated { 5 | static readonly type = '[Google API Action] User Authenticated'; 6 | 7 | constructor(public userData: User) {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/integrations/google-api.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpParams } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { BASE_URL } from '../../constants/api-endpoints.const'; 4 | import { User } from '../../models'; 5 | import { LoginResponseDTO } from '../api/auth.service'; 6 | import { UserMapper } from '../../mappers'; 7 | import { map } from 'rxjs'; 8 | 9 | @Injectable({ 10 | providedIn: 'root', 11 | }) 12 | export class GoogleAPIService { 13 | static readonly AUTH_API_ENDPOINT = BASE_URL + 'integrations/google/auth'; 14 | 15 | constructor(private http: HttpClient) {} 16 | 17 | authenticateUser(code: string) { 18 | let params = new HttpParams().set('code', code); 19 | return this.http 20 | .get(GoogleAPIService.AUTH_API_ENDPOINT, { 21 | headers: null, 22 | params: params, 23 | }) 24 | .pipe( 25 | map(date => { 26 | return UserMapper.toModel(date.user); 27 | }), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/integrations/slack.api.actions.ts: -------------------------------------------------------------------------------- 1 | export namespace SlackAPIAction { 2 | export class AddedToSlack { 3 | static readonly type = '[Slack API Action] Added To Slack'; 4 | } 5 | 6 | export class RemovedFromSlack { 7 | static readonly type = '[Slack API Action] Removed From Slack'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/integrations/slack.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { API_ENDPOINTS } from '../../constants/api-endpoints.const'; 3 | import { HttpClient } from '@angular/common/http'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class SlackService { 9 | constructor(private http: HttpClient) {} 10 | async addToSlack(code: string): Promise { 11 | const endpoint = `${API_ENDPOINTS.INTEGRATIONS.SLACK}`; 12 | const payload = { code }; 13 | await this.http.post(endpoint, payload).toPromise(); 14 | } 15 | 16 | async removeFromSlack(): Promise { 17 | const endpoint = `${API_ENDPOINTS.INTEGRATIONS.SLACK}`; 18 | await this.http.delete(endpoint).toPromise(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/pwa/device-camera.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Camera, CameraResultType } from '@capacitor/camera'; 3 | 4 | @Injectable({ 5 | providedIn: 'root', 6 | }) 7 | export class DeviceCameraService { 8 | public async takePicture(quality = 90): Promise { 9 | if (quality < 0 || quality > 100) { 10 | throw new Error('Quality must be between 0 and 100'); 11 | } 12 | 13 | try { 14 | const image = await Camera.getPhoto({ 15 | quality, 16 | resultType: CameraResultType.Uri, 17 | }); 18 | 19 | if (!image.webPath) { 20 | throw new Error('No image URL available'); 21 | } 22 | 23 | const imageUrl = image.webPath; 24 | 25 | return imageUrl; 26 | } catch (error) { 27 | console.error('Error taking picture:', error); 28 | throw error; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/pwa/pwa.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { SwUpdate } from '@angular/service-worker'; 3 | @Injectable() 4 | export class PwaService { 5 | constructor(private swUpdate: SwUpdate) { 6 | this.swUpdate.versionUpdates.subscribe(event => { 7 | if (event.type === 'VERSION_READY') { 8 | window.location.reload(); 9 | } 10 | }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/pwa/voice-recorder.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class VoiceRecorderService { 5 | private mediaRecorder!: MediaRecorder; 6 | private audioChunks: Blob[] = []; 7 | private stream: MediaStream; 8 | private _isRecording = false; 9 | 10 | get isRecording(): boolean { 11 | return this._isRecording; 12 | } 13 | 14 | set isRecording(value: boolean) { 15 | this._isRecording = value; 16 | } 17 | 18 | async startRecording(): Promise { 19 | this.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 20 | this.audioChunks = []; 21 | this.mediaRecorder = new MediaRecorder(this.stream); 22 | 23 | this.mediaRecorder.ondataavailable = event => { 24 | this.audioChunks.push(event.data); 25 | }; 26 | 27 | this.mediaRecorder.start(); 28 | this._isRecording = true; 29 | } 30 | 31 | stopRecording(): Promise { 32 | return new Promise(resolve => { 33 | this.mediaRecorder.onstop = () => { 34 | const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' }); 35 | this.cleanup(); 36 | resolve(audioBlob); 37 | }; 38 | this.mediaRecorder.stop(); 39 | this._isRecording = false; 40 | }); 41 | } 42 | 43 | private cleanup(): void { 44 | this.stream?.getTracks().forEach(track => track.stop()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/utility/dialog.service.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from '@angular/cdk/portal'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { MatDialog } from '@angular/material/dialog'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class DialogService { 9 | private dialog = inject(MatDialog); 10 | 11 | public showFullScreenDialog>(componentClass: C) { 12 | return this.dialog.open(componentClass, { 13 | maxWidth: '100vw', 14 | width: '100vw', 15 | height: '100vh', 16 | autoFocus: false, 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/utility/image-optimizer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import Compressor from 'compressorjs'; 3 | 4 | @Injectable({ 5 | providedIn: 'root', 6 | }) 7 | export class ImageOptimizerService { 8 | public optimizeImage(blob: Blob, quality = 0.6): Promise { 9 | return new Promise((resolve, reject) => { 10 | new Compressor(blob, { 11 | quality: quality, 12 | mimeType: 'image/jpeg', 13 | success(result) { 14 | resolve(result); 15 | }, 16 | error(err) { 17 | console.log(err.message); 18 | reject(err); 19 | }, 20 | }); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/app/shared/services/utility/window-ref.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | export interface ICustomWindow extends Window { 4 | __custom_global_stuff: string; 5 | } 6 | 7 | function getWindow(): any { 8 | return window; 9 | } 10 | 11 | @Injectable({ 12 | providedIn: 'root', 13 | }) 14 | export class WindowRefService { 15 | get nativeWindow(): ICustomWindow { 16 | return getWindow(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/app/shared/state/app.actions.ts: -------------------------------------------------------------------------------- 1 | import { Change } from '../models'; 2 | 3 | export namespace AppAction { 4 | export class Opened { 5 | static readonly type = '[App] Opened'; 6 | } 7 | 8 | export class Online { 9 | static readonly type = '[App] Is Online'; 10 | } 11 | 12 | export class Offline { 13 | static readonly type = '[App] Is Offline'; 14 | } 15 | 16 | export class NavigateToHomeScreen { 17 | static readonly type = '[App] Navigate To Home Screen'; 18 | } 19 | 20 | export class NavigateToProfileScreen { 21 | static readonly type = '[App] Navigate To Profile Screen'; 22 | } 23 | 24 | export class NavigateToLoginScreen { 25 | static readonly type = '[App] Navigate To Login Screen'; 26 | } 27 | 28 | export class NavigateToSingUpScreen { 29 | static readonly type = '[App] Navigate To SingUp Screen'; 30 | } 31 | 32 | export class UserNotAuthenticated { 33 | static readonly type = '[App API] User Not Authenticated'; 34 | } 35 | 36 | export class ChangeForSyncOccurred { 37 | static readonly type = '[App] Change Occurred'; 38 | 39 | constructor(public change: Change) {} 40 | } 41 | 42 | export class ShowErrorInUI { 43 | static readonly type = '[App] Show Error In UI'; 44 | 45 | constructor(public message: string) {} 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/app/shared/state/app.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { State, Action, StateContext, Selector } from '@ngxs/store'; 3 | import { AppAction } from './app.actions'; 4 | 5 | export interface AppStateModel { 6 | online: boolean; 7 | } 8 | 9 | @State({ 10 | name: 'app', 11 | defaults: { 12 | online: false, 13 | }, 14 | }) 15 | @Injectable() 16 | export class AppState { 17 | @Selector() 18 | static online(state: AppStateModel): boolean { 19 | return state.online; 20 | } 21 | 22 | @Action(AppAction.Opened) 23 | appOpened(ctx: StateContext) { 24 | // @TODO#FUNCTIONALITY - here need to perform operations on App opening 25 | } 26 | 27 | @Action(AppAction.Online) 28 | online(ctx: StateContext) { 29 | console.log('{ online: true }'); 30 | ctx.patchState({ online: true }); 31 | } 32 | 33 | @Action(AppAction.Offline) 34 | offline(ctx: StateContext) { 35 | ctx.patchState({ online: false }); 36 | } 37 | 38 | @Action(AppAction.ShowErrorInUI) 39 | showErrorInUI(ctx: StateContext) { 40 | console.log('TODO: Show error in UI'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/app/shared/state/sync.action.ts: -------------------------------------------------------------------------------- 1 | import { Change } from '../models'; 2 | 3 | export namespace SyncAction { 4 | export class LocalChangeWasCanceled { 5 | static readonly type = '[Sync] Local Change Was sCanceled'; 6 | 7 | constructor(public change: Change) {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/.gitkeep -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /frontend/src/assets/screenshots/375_667.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/screenshots/375_667.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/cancel-cross-icon-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/cancel-cross-icon-gray.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/cancel-cross-icon-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/cancel-cross-icon-red.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/checkmark-green-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/checkmark-green-dark.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/checkmark-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/checkmark-green.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/disconnect-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/edit.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/google-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/google-logo.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/home-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/home-icon.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/home-icon_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/home-icon_light.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/no-connection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/no-connection.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/no-synchronize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/no-synchronize.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/options.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/options_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/options_light.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/plus-icon-btn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/plus-icon-btn.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/plus-icon-btn_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/plus-icon-btn_light.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/icons/rubbish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/icons/rubbish.png -------------------------------------------------------------------------------- /frontend/src/assets/ui/main/ba_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/assets/ui/main/ba_logo.png -------------------------------------------------------------------------------- /frontend/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | baseUrl: `/api/`, 4 | slackAppClientId: '4302912159520.6331710262610', 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | baseUrl: `/api/`, 4 | slackAppClientId: '4302912159520.6331710262610', 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kir-ushakov/public-ba-codebase/459188364e8fbd65de261abc8c653a8ce8598314/frontend/src/favicon.ico -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | App 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/scss/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $breakpoint-sm: 576px; 2 | $breakpoint-md: 768px; 3 | $breakpoint-xl: 1200px; 4 | $breakpoint-xxl: 1400px; 5 | -------------------------------------------------------------------------------- /frontend/src/scss/_button.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use 'src/scss/colors'; 3 | 4 | @mixin button { 5 | border: 0; 6 | border-radius: 0.25rem; 7 | padding: 0.5rem 1.5rem; 8 | 9 | &:active { 10 | border: none; 11 | } 12 | } 13 | 14 | @mixin green-btn { 15 | @include button; 16 | 17 | background-color: map.get(colors.$colors-green, default); 18 | 19 | &:active { 20 | background-color: map.get(colors.$colors-green, light); 21 | } 22 | } 23 | 24 | @mixin light-gray-btn { 25 | @include button; 26 | 27 | background-color: map.get(colors.$colors-gray-palette, 200); 28 | color: map.get(map.get(colors.$colors-gray-palette, contrast), 200); 29 | 30 | &:active { 31 | background-color: map.get(colors.$colors-gray, light); 32 | } 33 | } 34 | 35 | button { 36 | &.green-btn { 37 | @include green-btn; 38 | } 39 | 40 | &.light-gray-btn { 41 | @include light-gray-btn; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/scss/_colors.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | 3 | $colors-gray-palette: ( 4 | 100: #495259, 5 | 200: #484356, 6 | 300: #433e51, 7 | 400: #2c2839, 8 | 500: #212a30, 9 | 600: #050515, 10 | 700: #000007, 11 | 800: #000007, 12 | 900: #000007, 13 | 14 | contrast: ( 15 | 100: #fff, 16 | 200: #f2f2f2, 17 | 300: #e6e6e6, 18 | 400: #d9d9d9, 19 | 500: #ccc, 20 | 600: #bfbfbf, 21 | 700: #b3b3b3, 22 | 800: #b3b3b3, 23 | 900: #b3b3b3, 24 | ), 25 | ); 26 | $colors-gray: ( 27 | light: map.get($colors-gray-palette, 100), 28 | default: map.get($colors-gray-palette, 500), 29 | dark: map.get($colors-gray-palette, 700), 30 | ); 31 | $colors-green-palette: ( 32 | 100: #439889, 33 | 500: #00695c, 34 | 700: #003d33, 35 | contrast: ( 36 | 100: black, 37 | 500: white, 38 | 700: white, 39 | ), 40 | ); 41 | $colors-green: ( 42 | light: map.get($colors-green-palette, 100), 43 | default: map.get($colors-green-palette, 500), 44 | dark: map.get($colors-green-palette, 700), 45 | ); 46 | $dark-warn: #9e0000; 47 | $color-main-bg: map.get($colors-gray-palette, 500); 48 | $color-panel-bg: map.get($colors-gray-palette, 600); 49 | $color-input-text: map.get(map.get($colors-gray-palette, contrast), 900); 50 | $color-main-text: map.get(map.get($colors-gray-palette, contrast), 400); 51 | 52 | /* special colors */ 53 | $color-avarat: #cfe9e5; 54 | -------------------------------------------------------------------------------- /frontend/src/scss/_constants.scss: -------------------------------------------------------------------------------- 1 | $main-padding: 12px; 2 | -------------------------------------------------------------------------------- /frontend/src/scss/_input.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "colors"; 3 | 4 | $padding-text-h: 10px; 5 | $color-main: map.get(map.get(colors.$colors-gray-palette, contrast), 500); 6 | $color-active: map.get(map.get(colors.$colors-gray-palette, contrast), 100); 7 | 8 | mat-form-field { 9 | .mat-form-field-infix { 10 | width: 100%; 11 | } 12 | } 13 | 14 | mat-form-field.mat-primary { 15 | .mat-form-field-infix { 16 | border: solid 1px $color-main; 17 | border-radius: 5px; 18 | padding: 7px $padding-text-h; 19 | } 20 | 21 | .mat-input-element { 22 | color: $color-active; 23 | font-size: 18px; 24 | } 25 | 26 | .mat-form-field-label { 27 | color: $color-main; 28 | left: $padding-text-h; 29 | opacity: 0.25; 30 | } 31 | 32 | mat-error { 33 | color: colors.$dark-warn; 34 | } 35 | 36 | &.mat-focused .mat-form-field-infix { 37 | border: solid 1px $color-active; 38 | } 39 | } 40 | 41 | input:-webkit-autofill, 42 | input:-webkit-autofill:hover, 43 | input:-webkit-autofill:focus, 44 | textarea:-webkit-autofill, 45 | textarea:-webkit-autofill:hover, 46 | textarea:-webkit-autofill:focus, 47 | select:-webkit-autofill, 48 | select:-webkit-autofill:hover, 49 | select:-webkit-autofill:focus { 50 | border: none; 51 | -webkit-text-fill-color: white; 52 | box-shadow: 0 0 0 1000px #000 inset; 53 | transition: background-color 5000s ease-in-out 0s; 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/scss/_mat-fileld.scss: -------------------------------------------------------------------------------- 1 | mat-form-field.mat-form-field-appearance-legacy { 2 | .mat-form-field-subscript-wrapper { 3 | margin-top: 0.5em; 4 | top: calc(100% - 2em); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/scss/index.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @use 'normalize.css'; 3 | @use '@angular/material/prebuilt-themes/indigo-pink.css'; 4 | @use 'constants'; 5 | @use 'colors'; 6 | @use 'input'; 7 | @use 'button'; 8 | @use 'mat-fileld'; 9 | @use 'breakpoints'; 10 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | font-family: Roboto, "Helvetica Neue", sans-serif; 9 | } 10 | 11 | img { 12 | max-width: 100%; 13 | max-height: 100%; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "declaration": false, 9 | "experimentalDecorators": true, 10 | "module": "ES2022", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "lib": ["ES2022", "dom"], 15 | "useDefineForClassFields": false 16 | //"strictNullChecks": true 17 | }, 18 | "angularCompilerOptions": { 19 | "fullTemplateTypeCheck": true, 20 | "strictInjectionParameters": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:latest -------------------------------------------------------------------------------- /nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name brainas.com www.brainas.com; 4 | return 301 https://$server_name$request_uri; 5 | } 6 | 7 | server { 8 | listen 443 ssl; 9 | server_name www.brainas.com; 10 | 11 | ssl_certificate ssl/server.crt; 12 | ssl_certificate_key ssl/server.key; 13 | 14 | root /srv/www/static; 15 | try_files $uri $uri/ /index.html; 16 | 17 | #rewrite ^(/api.*) https://brainas.com:3443$1 permanent; 18 | 19 | location /api/ { 20 | proxy_pass https://backend:3443/api/; 21 | } 22 | 23 | client_max_body_size 10M; 24 | 25 | } 26 | -------------------------------------------------------------------------------- /nginx/prod.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name 194.32.248.128 brainas.net www.brainas.net brainassistant.app www.brainassistant.app; 4 | 5 | root /srv/www/static; 6 | try_files $uri /index.html; 7 | 8 | return 301 https://$server_name$request_uri; 9 | } 10 | 11 | server { 12 | listen 543 ssl; 13 | server_name 194.32.248.128 brainas.net www.brainas.net brainassistant.app www.brainassistant.app; 14 | 15 | ssl_certificate ssl/brainassistant.app.crt; 16 | ssl_certificate_key ssl/brainassistant.app.key; 17 | 18 | root /srv/www/static; 19 | try_files $uri $uri/ /index.html; 20 | 21 | client_body_buffer_size 10M; 22 | client_max_body_size 10M; 23 | 24 | #rewrite ^(/api.*) https://brainas.com:3443$1 permanent; 25 | 26 | location /api/ { 27 | proxy_pass https://backend:3443/api/; 28 | } 29 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "lint-staged": { 3 | "frontend/**/*.{ts,js,json}": "npx eslint --config frontend/eslint.config.mjs --fix --no-warn-ignored", 4 | "backend/**/*.{ts,js,json}": "npx eslint --config backend/eslint.config.mjs --fix --no-warn-ignored" 5 | }, 6 | "devDependencies": { 7 | "husky": "^8.0.0", 8 | "lint-staged": "^15.5.1" 9 | }, 10 | "scripts": { 11 | "prepare": "husky install" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /production.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: ./frontend 7 | volumes: 8 | - ./frontend/dist:/usr/src/app/dist/app/browser 9 | environment: 10 | - BUILD_MODE=build-prod 11 | 12 | backend: 13 | restart: always 14 | volumes: 15 | - ./nginx/ssl:/usr/ssl 16 | build: 17 | context: ./backend 18 | dockerfile: ./Dockerfile.prod 19 | environment: 20 | - PORT=3443 21 | - CRT_PATH=/usr/ssl/brainassistant.app.crt 22 | - KEY_PATH=/usr/ssl/brainassistant.app.key 23 | 24 | nginx: 25 | ports: !override 26 | - '543:543' 27 | volumes: 28 | - ./nginx/prod.conf:/etc/nginx/conf.d/default.conf 29 | - ./nginx/ssl:/etc/nginx/ssl 30 | - ./frontend/dist:/srv/www/static:ro 31 | --------------------------------------------------------------------------------