diff --git a/.env.example b/.env.example index e8d4aed..369bc02 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ SALT_DATA_PASS= SECRET_JWT= +SUPABASE_URL= +SUPABASE_KEY= +SUPABASE_BUCKET= + MAIL_PORT= MAIL_HOST= MAIL_USER= diff --git a/package.json b/package.json index bbf91ec..b8a34aa 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@nestjs/swagger": "7.1.8", "@nestjs/throttler": "^6.2.1", "@prisma/client": "5.10.1", + "@supabase/supabase-js": "^2.46.1", "bcrypt": "5.1.1", "class-transformer": "0.5.1", "class-validator": "0.14.0", diff --git a/prisma/migrations/20241115164805_add_profile_image_url_profile_enum/migration.sql b/prisma/migrations/20241115164805_add_profile_image_url_profile_enum/migration.sql new file mode 100644 index 0000000..3938fca --- /dev/null +++ b/prisma/migrations/20241115164805_add_profile_image_url_profile_enum/migration.sql @@ -0,0 +1,8 @@ +-- CreateEnum +CREATE TYPE "Profile" AS ENUM ('parent', 'child'); + +-- AlterTable +ALTER TABLE "children_profiles" ADD COLUMN "profileImageUrl" TEXT; + +-- AlterTable +ALTER TABLE "parent_profiles" ADD COLUMN "profileImageUrl" TEXT; diff --git a/prisma/migrations/20241115170112_remove_profile_enum/migration.sql b/prisma/migrations/20241115170112_remove_profile_enum/migration.sql new file mode 100644 index 0000000..8e29a32 --- /dev/null +++ b/prisma/migrations/20241115170112_remove_profile_enum/migration.sql @@ -0,0 +1,2 @@ +-- DropEnum +DROP TYPE "Profile"; diff --git a/prisma/migrations/20241115174852_add_profile_fiels/migration.sql b/prisma/migrations/20241115174852_add_profile_fiels/migration.sql new file mode 100644 index 0000000..cfb8b9d --- /dev/null +++ b/prisma/migrations/20241115174852_add_profile_fiels/migration.sql @@ -0,0 +1,8 @@ +-- CreateEnum +CREATE TYPE "Profile" AS ENUM ('parent', 'child'); + +-- AlterTable +ALTER TABLE "children_profiles" ADD COLUMN "profile" "Profile" NOT NULL DEFAULT 'child'; + +-- AlterTable +ALTER TABLE "parent_profiles" ADD COLUMN "profile" "Profile" NOT NULL DEFAULT 'parent'; diff --git a/prisma/migrations/20241127170118_drop_profile_column/migration.sql b/prisma/migrations/20241127170118_drop_profile_column/migration.sql new file mode 100644 index 0000000..64b8cce --- /dev/null +++ b/prisma/migrations/20241127170118_drop_profile_column/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `profile` on the `children_profiles` table. All the data in the column will be lost. + - You are about to drop the column `profile` on the `parent_profiles` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "children_profiles" DROP COLUMN "profile"; + +-- AlterTable +ALTER TABLE "parent_profiles" DROP COLUMN "profile"; + +-- DropEnum +DROP TYPE "Profile"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e5af88..551f4eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,27 +47,29 @@ model ResetPasswordInfo { } model ParentProfile { - id String @id @default(uuid()) - fullname String - childrens ChildrenProfile[] - credentialId String @unique @map("credential_id") - credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade) - updatedAt DateTime @updatedAt @map("updated_at") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + fullname String + childrens ChildrenProfile[] + credentialId String @unique @map("credential_id") + credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade) + profileImageUrl String? + updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") @@map("parent_profiles") } model ChildrenProfile { - id Int @id @default(autoincrement()) - fullname String - birthdate DateTime - gender Genders - tasks Task[] - parentId String @map("parent_id") - parent ParentProfile @relation(fields: [parentId], references: [id], onDelete: Cascade) - updatedAt DateTime @updatedAt @map("updated_at") - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + fullname String + birthdate DateTime + gender Genders + tasks Task[] + parentId String @map("parent_id") + parent ParentProfile @relation(fields: [parentId], references: [id], onDelete: Cascade) + profileImageUrl String? + updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") @@map("children_profiles") } diff --git a/src/globals/uploadFiles.pipe.ts b/src/globals/uploadFiles.pipe.ts new file mode 100644 index 0000000..de4cd2c --- /dev/null +++ b/src/globals/uploadFiles.pipe.ts @@ -0,0 +1,42 @@ +import { + FileTypeValidator, + Injectable, + MaxFileSizeValidator, + PipeTransform, + UnprocessableEntityException, +} from '@nestjs/common'; +import { CustomHttpError } from '../globals/responses/exceptions'; + +@Injectable() +export class CustomUploadFilePipe implements PipeTransform { + transform(file: any) { + if (!file) { + throw new CustomHttpError('Nenhum arquivo foi enviado', 400); + } + + const fileTypeValidator = new FileTypeValidator({ + fileType: '^(image/png|image/jpeg|image/jpg|image/svg)$', + }); + + const isValidType = fileTypeValidator.isValid(file); + + if (!isValidType) { + throw new UnprocessableEntityException( + 'O tipo de arquivo enviado não é suportado. Apenas imagens nos formatos PNG, JPEG, JPG e SVG são aceitas.', + ); + } + + const maxSizeValidator = new MaxFileSizeValidator({ + maxSize: 20000, + }); + const isValidSize = maxSizeValidator.isValid(file); + + if (!isValidSize) { + throw new UnprocessableEntityException( + 'O arquivo excede o tamanho máximo permitido de 20KB.', + ); + } + + return file; + } +} diff --git a/src/globals/utils.ts b/src/globals/utils.ts index 455c3b9..4c9abf4 100644 --- a/src/globals/utils.ts +++ b/src/globals/utils.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import type { HashDataAsyncProps, EncryptDataAsyncProps } from './entity'; import * as bcrypt from 'bcrypt'; +import { UploadFileDto } from '../modules/account/account.dto'; const algorithm = 'sha256'; const digest = 'hex'; @@ -49,3 +50,16 @@ export function encryptDataAsync( }); }); } + +export function createFilePath( + file: UploadFileDto, + profile: string, + childrenId: number, + parentCredential: string, +) { + let pathFile = `${parentCredential}/${profile}/${file.originalname}`; + if (childrenId) + pathFile = `${parentCredential}/${profile}/${childrenId}/${file.originalname}`; + + return pathFile; +} diff --git a/src/modules/account/account.controller.ts b/src/modules/account/account.controller.ts index 6134a69..f4f2c7d 100644 --- a/src/modules/account/account.controller.ts +++ b/src/modules/account/account.controller.ts @@ -1,27 +1,34 @@ import { - Controller, - Post, Body, + Controller, + Get, HttpCode, + Post, Put, - Get, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { MailService } from '../mail/mail.service'; -import { AccountService } from './account.service'; import { responses } from 'src/globals/responses/docs'; -import { RecoveryControllerOutput } from './account.entity'; +import { User } from '../../decorators/account.decorator'; +import { CustomHttpError } from '../../globals/responses/exceptions'; +import { CustomUploadFilePipe } from '../../globals/uploadFiles.pipe'; +import { AuthorizationGuard, RequestToken } from '../../guard'; +import { MailService } from '../mail/mail.service'; import { + AccessTokenDto, CreateAccountDto, LoginDto, - AccessTokenDto, ResetPasswordDto, SetPasswordDto, + UploadFileDto, + UploadFileIdDto, } from './account.dto'; -import { AuthorizationGuard, RequestToken } from '../../guard'; -import { User } from '../../decorators/account.decorator'; +import { RecoveryControllerOutput } from './account.entity'; +import { AccountService } from './account.service'; @Controller('/auth') export class AccountController { @@ -88,6 +95,39 @@ export class AccountController { }; } + @Post('/upload-file') + @HttpCode(200) + @ApiTags('Upload Profile Image') + @ApiResponse(responses.ok) + @ApiResponse(responses.badRequest) + @ApiResponse(responses.internalError) + @UseInterceptors(FileInterceptor('file')) + async uploadFileChildren( + @UploadedFile(CustomUploadFilePipe) file: UploadFileDto, + @Body() { childrenId, parentCredential }: UploadFileIdDto, + ) { + const childrenIdNum = Number(childrenId); + let profile = 'parent'; + if (childrenIdNum) profile = 'children'; + + const upload = await this.accountService.uploadFile( + file, + profile, + childrenIdNum, + parentCredential, + ); + + if (!upload) + throw new CustomHttpError('Erro ao carregar foto de perfil', 400); + + return { + message: `Foto de perfil carregada com sucesso`, + data: { + upload, + }, + }; + } + @Post('/recovery') @HttpCode(200) @ApiTags('Reset Password') diff --git a/src/modules/account/account.dto.ts b/src/modules/account/account.dto.ts index e2aa511..b6bd710 100644 --- a/src/modules/account/account.dto.ts +++ b/src/modules/account/account.dto.ts @@ -1,24 +1,24 @@ +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { Genders } from '@prisma/client'; +import { Transform } from 'class-transformer'; import { - IsNotEmpty, - IsEmail, - IsStrongPassword, IsBoolean, - IsString, - IsEnum, IsDateString, + IsEmail, + IsEnum, + IsNotEmpty, + IsString, + IsStrongPassword, Matches, MinLength, } from 'class-validator'; -import { ApiProperty, PickType } from '@nestjs/swagger'; -import { Genders } from '@prisma/client'; +import { IsDateFormat, IsFullname } from 'src/decorators'; import { birthDateRegExp, fullnameRegExp, recoveryTokenRegExp, } from 'src/globals/constants'; import { messages } from 'src/globals/responses/validation'; -import { Transform } from 'class-transformer'; -import { IsDateFormat, IsFullname } from 'src/decorators'; export class CreateAccountDto { @ApiProperty({ example: 'email@example.com' }) @@ -88,3 +88,36 @@ export class LoginDto extends PickType(CreateAccountDto, [ 'email', 'password', ]) {} + +export class UploadFileDto { + @ApiProperty({ description: 'Nome do campo do arquivo', example: 'file' }) + readonly fieldname: string; + + @ApiProperty({ + description: 'Nome original do arquivo', + example: 'image.png', + }) + readonly originalname: string; + + @ApiProperty({ + description: 'Tipo MIME do arquivo', + example: 'image/png', + }) + readonly mimetype: string; + + @ApiProperty({ description: 'Conteúdo do arquivo em buffer' }) + readonly buffer: Buffer; + + @ApiProperty({ description: 'Tamanho do arquivo em bytes', example: 1024 }) + size: number; +} + +export class UploadFileIdDto { + @ApiProperty() + @IsNotEmpty() + @IsString({ message: messages.string }) + parentCredential: string; + + @ApiProperty({ example: 1 }) + childrenId: number; +} diff --git a/src/modules/account/account.repository.ts b/src/modules/account/account.repository.ts index 46aa516..d59a25a 100644 --- a/src/modules/account/account.repository.ts +++ b/src/modules/account/account.repository.ts @@ -1,16 +1,18 @@ import { Injectable } from '@nestjs/common'; +import { createClient } from '@supabase/supabase-js'; +import { handleErrors } from 'src/globals/errors'; import { PrismaService } from 'src/prisma/prisma.service'; +import { UploadFileDto } from './account.dto'; import { - NewAccountRepositoryInput, + GetCredential, GetCredentialIdByEmailOutput, - PasswordResetInput, getCredentialIdByRecoveryTokenInput, getCredentialIdByRecoveryTokenOutout, - SavePasswordInput, - GetCredential, + NewAccountRepositoryInput, + PasswordResetInput, SaveAccessTokenInput, + SavePasswordInput, } from './account.entity'; -import { handleErrors } from 'src/globals/errors'; @Injectable() export class AccountRepository { constructor(private prisma: PrismaService) {} @@ -220,4 +222,71 @@ export class AccountRepository { }) .catch((error) => handleErrors(error)); } + + supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY); + bucket = process.env.SUPABASE_BUCKET; + + async uploadFile(file: UploadFileDto, pathFile: string) { + try { + await this.supabase.storage + .from(this.bucket) + .upload(pathFile, file.buffer, { + contentType: file.mimetype, + upsert: true, + }); + + const url = this.supabase.storage + .from(this.bucket) + .getPublicUrl(pathFile); + + return url.data; + } catch (error) {} + } + + async saveProfileImage( + childrenId: number, + parentCredential: string, + path: string, + ) { + try { + if (childrenId) { + await this.prisma.childrenProfile.update({ + where: { id: childrenId }, + data: { profileImageUrl: path }, + }); + } else { + const parentId = await this.getParentId(parentCredential); + await this.prisma.parentProfile.update({ + where: { + id: parentId, + }, + data: { + profileImageUrl: path, + }, + }); + } + } catch (error) { + throw new Error('Erro ao salvar imagem ' + error); + } + } + + async getChildrenId(parentCredential: string) { + const parentId = await this.getParentId(parentCredential); + const childrenId = await this.prisma.childrenProfile.findMany({ + where: { parentId }, + }); + + return childrenId; + } + + async getParentId(email: string) { + const parentId = await this.prisma.credential.findUnique({ + where: { email }, + select: { + parentProfile: { select: { id: true } }, + }, + }); + + return parentId.parentProfile.id; + } } diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 4149607..260219a 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -1,31 +1,37 @@ -import { randomBytes } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { AccountRepository } from './account.repository'; +import { JwtService, JwtSignOptions } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'node:crypto'; +import { + CustomHttpError, + EmailNotFoundException, + ExpiredRecoveryTokenException, + InvalidCredentialsException, + PoliciesException, + TryingEncryptException, + TryingHashException, +} from 'src/globals/responses/exceptions'; +import { + createFilePath, + encryptDataAsync, + hashDataAsync, +} from 'src/globals/utils'; import { - CreateAccountDto, AccessTokenDto, + CreateAccountDto, ResetPasswordDto, SetPasswordDto, + UploadFileDto, } from './account.dto'; -import { encryptDataAsync, hashDataAsync } from 'src/globals/utils'; import { - PasswordResetOutput, - RandomTokenProps, - RandomTokenOutput, FormatLinkProps, iAuthTokenSubject, + PasswordResetOutput, + RandomTokenOutput, + RandomTokenProps, } from './account.entity'; -import { - EmailNotFoundException, - ExpiredRecoveryTokenException, - InvalidCredentialsException, - PoliciesException, - TryingEncryptException, - TryingHashException, -} from 'src/globals/responses/exceptions'; -import { JwtService, JwtSignOptions } from '@nestjs/jwt'; -import * as bcrypt from 'bcrypt'; +import { AccountRepository } from './account.repository'; @Injectable() export class AccountService { @@ -276,12 +282,53 @@ export class AccountService { await this.accountRepository.invalidateToken(existingToken.accessToken); } + async uploadFile( + file: UploadFileDto, + profile: string, + childrenId: number, + parentCredential: string, + ) { + if (childrenId) { + const getChildrenId = await this.accountRepository.getChildrenId( + parentCredential, + ); + const childrenIds = getChildrenId.map((child) => { + return child.id; + }); + + if (!childrenIds.includes(childrenId)) { + throw new CustomHttpError('Id de criança inválido', 400); + } + } + + try { + const pathFile = createFilePath( + file, + profile, + childrenId, + parentCredential, + ); + const data = await this.accountRepository.uploadFile(file, pathFile); + await this.accountRepository.saveProfileImage( + childrenId, + parentCredential, + data.publicUrl, + ); + + return data; + } catch (error) { + throw new CustomHttpError( + 'Erro ao fazer upload do arquivo', + error.status || 500, + ); + } + } + async getCredential(id: string) { const credential = await this.accountRepository.getCredentialId(id); if (!credential) { throw new EmailNotFoundException(); } - return { credential, }; diff --git a/yarn.lock b/yarn.lock index 4523018..4e852f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1277,6 +1277,63 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@supabase/auth-js@2.65.1": + version "2.65.1" + resolved "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.1.tgz" + integrity sha512-IA7i2Xq2SWNCNMKxwmPlHafBQda0qtnFr8QnyyBr+KaSxoXXqEzFCnQ1dGTy6bsZjVBgXu++o3qrDypTspaAPw== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/functions-js@2.4.3": + version "2.4.3" + resolved "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.3.tgz" + integrity sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/node-fetch@^2.6.14", "@supabase/node-fetch@2.6.15": + version "2.6.15" + resolved "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz" + integrity sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ== + dependencies: + whatwg-url "^5.0.0" + +"@supabase/postgrest-js@1.16.3": + version "1.16.3" + resolved "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.3.tgz" + integrity sha512-HI6dsbW68AKlOPofUjDTaosiDBCtW4XAm0D18pPwxoW3zKOE2Ru13Z69Wuys9fd6iTpfDViNco5sgrtnP0666A== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/realtime-js@2.10.7": + version "2.10.7" + resolved "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.7.tgz" + integrity sha512-OLI0hiSAqQSqRpGMTUwoIWo51eUivSYlaNBgxsXZE7PSoWh12wPRdVt0psUMaUzEonSB85K21wGc7W5jHnT6uA== + dependencies: + "@supabase/node-fetch" "^2.6.14" + "@types/phoenix" "^1.5.4" + "@types/ws" "^8.5.10" + ws "^8.14.2" + +"@supabase/storage-js@2.7.1": + version "2.7.1" + resolved "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz" + integrity sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/supabase-js@^2.46.1": + version "2.46.1" + resolved "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.46.1.tgz" + integrity sha512-HiBpd8stf7M6+tlr+/82L8b2QmCjAD8ex9YdSAKU+whB/SHXXJdus1dGlqiH9Umy9ePUuxaYmVkGd9BcvBnNvg== + dependencies: + "@supabase/auth-js" "2.65.1" + "@supabase/functions-js" "2.4.3" + "@supabase/node-fetch" "2.6.15" + "@supabase/postgrest-js" "1.16.3" + "@supabase/realtime-js" "2.10.7" + "@supabase/storage-js" "2.7.1" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz" @@ -1491,6 +1548,11 @@ resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/phoenix@^1.5.4": + version "1.6.5" + resolved "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.5.tgz" + integrity sha512-xegpDuR+z0UqG9fwHqNoy3rI7JDlvaPh2TY47Fl80oq6g+hXT+c/LEuE43X48clZ6lOfANl5WrPur9fYO1RJ/w== + "@types/prop-types@*": version "15.7.12" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz" @@ -1572,6 +1634,13 @@ resolved "https://registry.npmjs.org/@types/validator/-/validator-13.11.2.tgz" integrity sha512-nIKVVQKT6kGKysnNt+xLobr+pFJNssJRi2s034wgWeFBUx01fI8BeHTW2TcRp7VcFu9QCYG8IlChTuovcm0oKQ== +"@types/ws@^8.5.10": + version "8.5.13" + resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz" + integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.1" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.1.tgz" @@ -8378,6 +8447,11 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" +ws@^8.14.2: + version "8.18.0" + resolved "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + xregexp@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz"