diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6b34f7e34..37f985e68 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -45,4 +45,6 @@ jobs: application_name: ${{ env.EBS_APP_NAME }} environment_name: ${{ env.ENVIRONMENT }} version_label: ${{ github.sha }} + use_existing_version_if_available: true deployment_package: ./build.zip + wait_for_environment_recovery: 300 diff --git a/README.md b/README.md index d402bb1fa..b87161364 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,115 @@ Will be added soon ## 📦 CI/CD CI/CD implemented using [GitHub Actions](https://docs.github.com/en/actions) + +# 🗃️ DB diagram: + +```mermaid +erDiagram + users { + id integer PK + email character_varying(255) + password_hash text + stripe_customer_id character_varying(255) + created_at timestamp + updated_at timestamp + } + + user_details { + id integer PK + user_id integer FK + full_name character_varying(255) + avatar_url text + username character_varying(255) + date_of_birth date + weight integer + height integer + gender gender_enum + created_at timestamp + updated_at timestamp + } + + user_achievements { + id integer PK + user_id integer FK + achievement_id integer FK + created_at timestamp + updated_at timestamp + } + + achievements { + id integer PK + name character_varying(255) + activity_type activity_type_enum + requirement integer + requirement_metric requirement_metric_enum + created_at timestamp + updated_at timestamp + } + + subscriptions { + id integer PK + user_id integer FK + plan_id integer FK + stripe_subscription_id character_varying(255) + is_canceled boolean + expires_at timestamp + created_at timestamp + updated_at timestamp + status status_enum + } + + subscription_plans { + id integer PK + name character_varying(255) + price numeric(8-2) + description text + stripe_product_id character_varying(255) + stripe_price_id character_varying(255) + created_at timestamp + updated_at timestamp + } + + oauth_info { + id integer PK + user_id integer FK + token_type character_varying(255) + expires_at bigint + access_token text + refresh_token text + scope character_varying(255) + provider oauth_provider_enum + created_at timestamp + updated_at timestamp + } + + goals { + id integer PK + user_id integer FK + frequency integer + frequency_type frequency_type_enum + distance real + duration integer + progress real + completed_at timestamp + created_at timestamp + updated_at timestamp + } + + oauth_state { + id integer PK + user_id integer FK + uuid character_varying(255) + created_at timestamp + updated_at timestamp + } + + users ||--o{ user_details : user_id + users ||--o{ user_achievements : user_id + achievements ||--o{ user_achievements : achievement_id + users ||--o{ subscriptions : user_id + subscription_plans ||--o{ subscriptions : plan_id + users ||--o{ oauth_info : user_id + users ||--o{ goals : user_id + users ||--o{ oauth_state : user_id +``` diff --git a/backend/.env.example b/backend/.env.example index d5ca41704..a8478f9c8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,6 +5,8 @@ NODE_ENV=local PORT=3001 HOST=localhost JWT_SECRET=secret +TOKEN_EXPIRATION_TIME=1d //600s 1min 2hrs 7d +API_BASE_URL=http://localhost:3001/api/v1 # # DATABASE @@ -28,7 +30,7 @@ EMAIL_FROM=bsalimedev@gmail.com # Stripe # STRIPE_SECRET_KEY=stripe_secret_key -WEBHOOK_SECRET=webhook_secret_key +STRIPE_WEBHOOK_SECRET=stripe_webhook_secret_key # # OPEN-AI @@ -39,7 +41,6 @@ OPEN_AI_MODEL=version # # AWS S3 # - S3_ACCESS_KEY=key S3_SECRET_KEY=secret S3_BUCKET_NAME=name @@ -50,3 +51,9 @@ S3_REGION=region # STRAVA_CLIENT_ID=client_id STRAVA_CLIENT_SECRET=client_secret + +# +# GOOGLE FIT +# +GOOGLE_FIT_CLIENT_ID=client_id +GOOGLE_FIT_CLIENT_SECRET=client_secret diff --git a/backend/package.json b/backend/package.json index 734372675..d91c730db 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "fastify": "4.25.2", "fastify-multer": "2.0.3", "fastify-raw-body": "4.3.0", + "googleapis": "133.0.0", "jose": "5.2.2", "knex": "3.1.0", "objection": "3.1.3", diff --git a/backend/src/bundles/goals/enums/enums.ts b/backend/src/bundles/goals/enums/enums.ts new file mode 100644 index 000000000..2a6c83563 --- /dev/null +++ b/backend/src/bundles/goals/enums/enums.ts @@ -0,0 +1 @@ +export { FrequencyType, GoalsApiPath } from 'shared'; diff --git a/backend/src/bundles/goals/goal.controller.ts b/backend/src/bundles/goals/goal.controller.ts new file mode 100644 index 000000000..00d91fc0e --- /dev/null +++ b/backend/src/bundles/goals/goal.controller.ts @@ -0,0 +1,408 @@ +import { HttpCode } from 'shared'; + +import { type UserAuthResponseDto } from '~/bundles/users/types/types.js'; +import { + type ApiHandlerOptions, + type ApiHandlerResponse, + ApiHandlerResponseType, +} from '~/common/controller/controller.js'; +import { BaseController } from '~/common/controller/controller.js'; +import { ApiPath } from '~/common/enums/enums.js'; +import { type Logger } from '~/common/logger/logger.js'; + +import { GoalsApiPath } from './enums/enums.js'; +import { type GoalService } from './goal.service.js'; +import { type GoalRequestDto } from './types/types.js'; +import { goalValidationSchema } from './validation-schemas/validation-schemas.js'; + +/** + * @swagger + * components: + * schemas: + * GoalResponseDto: + * type: object + * properties: + * id: + * type: number + * format: number + * minimum: 1 + * activityType: + * type: string + * enum: + * - cycling + * - running + * - walking + * frequency: + * type: number + * minimum: 1 + * frequencyType: + * type: string + * enum: + * - day + * - week + * - month + * distance: + * type: number + * minimum: 1 + * nullable: true + * duration: + * type: number + * minimum: 1 + * nullable: true + * progress: + * type: number + * completedAt: + * type: string + * nullable: true + * format: date + * GoalRequestDto: + * type: object + * properties: + * activityType: + * type: string + * enum: + * - cycling + * - running + * - walking + * frequency: + * type: number + * minimum: 1 + * frequencyType: + * type: string + * enum: + * - day + * - week + * - month + * distance: + * type: number + * minimum: 1 + * nullable: true + * duration: + * type: number + * minimum: 1 + * nullable: true + */ +class GoalController extends BaseController { + private goalService: GoalService; + + public constructor(logger: Logger, goalService: GoalService) { + super(logger, ApiPath.GOALS); + this.goalService = goalService; + + this.addRoute({ + path: GoalsApiPath.ID, + method: 'GET', + isProtected: true, + handler: (options) => + this.find( + options as ApiHandlerOptions<{ + params: { id: string }; + user: UserAuthResponseDto; + }>, + ), + }); + + this.addRoute({ + path: GoalsApiPath.ROOT, + method: 'GET', + isProtected: true, + handler: (options) => + this.findAll( + options as ApiHandlerOptions<{ + user: UserAuthResponseDto; + }>, + ), + }); + + this.addRoute({ + path: GoalsApiPath.ROOT, + method: 'POST', + validation: { + body: goalValidationSchema, + }, + isProtected: true, + handler: (options) => { + return this.create( + options as ApiHandlerOptions<{ + body: GoalRequestDto; + user: UserAuthResponseDto; + }>, + ); + }, + }); + + this.addRoute({ + path: GoalsApiPath.ID, + method: 'PUT', + validation: { + body: goalValidationSchema, + }, + isProtected: true, + handler: (options) => { + return this.update( + options as ApiHandlerOptions<{ + body: GoalRequestDto; + params: { id: string }; + user: UserAuthResponseDto; + }>, + ); + }, + }); + + this.addRoute({ + path: GoalsApiPath.ID, + method: 'DELETE', + isProtected: true, + handler: (options) => { + return this.delete( + options as ApiHandlerOptions<{ + params: { id: string }; + user: UserAuthResponseDto; + }>, + ); + }, + }); + } + + /** + * @swagger + * /api/v1/goals/: + * get: + * tags: + * - Goals + * description: Returns an array of goals + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * items: + * $ref: '#/components/schemas/GoalResponseDto' + * 401: + * description: Failed operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + private async find( + options: ApiHandlerOptions<{ + params: { id: string }; + user: UserAuthResponseDto; + }>, + ): Promise { + const { id } = options.params; + const { id: userId } = options.user; + return { + type: ApiHandlerResponseType.DATA, + status: HttpCode.OK, + payload: await this.goalService.find({ id, userId }), + }; + } + + /** + * @swagger + * /api/v1/goals/{id}: + * get: + * parameters: + * - in: path + * name: id + * required: true + * tags: + * - Goals + * description: Returns goal by id + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/GoalResponseDto' + * 401: + * description: Failed operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + private async findAll( + options: ApiHandlerOptions<{ + user: UserAuthResponseDto; + }>, + ): Promise { + const { id: userId } = options.user; + return { + type: ApiHandlerResponseType.DATA, + status: HttpCode.OK, + payload: await this.goalService.findAll({ + userId, + }), + }; + } + + /** + * @swagger + * /api/v1/goals/: + * post: + * tags: + * - Goals + * description: Create goal and return it + * security: + * - bearerAuth: [] + * requestBody: + * description: Goal data + * required: true + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/GoalRequestDto' + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/GoalResponseDto' + * 401: + * description: Failed operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + private async create( + options: ApiHandlerOptions<{ + body: GoalRequestDto; + user: UserAuthResponseDto; + }>, + ): Promise { + const { id: userId } = options.user; + return { + type: ApiHandlerResponseType.DATA, + status: HttpCode.CREATED, + payload: await this.goalService.create({ + ...options.body, + userId, + }), + }; + } + + /** + * @swagger + * /api/v1/goals/{id}: + * put: + * parameters: + * - in: path + * name: id + * required: true + * tags: + * - Goals + * description: Update goal and return it + * security: + * - bearerAuth: [] + * requestBody: + * description: Goal data + * required: true + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/GoalRequestDto' + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/GoalResponseDto' + * 401: + * description: Failed operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + private async update( + options: ApiHandlerOptions<{ + body: GoalRequestDto; + params: { id: string }; + user: UserAuthResponseDto; + }>, + ): Promise { + const { id } = options.params; + const { id: userId } = options.user; + return { + type: ApiHandlerResponseType.DATA, + status: HttpCode.OK, + payload: await this.goalService.update( + { id, userId }, + { + ...options.body, + userId, + }, + ), + }; + } + + /** + * @swagger + * /api/v1/goals/{id}: + * delete: + * parameters: + * - in: path + * name: id + * required: true + * tags: + * - Goals + * description: Delete goal and return true + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: boolean + * 401: + * description: Failed operation + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + private async delete( + options: ApiHandlerOptions<{ + params: { id: string }; + user: UserAuthResponseDto; + }>, + ): Promise { + const { id } = options.params; + const { id: userId } = options.user; + return { + type: ApiHandlerResponseType.DATA, + status: HttpCode.OK, + payload: await this.goalService.delete({ id, userId }), + }; + } +} + +export { GoalController }; diff --git a/backend/src/bundles/goals/goal.entity.ts b/backend/src/bundles/goals/goal.entity.ts new file mode 100644 index 000000000..54b791997 --- /dev/null +++ b/backend/src/bundles/goals/goal.entity.ts @@ -0,0 +1,168 @@ +import { type ActivityType } from '~/common/enums/enums.js'; +import { type Entity, type ValueOf } from '~/common/types/types.js'; + +import { type FrequencyType } from './enums/enums.js'; + +class GoalEntity implements Entity { + private 'id': number | null; + + private 'userId': number; + + private 'activityType': ValueOf; + + private 'frequency': number; + + private 'frequencyType': ValueOf; + + private 'distance': number | null; + + private 'duration': number | null; + + private 'progress': number; + + private 'completedAt': string | null; + + private constructor({ + id, + userId, + activityType, + frequency, + frequencyType, + distance, + duration, + progress, + completedAt, + }: { + id: number | null; + userId: number; + activityType: ValueOf; + frequency: number; + frequencyType: ValueOf; + distance: number | null; + duration: number | null; + progress: number; + completedAt: string | null; + }) { + this.id = id; + this.userId = userId; + this.activityType = activityType; + this.frequency = frequency; + this.frequencyType = frequencyType; + this.distance = distance; + this.duration = duration; + this.progress = progress; + this.completedAt = completedAt; + } + + public static initialize({ + id, + userId, + activityType, + frequency, + frequencyType, + distance, + duration, + progress, + completedAt, + }: { + id: number; + userId: number; + activityType: ValueOf; + frequency: number; + frequencyType: ValueOf; + distance: number | null; + duration: number | null; + progress: number; + completedAt: string | null; + }): GoalEntity { + return new GoalEntity({ + id, + userId, + activityType, + frequency, + frequencyType, + distance, + duration, + progress, + completedAt, + }); + } + + public static initializeNew({ + userId, + activityType, + frequency, + frequencyType, + distance, + duration, + progress, + completedAt, + }: { + userId: number; + activityType: ValueOf; + frequency: number; + frequencyType: ValueOf; + distance: number | null; + duration: number | null; + progress?: number; + completedAt?: string | null; + }): GoalEntity { + return new GoalEntity({ + id: null, + userId, + activityType, + frequency, + frequencyType, + distance, + duration, + progress: progress ?? 0, + completedAt: completedAt ?? null, + }); + } + + public toObject(): { + id: number; + activityType: ValueOf; + frequency: number; + frequencyType: ValueOf; + distance: number | null; + duration: number | null; + progress: number; + completedAt: string | null; + } { + return { + id: this.id as number, + activityType: this.activityType, + frequency: this.frequency, + frequencyType: this.frequencyType, + distance: this.distance, + duration: this.duration, + progress: this.progress, + completedAt: this.completedAt, + }; + } + + public toNewObject(): { + userId: number; + activityType: ValueOf; + frequency: number; + frequencyType: ValueOf; + distance: number | null; + duration: number | null; + progress: number; + completedAt: string | null; + } { + return { + userId: this.userId, + activityType: this.activityType, + frequency: this.frequency, + frequencyType: this.frequencyType, + distance: this.distance, + duration: this.duration, + progress: this.progress, + completedAt: this.completedAt, + }; + } +} + +export { GoalEntity }; diff --git a/backend/src/bundles/goals/goal.model.ts b/backend/src/bundles/goals/goal.model.ts new file mode 100644 index 000000000..78dd285ba --- /dev/null +++ b/backend/src/bundles/goals/goal.model.ts @@ -0,0 +1,32 @@ +import { + AbstractModel, + DatabaseTableName, +} from '~/common/database/database.js'; +import { type ActivityType } from '~/common/enums/enums.js'; +import { type ValueOf } from '~/common/types/types.js'; + +import { type FrequencyType } from './enums/enums.js'; + +class GoalModel extends AbstractModel { + public 'userId': number; + + public 'activityType': ValueOf; + + public 'frequency': number; + + public 'frequencyType': ValueOf; + + public 'distance': number | null; + + public 'duration': number | null; + + public 'progress': number; + + public 'completedAt': string | null; + + public static override get tableName(): string { + return DatabaseTableName.GOALS; + } +} + +export { GoalModel }; diff --git a/backend/src/bundles/goals/goal.repository.ts b/backend/src/bundles/goals/goal.repository.ts new file mode 100644 index 000000000..e286cd7f6 --- /dev/null +++ b/backend/src/bundles/goals/goal.repository.ts @@ -0,0 +1,70 @@ +import { GoalEntity } from '~/bundles/goals/goal.entity.js'; +import { type GoalModel } from '~/bundles/goals/goal.model.js'; +import { type Repository } from '~/common/types/repository.type.js'; + +class GoalRepository implements Repository { + private goalModel: typeof GoalModel; + + public constructor(goalModel: typeof GoalModel) { + this.goalModel = goalModel; + } + + public async find( + query: Record, + ): Promise { + const goal = await this.goalModel.query().findOne(query).execute(); + + if (!goal) { + return null; + } + + return GoalEntity.initialize(goal); + } + + public async findAll( + query: Record, + ): Promise { + const goals = await this.goalModel.query().where(query).execute(); + + return goals.map((goal) => GoalEntity.initialize(goal)); + } + + public async create(entity: GoalEntity): Promise { + const goal = entity.toNewObject(); + const createdGoal = await this.goalModel.query().insert(goal).execute(); + return GoalEntity.initialize(createdGoal); + } + + public async update( + query: Record, + entity: GoalEntity, + ): Promise { + const goal = await this.goalModel.query().findOne(query).execute(); + + if (!goal) { + return null; + } + + const newGoal = entity.toNewObject(); + const [updatedGoal] = await this.goalModel + .query() + .where(query) + .update(newGoal) + .returning('*') + .execute(); + + if (!updatedGoal) { + return null; + } + + return GoalEntity.initialize(updatedGoal); + } + + public async delete(query: Record): Promise { + return (await this.goalModel.query().where(query).del().execute()) + ? true + : false; + } +} + +export { GoalRepository }; diff --git a/backend/src/bundles/goals/goal.service.ts b/backend/src/bundles/goals/goal.service.ts new file mode 100644 index 000000000..48e8dff28 --- /dev/null +++ b/backend/src/bundles/goals/goal.service.ts @@ -0,0 +1,59 @@ +import { type GoalRepository } from '~/bundles/goals/goal.repository.js'; +import { type Service } from '~/common/types/types.js'; + +import { GoalEntity } from './goal.entity.js'; +import { + type CreateGoalRequestDto, + type GoalResponseDto, + type UpdateGoalRequestDto, +} from './types/types.js'; + +class GoalService implements Service { + private goalRepository: GoalRepository; + + public constructor(goalRepository: GoalRepository) { + this.goalRepository = goalRepository; + } + + public async find( + query: Record, + ): Promise { + const item = await this.goalRepository.find(query); + return item ? item.toObject() : null; + } + + public async findAll( + query: Record, + ): Promise<{ items: GoalResponseDto[] }> { + const items = await this.goalRepository.findAll(query); + return { + items: items.map((it) => it.toObject()), + }; + } + + public async create( + payload: CreateGoalRequestDto, + ): Promise { + const item = await this.goalRepository.create( + GoalEntity.initializeNew(payload), + ); + return item.toObject() as GoalResponseDto; + } + + public async update( + query: Record, + payload: UpdateGoalRequestDto, + ): Promise { + const item = await this.goalRepository.update( + query, + GoalEntity.initializeNew(payload), + ); + return item ? (item.toObject() as GoalResponseDto) : null; + } + + public delete(query: Record): Promise { + return this.goalRepository.delete(query); + } +} + +export { GoalService }; diff --git a/backend/src/bundles/goals/goals.ts b/backend/src/bundles/goals/goals.ts new file mode 100644 index 000000000..6e3538c79 --- /dev/null +++ b/backend/src/bundles/goals/goals.ts @@ -0,0 +1,16 @@ +import { logger } from '~/common/logger/logger.js'; + +import { GoalController } from './goal.controller.js'; +import { GoalModel } from './goal.model.js'; +import { GoalRepository } from './goal.repository.js'; +import { GoalService } from './goal.service.js'; + +const goalRepository = new GoalRepository(GoalModel); +const goalService = new GoalService(goalRepository); +const goalController = new GoalController(logger, goalService); + +export { goalController, goalService }; +export { GoalEntity } from './goal.entity.js'; +export { GoalModel } from './goal.model.js'; +export { GoalService } from './goal.service.js'; +export { type GoalRequestDto, type GoalResponseDto } from './types/types.js'; diff --git a/backend/src/bundles/goals/types/create-goal-request-dto.ts b/backend/src/bundles/goals/types/create-goal-request-dto.ts new file mode 100644 index 000000000..22538e720 --- /dev/null +++ b/backend/src/bundles/goals/types/create-goal-request-dto.ts @@ -0,0 +1,7 @@ +import { type GoalRequestDto } from './types.js'; + +type CreateGoalRequestDto = GoalRequestDto & { + userId: number; +}; + +export { type CreateGoalRequestDto }; diff --git a/backend/src/bundles/goals/types/types.ts b/backend/src/bundles/goals/types/types.ts new file mode 100644 index 000000000..93fe0f75d --- /dev/null +++ b/backend/src/bundles/goals/types/types.ts @@ -0,0 +1,3 @@ +export { type CreateGoalRequestDto } from './create-goal-request-dto.js'; +export { type UpdateGoalRequestDto } from './update-goal-request-dto.js'; +export { type GoalRequestDto, type GoalResponseDto } from 'shared'; diff --git a/backend/src/bundles/goals/types/update-goal-request-dto.ts b/backend/src/bundles/goals/types/update-goal-request-dto.ts new file mode 100644 index 000000000..488de1d7f --- /dev/null +++ b/backend/src/bundles/goals/types/update-goal-request-dto.ts @@ -0,0 +1,9 @@ +import { type GoalRequestDto } from './types.js'; + +type UpdateGoalRequestDto = GoalRequestDto & { + userId: number; + progress?: number; + completedAt?: string; +}; + +export { type UpdateGoalRequestDto }; diff --git a/backend/src/bundles/goals/validation-schemas/validation-schemas.ts b/backend/src/bundles/goals/validation-schemas/validation-schemas.ts new file mode 100644 index 000000000..a70deaecf --- /dev/null +++ b/backend/src/bundles/goals/validation-schemas/validation-schemas.ts @@ -0,0 +1 @@ +export { goalValidationSchema } from 'shared'; diff --git a/backend/src/bundles/google-fit/constants/constants.ts b/backend/src/bundles/google-fit/constants/constants.ts new file mode 100644 index 000000000..f56c53e60 --- /dev/null +++ b/backend/src/bundles/google-fit/constants/constants.ts @@ -0,0 +1,6 @@ +export { GOOGLE_FIT_ACCESS_TYPE } from './google-fit-access-type.constant.js'; +export { GOOGLE_FIT_API_URL } from './google-fit-api-url.constant.js'; +export { + READ_SCOPE, + WRITE_SCOPE, +} from './google-fit-required-scopes.constant.js'; diff --git a/backend/src/bundles/google-fit/constants/google-fit-access-type.constant.ts b/backend/src/bundles/google-fit/constants/google-fit-access-type.constant.ts new file mode 100644 index 000000000..56fc4e764 --- /dev/null +++ b/backend/src/bundles/google-fit/constants/google-fit-access-type.constant.ts @@ -0,0 +1,3 @@ +const GOOGLE_FIT_ACCESS_TYPE = 'offline'; + +export { GOOGLE_FIT_ACCESS_TYPE }; diff --git a/backend/src/bundles/google-fit/constants/google-fit-api-url.constant.ts b/backend/src/bundles/google-fit/constants/google-fit-api-url.constant.ts new file mode 100644 index 000000000..8c0a2c955 --- /dev/null +++ b/backend/src/bundles/google-fit/constants/google-fit-api-url.constant.ts @@ -0,0 +1,3 @@ +const GOOGLE_FIT_API_URL = 'https://www.googleapis.com/auth/'; + +export { GOOGLE_FIT_API_URL }; diff --git a/backend/src/bundles/google-fit/constants/google-fit-required-scopes.constant.ts b/backend/src/bundles/google-fit/constants/google-fit-required-scopes.constant.ts new file mode 100644 index 000000000..24d5d8b9a --- /dev/null +++ b/backend/src/bundles/google-fit/constants/google-fit-required-scopes.constant.ts @@ -0,0 +1,4 @@ +const READ_SCOPE = 'fitness.activity.read'; +const WRITE_SCOPE = 'fitness.activity.write'; + +export { READ_SCOPE, WRITE_SCOPE }; diff --git a/backend/src/bundles/google-fit/enums/enums.ts b/backend/src/bundles/google-fit/enums/enums.ts new file mode 100644 index 000000000..d88126a4b --- /dev/null +++ b/backend/src/bundles/google-fit/enums/enums.ts @@ -0,0 +1 @@ +export { ApiPath } from 'shared'; diff --git a/backend/src/bundles/google-fit/google-fit-oauth-strategy.ts b/backend/src/bundles/google-fit/google-fit-oauth-strategy.ts new file mode 100644 index 000000000..c68edbb77 --- /dev/null +++ b/backend/src/bundles/google-fit/google-fit-oauth-strategy.ts @@ -0,0 +1,132 @@ +import { google } from 'googleapis'; + +import { + GOOGLE_FIT_ACCESS_TYPE, + GOOGLE_FIT_API_URL, + READ_SCOPE, + WRITE_SCOPE, +} from '~/bundles/google-fit/constants/constants.js'; +import { + type OAuthExchangeAuthCodeDto, + type OAuthStateEntity, + type OAuthStrategy, + ErrorMessage, + HttpCode, + HttpError, + OAuthEntity, +} from '~/bundles/oauth/oauth.js'; +import { OAuthActionsPath, OAuthProvider } from '~/bundles/oauth/oauth.js'; +import { type Config } from '~/common/config/config.js'; + +import { ApiPath } from './enums/enums.js'; + +class GoogleFitOAuthStrategy implements OAuthStrategy { + private config: Config; + private OAuth2; + + public constructor(config: Config) { + this.config = config; + this.OAuth2 = new google.auth.OAuth2( + this.config.ENV.GOOGLE_FIT.CLIENT_ID, + this.config.ENV.GOOGLE_FIT.CLIENT_SECRET, + `${this.config.ENV.APP.API_BASE_URL}${ApiPath.OAUTH}/${OAuthProvider.GOOGLE_FIT}${OAuthActionsPath.EXCHANGE_TOKEN}`, + ); + } + + public getAuthorizeRedirectUrl(oAuthStateEntity: OAuthStateEntity): URL { + const { userId, uuid } = oAuthStateEntity.toObject(); + const url = this.OAuth2.generateAuthUrl({ + access_type: GOOGLE_FIT_ACCESS_TYPE, + scope: [ + `${GOOGLE_FIT_API_URL}${READ_SCOPE}`, + `${GOOGLE_FIT_API_URL}${WRITE_SCOPE}`, + ], + state: JSON.stringify({ userId, uuid }), + }); + return new URL(url); + } + + public async exchangeAuthCode( + payload: OAuthExchangeAuthCodeDto, + ): Promise { + const { code, scope, userId } = payload; + const { + res, + tokens: { access_token, refresh_token, token_type, expiry_date }, + } = await this.OAuth2.getToken(code); + + if (res?.status !== HttpCode.OK) { + throw new HttpError({ + message: ErrorMessage.INVALID_PARAMS, + status: HttpCode.BAD_REQUEST, + }); + } + + return OAuthEntity.initializeNew({ + userId, + tokenType: token_type as string, + scope, + provider: OAuthProvider.GOOGLE_FIT, + accessToken: access_token as string, + expiresAt: expiry_date as number, + refreshToken: refresh_token as string, + }); + } + + public checkScope(scope: string | null): boolean { + if (!scope) { + return false; + } + return scope.includes(WRITE_SCOPE && READ_SCOPE); + } + + public async exchangeRefreshToken( + oAuthEntity: OAuthEntity, + ): Promise { + const { userId, refreshToken, scope } = oAuthEntity.toObject(); + this.OAuth2.setCredentials({ + refresh_token: refreshToken, + }); + + const { + res, + credentials: { + refresh_token, + token_type, + expiry_date, + access_token, + }, + } = await this.OAuth2.refreshAccessToken(); + + if (res?.status !== HttpCode.OK) { + throw new HttpError({ + status: HttpCode.FORBIDDEN, + message: ErrorMessage.UNVERIFIED, + }); + } + + return OAuthEntity.initializeNew({ + provider: OAuthProvider.GOOGLE_FIT, + expiresAt: expiry_date as number, + accessToken: access_token as string, + tokenType: token_type as string, + refreshToken: refresh_token as string, + scope, + userId, + }); + } + + public async deauthorize(oAuthEntity: OAuthEntity): Promise { + const { refreshToken } = oAuthEntity.toObject(); + const { status } = await this.OAuth2.revokeToken(refreshToken); + + if (status !== HttpCode.OK) { + throw new HttpError({ + status: HttpCode.FORBIDDEN, + message: ErrorMessage.UNVERIFIED, + }); + } + } +} + +export { GoogleFitOAuthStrategy }; diff --git a/backend/src/bundles/google-fit/google-fit.ts b/backend/src/bundles/google-fit/google-fit.ts new file mode 100644 index 000000000..48c40dcaf --- /dev/null +++ b/backend/src/bundles/google-fit/google-fit.ts @@ -0,0 +1,7 @@ +import { config } from '~/common/config/config.js'; + +import { GoogleFitOAuthStrategy } from './google-fit-oauth-strategy.js'; + +const googleFitOAuthStrategy = new GoogleFitOAuthStrategy(config); + +export { googleFitOAuthStrategy }; diff --git a/backend/src/bundles/oauth/oauth.controller.ts b/backend/src/bundles/oauth/oauth.controller.ts index 6fbfd9567..c8007ca8d 100644 --- a/backend/src/bundles/oauth/oauth.controller.ts +++ b/backend/src/bundles/oauth/oauth.controller.ts @@ -39,8 +39,6 @@ class OAuthController extends BaseController { private config: Config; - private baseUrl: string; - public constructor( logger: Logger, oAuthService: OAuthService, @@ -50,7 +48,6 @@ class OAuthController extends BaseController { this.oAuthService = oAuthService; this.config = config; - this.baseUrl = `http://${this.config.ENV.APP.HOST}:${this.config.ENV.APP.PORT}/api/v1`; this.addRoute({ path: OAuthActionsPath.$PROVIDER_AUTHORIZE, @@ -152,13 +149,20 @@ class OAuthController extends BaseController { params: OAuthProviderParameterDto; }>, ): Promise { + const query = options.query; + const isStateJSON = /{.*}/.test(query.state); const { provider } = options.params; - await this.oAuthService.exchangeAuthCode(provider, options.query); + const data = isStateJSON ? JSON.parse(query.state) : query; + const payload = { ...query, userId: data.userId, state: data.uuid }; + await this.oAuthService.exchangeAuthCode( + provider, + isStateJSON ? payload : query, + ); return { type: ApiHandlerResponseType.REDIRECT, status: HttpCode.FOUND, - redirectUrl: `${this.baseUrl}${ApiPath.CONNECTIONS}${ConnectionsPath.ROOT}`, + redirectUrl: `${this.config.ENV.APP.API_BASE_URL}${ApiPath.CONNECTIONS}${ConnectionsPath.ROOT}`, }; } @@ -202,7 +206,7 @@ class OAuthController extends BaseController { return { type: ApiHandlerResponseType.REDIRECT, status: HttpCode.FOUND, - redirectUrl: `${this.baseUrl}${ApiPath.CONNECTIONS}${ConnectionsPath.ROOT}`, + redirectUrl: `${this.config.ENV.APP.API_BASE_URL}${ApiPath.CONNECTIONS}${ConnectionsPath.ROOT}`, }; } } diff --git a/backend/src/bundles/oauth/oauth.service.ts b/backend/src/bundles/oauth/oauth.service.ts index 236881832..49423cd3b 100644 --- a/backend/src/bundles/oauth/oauth.service.ts +++ b/backend/src/bundles/oauth/oauth.service.ts @@ -169,8 +169,10 @@ class OAuthService { provider: ValueOf, userId: number, ): Promise { - const oAuthEntity = await this.oAuthRepository.find({ userId }); - + const oAuthEntity = await this.oAuthRepository.find({ + userId, + provider, + }); if (!oAuthEntity) { throw new HttpError({ status: HttpCode.BAD_REQUEST, @@ -179,10 +181,9 @@ class OAuthService { } const strategy = this.getStrategy(provider); - await strategy.deauthorize(oAuthEntity); - await this.oAuthRepository.delete({ userId }); + await this.oAuthRepository.delete({ userId, provider }); } } diff --git a/backend/src/bundles/oauth/oauth.ts b/backend/src/bundles/oauth/oauth.ts index 9d3b23501..f1c00133c 100644 --- a/backend/src/bundles/oauth/oauth.ts +++ b/backend/src/bundles/oauth/oauth.ts @@ -1,3 +1,4 @@ +import { googleFitOAuthStrategy } from '~/bundles/google-fit/google-fit.js'; import { stravaOAuthStrategy } from '~/bundles/strava/strava.js'; import { config } from '~/common/config/config.js'; import { logger } from '~/common/logger/logger.js'; @@ -12,8 +13,7 @@ import { OAuthStateRepository } from './oauth-state.repository.js'; const oAuthStrategies = { [OAuthProvider.STRAVA]: stravaOAuthStrategy, - // Replace with GoogleFit strategy - [OAuthProvider.GOOGLE_FIT]: stravaOAuthStrategy, + [OAuthProvider.GOOGLE_FIT]: googleFitOAuthStrategy, }; const oAuthStateRepository = new OAuthStateRepository(OAuthStateModel); diff --git a/backend/src/bundles/password-reset/password-reset.controller.ts b/backend/src/bundles/password-reset/password-reset.controller.ts index 4c0b2921b..678d0893e 100644 --- a/backend/src/bundles/password-reset/password-reset.controller.ts +++ b/backend/src/bundles/password-reset/password-reset.controller.ts @@ -44,7 +44,7 @@ class PasswordResetController extends BaseController { }); this.addRoute({ path: PasswordResetApiPath.RESET_PASSWORD, - method: 'POST', + method: 'PUT', validation: { body: passwordResetValidationSchema, }, diff --git a/backend/src/bundles/password-reset/password-reset.service.ts b/backend/src/bundles/password-reset/password-reset.service.ts index 7b1336dee..a0136cecc 100644 --- a/backend/src/bundles/password-reset/password-reset.service.ts +++ b/backend/src/bundles/password-reset/password-reset.service.ts @@ -59,9 +59,9 @@ class PasswordResetService { public async resetPassword( passwordResetRequestDto: PasswordResetRequestDto, ): Promise { - const user = (await this.userService.find({ + const user = await this.userService.find({ id: passwordResetRequestDto.id, - })) as unknown as UserModel; + }); if (!user) { throw new HttpError({ @@ -73,7 +73,7 @@ class PasswordResetService { try { await jwtService.verifyToken( passwordResetRequestDto.token, - user.passwordHash, + user.getPasswordHash(), ); } catch { throw new HttpError({ @@ -94,7 +94,7 @@ class PasswordResetService { const existPassword = cryptService.compareSyncPassword( passwordResetRequestDto.password, - user.passwordHash, + user.getPasswordHash(), ); if (existPassword) { @@ -110,14 +110,14 @@ class PasswordResetService { try { await this.userService.update( - { userId: passwordResetRequestDto.id }, + { id: passwordResetRequestDto.id }, { passwordHash: hash, }, ); } catch { throw new HttpError({ - message: PasswordResetValidationMessage.USER_NOT_FOUND, + message: PasswordResetValidationMessage.PASSWORD_NOT_CHANGED, status: HttpCode.INTERNAL_SERVER_ERROR, }); } diff --git a/backend/src/bundles/strava/constants/constants.ts b/backend/src/bundles/strava/constants/constants.ts index 6e629371d..6f80fdd57 100644 --- a/backend/src/bundles/strava/constants/constants.ts +++ b/backend/src/bundles/strava/constants/constants.ts @@ -1,3 +1,3 @@ -export { STRAVA_API_URL } from './strava-api-url.constants.js'; -export { REQUIRED_SCOPE } from './strava-required-scope.js'; -export { STRAVA_URL } from './strava-url.constants.js'; +export { STRAVA_API_URL } from './strava-api-url.constant.js'; +export { REQUIRED_SCOPE } from './strava-required-scope.constant.js'; +export { STRAVA_URL } from './strava-url.constant.js'; diff --git a/backend/src/bundles/strava/constants/strava-api-url.constants.ts b/backend/src/bundles/strava/constants/strava-api-url.constant.ts similarity index 57% rename from backend/src/bundles/strava/constants/strava-api-url.constants.ts rename to backend/src/bundles/strava/constants/strava-api-url.constant.ts index 01f62f687..8a51c5220 100644 --- a/backend/src/bundles/strava/constants/strava-api-url.constants.ts +++ b/backend/src/bundles/strava/constants/strava-api-url.constant.ts @@ -1,4 +1,4 @@ -import { STRAVA_URL } from './strava-url.constants.js'; +import { STRAVA_URL } from './strava-url.constant.js'; const STRAVA_API_URL = `${STRAVA_URL}/api/v3`; diff --git a/backend/src/bundles/strava/constants/strava-required-scope.ts b/backend/src/bundles/strava/constants/strava-required-scope.constant.ts similarity index 100% rename from backend/src/bundles/strava/constants/strava-required-scope.ts rename to backend/src/bundles/strava/constants/strava-required-scope.constant.ts diff --git a/backend/src/bundles/strava/constants/strava-url.constants.ts b/backend/src/bundles/strava/constants/strava-url.constant.ts similarity index 100% rename from backend/src/bundles/strava/constants/strava-url.constants.ts rename to backend/src/bundles/strava/constants/strava-url.constant.ts diff --git a/backend/src/bundles/strava/strava-oauth-strategy.ts b/backend/src/bundles/strava/strava-oauth-strategy.ts index 48079f0e9..424e9d4d4 100644 --- a/backend/src/bundles/strava/strava-oauth-strategy.ts +++ b/backend/src/bundles/strava/strava-oauth-strategy.ts @@ -11,29 +11,22 @@ import { } from '~/bundles/oauth/oauth.js'; import { type Config } from '~/common/config/config.js'; -import { REQUIRED_SCOPE } from './constants/strava-required-scope.js'; +import { REQUIRED_SCOPE } from './constants/strava-required-scope.constant.js'; import { ApiPath, StravaPath } from './enums/enums.js'; import { type StravaOAuthResponseDto } from './types/types.js'; class StravaOAuthStrategy implements OAuthStrategy { private config: Config; - private baseUrl: string; - - private apiPath: string; - public constructor(config: Config) { this.config = config; - this.baseUrl = `http://${config.ENV.APP.HOST}:${config.ENV.APP.PORT}`; - this.apiPath = '/api/v1'; } public getAuthorizeRedirectUrl(oAuthStateEntity: OAuthStateEntity): URL { const { userId, uuid } = oAuthStateEntity.toObject(); const redirectUri = new URL( - `${this.apiPath}${ApiPath.OAUTH}/${OAuthProvider.STRAVA}${OAuthActionsPath.EXCHANGE_TOKEN}`, - this.baseUrl, + `${this.config.ENV.APP.API_BASE_URL}${ApiPath.OAUTH}/${OAuthProvider.STRAVA}${OAuthActionsPath.EXCHANGE_TOKEN}`, ); redirectUri.searchParams.set('userId', userId.toString()); diff --git a/backend/src/common/config/base-config.package.ts b/backend/src/common/config/base-config.package.ts index 5dc5e9d5d..6c3888c3e 100644 --- a/backend/src/common/config/base-config.package.ts +++ b/backend/src/common/config/base-config.package.ts @@ -53,6 +53,12 @@ class BaseConfig implements Config { env: 'JWT_SECRET', default: null, }, + TOKEN_EXPIRATION_TIME: { + doc: 'Token expiration time', + format: String, + env: 'TOKEN_EXPIRATION_TIME', + default: null, + }, OPEN_AI_API_KEY: { doc: 'Api key for working with AI', format: String, @@ -65,6 +71,12 @@ class BaseConfig implements Config { env: 'OPEN_AI_MODEL', default: null, }, + API_BASE_URL: { + doc: 'Base api url for our app', + format: String, + env: 'API_BASE_URL', + default: null, + }, }, AWS: { S3_ACCESS_KEY: { @@ -184,6 +196,20 @@ class BaseConfig implements Config { default: null, }, }, + GOOGLE_FIT: { + CLIENT_ID: { + doc: 'Google fit Client ID', + format: String, + env: 'GOOGLE_FIT_CLIENT_ID', + default: null, + }, + CLIENT_SECRET: { + doc: 'Google fit Client Secret', + format: String, + env: 'GOOGLE_FIT_CLIENT_SECRET', + default: null, + }, + }, }); } } diff --git a/backend/src/common/config/types/environment-schema.type.ts b/backend/src/common/config/types/environment-schema.type.ts index 93f8632ae..9abfd01c3 100644 --- a/backend/src/common/config/types/environment-schema.type.ts +++ b/backend/src/common/config/types/environment-schema.type.ts @@ -9,6 +9,8 @@ type EnvironmentSchema = { JWT_SECRET: string; OPEN_AI_API_KEY: string; OPEN_AI_MODEL: string; + TOKEN_EXPIRATION_TIME: string; + API_BASE_URL: string; }; AWS: { S3_ACCESS_KEY: string; @@ -38,6 +40,10 @@ type EnvironmentSchema = { CLIENT_ID: string; CLIENT_SECRET: string; }; + GOOGLE_FIT: { + CLIENT_ID: string; + CLIENT_SECRET: string; + }; }; export { type EnvironmentSchema }; diff --git a/backend/src/common/database/enums/database-table-name.enum.ts b/backend/src/common/database/enums/database-table-name.enum.ts index 3ff52c814..7c84b8dce 100644 --- a/backend/src/common/database/enums/database-table-name.enum.ts +++ b/backend/src/common/database/enums/database-table-name.enum.ts @@ -2,6 +2,7 @@ enum DatabaseTableName { MIGRATIONS = 'migrations', USERS = 'users', USER_DETAILS = 'user_details', + GOALS = 'goals', SUBSCRIPTION_PLANS = 'subscription_plans', SUBSCRIPTIONS = 'subscriptions', OAUTH_INFO = 'oauth_info', diff --git a/backend/src/common/enums/enums.ts b/backend/src/common/enums/enums.ts index d92d5d70c..ff5c6255c 100644 --- a/backend/src/common/enums/enums.ts +++ b/backend/src/common/enums/enums.ts @@ -1,2 +1,2 @@ export { PluginName } from './plugin-name.enum.js'; -export { ApiPath, AppEnvironment, ServerErrorType } from 'shared'; +export { ActivityType, ApiPath, AppEnvironment, ServerErrorType } from 'shared'; diff --git a/backend/src/common/http/enums/enums.ts b/backend/src/common/http/enums/enums.ts index 2e94a326e..9fbeb4f06 100644 --- a/backend/src/common/http/enums/enums.ts +++ b/backend/src/common/http/enums/enums.ts @@ -1 +1 @@ -export { HttpCode } from 'shared'; +export { HttpCode, HttpHeader } from 'shared'; diff --git a/backend/src/common/http/http.ts b/backend/src/common/http/http.ts index cc03c9476..0c27b92f0 100644 --- a/backend/src/common/http/http.ts +++ b/backend/src/common/http/http.ts @@ -1,3 +1,3 @@ -export { HttpCode } from './enums/enums.js'; +export { HttpCode, HttpHeader } from './enums/enums.js'; export { HttpError } from './exceptions/exceptions.js'; export { type HttpMethod } from './types/types.js'; diff --git a/backend/src/common/plugins/verify-stripe-webhook-plugin.ts b/backend/src/common/plugins/verify-stripe-webhook-plugin.ts index ef27c1921..b6efe7bfc 100644 --- a/backend/src/common/plugins/verify-stripe-webhook-plugin.ts +++ b/backend/src/common/plugins/verify-stripe-webhook-plugin.ts @@ -1,10 +1,9 @@ import { type FastifyRequest } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; -import { HttpCode, HttpHeader } from 'shared/src/framework/http/http.js'; import { type Stripe } from '~/bundles/subscriptions/types/types.js'; -import { HttpError } from '../http/http.js'; +import { HttpCode, HttpError, HttpHeader } from '../http/http.js'; import { type StripeService } from '../services/stripe/stripe.service.js'; type Options = { diff --git a/backend/src/common/server-application/base-server-app-api.ts b/backend/src/common/server-application/base-server-app-api.ts index a6b506dff..2cca52026 100644 --- a/backend/src/common/server-application/base-server-app-api.ts +++ b/backend/src/common/server-application/base-server-app-api.ts @@ -42,7 +42,7 @@ class BaseServerAppApi implements ServerAppApi { definition: { openapi: '3.0.0', info: { - title: 'Hello World', + title: 'LIME API Documentation', version: `${this.version}.0.0`, }, components: { @@ -50,7 +50,7 @@ class BaseServerAppApi implements ServerAppApi { bearerAuth: { bearerFormat: 'JWT', scheme: 'bearer', - type: 'http', + type: 'https', }, }, }, diff --git a/backend/src/common/server-application/base-server-app.ts b/backend/src/common/server-application/base-server-app.ts index f58744cdd..c3d7ba29d 100644 --- a/backend/src/common/server-application/base-server-app.ts +++ b/backend/src/common/server-application/base-server-app.ts @@ -152,7 +152,10 @@ class BaseServerApp implements ServerApp { return >( data: T, ): R => { - return schema.parse(data) as R; + const result = schema.parse(data); + return { + value: result, + } as R; }; }); } diff --git a/backend/src/common/server-application/server-application.ts b/backend/src/common/server-application/server-application.ts index a4d6a4b9e..5a5c1db48 100644 --- a/backend/src/common/server-application/server-application.ts +++ b/backend/src/common/server-application/server-application.ts @@ -1,5 +1,6 @@ import { authController } from '~/bundles/auth/auth.js'; import { connectionController } from '~/bundles/connections/connections.js'; +import { goalController } from '~/bundles/goals/goals.js'; import { oAuthController } from '~/bundles/oauth/oauth.js'; import { passwordResetController } from '~/bundles/password-reset/password-reset.js'; import { subscriptionPlanController } from '~/bundles/subscription-plans/subscription-plan.js'; @@ -17,6 +18,7 @@ const apiV1 = new BaseServerAppApi( config, ...authController.routes, ...userController.routes, + ...goalController.routes, ...subscriptionController.routes, ...subscriptionPlanController.routes, ...connectionController.routes, diff --git a/backend/src/common/services/jwt/jwt.service.ts b/backend/src/common/services/jwt/jwt.service.ts index 967c7eaf2..55627e955 100644 --- a/backend/src/common/services/jwt/jwt.service.ts +++ b/backend/src/common/services/jwt/jwt.service.ts @@ -5,14 +5,16 @@ import { type JwtPayloadOptions } from '~/common/types/jwt.type.js'; class JwtService { private readonly secretKey: string; + private readonly tokenExpirationTime: string; - public constructor(secretKey: string) { + public constructor(secretKey: string, tokenExpirationTime: string) { this.secretKey = secretKey; + this.tokenExpirationTime = tokenExpirationTime; } public async createToken( payload: JwtPayloadOptions, - time: string = '7d', + time: string = this.tokenExpirationTime, additional: string = '', ): Promise { return await new SignJWT(payload) diff --git a/backend/src/common/services/services.ts b/backend/src/common/services/services.ts index 6e6243c2f..ed0498746 100644 --- a/backend/src/common/services/services.ts +++ b/backend/src/common/services/services.ts @@ -8,12 +8,13 @@ import { OpenAIService } from './open-ai/open-ai.service.js'; import { StripeService } from './stripe/stripe.service.js'; const { API_KEY, FROM } = config.ENV.EMAIL; -const { JWT_SECRET, OPEN_AI_API_KEY, OPEN_AI_MODEL } = config.ENV.APP; +const { JWT_SECRET, OPEN_AI_API_KEY, OPEN_AI_MODEL, TOKEN_EXPIRATION_TIME } = + config.ENV.APP; const { S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET_NAME } = config.ENV.AWS; const cryptService = new CryptService(); -const jwtService = new JwtService(JWT_SECRET); +const jwtService = new JwtService(JWT_SECRET, TOKEN_EXPIRATION_TIME); const emailService = new EmailService(API_KEY, FROM); const stripeService = new StripeService( config.ENV.STRIPE.SECRET_KEY, diff --git a/backend/src/common/types/repository.type.ts b/backend/src/common/types/repository.type.ts index 4191bd66f..c18403791 100644 --- a/backend/src/common/types/repository.type.ts +++ b/backend/src/common/types/repository.type.ts @@ -1,11 +1,8 @@ type Repository = { find(query: Record): Promise; - findAll(): Promise; + findAll(query: Record): Promise; create(payload: unknown): Promise; - update( - query: Record, - payload: Record, - ): Promise; + update(query: Record, payload: unknown): Promise; delete(payload: unknown): Promise; }; diff --git a/backend/src/common/types/service.type.ts b/backend/src/common/types/service.type.ts index 8e0c478cd..bf62cbe08 100644 --- a/backend/src/common/types/service.type.ts +++ b/backend/src/common/types/service.type.ts @@ -1,6 +1,6 @@ type Service = { find(query: Record): Promise; - findAll(): Promise<{ + findAll(query: Record): Promise<{ items: T[]; }>; create(payload: unknown): Promise; diff --git a/backend/src/migrations/20240222115923_add_goals_table.ts b/backend/src/migrations/20240222115923_add_goals_table.ts new file mode 100644 index 000000000..a8f755e6a --- /dev/null +++ b/backend/src/migrations/20240222115923_add_goals_table.ts @@ -0,0 +1,86 @@ +import { type Knex } from 'knex'; + +const TABLE_NAME = 'goals'; +const USERS_TABLE_NAME = 'users'; + +const ColumnName = { + ID: 'id', + USER_ID: 'user_id', + ACTIVITY_TYPE: 'activity_type', + FREQUENCY: 'frequency', + FREQUENCY_TYPE: 'frequency_type', + DISTANCE: 'distance', + DURATION: 'duration', + PROGRESS: 'progress', + COMPLETED_AT: 'completed_at', + CREATED_AT: 'created_at', + UPDATED_AT: 'updated_at', +}; + +const ACTIVITY_TYPE_ENUM = `${ColumnName.ACTIVITY_TYPE}_enum`; + +const FREQUENCY_TYPE_ENUM = `${ColumnName.FREQUENCY_TYPE}_enum`; + +const ActivityType = { + CYCLING: 'cycling', + RUNNING: 'running', + WALKING: 'walking', +}; + +const FrequencyType = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', +}; + +async function up(knex: Knex): Promise { + await knex.schema.createTable(TABLE_NAME, (table) => { + table.increments(ColumnName.ID).primary(); + table + .integer(ColumnName.USER_ID) + .unsigned() + .notNullable() + .references(ColumnName.ID) + .inTable(USERS_TABLE_NAME) + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table + .enum(ColumnName.ACTIVITY_TYPE, Object.values(ActivityType), { + useNative: true, + enumName: ACTIVITY_TYPE_ENUM, + existingType: true, + }) + .notNullable(); + table.integer(ColumnName.FREQUENCY).unsigned().notNullable(); + table + .enum(ColumnName.FREQUENCY_TYPE, Object.values(FrequencyType), { + useNative: true, + enumName: FREQUENCY_TYPE_ENUM, + }) + .notNullable(); + table.float(ColumnName.DISTANCE).unsigned().nullable(); + table.integer(ColumnName.DURATION).unsigned().nullable(); + table + .float(ColumnName.PROGRESS) + .unsigned() + .notNullable() + .checkBetween([0, 100]); + table.dateTime(ColumnName.COMPLETED_AT).nullable(); + table + .dateTime(ColumnName.CREATED_AT) + .notNullable() + .defaultTo(knex.fn.now()); + table + .dateTime(ColumnName.UPDATED_AT) + .notNullable() + .defaultTo(knex.fn.now()); + }); +} + +async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TABLE_NAME); + await knex.schema.raw(`DROP TYPE IF EXISTS ${ACTIVITY_TYPE_ENUM};`); + await knex.schema.raw(`DROP TYPE IF EXISTS ${FREQUENCY_TYPE_ENUM};`); +} + +export { down, up }; diff --git a/frontend/package.json b/frontend/package.json index 693f432e4..fad3dc839 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,11 +34,13 @@ "@heroicons/react": "2.1.1", "@hookform/resolvers": "3.3.4", "@reduxjs/toolkit": "2.1.0", + "chart.js": "4.4.1", "@stripe/react-stripe-js": "2.5.0", "@stripe/stripe-js": "3.0.4", "clsx": "2.1.0", "jwt-decode": "4.0.0", "react": "18.2.0", + "react-chartjs-2": "5.2.0", "react-dom": "18.2.0", "react-hook-form": "7.50.1", "react-multi-date-picker": "4.4.1", diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 2cf24d86d..21d344646 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,6 +1,5 @@ import { actions as appActions } from '~/app/store/app.js'; import { actions as authActions } from '~/bundles/auth/store/auth.js'; -import { BaseLayout } from '~/bundles/common/components/base-layout/base-layout.js'; import { Loader, RouterOutlet, @@ -12,6 +11,7 @@ import { useNavigate, useState, } from '~/bundles/common/hooks/hooks.js'; +import { storage, StorageKey } from '~/framework/storage/storage.js'; const App: React.FC = () => { const [isRefreshing, setIsRefreshing] = useState(true); @@ -31,25 +31,21 @@ const App: React.FC = () => { useEffect(() => { const refreshUser = async (): Promise => { - try { + const token = await storage.get(StorageKey.TOKEN); + + if (token) { await dispatch(authActions.refreshUser()); - } finally { - setIsRefreshing(false); } }; - void refreshUser(); + void refreshUser().finally(() => setIsRefreshing(false)); }, [dispatch]); if (isRefreshing) { return ; } - return ( - - - - ); + return ; }; export { App }; diff --git a/frontend/src/assets/css/base-styles.css b/frontend/src/assets/css/base-styles.css new file mode 100644 index 000000000..627581617 --- /dev/null +++ b/frontend/src/assets/css/base-styles.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .h1 { + @apply text-4xl leading-9; + } + + .h2 { + @apply text-3xl leading-8; + } + + .h3 { + @apply text-2xl leading-7; + } + + .h4 { + @apply text-xl leading-6; + } + + .h5 { + @apply text-base leading-5; + } + + .body { + @apply text-sm leading-4; + } + + .body-sm { + @apply text-xs leading-3; + } +} + +body { + background-color: var(--background-primary); +} diff --git a/frontend/src/assets/css/styles.css b/frontend/src/assets/css/styles.css index ac608fc4d..6c223f26c 100644 --- a/frontend/src/assets/css/styles.css +++ b/frontend/src/assets/css/styles.css @@ -1,71 +1,4 @@ -@import url("./date-picker.css"); @import url("https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap"); - -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer components { - .h1 { - @apply text-4xl leading-9; - } - - .h2 { - @apply text-3xl leading-8; - } - - .h3 { - @apply text-2xl leading-7; - } - - .h4 { - @apply text-xl leading-6; - } - - .h5 { - @apply text-base leading-5; - } - - .body { - @apply text-sm leading-4; - } - - .body-sm { - @apply text-xs leading-3; - } -} - -:root, -.light { - /* BUTTON */ - --button-text: rgb(255 255 255); /* White */ - --button-primary: rgb(178 202 13); /* #B2CA0D */ - --button-secondary: rgb(148 168 7); /* #94A807 */ - --button-tertiary: rgb(178 192 211); /* #B2C0D3 */ - - /* TEXT */ - --text-primary: rgb(0 0 0); /* Black */ - --text-secondary: rgb(49 49 52); /* lm-grey-400 */ - --text-action: rgb(178 202 13); /* lm-yellow-200 */ - - /* BACKGROUND */ - --background-primary: rgb(255 255 255); /* White */ - --background-secondary: rgb(243 243 243); /* #F3F3F3 */ -} - -.dark { - /* BUTTON */ - --button-text: rgb(49 49 52); /* lm-black-300 */ - --button-primary: rgb(224 254 16); /* lm-yellow-100 */ - --button-secondary: rgb(178 202 13); /* lm-yellow-200 */ - --button-tertiary: rgb(71 85 105); /* lm-grey-300 */ - - /* TEXT */ - --text-primary: rgb(255 255 255); /* White */ - --text-secondary: rgb(121 131 146); /* lm-grey-200 */ - --text-action: rgb(224 254 16); /* lm-yellow-100 */ - - /* BACKGROUND */ - --background-primary: rgb(28 34 39); /* lm-black-200 */ - --background-secondary: rgb(42 47 55); /* lm-black-100 */ -} +@import url("./base-styles.css"); +@import url("./theme.css"); +@import url("./date-picker.css"); diff --git a/frontend/src/assets/css/theme.css b/frontend/src/assets/css/theme.css new file mode 100644 index 000000000..931d2b40e --- /dev/null +++ b/frontend/src/assets/css/theme.css @@ -0,0 +1,34 @@ +:root, +.light { + /* BUTTON */ + --button-text: rgb(255 255 255); /* White */ + --button-primary: rgb(178 202 13); /* #B2CA0D */ + --button-secondary: rgb(148 168 7); /* #94A807 */ + --button-tertiary: rgb(178 192 211); /* #B2C0D3 */ + + /* TEXT */ + --text-primary: rgb(0 0 0); /* Black */ + --text-secondary: rgb(49 49 52); /* lm-grey-400 */ + --text-action: rgb(178 202 13); /* lm-yellow-200 */ + + /* BACKGROUND */ + --background-primary: rgb(255 255 255); /* White */ + --background-secondary: rgb(243 243 243); /* #F3F3F3 */ +} + +.dark { + /* BUTTON */ + --button-text: rgb(49 49 52); /* lm-black-300 */ + --button-primary: rgb(224 254 16); /* lm-yellow-100 */ + --button-secondary: rgb(178 202 13); /* lm-yellow-200 */ + --button-tertiary: rgb(71 85 105); /* lm-grey-300 */ + + /* TEXT */ + --text-primary: rgb(255 255 255); /* White */ + --text-secondary: rgb(121 131 146); /* lm-grey-200 */ + --text-action: rgb(224 254 16); /* lm-yellow-100 */ + + /* BACKGROUND */ + --background-primary: rgb(28 34 39); /* lm-black-200 */ + --background-secondary: rgb(42 47 55); /* lm-black-100 */ +} diff --git a/frontend/src/assets/img/icons/calories-icon.svg b/frontend/src/assets/img/icons/calories-icon.svg new file mode 100644 index 000000000..c9c6b2779 --- /dev/null +++ b/frontend/src/assets/img/icons/calories-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/img/icons/steps-icon.svg b/frontend/src/assets/img/icons/steps-icon.svg new file mode 100644 index 000000000..b7969a7ae --- /dev/null +++ b/frontend/src/assets/img/icons/steps-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/img/icons/workout-icon.svg b/frontend/src/assets/img/icons/workout-icon.svg index 4ed46bedd..4534e5e52 100644 --- a/frontend/src/assets/img/icons/workout-icon.svg +++ b/frontend/src/assets/img/icons/workout-icon.svg @@ -1,13 +1,3 @@ - - - \ No newline at end of file + + + diff --git a/frontend/src/bundles/auth/auth-api.ts b/frontend/src/bundles/auth/auth-api.ts index 219f1cbc0..949af824e 100644 --- a/frontend/src/bundles/auth/auth-api.ts +++ b/frontend/src/bundles/auth/auth-api.ts @@ -44,6 +44,14 @@ class AuthApi extends BaseHttpApi { return await response.json(); } + + public async logout(): Promise { + await this.load(this.getFullEndpoint(AuthApiPath.LOGOUT, {}), { + method: 'GET', + hasAuth: true, + contentType: ContentType.JSON, + }); + } } export { AuthApi }; diff --git a/frontend/src/bundles/auth/components/password-forgot-succsess-message/password-forgot-succsess-message.tsx b/frontend/src/bundles/auth/components/password-forgot-succsess-message/password-forgot-succsess-message.tsx index 404d4a971..d3492f3d5 100644 --- a/frontend/src/bundles/auth/components/password-forgot-succsess-message/password-forgot-succsess-message.tsx +++ b/frontend/src/bundles/auth/components/password-forgot-succsess-message/password-forgot-succsess-message.tsx @@ -2,13 +2,15 @@ import SuccessIcon from '~/assets/img/success-icon.svg?react'; const PasswordForgotSuccessMessage: React.FC = () => { return ( -
+
-

- Link for password reset was sent to your email. Check your inbox - letters and follow the instructions. If you don’t see the email, - please check your spam folder -

+
+

+ Link for password reset was sent to your email. Check your + inbox letters and follow the instructions. If you don’t see + the email, please check your spam folder. +

+
); }; diff --git a/frontend/src/bundles/auth/pages/auth.tsx b/frontend/src/bundles/auth/pages/auth.tsx index 85024cfea..a52f23668 100644 --- a/frontend/src/bundles/auth/pages/auth.tsx +++ b/frontend/src/bundles/auth/pages/auth.tsx @@ -13,7 +13,6 @@ import { useEffect, useLocation, useNavigate, - useRef, useState, } from '~/bundles/common/hooks/hooks.js'; import { actions as passwordResetActions } from '~/bundles/password-reset/store/password-reset.js'; @@ -52,8 +51,6 @@ const Auth: React.FC = () => { const isResetPasswordLoading = resetPasswordStatus === DataStatus.PENDING; - const intervalReference = useRef(0); - const handleSignInSubmit = useCallback( (payload: UserAuthRequestDto): void => { void dispatch(authActions.signIn(payload)); @@ -84,9 +81,11 @@ const Auth: React.FC = () => { const handleCloseModal = useCallback((): void => { void setIsOpen(false); - void setIsPasswordForgot(false); - clearTimeout(intervalReference.current); - }, []); + + if (isPasswordForgot) { + void setIsPasswordForgot(false); + } + }, [isPasswordForgot]); useEffect(() => { if (dataStatus === DataStatus.FULFILLED) { @@ -97,17 +96,7 @@ const Auth: React.FC = () => { useEffect(() => { if (resetPasswordStatus === DataStatus.FULFILLED) { setIsPasswordForgot(true); - const intervalId = window.setTimeout(() => { - setIsPasswordForgot(false); - setIsOpen(false); - }, 5000); - - intervalReference.current = intervalId; } - return () => { - setIsPasswordForgot(false); - clearTimeout(intervalReference.current); - }; }, [navigate, resetPasswordStatus]); const getScreen = (screen: string): React.ReactNode => { diff --git a/frontend/src/bundles/auth/store/actions.ts b/frontend/src/bundles/auth/store/actions.ts index b2f1e02d7..f13826660 100644 --- a/frontend/src/bundles/auth/store/actions.ts +++ b/frontend/src/bundles/auth/store/actions.ts @@ -49,6 +49,13 @@ const refreshUser = createAsyncThunk< return userApi.refreshUser(); }); +const logout = createAsyncThunk( + `${sliceName}/logout`, + async () => { + await storage.drop(StorageKey.TOKEN); + }, +); + const updateUser = createAsyncThunk< UserAuthResponseDto, UserUpdateProfileRequestDto, @@ -58,4 +65,4 @@ const updateUser = createAsyncThunk< return await userApi.updateUser(updateUserPayload); }); -export { refreshUser, signIn, signUp, updateUser }; +export { logout, refreshUser, signIn, signUp, updateUser }; diff --git a/frontend/src/bundles/auth/store/auth.ts b/frontend/src/bundles/auth/store/auth.ts index 796e9809e..c98b1e3b9 100644 --- a/frontend/src/bundles/auth/store/auth.ts +++ b/frontend/src/bundles/auth/store/auth.ts @@ -1,4 +1,4 @@ -import { refreshUser, signIn, signUp, updateUser } from './actions.js'; +import { logout, refreshUser, signIn, signUp, updateUser } from './actions.js'; import { actions } from './slice.js'; const allActions = { @@ -7,6 +7,7 @@ const allActions = { signIn, updateUser, refreshUser, + logout, }; export { allActions as actions }; diff --git a/frontend/src/bundles/auth/store/slice.ts b/frontend/src/bundles/auth/store/slice.ts index 51ad5e521..5bd4ad460 100644 --- a/frontend/src/bundles/auth/store/slice.ts +++ b/frontend/src/bundles/auth/store/slice.ts @@ -6,7 +6,7 @@ import { type ValueOf, } from '~/bundles/common/types/types.js'; -import { refreshUser, signIn, signUp, updateUser } from './actions.js'; +import { logout, refreshUser, signIn, signUp, updateUser } from './actions.js'; type State = { dataStatus: ValueOf; @@ -58,6 +58,16 @@ const { reducer, actions, name } = createSlice({ state.dataStatus = DataStatus.REJECTED; state.isRefreshing = false; }); + builder.addCase(logout.pending, (state) => { + state.dataStatus = DataStatus.PENDING; + }); + builder.addCase(logout.fulfilled, (state) => { + state.user = null; + state.dataStatus = DataStatus.FULFILLED; + }); + builder.addCase(logout.rejected, (state) => { + state.dataStatus = DataStatus.REJECTED; + }); builder.addCase(updateUser.pending, (state) => { state.dataStatus = DataStatus.PENDING; }); diff --git a/frontend/src/bundles/common/components/base-layout/base-layout.tsx b/frontend/src/bundles/common/components/base-layout/base-layout.tsx index 0aa02a81e..1c19a7c76 100644 --- a/frontend/src/bundles/common/components/base-layout/base-layout.tsx +++ b/frontend/src/bundles/common/components/base-layout/base-layout.tsx @@ -1,22 +1,31 @@ -import { type ReactNode } from 'react'; - import { Header } from '~/bundles/common/components/header/header.js'; import { Sidebar } from '~/bundles/common/components/sidebar/sidebar.js'; +import { type ReactNode } from '~/bundles/common/types/types.js'; +import { getValidClassNames } from '../../helpers/helpers.js'; +import { useSidebarToggle } from '../../hooks/hooks.js'; +import { RouterOutlet } from '../components.js'; import styles from './styles.module.css'; type Properties = { children?: ReactNode; }; -const BaseLayout: React.FC = ({ children }) => { +const BaseLayout: React.FC = () => { + const { toggleSidebar, isOpen } = useSidebarToggle(); return ( -
-
- - +
+
+ -
{children}
+
+ +
); }; diff --git a/frontend/src/bundles/common/components/base-layout/styles.module.css b/frontend/src/bundles/common/components/base-layout/styles.module.css index ce5f56207..2fb52a402 100644 --- a/frontend/src/bundles/common/components/base-layout/styles.module.css +++ b/frontend/src/bundles/common/components/base-layout/styles.module.css @@ -1,13 +1,39 @@ .base-layout { - @apply grid grid-cols-2 grid-rows-2; - grid-template: - "header header" 88px - "aside main" / 288px; + "header header" + "aside main"; + + @apply grid grid-cols-[256px,auto] grid-rows-[88px,auto] overflow-hidden transition-all duration-[0.5s] ease-[ease-in-out] lg:grid-cols-[288px,auto]; } .content-container { - @apply bg-lm-black-200 p-8; + @apply bg-lm-black-200 max-h-90 overflow-y-auto overflow-x-hidden p-8; grid-area: main; } + +.base-layout:not(.sidebar-closed) { + @apply overflow-hidden; +} + +.base-layout.sidebar-closed { + grid-template: + "header header" 88px + "aside main" /0; +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--background-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--button-primary); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--button-secondary); +} diff --git a/frontend/src/bundles/common/components/button/button.tsx b/frontend/src/bundles/common/components/button/button.tsx index 7eb5caaf8..84db50241 100644 --- a/frontend/src/bundles/common/components/button/button.tsx +++ b/frontend/src/bundles/common/components/button/button.tsx @@ -1,8 +1,6 @@ -import { type ReactNode } from 'react'; - import { ComponentSize } from '~/bundles/common/enums/enums.js'; import { getValidClassNames } from '~/bundles/common/helpers/helpers.js'; -import { type ValueOf } from '~/bundles/common/types/types.js'; +import { type ReactNode, type ValueOf } from '~/bundles/common/types/types.js'; const ButtonVariant = { PRIMARY: 'primary', diff --git a/frontend/src/bundles/common/components/card/card.tsx b/frontend/src/bundles/common/components/card/card.tsx index 6a98dad13..baf0efe35 100644 --- a/frontend/src/bundles/common/components/card/card.tsx +++ b/frontend/src/bundles/common/components/card/card.tsx @@ -8,7 +8,7 @@ type Properties = { const Card: React.FC = ({ title, imageSrc, name, data, chip }) => { return ( -
+
{title && (
{title} diff --git a/frontend/src/bundles/common/components/components.ts b/frontend/src/bundles/common/components/components.ts index 7327fbf10..1c05cd242 100644 --- a/frontend/src/bundles/common/components/components.ts +++ b/frontend/src/bundles/common/components/components.ts @@ -1,9 +1,12 @@ export { Avatar } from './avatar/avatar.js'; export { Button, ButtonVariant } from './button/button.js'; +export { Card } from './card/card.js'; export { DatePicker } from './date-picker/date-picker.js'; +export { DownloadBanner } from './download-banner/download-banner.jsx'; export { ForgotPasswordForm } from './forgot-password-form/forgot-password-form.js'; export { Header } from './header/header.js'; export { Icon } from './icon/icon.js'; +export { InfoSection } from './info-section/info-section.js'; export { Input } from './input/input.js'; export { Layout } from './layout/layout.js'; export { Link } from './link/link.js'; diff --git a/frontend/src/bundles/common/components/download-banner/download-banner.tsx b/frontend/src/bundles/common/components/download-banner/download-banner.tsx new file mode 100644 index 000000000..c309beb20 --- /dev/null +++ b/frontend/src/bundles/common/components/download-banner/download-banner.tsx @@ -0,0 +1,107 @@ +import { XMarkIcon } from '@heroicons/react/24/solid'; + +import { ComponentSize } from '~/bundles/common/enums/enums.js'; +import { + useCallback, + useEffect, + useState, +} from '~/bundles/common/hooks/hooks.js'; + +import { Button, ButtonVariant, Icon } from '../components.js'; +import { IconColor } from '../icon/enums/icon-colors.enum.js'; + +interface ExtendedNavigator extends Navigator { + standalone?: boolean; +} +interface BeforeInstallPromptEvent extends Event { + prompt(): void; + userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + }>; +} +const isAppInstalled = (): boolean => { + const extendedNavigator = window.navigator as ExtendedNavigator; + + return ( + extendedNavigator.standalone || + window.matchMedia('(display-mode: standalone)').matches + ); +}; + +const DownloadBanner = (): JSX.Element | null => { + const [isBannerVisible, setBannerVisibility] = useState(true); + const [deferredPrompt, setDeferredPrompt] = + useState(null); + + useEffect(() => { + const beforeInstallPromptHandler = (event: Event): void => { + if (event.type === 'beforeinstallprompt') { + const beforeInstallPromptEvent = + event as BeforeInstallPromptEvent; + beforeInstallPromptEvent.preventDefault(); + setDeferredPrompt(beforeInstallPromptEvent); + } + }; + + window.addEventListener( + 'beforeinstallprompt', + beforeInstallPromptHandler, + ); + + return () => { + window.removeEventListener( + 'beforeinstallprompt', + beforeInstallPromptHandler, + ); + }; + }, []); + + const handleInstall = useCallback((): void => { + if (deferredPrompt && !isAppInstalled()) { + void deferredPrompt.prompt(); + } + }, [deferredPrompt]); + + const closeBanner = useCallback((): void => { + void setBannerVisibility(false); + }, []); + + useEffect(() => { + if (isAppInstalled()) { + setBannerVisibility(false); + } + }, []); + + if (!isBannerVisible) { + return null; + } + return ( +
+ + +

+ Install LIME for a better experience! +

+
+
+
+ ); +}; + +export { DownloadBanner }; diff --git a/frontend/src/bundles/common/components/header/components/navigation/navigation.tsx b/frontend/src/bundles/common/components/header/components/navigation/navigation.tsx index d588daf13..76ff2ca01 100644 --- a/frontend/src/bundles/common/components/header/components/navigation/navigation.tsx +++ b/frontend/src/bundles/common/components/header/components/navigation/navigation.tsx @@ -20,18 +20,16 @@ const Navigation = ({ avatarUrl }: Properties): JSX.Element => {
  • - +
  • - - avatar - + avatar
  • diff --git a/frontend/src/bundles/common/components/header/header.tsx b/frontend/src/bundles/common/components/header/header.tsx index 06a7db051..178f8b523 100644 --- a/frontend/src/bundles/common/components/header/header.tsx +++ b/frontend/src/bundles/common/components/header/header.tsx @@ -1,12 +1,18 @@ +import { Bars3BottomLeftIcon } from '@heroicons/react/24/solid'; + import logo from '~/assets/img/logo.svg'; import { AppRoute, ComponentSize } from '../../enums/enums.js'; -import { Icon, Layout, Link } from '../components.js'; +import { Button, Icon, Layout, Link } from '../components.js'; import { IconColor } from '../icon/enums/enums.js'; import { Message, Navigation } from './components/components.js'; import styles from './styles.module.css'; -const Header = (): JSX.Element => { +type HeaderProperties = { + toggleSidebar: () => void; +}; + +const Header = ({ toggleSidebar }: HeaderProperties): JSX.Element => { return (
    @@ -16,12 +22,24 @@ const Header = (): JSX.Element => {
    - } + size="sm" + className="p-4 px-1 py-0" />
    +
    + + + +
    diff --git a/frontend/src/bundles/common/components/header/styles.module.css b/frontend/src/bundles/common/components/header/styles.module.css index 0542c0ed1..8481f8308 100644 --- a/frontend/src/bundles/common/components/header/styles.module.css +++ b/frontend/src/bundles/common/components/header/styles.module.css @@ -1,5 +1,5 @@ .header { - @apply bg-lm-black-100 flex w-full items-center; + @apply bg-lm-black-100 z-[2] flex w-full items-center; grid-area: header; } diff --git a/frontend/src/bundles/common/components/icon/enums/icon-components.enum.ts b/frontend/src/bundles/common/components/icon/enums/icon-components.enum.ts index 7e7ba24bd..c261d7cbf 100644 --- a/frontend/src/bundles/common/components/icon/enums/icon-components.enum.ts +++ b/frontend/src/bundles/common/components/icon/enums/icon-components.enum.ts @@ -1,10 +1,12 @@ import ArrowDownIcon from '~/assets/img/icons/arrow-down-icon.svg?react'; +import CaloriesIcon from '~/assets/img/icons/calories-icon.svg?react'; import FacebookIcon from '~/assets/img/icons/facebook.svg?react'; import GoalsIcon from '~/assets/img/icons/goals-icon.svg?react'; import GoogleFitIcon from '~/assets/img/icons/google-fit-icon.svg?react'; import GoogleLogoIcon from '~/assets/img/icons/google-logo.svg?react'; import LogoIcon from '~/assets/img/icons/logo-icon.svg?react'; import NotFoundIcon from '~/assets/img/icons/not-found-icon.svg?react'; +import StepsIcon from '~/assets/img/icons/steps-icon.svg?react'; import StravaIcon from '~/assets/img/icons/strava-icon.svg?react'; import WorkoutIcon from '~/assets/img/icons/workout-icon.svg?react'; import { type ValueOf } from '~/bundles/common/types/types.js'; @@ -22,6 +24,8 @@ const IconComponent: Record< workoutIcon: WorkoutIcon, stravaIcon: StravaIcon, googleFitIcon: GoogleFitIcon, + caloriesIcon: CaloriesIcon, + stepsIcon: StepsIcon, googleLogoIcon: GoogleLogoIcon, facebookIcon: FacebookIcon, } as const; diff --git a/frontend/src/bundles/common/components/icon/enums/icon-name.enum.ts b/frontend/src/bundles/common/components/icon/enums/icon-name.enum.ts index b2fd5fc46..7952ddd36 100644 --- a/frontend/src/bundles/common/components/icon/enums/icon-name.enum.ts +++ b/frontend/src/bundles/common/components/icon/enums/icon-name.enum.ts @@ -8,6 +8,8 @@ const IconName = { goalsIcon: 'goalsIcon', facebookIcon: 'facebookIcon', workoutIcon: 'workoutIcon', + caloriesIcon: 'caloriesIcon', + stepsIcon: 'stepsIcon', } as const; export { IconName }; diff --git a/frontend/src/bundles/common/components/info-section/components/components.ts b/frontend/src/bundles/common/components/info-section/components/components.ts new file mode 100644 index 000000000..d94b9143f --- /dev/null +++ b/frontend/src/bundles/common/components/info-section/components/components.ts @@ -0,0 +1 @@ +export { ViewAllButton } from './view-all-button/view-all-button.js'; diff --git a/frontend/src/bundles/common/components/info-section/components/view-all-button/view-all-button.tsx b/frontend/src/bundles/common/components/info-section/components/view-all-button/view-all-button.tsx new file mode 100644 index 000000000..e81ab93f5 --- /dev/null +++ b/frontend/src/bundles/common/components/info-section/components/view-all-button/view-all-button.tsx @@ -0,0 +1,22 @@ +import { ChevronRightIcon } from '@heroicons/react/24/solid'; + +import { Link } from '~/bundles/common/components/components.js'; +import { type AppRoute } from '~/bundles/common/enums/enums.js'; +import { type ValueOf } from '~/bundles/common/types/types.js'; + +type ViewAllButtonProperties = { + to: ValueOf; +}; + +const ViewAllButton: React.FC = ({ to }) => { + return ( + + + View All + + + + ); +}; + +export { ViewAllButton }; diff --git a/frontend/src/bundles/common/components/info-section/info-section.tsx b/frontend/src/bundles/common/components/info-section/info-section.tsx new file mode 100644 index 000000000..e29b978fc --- /dev/null +++ b/frontend/src/bundles/common/components/info-section/info-section.tsx @@ -0,0 +1,33 @@ +import { type AppRoute } from '~/bundles/common/enums/enums.js'; +import { getValidClassNames } from '~/bundles/common/helpers/helpers.js'; +import { type ValueOf } from '~/bundles/common/types/types.js'; + +import { ViewAllButton } from './components/components.js'; + +type InfoSectionProperties = { + children: React.ReactNode; + viewAllLink?: ValueOf; + title: string; + className?: string; +}; + +const InfoSection: React.FC = ({ + children, + className, + title, + viewAllLink, +}) => { + return ( +
    +
    +

    + {title} +

    + {viewAllLink && } +
    + {children} +
    + ); +}; + +export { InfoSection }; diff --git a/frontend/src/bundles/common/components/layout/layout.tsx b/frontend/src/bundles/common/components/layout/layout.tsx index 48daaa47e..a60381692 100644 --- a/frontend/src/bundles/common/components/layout/layout.tsx +++ b/frontend/src/bundles/common/components/layout/layout.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react'; +import { type ReactNode } from '~/bundles/common/types/types.js'; import { getValidClassNames } from '../../helpers/helpers.js'; diff --git a/frontend/src/bundles/common/components/modal/modal.tsx b/frontend/src/bundles/common/components/modal/modal.tsx index 1130cb060..5a5161f58 100644 --- a/frontend/src/bundles/common/components/modal/modal.tsx +++ b/frontend/src/bundles/common/components/modal/modal.tsx @@ -1,5 +1,6 @@ import { XMarkIcon } from '@heroicons/react/16/solid'; -import { type ReactNode } from 'react'; + +import { type ReactNode } from '~/bundles/common/types/types.js'; import { getValidClassNames } from '../../helpers/helpers.js'; diff --git a/frontend/src/bundles/common/components/protected-route/protected-route.tsx b/frontend/src/bundles/common/components/protected-route/protected-route.tsx index 83cc1ec90..4307f1ead 100644 --- a/frontend/src/bundles/common/components/protected-route/protected-route.tsx +++ b/frontend/src/bundles/common/components/protected-route/protected-route.tsx @@ -1,8 +1,7 @@ -import { type ReactNode } from 'react'; - import { Navigate } from '~/bundles/common/components/components.js'; import { AppRoute } from '~/bundles/common/enums/enums.js'; import { useAppSelector } from '~/bundles/common/hooks/hooks.js'; +import { type ReactNode } from '~/bundles/common/types/types.js'; type ProtectedRouteProperties = { children: ReactNode; diff --git a/frontend/src/bundles/common/components/reset-password-form/reset-password-form.tsx b/frontend/src/bundles/common/components/reset-password-form/reset-password-form.tsx index f744f46bc..20cf76208 100644 --- a/frontend/src/bundles/common/components/reset-password-form/reset-password-form.tsx +++ b/frontend/src/bundles/common/components/reset-password-form/reset-password-form.tsx @@ -20,7 +20,7 @@ const ResetPasswordForm: React.FC = ({ onSubmit, isLoading }) => { const { control, errors, handleSubmit } = useAppForm({ defaultValues: DEFAULT_PASSWORD_RESET_PAYLOAD, validationSchema: passwordResetValidationSchema, - mode: 'onTouched', + mode: 'onSubmit', }); const handleFormSubmit = useCallback( @@ -32,43 +32,48 @@ const ResetPasswordForm: React.FC = ({ onSubmit, isLoading }) => { return ( <> -
    -
    - -
    +
    +

    + Set Up Your New Password +

    + +
    + +
    -
    - + +
    + +
    - -
    +

    + Don`t want to change the password? Go to{' '} - Log in + Sign in

    diff --git a/frontend/src/bundles/common/components/select/helpers/get-styles/get-styles.helper.ts b/frontend/src/bundles/common/components/select/helpers/get-styles/get-styles.helper.ts index 1bd7f8a9e..44750fa18 100644 --- a/frontend/src/bundles/common/components/select/helpers/get-styles/get-styles.helper.ts +++ b/frontend/src/bundles/common/components/select/helpers/get-styles/get-styles.helper.ts @@ -17,7 +17,7 @@ const getStyles = < control: (state) => getValidClassNames( state.isFocused ? `${borderColor}` : 'border-0', - `w-full p-2.5 min-h-11 bg-primary border + `w-full p-2.5 min-h-11 bg-secondary border outline-none rounded-md text-inherit shadow-none hover:cursor-pointer`, ), dropdownIndicator: (state) => { @@ -27,7 +27,7 @@ const getStyles = < placeholder: () => 'font-inherit text-lm-grey-200', valueContainer: () => 'm-0 p-0', - input: () => 'm-0 p-0 text-white', + input: () => 'm-0 p-0 text-primary', option: () => getValidClassNames( errorMessage ? 'hover:text-lm-red' : 'hover:text-action', diff --git a/frontend/src/bundles/common/components/select/select.tsx b/frontend/src/bundles/common/components/select/select.tsx index 9b73b6f60..9f8f46f37 100644 --- a/frontend/src/bundles/common/components/select/select.tsx +++ b/frontend/src/bundles/common/components/select/select.tsx @@ -92,7 +92,7 @@ const Select = < ); return ( -
    +
    {label && ( {label} diff --git a/frontend/src/bundles/common/components/sidebar/components/sidebar-nav/sidebar-nav.tsx b/frontend/src/bundles/common/components/sidebar/components/sidebar-nav/sidebar-nav.tsx index 6ab87db78..717cbcf6f 100644 --- a/frontend/src/bundles/common/components/sidebar/components/sidebar-nav/sidebar-nav.tsx +++ b/frontend/src/bundles/common/components/sidebar/components/sidebar-nav/sidebar-nav.tsx @@ -6,7 +6,6 @@ import { import { addSizePropertyHeroIcons } from '~/bundles/common/components/icon/helpers/helpers.js'; import { type AppRoute, ComponentSize } from '~/bundles/common/enums/enums.js'; import { getValidClassNames } from '~/bundles/common/helpers/helpers.js'; -import { useCallback, useNavigate } from '~/bundles/common/hooks/hooks.js'; import { type ValueOf } from '~/bundles/common/types/types.js'; type SidebarNavProperties = { @@ -22,11 +21,6 @@ const SidebarNav = ({ icon, isActive = false, }: SidebarNavProperties): JSX.Element => { - const navigate = useNavigate(); - const handleNavigation = useCallback((): void => { - navigate(to); - }, [navigate, to]); - const classes = { active: 'text-lm-black-100 hover:text-lm-black-200', inactive: 'text-lm-grey-200 hover:text-lm-black-400', @@ -47,7 +41,6 @@ const SidebarNav = ({ )} leftIcon={enhacedIcon} variant={ButtonVariant.SIDEBAR} - onClick={handleNavigation} isActive={isActive} size={ComponentSize.MEDIUM} /> diff --git a/frontend/src/bundles/common/components/sidebar/sidebar.tsx b/frontend/src/bundles/common/components/sidebar/sidebar.tsx index 425558ada..aeebe0043 100644 --- a/frontend/src/bundles/common/components/sidebar/sidebar.tsx +++ b/frontend/src/bundles/common/components/sidebar/sidebar.tsx @@ -23,7 +23,7 @@ type Properties = { const styles = { baseStyle: - 'bg-lm-black-100 flex h-[95vh] w-72 flex-col content-center items-center p-7 text-white', + 'bg-lm-black-100 flex min-h-90 lg:w-72 w-64 flex-col content-center items-center p-7 text-white', animationStyle: 'transition-transform duration-[0.5s] ease-[ease-in-out]', }; @@ -82,7 +82,7 @@ const Sidebar = ({ isOpen = true }: Properties): JSX.Element => {
    -
    +
    } text="Help" diff --git a/frontend/src/bundles/common/components/sub-navigation/sub-navigation.tsx b/frontend/src/bundles/common/components/sub-navigation/sub-navigation.tsx index a7ffb9923..3c6a63256 100644 --- a/frontend/src/bundles/common/components/sub-navigation/sub-navigation.tsx +++ b/frontend/src/bundles/common/components/sub-navigation/sub-navigation.tsx @@ -9,19 +9,31 @@ type Properties = { items: { id: string; label: string; to: string }[]; title?: string; button?: { label: string; onClick: () => void }; + className?: string; }; -const SubNavigation = ({ items, title, button }: Properties): JSX.Element => { +const SubNavigation = ({ + items, + title, + button, + className, +}: Properties): JSX.Element => { const bgColors = [ 'bg-lm-yellow-100', - 'bg-lm-magenta', - 'bg-lm-purple', + 'bg-lm-magenta-100', + 'bg-lm-purple-100', 'bg-lm-green', + 'bg-lm-blue-400', + 'bg-lm-blue-500', ]; return ( -
    - {title &&

    {title}

    } +
    + {title && ( +

    {title}

    + )} {items.map((item, index) => ( { const classes = { - base: 'w-[15.625rem] h-[10.5rem] rounded-[0.5rem] text-white p-[1rem] flex gap-[1rem] bg-no-repeat bg-bottom', + base: 'h-[10.5rem] rounded-[0.5rem] text-white p-[1rem] flex gap-[1rem] bg-no-repeat bg-bottom', icon: 'h-[2.5rem] w-[2.5rem] rounded-[0.25rem] flex items-center justify-center', }; diff --git a/frontend/src/bundles/common/enums/app-route.enum.ts b/frontend/src/bundles/common/enums/app-route.enum.ts index ae55e4c36..aa4d60b74 100644 --- a/frontend/src/bundles/common/enums/app-route.enum.ts +++ b/frontend/src/bundles/common/enums/app-route.enum.ts @@ -4,13 +4,17 @@ const AppRoute = { HELP: '/help', SIGN_IN: '/sign-in', SIGN_UP: '/sign-up', - SUBSCRIPTION: '/subscriptions', - SUBSCRIPTION_CHECKOUT: '/subscriptions-checkout', PASSWORD_RESET: '/reset-password/:resetToken', OVERVIEW: '/overview', GOALS: '/goals', WORKOUT: '/workout', SCHEDULE: '/schedule', + PROFILE_INFORMATION: '/profile/information', + PROFILE_GOALS: '/profile/goals', + PROFILE_PREFERENCES: '/profile/preferences', + PROFILE_CONECTIONS: '/profile/conections', + PROFILE_SUBSCRIPTION: '/profile/subscriptions', + PROFILE_SUBSCRIPTION_CHECKOUT: '/profile/subscriptions-checkout', NOT_FOUND: '*', PROFILE: '/profile/settings', } as const; diff --git a/frontend/src/bundles/common/hooks/hooks.ts b/frontend/src/bundles/common/hooks/hooks.ts index 3f9d38e88..01859c915 100644 --- a/frontend/src/bundles/common/hooks/hooks.ts +++ b/frontend/src/bundles/common/hooks/hooks.ts @@ -1,6 +1,7 @@ export { useAppDispatch } from './use-app-dispatch/use-app-dispatch.hook.js'; export { useAppForm } from './use-app-form/use-app-form.hook.js'; export { useAppSelector } from './use-app-selector/use-app-selector.hook.js'; +export { useSidebarToggle } from './use-sidebar-toggle/use-sidebar-toggle.js'; export { useCallback, useEffect, diff --git a/frontend/src/bundles/common/hooks/use-sidebar-toggle/use-sidebar-toggle.tsx b/frontend/src/bundles/common/hooks/use-sidebar-toggle/use-sidebar-toggle.tsx new file mode 100644 index 000000000..4e7a128f7 --- /dev/null +++ b/frontend/src/bundles/common/hooks/use-sidebar-toggle/use-sidebar-toggle.tsx @@ -0,0 +1,31 @@ +import { + useCallback, + useEffect, + useState, +} from '~/bundles/common/hooks/hooks.js'; + +const useSidebarToggle = (): { isOpen: boolean; toggleSidebar: () => void } => { + const [width, setWidth] = useState(window.innerWidth); + const [isOpen, setIsOpen] = useState(width > 768); + const toggleSidebar = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen, setIsOpen]); + + const handleResize = useCallback((): void => { + setWidth(window.innerWidth); + if (width > 768) { + setIsOpen(true); + } else { + setIsOpen(false); + } + }, [width, setIsOpen]); + + useEffect(() => { + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [handleResize, width, setIsOpen]); + + return { isOpen, toggleSidebar }; +}; + +export { useSidebarToggle }; diff --git a/frontend/src/bundles/common/pages/home/home.page.tsx b/frontend/src/bundles/common/pages/home/home.page.tsx new file mode 100644 index 000000000..401acf980 --- /dev/null +++ b/frontend/src/bundles/common/pages/home/home.page.tsx @@ -0,0 +1,150 @@ +import { + ActivityWidget, + ActivityWidgetColor, + Card, + Icon, + InfoSection, +} from '~/bundles/common/components/components.js'; +import { + IconColor, + IconName, +} from '~/bundles/common/components/icon/enums/enums.js'; +import { AppRoute } from '~/bundles/common/enums/enums.js'; +import { GoalWidget } from '~/bundles/overview/components/components.js'; +import { GoalTypes } from '~/bundles/overview/components/goal-widget/enums/goal-types.enums.js'; + +import styles from './styles.module.css'; + +const scheduleData = [ + { + id: 1, + title: 'Monday', + name: 'Stretch', + data: 'At 08:00', + chip: '20 Pieces', + }, + { + id: 2, + title: 'Wednesday', + name: 'Yoga', + data: 'At 08:00', + chip: '10 min', + }, + { + id: 3, + title: 'Tuesday', + name: 'Back Stretch', + data: 'At 08:00', + chip: '10 Round', + }, +]; + +const goalsData = [ + { + id: 1, + name: 'Running on Track', + data: 'Saturday, April 14 | 08:00 AM', + chip: '04 Rounds', + }, + { + id: 2, + name: 'Push Up', + data: 'Sunday, April 15 | 08:00 AM', + chip: '50 Pieces', + }, +]; + +const Home: React.FC = () => { + return ( +
    +
    + +
      +
    • + + } + /> +
    • +
    • + } + /> +
    • +
    • + } + /> +
    • +
    +
    Diagram
    +
    Achievements
    +
    +
    + + {scheduleData.length > 0 ? ( +
      + {scheduleData.map((scheduleItem) => ( +
    • + +
    • + ))} +
    + ) : ( +

    Empty schedule

    + )} +
    + + {goalsData.length > 0 ? ( +
      + {goalsData.map((goalItem) => ( +
    • + +
    • + ))} +
    + ) : ( +

    Empty goals

    + )} +
    + + Recomendations + +
    +
    + ); +}; + +export { Home }; diff --git a/frontend/src/bundles/common/pages/home/styles.module.css b/frontend/src/bundles/common/pages/home/styles.module.css new file mode 100644 index 000000000..6c093ff73 --- /dev/null +++ b/frontend/src/bundles/common/pages/home/styles.module.css @@ -0,0 +1,7 @@ +.schedule__item + .schedule__item { + @apply mt-3; +} + +.goal__item + .goal__item { + @apply mt-4; +} diff --git a/frontend/src/bundles/common/pages/pages.ts b/frontend/src/bundles/common/pages/pages.ts index 51ba1cba7..e67897463 100644 --- a/frontend/src/bundles/common/pages/pages.ts +++ b/frontend/src/bundles/common/pages/pages.ts @@ -1 +1,2 @@ +export { Home } from './home/home.page.js'; export { NotFound } from './not-found/not-found.js'; diff --git a/frontend/src/bundles/common/types/types.ts b/frontend/src/bundles/common/types/types.ts index c0579aefd..44ba44c08 100644 --- a/frontend/src/bundles/common/types/types.ts +++ b/frontend/src/bundles/common/types/types.ts @@ -2,7 +2,9 @@ export { type Theme } from '../enums/theme.js'; export { type AsyncThunkConfig } from './async-thunk-config.type.js'; export { type RouteObject } from './route-object.js'; export { type PayloadAction } from '@reduxjs/toolkit'; +export { type ReactNode } from 'react'; export { type RouteObject as LibraryRouteObject } from 'react-router-dom'; +export { type SingleValue } from 'react-select'; export { type AuthResponseDto, type ServerErrorDetail, diff --git a/frontend/src/bundles/overview/components/chart-goal-progress/chart-goal-progress.tsx b/frontend/src/bundles/overview/components/chart-goal-progress/chart-goal-progress.tsx new file mode 100644 index 000000000..febf76cae --- /dev/null +++ b/frontend/src/bundles/overview/components/chart-goal-progress/chart-goal-progress.tsx @@ -0,0 +1,70 @@ +import { Select } from '~/bundles/common/components/components.js'; +import { type SelectOption } from '~/bundles/common/components/select/types/types.js'; +import { + useAppForm, + useCallback, + useState, +} from '~/bundles/common/hooks/hooks.js'; +import { type SingleValue } from '~/bundles/common/types/types.js'; + +import { BarChart } from '../components.js'; +import { dataMappingSelectDatabase as dataMapping } from './helpers/data-select-database-mapping.helper.js'; +import { weeklyData } from './mock-database/mock-database-goal.js'; + +const selectData: SelectOption[] = [ + { + value: 'weekly', + label: 'Weekly', + }, + { + value: 'monthly', + label: 'Monthly', + }, + { + value: 'yearly', + label: 'Yearly', + }, +]; + +const ChartGoalProgress = (): JSX.Element => { + const [currentData, setCurrentData] = useState( + selectData[0] as SelectOption, + ); + + const { control, errors } = useAppForm({ + defaultValues: { + select: currentData.value, + }, + mode: 'onChange', + }); + + const handleChange = useCallback((newValue: SingleValue) => { + if (newValue) { + setCurrentData(newValue); + } + }, []); + + return ( +
    +
    +

    Goal Progress

    +
    +