diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7878076 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Banco +DB_HOST=db +DB_PORT=5432 +DB_USER=user +DB_PASSWORD=password +DB_NAME=vbio +DB_URL=postgresql://user:password@db:5432/vbio +# Backend +APP_PORT=8080 +JWT_SECRET=segredo +BREVO_API_KEY=*** +REACT_APP_URL=http://localhost:3000 +# Frontend +FRONTEND_PORT=3000 +NEXT_PUBLIC_API_URL=http://localhost:8080/api \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/README.md b/README.md index 9d1f83a..7b28fb0 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,128 @@ -# **TESTE BACKEND** +# 🛠️ Bvio Mono -## SITUAÇÂO-PROBLEMA -A ideia é construir uma página de competição muito simples que incentiva as pessoas a divulgarem notícias sobre compensações de carbono. Os usuários chegam à página e preenchem um formulário de inscrição. Após uma inscrição bem-sucedida, eles ganham um ponto para a competição. Neste momento, eles têm a opção de compartilhar o link de inscrição. Cada inscrição bem-sucedida feita através do link compartilhado dará pontos extras para o autor original do link. Não há limite para o número de pontos que uma pessoa pode obter. No final da competição, as 10 pessoas com mais pontos vencem. +Monorepo da aplicação **Bvio**, uma plataforma com foco em competições, rankings e recompensas via sistema de indicações. ---------------------------------------------------------------------- +Este repositório contém tanto o **backend** (Go) quanto o **frontend** (Next.js), com orquestração via Docker e arquitetura orientada por **DDD (Domain-Driven Design)** no backend. -## REQUISITOS OBRIGATÓRIOS -- O formulário de inscrição consiste em nome, e-mail e números de telefone; -- Quando o formulário é enviado, o usuário ganha um ponto e será direcionado para uma página com opção de compartilhar o link especial; -- Quando as pessoas chegam à página da competição através do link especial, o autor original ganha um ponto extra; -- Após o término da competição, gerar uma tabela dos vencedores; -- Enviar notificação via e-mail para os ganhadores e para cada vez que alguém fizer um ponto a partir do link filiado. -- Seja original, projetos suspeitos de serem copiados serão descartados -- Queremos ver seu codigo, não o de outros. -- Criar coleção no Postman (seu teste será testado por aqui). -- Criar um frontend que consuma a API(React) +--- ---------------------------------------------------------------------- +## ✨ Funcionalidades -## GIT -- Faça um fork deste repositório. -- Crie uma branch para codar as suas features. -- Faça um pull-request quando o teste for finalizado. +- Cadastro de usuários com código de convite único +- Autenticação via JWT +- Atribuição de pontos por convites +- Ranking de usuários por pontuação +- Finalização da competição: + - Envio de e-mails para os 10 primeiros colocados + - Reset da pontuação de todos os usuários -##### **NOTA: Será avaliado também se o nome da branch, títulos de commit, push e comentários possuem boa legibilidade.** +--- ---------------------------------------------------------------------- +## 🧠 Arquitetura e DDD -## FRAMEWORK -- Servidor: Golang(Fiber ou Gin) -- Banco de dados: MongoDB, DynamoDB, MySQL, Postgres... +A arquitetura segue os princípios de Domain-Driven Design: -------------------------------------------------------- +- **Domain Layer**: contém entidades e encapsulamento das regras das mesmas +- **Application Layer**: orquestra os casos de uso +- **Infrastructure Layer**: implementações concretas de repositórios (DB, e-mail) +- **Interface Layer**: entrega da aplicação (ex: HTTP handlers com Gin) -## REQUISITOS DIFERENCIAIS: -- Seguir os princípios de SOLID. -- Codar um código performático. -- Utilizar inglês no projeto todo. -- Utilizar Injeção de dependências. -- Fazer deploy do mesmo (heroku, aws, google cloud ou outro da preferência). +### Benefícios -## ENTREGA +- Alta testabilidade +- Baixo acoplamento +- Clareza na separação de responsabilidades +- Facilita manutenção e extensão de funcionalidades + +--- + +## 🐳 Docker + +A aplicação é inteiramente dockerizada. Para subir o ambiente localmente: + +```bash +docker-compose up --build +``` + +Serviços disponíveis: + +- `backend`: aplicação Go na porta `${APP_PORT}` +- `frontend`: aplicação Next.js na porta `${FRONTEND_PORT}` +- `db`: banco PostgreSQL na porta `5432` + +--- + +## 🔐 Autenticação + +- Feita via **JWT** +- Após login, o token é retornado no corpo da resposta +- Rotas privadas requerem o cabeçalho: + +``` +Authorization: +``` + +--- + +## 🥪 Testes + +Os testes unitários são escritos com `testify` e `testify/mock`. + +### Estrutura dos testes: + +1. Setup de mocks +2. Execução do caso de uso +3. Verificações com `assert` e `AssertExpectations` + +```bash +cd backend +go test ./... +``` + +--- + +## 🌐 Variáveis de Ambiente + +As variáveis estão centralizadas em um único arquivo `.env`: + +```env +# Backend +APP_PORT=8080 + +# Frontend +FRONTEND_PORT=3000 +NEXT_PUBLIC_API_URL=http://localhost:8080 + +# Banco de dados +DB_NAME=bvio +DB_USER=postgres +DB_PASSWORD=secret +``` + +O Docker Compose injeta esse `.env` automaticamente nos serviços. + +--- + +## 🚀 Deploy + +### Backend + +- Hospedado na [Render](https://render.com) + +--- + +## 🤝 Contribuindo + +Contribuições são bem-vindas! Sinta-se à vontade para abrir: + +- Issues com dúvidas, bugs ou sugestões +- Pull Requests com melhorias ou novas funcionalidades + +--- + +## 📬 Contato + +Feito com 💙 por Cássius Queiroz Bessa. + +Se tiver dúvidas ou quiser trocar ideias, abra uma issue ou entre em contato! -- Faça um pull request e nomeie-o como no ex.: Teste de (Seu nome aqui). -- Envie um email para morelli@gss.eco com o link do pull request, do deploy (tanto do front quanto do back se feito), e anexe a coleção do postman. -- Assim que avaliarmos seu teste, enviaremos uma devolutiva de sucesso ou falha, e caso seja aprovado, um link para agendar sua entrevista técnica. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2f71222 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.24.1 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o backend-test ./cmd/main.go + +FROM gcr.io/distroless/base-debian12 + +WORKDIR /app + +COPY --from=builder /app/backend-test . + +EXPOSE 8080 + +CMD ["./backend-test"] diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..ed36291 --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/routes" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found") + } + + db.Connect() + + port := os.Getenv("APP_PORT") + if port == "" { + port = "8080" + } + + r := gin.Default() + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Authorization", "Content-Type"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + api := r.Group("/api") + routes.WithCreateUser(api) + routes.WithLogin(api) + routes.WithLoadUserByToken(api) + routes.WithUsersRanking(api) + routes.WithFinishCompetition(api) + + log.Printf("Server running on port %s 🚀", port) + if err := r.Run(":" + port); err != nil { + log.Fatal(err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a75e242 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,60 @@ +module github.com/cassiusbessa/backend-test + +go 1.24.1 + +require ( + github.com/gin-contrib/cors v1.7.5 + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.36.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/antihax/optional v1.0.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect +) + +require ( + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/getbrevo/brevo-go v1.1.3 + github.com/gin-contrib/sse v1.0.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..b2ba992 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,125 @@ +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/getbrevo/brevo-go v1.1.3 h1:8TYrhhxbfAJLGArlPzCDKzbNfzvjIykBRhTDzLJqmyw= +github.com/getbrevo/brevo-go v1.1.3/go.mod h1:ExhytIoPxt/cOBl6ZEMeEZNLUKrWEYA5U3hM/8WP2bg= +github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk= +github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/backend/internal/application/dto/pagination-dto.go b/backend/internal/application/dto/pagination-dto.go new file mode 100644 index 0000000..d8fac7b --- /dev/null +++ b/backend/internal/application/dto/pagination-dto.go @@ -0,0 +1,6 @@ +package dto + +type PaginationInput struct { + Page int + Limit int +} diff --git a/backend/internal/application/dto/user-dto.go b/backend/internal/application/dto/user-dto.go new file mode 100644 index 0000000..5e12e53 --- /dev/null +++ b/backend/internal/application/dto/user-dto.go @@ -0,0 +1,41 @@ +package dto + +type CreateUserInput struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + Phone string `json:"phone" binding:"required"` + InviteCode *string `json:"invite_code" binding:"omitempty"` +} + +type CreateUserOutput struct { + UserID string `json:"user_id"` +} + +type LoginInput struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type LoginOutput struct { + Token string `json:"token"` +} + +type LoadedUserOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + LinkCode string `json:"link_code"` + Points int `json:"points"` +} + +type GetUsersRankingInput struct { + PaginationInput +} + +type UserRankingItem struct { + UserID string `json:"user_id"` + Name string `json:"name"` + Points int `json:"points"` +} diff --git a/backend/internal/application/ports/input/create-user-usecase.go b/backend/internal/application/ports/input/create-user-usecase.go new file mode 100644 index 0000000..dff6c9f --- /dev/null +++ b/backend/internal/application/ports/input/create-user-usecase.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/application/dto" + +type CreateUserUseCase interface { + Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) +} diff --git a/backend/internal/application/ports/input/finish-competition-usecase.go b/backend/internal/application/ports/input/finish-competition-usecase.go new file mode 100644 index 0000000..2436b82 --- /dev/null +++ b/backend/internal/application/ports/input/finish-competition-usecase.go @@ -0,0 +1,5 @@ +package input_ports + +type FinishCompetitionUseCase interface { + Execute() error +} diff --git a/backend/internal/application/ports/input/get-users-ranking-usecase.go b/backend/internal/application/ports/input/get-users-ranking-usecase.go new file mode 100644 index 0000000..9311e0a --- /dev/null +++ b/backend/internal/application/ports/input/get-users-ranking-usecase.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/application/dto" + +type GetUsersRankingUseCase interface { + Execute(input dto.GetUsersRankingInput) ([]dto.UserRankingItem, error) +} diff --git a/backend/internal/application/ports/input/load-user-by-token.go b/backend/internal/application/ports/input/load-user-by-token.go new file mode 100644 index 0000000..ba61983 --- /dev/null +++ b/backend/internal/application/ports/input/load-user-by-token.go @@ -0,0 +1,9 @@ +package input_ports + +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" +) + +type LoadUserByTokenUseCase interface { + Execute(token string) (*dto.LoadedUserOutput, error) +} diff --git a/backend/internal/application/ports/input/login-usecase.go b/backend/internal/application/ports/input/login-usecase.go new file mode 100644 index 0000000..2fdeb84 --- /dev/null +++ b/backend/internal/application/ports/input/login-usecase.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/application/dto" + +type LoginUseCase interface { + Execute(input dto.LoginInput) (dto.LoginOutput, error) +} diff --git a/backend/internal/application/ports/output/email-service.go b/backend/internal/application/ports/output/email-service.go new file mode 100644 index 0000000..d008b61 --- /dev/null +++ b/backend/internal/application/ports/output/email-service.go @@ -0,0 +1,5 @@ +package output_ports + +type EmailService interface { + SendEmail(to string, subject string, body string) error +} diff --git a/backend/internal/application/ports/output/token-service.go b/backend/internal/application/ports/output/token-service.go new file mode 100644 index 0000000..eed3c16 --- /dev/null +++ b/backend/internal/application/ports/output/token-service.go @@ -0,0 +1,6 @@ +package output_ports + +type TokenService interface { + GenerateToken(userID string) (string, error) + ValidateToken(token string) (string, error) +} diff --git a/backend/internal/application/ports/output/user-repository.go b/backend/internal/application/ports/output/user-repository.go new file mode 100644 index 0000000..30db261 --- /dev/null +++ b/backend/internal/application/ports/output/user-repository.go @@ -0,0 +1,12 @@ +package output_ports + +import "github.com/cassiusbessa/backend-test/internal/domain/entities" + +type UserRepository interface { + Save(user entities.User) error + FindByEmail(email string) (*entities.User, error) + FindByID(id string) (*entities.User, error) + FindByInviteCode(inviteCode string) (*entities.User, error) + FindUsersOrderedByPoints(page int, limit int) ([]entities.User, error) + ResetAllScores() error +} diff --git a/backend/internal/application/use-cases/create-user.go b/backend/internal/application/use-cases/create-user.go new file mode 100644 index 0000000..c10f33b --- /dev/null +++ b/backend/internal/application/use-cases/create-user.go @@ -0,0 +1,124 @@ +package usecases + +import ( + "log" + "strconv" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" +) + +type CreateUserUseCase struct { + userRepo output_ports.UserRepository + emailService output_ports.EmailService +} + +func NewCreateUserUseCase(repo output_ports.UserRepository, emailService output_ports.EmailService) *CreateUserUseCase { + return &CreateUserUseCase{userRepo: repo, emailService: emailService} +} + +func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) { + newUser, err := uc.createUserDTOToUser(input) + if err != nil { + return dto.CreateUserOutput{}, err + } + + existingUser, err := uc.userRepo.FindByEmail(input.Email) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrInternal + } + if existingUser != nil { + return dto.CreateUserOutput{}, shared.ErrConflictError + } + + err = uc.userRepo.Save(*newUser) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrInternal + } + + err = uc.processInviteCode(input.InviteCode) + if err != nil { + return dto.CreateUserOutput{}, err + } + + return dto.CreateUserOutput{UserID: newUser.ID().String()}, nil +} + +func (uc *CreateUserUseCase) createUserDTOToUser(input dto.CreateUserInput) (*entities.User, error) { + email, err := object_values.NewEmail(input.Email) + if err != nil { + log.Println("Error creating email:", err) + return nil, err + } + + password, err := object_values.NewPassword(input.Password) + if err != nil { + log.Println("Error creating password:", err) + return nil, err + } + + phone, err := object_values.NewPhoneNumber(input.Phone) + if err != nil { + log.Println("Error creating phone number:", err) + return nil, err + } + + var inviteCode *uuid.UUID + if input.InviteCode != nil && *input.InviteCode != "" { + inviteCodeUUID, err := uuid.Parse(*input.InviteCode) + if err != nil { + log.Println("Error parsing invite code:", err) + return nil, err + } + inviteCode = &inviteCodeUUID + } + + user, err := entities.NewUser(input.Name, email, password, phone, inviteCode) + if err != nil { + log.Println("Error creating user:", err) + return nil, err + } + + return &user, nil +} + +func (uc *CreateUserUseCase) processInviteCode(inviteCode *string) error { + if inviteCode == nil || *inviteCode == "" { + return nil + } + + inviter, err := uc.userRepo.FindByInviteCode(*inviteCode) + if err != nil { + return shared.ErrInternal + } + + if inviter == nil { + return shared.ErrNotFound + } + + inviter.AddPoint() + if err := uc.userRepo.Save(*inviter); err != nil { + return shared.ErrInternal + } + + if err := uc.emailService.SendEmail( + inviter.Email().Value(), + "New user invited by you", + uc.emailConfirmationToInviterBody(*inviter), + ); err != nil { + return shared.ErrInternal + } + + return nil +} + +func (uc *CreateUserUseCase) emailConfirmationToInviterBody(inviter entities.User) string { + return "Hello " + inviter.Name() + ",\n\n" + + "Now you have " + strconv.Itoa(inviter.Points()) + " points.\n\n" + + "Best regards,\n" + + "bvio" +} diff --git a/backend/internal/application/use-cases/create-user_test.go b/backend/internal/application/use-cases/create-user_test.go new file mode 100644 index 0000000..f029920 --- /dev/null +++ b/backend/internal/application/use-cases/create-user_test.go @@ -0,0 +1,228 @@ +package usecases + +import ( + "strings" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func setupTest() (*mocks.MockUserRepository, *mocks.MockEmailService, *CreateUserUseCase) { + mockRepo := mocks.NewMockUserRepository() + mockEmail := mocks.NewMockEmailService() + useCase := NewCreateUserUseCase(mockRepo, mockEmail) + return mockRepo, mockEmail, useCase +} + +func TestCreateUserUseCase_Execute(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockRepo, _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("Save", mock.Anything).Return(nil) + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) + }) + + t.Run("invalid email", func(t *testing.T) { + _, _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "invalid-email", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrValidation) + }) + + t.Run("invalid phone", func(t *testing.T) { + _, _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "1234", + } + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrValidation) + }) + + t.Run("invalid password", func(t *testing.T) { + _, _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "123", + Phone: "+5511987654321", + } + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrValidation) + }) + + t.Run("user already exists", func(t *testing.T) { + mockRepo, _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + email, _ := object_values.NewEmail(input.Email) + phone, _ := object_values.NewPhoneNumber(input.Phone) + password, _ := object_values.NewPassword(input.Password) + + existingUser, _ := entities.NewUser("John Doe", email, password, phone, nil) + + mockRepo.On("FindByEmail", input.Email).Return(&existingUser, nil) + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrConflictError) + mockRepo.AssertNotCalled(t, "Save") + mockRepo.AssertExpectations(t) + }) + + t.Run("repository save error", func(t *testing.T) { + mockRepo, _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("Save", mock.Anything).Return(shared.ErrInternal) + + _, err := useCase.Execute(input) + + assert.EqualError(t, err, shared.ErrInternal.Error()) + mockRepo.AssertExpectations(t) + }) + + t.Run("invited user", func(t *testing.T) { + mockRepo, mockEmail, useCase := setupTest() + + validEmail, _ := object_values.NewEmail("inviter@example.com") + validPhone, _ := object_values.NewPhoneNumber("+5511987654321") + validPassword, _ := object_values.NewPassword("StrongP@ssw0rd") + inviterCode := uuid.NewString() + + inviter := entities.LoadUser( + uuid.New(), + "Inviter", + validEmail, + validPassword, + validPhone, + inviterCode, + nil, + 1, + ) + + input := dto.CreateUserInput{ + Name: "New User", + Email: "newuser@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511999999999", + InviteCode: &inviterCode, + } + + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("FindByInviteCode", *input.InviteCode).Return(&inviter, nil) + mockRepo.On("Save", mock.Anything).Return(nil) + mockEmail.On( + "SendEmail", + inviter.Email().Value(), + "New user invited by you", + mock.MatchedBy(func(body string) bool { + return strings.Contains(body, "Hello Inviter") && + strings.Contains(body, "points") && + strings.Contains(body, "bvio") + }), + ).Return(nil).Once() + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) + mockEmail.AssertExpectations(t) + }) + + t.Run("should return error if invite code is invalid", func(t *testing.T) { + mockRepo, mockEmail, useCase := setupTest() + + badCode := "invalid-uuid" + + input := dto.CreateUserInput{ + Name: "Errored User", + Email: "errored@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511999999999", + InviteCode: &badCode, + } + + output, err := useCase.Execute(input) + + assert.Error(t, err) + assert.Empty(t, output.UserID) + mockRepo.AssertExpectations(t) + mockEmail.AssertExpectations(t) + }) + + t.Run("should handle empty string invite code gracefully", func(t *testing.T) { + mockRepo, mockEmail, useCase := setupTest() + + emptyCode := "" + + input := dto.CreateUserInput{ + Name: "User Without Code", + Email: "userwithout@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511999999999", + InviteCode: &emptyCode, + } + + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("Save", mock.Anything).Return(nil) + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) + mockEmail.AssertExpectations(t) + }) + +} diff --git a/backend/internal/application/use-cases/finish-competition.go b/backend/internal/application/use-cases/finish-competition.go new file mode 100644 index 0000000..11f7124 --- /dev/null +++ b/backend/internal/application/use-cases/finish-competition.go @@ -0,0 +1,61 @@ +package usecases + +import ( + "fmt" + "sync" + + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" +) + +type FinishCompetitionUseCase struct { + userRepo output_ports.UserRepository + emailService output_ports.EmailService +} + +func NewFinishCompetitionUseCase(userRepo output_ports.UserRepository, emailService output_ports.EmailService) *FinishCompetitionUseCase { + return &FinishCompetitionUseCase{ + userRepo: userRepo, + emailService: emailService, + } +} + +func (uc *FinishCompetitionUseCase) Execute() error { + topUsers, err := uc.userRepo.FindUsersOrderedByPoints(0, 10) + if err != nil { + return fmt.Errorf("failed to get top users: %w", err) + } + + var wg sync.WaitGroup + errCh := make(chan error, len(topUsers)) + + for i, user := range topUsers { + wg.Add(1) + go uc.sendEmailAsync(i+1, user.Name(), user.Email().Value(), &wg, errCh) + } + + wg.Wait() + close(errCh) + + for err := range errCh { + if err != nil { + return err + } + } + + if err := uc.userRepo.ResetAllScores(); err != nil { + return fmt.Errorf("failed to reset scores: %w", err) + } + + return nil +} + +func (uc *FinishCompetitionUseCase) sendEmailAsync(rank int, name, email string, wg *sync.WaitGroup, errCh chan<- error) { + defer wg.Done() + + subject := fmt.Sprintf("🏆 Parabéns! Você foi Top %d", rank) + body := fmt.Sprintf("Olá %s,\n\nVocê ficou entre os 10 melhores da competição!\nParabéns por sua dedicação e esforço.\n\nEquipe vbio 🚀", name) + + if err := uc.emailService.SendEmail(email, subject, body); err != nil { + errCh <- fmt.Errorf("failed to send email to %s: %w", email, err) + } +} diff --git a/backend/internal/application/use-cases/finish-competition_test.go b/backend/internal/application/use-cases/finish-competition_test.go new file mode 100644 index 0000000..6e9726a --- /dev/null +++ b/backend/internal/application/use-cases/finish-competition_test.go @@ -0,0 +1,59 @@ +package usecases + +import ( + "strconv" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func setupFinishCompetitionTest() (*mocks.MockUserRepository, *mocks.MockEmailService, *FinishCompetitionUseCase) { + repo := mocks.NewMockUserRepository() + email := mocks.NewMockEmailService() + usecase := NewFinishCompetitionUseCase(repo, email) + return repo, email, usecase +} + +func TestFinishCompetitionUseCase_Execute(t *testing.T) { + t.Run("should send emails to top 10 and reset scores", func(t *testing.T) { + repo, email, usecase := setupFinishCompetitionTest() + + topUsers := []entities.User{} + for i := range 10 { + emailStr := "user" + strconv.Itoa(i) + "@example.com" + phoneStr := "+551199999999" + strconv.Itoa(i) + + u := entities.LoadUser( + uuid.New(), + "User", + mocks.MustEmail(t, emailStr), + mocks.MustPassword(t, "StrongP@ssw0rd"), + mocks.MustPhone(t, phoneStr), + uuid.NewString(), + nil, + 100-i, + ) + topUsers = append(topUsers, u) + + email.On( + "SendEmail", + u.Email().Value(), + mock.Anything, + mock.Anything, + ).Return(nil).Once() + } + + repo.On("FindUsersOrderedByPoints", 0, 10).Return(topUsers, nil).Once() + repo.On("ResetAllScores").Return(nil).Once() + + err := usecase.Execute() + + assert.NoError(t, err) + repo.AssertExpectations(t) + email.AssertExpectations(t) + }) +} diff --git a/backend/internal/application/use-cases/load-user-by-token.go b/backend/internal/application/use-cases/load-user-by-token.go new file mode 100644 index 0000000..8efa935 --- /dev/null +++ b/backend/internal/application/use-cases/load-user-by-token.go @@ -0,0 +1,53 @@ +package usecases + +import ( + "os" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/entities" +) + +type LoadUserByTokenUseCase struct { + userRepo output_ports.UserRepository + tokenService output_ports.TokenService +} + +func NewLoadUserByTokenUseCase( + userRepo output_ports.UserRepository, + tokenService output_ports.TokenService, +) input_ports.LoadUserByTokenUseCase { + return LoadUserByTokenUseCase{ + userRepo: userRepo, + tokenService: tokenService, + } +} + +func (uc LoadUserByTokenUseCase) Execute(token string) (*dto.LoadedUserOutput, error) { + userID, err := uc.tokenService.ValidateToken(token) + if err != nil { + return nil, err + } + + user, err := uc.userRepo.FindByID(userID) + if err != nil { + return nil, err + } + + if user == nil { + return nil, nil + } + return &dto.LoadedUserOutput{ + ID: user.ID().String(), + Name: user.Name(), + Email: user.Email().Value(), + Phone: user.Phone().Value(), + LinkCode: uc.codeToLink(*user), + Points: user.Points(), + }, nil +} + +func (uc LoadUserByTokenUseCase) codeToLink(u entities.User) string { + return os.Getenv("REACT_APP_URL") + "/?" + "invite=" + u.InviteCode() +} diff --git a/backend/internal/application/use-cases/load-user-by-token_test.go b/backend/internal/application/use-cases/load-user-by-token_test.go new file mode 100644 index 0000000..cd28707 --- /dev/null +++ b/backend/internal/application/use-cases/load-user-by-token_test.go @@ -0,0 +1,88 @@ +package usecases_test + +import ( + "testing" + + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func validEmail() object_values.Email { + email, _ := object_values.NewEmail("john@example.com") + return email +} + +func validPhone() object_values.PhoneNumber { + phone, _ := object_values.NewPhoneNumber("11999999999") + return phone +} + +func validPassword() object_values.Password { + password, _ := object_values.NewPassword("123456") + return password +} + +func setupLoadUserByTokenTest() (*mocks.MockUserRepository, *mocks.MockTokenService, input_ports.LoadUserByTokenUseCase) { + userRepo := mocks.NewMockUserRepository() + tokenService := mocks.NewMockTokenService() + return userRepo, tokenService, usecases.NewLoadUserByTokenUseCase(userRepo, tokenService) +} + +func TestLoadUserByTokenUseCase_Execute(t *testing.T) { + t.Run("should return user when token is valid", func(t *testing.T) { + userRepo, tokenService, useCase := setupLoadUserByTokenTest() + + token := "valid-token" + userID := uuid.New() + expectedUser, _ := entities.NewUser("John Doe", validEmail(), validPassword(), validPhone(), nil) + + tokenService.On("ValidateToken", token).Return(userID.String(), nil) + userRepo.On("FindByID", userID.String()).Return(&expectedUser, nil) + + user, err := useCase.Execute(token) + + assert.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, expectedUser.Name(), user.Name) + assert.Equal(t, expectedUser.Email().Value(), user.Email) + assert.Equal(t, expectedUser.Phone().Value(), user.Phone) + + tokenService.AssertExpectations(t) + userRepo.AssertExpectations(t) + }) + + t.Run("should return error when token is invalid", func(t *testing.T) { + _, tokenService, useCase := setupLoadUserByTokenTest() + + token := "invalid-token" + tokenService.On("ValidateToken", token).Return("", shared.ErrAuthorization) + + user, err := useCase.Execute(token) + + assert.ErrorIs(t, err, shared.ErrAuthorization) + assert.Nil(t, user) + tokenService.AssertExpectations(t) + }) + + t.Run("should return error when user is not found", func(t *testing.T) { + userRepo, tokenService, useCase := setupLoadUserByTokenTest() + + token := "valid-token" + userID := uuid.New().String() + tokenService.On("ValidateToken", token).Return(userID, nil) + userRepo.On("FindByID", userID).Return(nil, shared.ErrNotFound) + + user, err := useCase.Execute(token) + + assert.ErrorIs(t, err, shared.ErrNotFound) + assert.Nil(t, user) + tokenService.AssertExpectations(t) + userRepo.AssertExpectations(t) + }) +} diff --git a/backend/internal/application/use-cases/load-users-ordered-by-points.go b/backend/internal/application/use-cases/load-users-ordered-by-points.go new file mode 100644 index 0000000..5f76f5f --- /dev/null +++ b/backend/internal/application/use-cases/load-users-ordered-by-points.go @@ -0,0 +1,37 @@ +package usecases + +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" +) + +type LoadUsersOrderedByPointsUseCase struct { + userRepo output_ports.UserRepository +} + +func NewLoadUsersOrderedByPointsUseCase(userRepo output_ports.UserRepository) input_ports.GetUsersRankingUseCase { + return LoadUsersOrderedByPointsUseCase{ + userRepo: userRepo, + } +} + +func (uc LoadUsersOrderedByPointsUseCase) Execute(input dto.GetUsersRankingInput) ([]dto.UserRankingItem, error) { + users, err := uc.userRepo.FindUsersOrderedByPoints(input.Page, input.Limit) + if err != nil { + return nil, err + } + + usersOutputs := make([]dto.UserRankingItem, len(users)) + + for i, user := range users { + usersOutputs[i] = dto.UserRankingItem{ + UserID: user.ID().String(), + Name: user.Name(), + Points: user.Points(), + } + } + + return usersOutputs, nil + +} diff --git a/backend/internal/application/use-cases/load-users-ordered-by-points_test.go b/backend/internal/application/use-cases/load-users-ordered-by-points_test.go new file mode 100644 index 0000000..937c047 --- /dev/null +++ b/backend/internal/application/use-cases/load-users-ordered-by-points_test.go @@ -0,0 +1,69 @@ +package usecases_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/google/uuid" +) + +func TestLoadUsersOrderedByPointsUseCase_Execute(t *testing.T) { + t.Run("successfully returns ranked users", func(t *testing.T) { + mockRepo := mocks.NewMockUserRepository() + useCase := usecases.NewLoadUsersOrderedByPointsUseCase(mockRepo) + + email, _ := object_values.NewEmail("user1@example.com") + phone, _ := object_values.NewPhoneNumber("+5511999999999") + password, _ := object_values.NewPassword("StrongP@ssw0rd") + user1 := entities.LoadUser(uuid.New(), "Alice", email, password, phone, "", nil, 10) + + email2, _ := object_values.NewEmail("user2@example.com") + phone2, _ := object_values.NewPhoneNumber("+5511988888888") + password2, _ := object_values.NewPassword("AnotherP@ss") + user2 := entities.LoadUser(uuid.New(), "Bob", email2, password2, phone2, "", nil, 5) + + mockRepo.On("FindUsersOrderedByPoints", 1, 10).Return([]entities.User{user1, user2}, nil) + + input := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.Len(t, output, 2) + assert.Equal(t, "Alice", output[0].Name) + assert.Equal(t, 10, output[0].Points) + assert.Equal(t, "Bob", output[1].Name) + assert.Equal(t, 5, output[1].Points) + mockRepo.AssertExpectations(t) + }) + + t.Run("repository returns error", func(t *testing.T) { + mockRepo := mocks.NewMockUserRepository() + useCase := usecases.NewLoadUsersOrderedByPointsUseCase(mockRepo) + + mockRepo.On("FindUsersOrderedByPoints", 1, 10).Return(nil, errors.New("db error")) + + input := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + output, err := useCase.Execute(input) + + assert.Error(t, err) + assert.Nil(t, output) + mockRepo.AssertExpectations(t) + }) +} diff --git a/backend/internal/application/use-cases/login.go b/backend/internal/application/use-cases/login.go new file mode 100644 index 0000000..a24e981 --- /dev/null +++ b/backend/internal/application/use-cases/login.go @@ -0,0 +1,44 @@ +package usecases + +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type LoginUserUseCase struct { + userRepo output_ports.UserRepository + tokenService output_ports.TokenService +} + +func NewLoginUserUseCase( + userRepo output_ports.UserRepository, + tokenService output_ports.TokenService, +) *LoginUserUseCase { + return &LoginUserUseCase{ + userRepo: userRepo, + tokenService: tokenService, + } +} + +func (uc *LoginUserUseCase) Execute(input dto.LoginInput) (dto.LoginOutput, error) { + user, err := uc.userRepo.FindByEmail(input.Email) + if err != nil { + return dto.LoginOutput{}, shared.ErrInternal + } + + if user == nil { + return dto.LoginOutput{}, shared.ErrAuthorization + } + + if !user.Password().Compare(input.Password) { + return dto.LoginOutput{}, shared.ErrAuthorization + } + + token, err := uc.tokenService.GenerateToken(user.ID().String()) + if err != nil { + return dto.LoginOutput{}, shared.ErrInternal + } + + return dto.LoginOutput{Token: token}, nil +} diff --git a/backend/internal/application/use-cases/login_test.go b/backend/internal/application/use-cases/login_test.go new file mode 100644 index 0000000..1e36241 --- /dev/null +++ b/backend/internal/application/use-cases/login_test.go @@ -0,0 +1,85 @@ +package usecases_test + +import ( + "errors" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/stretchr/testify/assert" +) + +func setupTest() (*mocks.MockUserRepository, *mocks.MockTokenService, *usecases.LoginUserUseCase) { + mockRepo := mocks.NewMockUserRepository() + mockTokenService := mocks.NewMockTokenService() + useCase := usecases.NewLoginUserUseCase(mockRepo, mockTokenService) + return mockRepo, mockTokenService, useCase +} + +func TestLoginUserUseCase_Execute(t *testing.T) { + mockRepo, mockTokenService, useCase := setupTest() + + t.Run("success", func(t *testing.T) { + + password, _ := object_values.NewPassword("123456") + email, _ := object_values.NewEmail("test@example.com") + phoneNumber, _ := object_values.NewPhoneNumber("1234567890") + + user, _ := entities.NewUser("teste", email, password, phoneNumber, nil) + + input := dto.LoginInput{Email: "test@example.com", Password: "123456"} + expectedToken := "fake.jwt.token" + + mockRepo.On("FindByEmail", input.Email).Return(&user, nil) + mockTokenService.On("GenerateToken", user.ID().String()).Return(expectedToken, nil) + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.Equal(t, expectedToken, output.Token) + }) + + t.Run("user not found", func(t *testing.T) { + input := dto.LoginInput{Email: "missing@example.com", Password: "any"} + + mockRepo.On("FindByEmail", input.Email).Return(nil, errors.New("not found")) + + _, err := useCase.Execute(input) + assert.ErrorIs(t, err, shared.ErrNotFound) + }) + + t.Run("invalid password", func(t *testing.T) { + email, _ := object_values.NewEmail("user@example.com") + password, _ := object_values.NewPassword("correct-password") + phone, _ := object_values.NewPhoneNumber("1234567890") + + user, _ := entities.NewUser("User", email, password, phone, nil) + + input := dto.LoginInput{Email: "user@example.com", Password: "wrong-password"} + + mockRepo.On("FindByEmail", input.Email).Return(&user, nil) + + _, err := useCase.Execute(input) + assert.ErrorIs(t, err, shared.ErrAuthorization) + }) + + t.Run("token generation error", func(t *testing.T) { + email, _ := object_values.NewEmail("user2@example.com") + password, _ := object_values.NewPassword("pass123") + phone, _ := object_values.NewPhoneNumber("1234567890") + + user, _ := entities.NewUser("User", email, password, phone, nil) + + input := dto.LoginInput{Email: "user2@example.com", Password: "pass123"} + + mockRepo.On("FindByEmail", input.Email).Return(&user, nil) + mockTokenService.On("GenerateToken", user.ID().String()).Return("", errors.New("token error")) + + _, err := useCase.Execute(input) + assert.ErrorIs(t, err, shared.ErrInternal) + }) +} diff --git a/backend/internal/application/use-cases/mocks/object-values.go b/backend/internal/application/use-cases/mocks/object-values.go new file mode 100644 index 0000000..3b7aa0c --- /dev/null +++ b/backend/internal/application/use-cases/mocks/object-values.go @@ -0,0 +1,26 @@ +package mocks + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/stretchr/testify/assert" +) + +func MustEmail(t *testing.T, value string) object_values.Email { + email, err := object_values.NewEmail(value) + assert.NoError(t, err) + return email +} + +func MustPassword(t *testing.T, value string) object_values.Password { + pass, err := object_values.NewPassword(value) + assert.NoError(t, err) + return pass +} + +func MustPhone(t *testing.T, value string) object_values.PhoneNumber { + phone, err := object_values.NewPhoneNumber(value) + assert.NoError(t, err) + return phone +} diff --git a/backend/internal/application/use-cases/mocks/token-service.go b/backend/internal/application/use-cases/mocks/token-service.go new file mode 100644 index 0000000..8e6bfb9 --- /dev/null +++ b/backend/internal/application/use-cases/mocks/token-service.go @@ -0,0 +1,21 @@ +package mocks + +import "github.com/stretchr/testify/mock" + +type MockTokenService struct { + mock.Mock +} + +func (m *MockTokenService) GenerateToken(userID string) (string, error) { + args := m.Called(userID) + return args.String(0), args.Error(1) +} + +func (m *MockTokenService) ValidateToken(token string) (string, error) { + args := m.Called(token) + return args.String(0), args.Error(1) +} + +func NewMockTokenService() *MockTokenService { + return new(MockTokenService) +} diff --git a/backend/internal/application/use-cases/mocks/user-repository.go b/backend/internal/application/use-cases/mocks/user-repository.go new file mode 100644 index 0000000..61b3fef --- /dev/null +++ b/backend/internal/application/use-cases/mocks/user-repository.go @@ -0,0 +1,62 @@ +package mocks + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/entities" + "github.com/stretchr/testify/mock" +) + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) FindByEmail(email string) (*entities.User, error) { + args := m.Called(email) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockUserRepository) Save(user entities.User) error { + args := m.Called(user) + if err := args.Error(0); err != nil { + return err + } + return nil +} + +func (m *MockUserRepository) FindByID(id string) (*entities.User, error) { + args := m.Called(id) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockUserRepository) FindByInviteCode(inviteCode string) (*entities.User, error) { + args := m.Called(inviteCode) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockUserRepository) FindUsersOrderedByPoints(page int, limit int) ([]entities.User, error) { + args := m.Called(page, limit) + if users := args.Get(0); users != nil { + return users.([]entities.User), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockUserRepository) ResetAllScores() error { + args := m.Called() + if err := args.Error(0); err != nil { + return err + } + return nil +} + +func NewMockUserRepository() *MockUserRepository { + return new(MockUserRepository) +} diff --git a/backend/internal/application/use-cases/mocks/user-service.go b/backend/internal/application/use-cases/mocks/user-service.go new file mode 100644 index 0000000..1a62a19 --- /dev/null +++ b/backend/internal/application/use-cases/mocks/user-service.go @@ -0,0 +1,16 @@ +package mocks + +import "github.com/stretchr/testify/mock" + +type MockEmailService struct { + mock.Mock +} + +func NewMockEmailService() *MockEmailService { + return &MockEmailService{} +} + +func (m *MockEmailService) SendEmail(to string, subject string, body string) error { + args := m.Called(to, subject, body) + return args.Error(0) +} diff --git a/backend/internal/domain/entities/user.go b/backend/internal/domain/entities/user.go new file mode 100644 index 0000000..58b6b1a --- /dev/null +++ b/backend/internal/domain/entities/user.go @@ -0,0 +1,99 @@ +package entities + +import ( + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" +) + +type User struct { + id uuid.UUID + name string + email object_values.Email + phone object_values.PhoneNumber + password object_values.Password + inviteCode string + invitedBy *uuid.UUID + points int +} + +func NewUser( + name string, + email object_values.Email, + hashedPass object_values.Password, + phone object_values.PhoneNumber, + invitedBy *uuid.UUID, +) (User, error) { + if name == "" { + return User{}, shared.ErrValidation + } + + return User{ + id: uuid.New(), + name: name, + email: email, + phone: phone, + password: hashedPass, + inviteCode: uuid.NewString(), + invitedBy: invitedBy, + points: 1, + }, nil +} + +func LoadUser( + id uuid.UUID, + name string, + email object_values.Email, + hashedPass object_values.Password, + phone object_values.PhoneNumber, + inviteCode string, + invitedBy *uuid.UUID, + points int, +) User { + return User{ + id: id, + name: name, + email: email, + phone: phone, + password: hashedPass, + inviteCode: inviteCode, + invitedBy: invitedBy, + points: points, + } +} + +func (u User) ID() uuid.UUID { + return u.id +} + +func (u User) Name() string { + return u.name +} + +func (u User) Email() object_values.Email { + return u.email +} + +func (u User) Password() object_values.Password { + return u.password +} + +func (u User) Phone() object_values.PhoneNumber { + return u.phone +} + +func (u User) InviteCode() string { + return u.inviteCode +} + +func (u User) InvitedBy() *uuid.UUID { + return u.invitedBy +} + +func (u *User) AddPoint() { + u.points++ +} + +func (u User) Points() int { + return u.points +} diff --git a/backend/internal/domain/entities/user_test.go b/backend/internal/domain/entities/user_test.go new file mode 100644 index 0000000..86169f4 --- /dev/null +++ b/backend/internal/domain/entities/user_test.go @@ -0,0 +1,38 @@ +package entities_test + +import ( + "testing" + + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/google/uuid" +) + +func TestNewUser(t *testing.T) { + + tests := []struct { + name string + email string + phone string + invitedBy *uuid.UUID + expectError bool + }{ + {"John Doe", "user@example.com", "+14155552671", nil, false}, + {"", "user@example.com", "+14155552671", nil, true}, + } + + for _, tt := range tests { + email, _ := object_values.NewEmail(tt.email) + phone, _ := object_values.NewPhoneNumber(tt.phone) + password, _ := object_values.NewPassword("defaultPassword123") + + u, err := entities.NewUser(tt.name, email, password, phone, tt.invitedBy) + if (err != nil) != tt.expectError { + t.Errorf("NewUser(%s, %s, %s) expected error: %v, got: %v", tt.name, tt.email, tt.phone, tt.expectError, err) + } + + if err == nil && u.Points() != 1 { + t.Errorf("expected initial points to be 1, got %d", u.Points()) + } + } +} diff --git a/backend/internal/domain/object-values/email.go b/backend/internal/domain/object-values/email.go new file mode 100644 index 0000000..f29ea4d --- /dev/null +++ b/backend/internal/domain/object-values/email.go @@ -0,0 +1,26 @@ +package object_values + +import ( + "regexp" + + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type Email struct { + value string +} + +var ( + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) +) + +func NewEmail(email string) (Email, error) { + if !emailRegex.MatchString(email) { + return Email{}, shared.ErrValidation + } + return Email{value: email}, nil +} + +func (e Email) Value() string { + return e.value +} diff --git a/backend/internal/domain/object-values/email_test.go b/backend/internal/domain/object-values/email_test.go new file mode 100644 index 0000000..7a6b39b --- /dev/null +++ b/backend/internal/domain/object-values/email_test.go @@ -0,0 +1,27 @@ +package object_values_test + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewEmail(t *testing.T) { + tests := []struct { + email string + expectError bool + }{ + {"test@example.com", false}, + {"invalidemail", true}, + {"@nouser.com", true}, + {"nodomain@", true}, + {"", true}, + } + + for _, tt := range tests { + _, err := object_values.NewEmail(tt.email) + if (err != nil) != tt.expectError { + t.Errorf("NewEmail(%s) expected error: %v, got: %v", tt.email, tt.expectError, err) + } + } +} diff --git a/backend/internal/domain/object-values/password.go b/backend/internal/domain/object-values/password.go new file mode 100644 index 0000000..6cd56cb --- /dev/null +++ b/backend/internal/domain/object-values/password.go @@ -0,0 +1,36 @@ +package object_values + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "golang.org/x/crypto/bcrypt" +) + +type Password struct { + hash string +} + +func NewPassword(plainText string) (Password, error) { + if len(plainText) < 6 { + return Password{}, shared.ErrValidation + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost) + if err != nil { + return Password{}, err + } + + return Password{hash: string(hashed)}, nil +} + +func NewPasswordFromHash(hash string) Password { + return Password{hash: hash} +} + +func (p Password) Hash() string { + return p.hash +} + +func (p Password) Compare(plainText string) bool { + err := bcrypt.CompareHashAndPassword([]byte(p.hash), []byte(plainText)) + return err == nil +} diff --git a/backend/internal/domain/object-values/password_test.go b/backend/internal/domain/object-values/password_test.go new file mode 100644 index 0000000..942d068 --- /dev/null +++ b/backend/internal/domain/object-values/password_test.go @@ -0,0 +1,37 @@ +package object_values_test + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewPassword(t *testing.T) { + tests := []struct { + password string + expectError bool + }{ + {"password123", false}, + {"short", true}, + {"", true}, + } + + for _, tt := range tests { + _, err := object_values.NewPassword(tt.password) + if (err != nil) != tt.expectError { + t.Errorf("NewPassword(%q) expected error: %v, got: %v", tt.password, tt.expectError, err) + } + } +} + +func TestPasswordCompare(t *testing.T) { + pass, _ := object_values.NewPassword("password123") + + if !pass.Compare("password123") { + t.Errorf("Expected password to match") + } + + if pass.Compare("wrongpassword") { + t.Errorf("Expected password to NOT match") + } +} diff --git a/backend/internal/domain/object-values/phone-number.go b/backend/internal/domain/object-values/phone-number.go new file mode 100644 index 0000000..9fad383 --- /dev/null +++ b/backend/internal/domain/object-values/phone-number.go @@ -0,0 +1,20 @@ +package object_values + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type PhoneNumber struct { + value string +} + +func NewPhoneNumber(phone string) (PhoneNumber, error) { + if len(phone) < 8 || len(phone) > 20 { + return PhoneNumber{}, shared.ErrValidation + } + return PhoneNumber{value: phone}, nil +} + +func (p PhoneNumber) Value() string { + return p.value +} diff --git a/backend/internal/domain/object-values/phone-number_test.go b/backend/internal/domain/object-values/phone-number_test.go new file mode 100644 index 0000000..8318045 --- /dev/null +++ b/backend/internal/domain/object-values/phone-number_test.go @@ -0,0 +1,26 @@ +package object_values_test + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewPhoneNumber(t *testing.T) { + tests := []struct { + phone string + expectError bool + }{ + {"+14155552671", false}, // Valid: EUA + {"+5511987654321", false}, // Valid: Brasil + {"+442071838750", false}, // Valid: Reino Unido + {"+123", true}, // Invalid: Muito curto + } + + for _, tt := range tests { + _, err := object_values.NewPhoneNumber(tt.phone) + if (err != nil) != tt.expectError { + t.Errorf("NewPhoneNumber(%s) expected error: %v, got: %v", tt.phone, tt.expectError, err) + } + } +} diff --git a/backend/internal/domain/shared/errors.go b/backend/internal/domain/shared/errors.go new file mode 100644 index 0000000..92201c5 --- /dev/null +++ b/backend/internal/domain/shared/errors.go @@ -0,0 +1,12 @@ +package shared + +import "errors" + +var ( + ErrInternal = errors.New("erro interno do sistema") + + ErrNotFound = errors.New("recurso não encontrado") + ErrAuthorization = errors.New("não autorizado") + ErrValidation = errors.New("erro de validação") + ErrConflictError = errors.New("conflito de dados") +) diff --git a/backend/internal/infra/db/db.go b/backend/internal/infra/db/db.go new file mode 100644 index 0000000..721c777 --- /dev/null +++ b/backend/internal/infra/db/db.go @@ -0,0 +1,45 @@ +package db + +import ( + "log" + "os" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Connect() { + + dsn := os.Getenv("DB_URL") + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + log.Fatalf("❌ Failed to connect to database: %v", err) + } + + sqlDB, err := DB.DB() + if err != nil { + log.Fatal(err) + } + sqlDB.SetConnMaxLifetime(time.Minute * 5) + + if err := DB.AutoMigrate(&UserModel{}); err != nil { + log.Fatalf("❌ Failed to migrate database: %v", err) + } + + log.Println("✅ Connected to database and ran migrations") +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/backend/internal/infra/db/user-model.go b/backend/internal/infra/db/user-model.go new file mode 100644 index 0000000..1fea0db --- /dev/null +++ b/backend/internal/infra/db/user-model.go @@ -0,0 +1,52 @@ +package db + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/google/uuid" +) + +type UserModel struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `gorm:"column:name;not null"` + Email string `gorm:"column:email;unique;not null"` + Phone string `gorm:"column:phone;not null"` + Password string `gorm:"column:password;not null"` + InviteCode string `gorm:"column:invite_code;unique;not null"` + InvitedBy *uuid.UUID `gorm:"column:invited_by"` + Points int `gorm:"column:points;not null;default:1"` +} + +func (model UserModel) ToDomain() entities.User { + email, _ := object_values.NewEmail(model.Email) + phone, _ := object_values.NewPhoneNumber(model.Phone) + hashedPass := object_values.NewPasswordFromHash(model.Password) + + return entities.LoadUser( + model.ID, + model.Name, + email, + hashedPass, + phone, + model.InviteCode, + model.InvitedBy, + model.Points, + ) +} + +func UserToModel(user entities.User) UserModel { + return UserModel{ + ID: user.ID(), + Name: user.Name(), + Email: user.Email().Value(), + Phone: user.Phone().Value(), + Password: user.Password().Hash(), + InviteCode: user.InviteCode(), + InvitedBy: user.InvitedBy(), + Points: user.Points(), + } +} + +func (UserModel) TableName() string { + return "users" +} diff --git a/backend/internal/infra/db/user-repository.go b/backend/internal/infra/db/user-repository.go new file mode 100644 index 0000000..a9711c5 --- /dev/null +++ b/backend/internal/infra/db/user-repository.go @@ -0,0 +1,81 @@ +package db + +import ( + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + "gorm.io/gorm" +) + +type UserGormRepository struct { + db *gorm.DB +} + +func NewUserGormRepository(db *gorm.DB) output_ports.UserRepository { + return &UserGormRepository{db: db} +} + +func (r *UserGormRepository) Save(user entities.User) error { + model := UserToModel(user) + return r.db.Save(&model).Error +} + +func (r *UserGormRepository) FindByEmail(email string) (*entities.User, error) { + var model UserModel + err := r.db.Where("email = ?", email).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + user := model.ToDomain() + return &user, nil +} + +func (r *UserGormRepository) FindByID(id string) (*entities.User, error) { + var model UserModel + err := r.db.Where("id = ?", id).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + user := model.ToDomain() + return &user, nil +} + +func (r *UserGormRepository) FindByInviteCode(inviteCode string) (*entities.User, error) { + var model UserModel + err := r.db.Where("invite_code = ?", inviteCode).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + user := model.ToDomain() + return &user, nil +} + +func (r *UserGormRepository) FindUsersOrderedByPoints(page int, limit int) ([]entities.User, error) { + var models []UserModel + err := r.db.Order("points desc").Offset(page * limit).Limit(limit).Find(&models).Error + if err != nil { + return nil, err + } + + users := make([]entities.User, len(models)) + for i, model := range models { + users[i] = model.ToDomain() + } + + return users, nil +} + +func (r *UserGormRepository) ResetAllScores() error { + return r.db.Model(&UserModel{}).Where("points > ?", 0).Update("points", 0).Error +} diff --git a/backend/internal/infra/email/brevo-email.go b/backend/internal/infra/email/brevo-email.go new file mode 100644 index 0000000..9da1026 --- /dev/null +++ b/backend/internal/infra/email/brevo-email.go @@ -0,0 +1,45 @@ +package email + +import ( + "context" + "os" + + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/getbrevo/brevo-go/lib" +) + +type BrevoEmailService struct { + client *lib.APIClient + sender lib.SendSmtpEmailSender +} + +func NewBrevoEmailService(senderEmail, senderName string) output_ports.EmailService { + cfg := lib.NewConfiguration() + cfg.AddDefaultHeader("api-key", os.Getenv("BREVO_API_KEY")) + + client := lib.NewAPIClient(cfg) + + return &BrevoEmailService{ + client: client, + sender: lib.SendSmtpEmailSender{Email: senderEmail, Name: senderName}, + } +} + +func (s *BrevoEmailService) SendEmail(to string, subject string, body string) error { + ctx := context.Background() + + email := lib.SendSmtpEmail{ + Sender: &s.sender, + To: []lib.SendSmtpEmailTo{{Email: to}}, + Subject: subject, + TextContent: body, + } + + _, _, err := s.client.TransactionalEmailsApi.SendTransacEmail(ctx, email) + if err != nil { + return shared.ErrInternal + } + + return nil +} diff --git a/backend/internal/infra/token/jwt.go b/backend/internal/infra/token/jwt.go new file mode 100644 index 0000000..717c3ab --- /dev/null +++ b/backend/internal/infra/token/jwt.go @@ -0,0 +1,43 @@ +package token + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JWTService struct { + secretKey string + expiry time.Duration +} + +func NewJWTService(secretKey string, expiry time.Duration) *JWTService { + return &JWTService{secretKey: secretKey, expiry: expiry} +} + +func (s *JWTService) GenerateToken(userID string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(s.expiry).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.secretKey)) +} + +func (s *JWTService) ValidateToken(tokenStr string) (string, error) { + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + return []byte(s.secretKey), nil + }) + if err != nil || !token.Valid { + return "", err + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", err + } + userID, ok := claims["user_id"].(string) + if !ok { + return "", err + } + return userID, nil +} diff --git a/backend/internal/interfaces/http/factory/handler-factory.go b/backend/internal/interfaces/http/factory/handler-factory.go new file mode 100644 index 0000000..926f0a1 --- /dev/null +++ b/backend/internal/interfaces/http/factory/handler-factory.go @@ -0,0 +1,76 @@ +package factory + +import ( + "os" + "sync" + "time" + + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/cassiusbessa/backend-test/internal/infra/email" + "github.com/cassiusbessa/backend-test/internal/infra/token" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" +) + +type dependencies struct { + UserRepository output_ports.UserRepository + TokenService output_ports.TokenService + EmailService output_ports.EmailService +} + +var ( + depsInstance *dependencies + once sync.Once +) + +func getDependencies() *dependencies { + once.Do(func() { + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "segredo" + } + weekDuration := 7 * 24 * time.Hour + + depsInstance = &dependencies{ + UserRepository: db.NewUserGormRepository(db.DB), + TokenService: token.NewJWTService(jwtSecret, weekDuration), + EmailService: email.NewBrevoEmailService( + "cassiusbessa@gmail.com", + "bvio", + ), + } + }) + + return depsInstance +} + +func BuildCreateUserHandler() *handlers.CreateUserHandler { + deps := getDependencies() + useCase := usecases.NewCreateUserUseCase(deps.UserRepository, deps.EmailService) + return handlers.NewCreateUserHandler(useCase) +} + +func BuildLoginHandler() *handlers.LoginHandler { + deps := getDependencies() + useCase := usecases.NewLoginUserUseCase(deps.UserRepository, deps.TokenService) + return handlers.NewLoginHandler(useCase) +} + +func BuildLoadUserByTokenHandler() *handlers.LoadUserByTokenHandler { + deps := getDependencies() + useCase := usecases.NewLoadUserByTokenUseCase(deps.UserRepository, deps.TokenService) + return handlers.NewLoadUserByTokenHandler(useCase) +} + +func BuildLoadUsersOrderedByPointsHandler() *handlers.LoadUsersOrderedByPointsHandler { + deps := getDependencies() + useCase := usecases.NewLoadUsersOrderedByPointsUseCase(deps.UserRepository) + return handlers.NewLoadUsersOrderedByPointsHandler(useCase) +} + +func BuildFinishCompetitionHandler() *handlers.FinishCompetitionHandler { + deps := getDependencies() + useCase := usecases.NewFinishCompetitionUseCase(deps.UserRepository, deps.EmailService) + return handlers.NewFinishCompetitionHandler(useCase) +} diff --git a/backend/internal/interfaces/http/handlers/create-user-handler.go b/backend/internal/interfaces/http/handlers/create-user-handler.go new file mode 100644 index 0000000..b821334 --- /dev/null +++ b/backend/internal/interfaces/http/handlers/create-user-handler.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "net/http" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" +) + +type CreateUserHandler struct { + uc input_ports.CreateUserUseCase +} + +func NewCreateUserHandler(uc input_ports.CreateUserUseCase) *CreateUserHandler { + return &CreateUserHandler{uc: uc} +} + +func (h *CreateUserHandler) Execute(ctx *gin.Context) { + var input dto.CreateUserInput + if err := ctx.ShouldBindJSON(&input); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + output, err := h.uc.Execute(input) + switch err { + case shared.ErrNotFound: + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + case shared.ErrAuthorization: + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + case shared.ErrValidation: + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + case shared.ErrConflictError: + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + case shared.ErrInternal: + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + default: + } + + ctx.JSON(http.StatusCreated, output) +} diff --git a/backend/internal/interfaces/http/handlers/create-user-handler_test.go b/backend/internal/interfaces/http/handlers/create-user-handler_test.go new file mode 100644 index 0000000..099acbd --- /dev/null +++ b/backend/internal/interfaces/http/handlers/create-user-handler_test.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockCreateUserUseCase struct { + mock.Mock +} + +func (m *MockCreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) { + args := m.Called(input) + return args.Get(0).(dto.CreateUserOutput), args.Error(1) +} + +func setupTestHandler() (*MockCreateUserUseCase, *gin.Engine) { + mockUseCase := new(MockCreateUserUseCase) + handler := NewCreateUserHandler(mockUseCase) + + router := gin.Default() + router.POST("/users", handler.Execute) + + return mockUseCase, router +} + +func TestCreateUserHandler_Execute(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockUseCase, router := setupTestHandler() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + output := dto.CreateUserOutput{UserID: "123"} + + mockUseCase.On("Execute", input).Return(output, nil) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + mockUseCase.AssertExpectations(t) + }) + + t.Run("invalid request body", func(t *testing.T) { + _, router := setupTestHandler() + + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer([]byte("{invalid_json}"))) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("conflict error", func(t *testing.T) { + mockUseCase, router := setupTestHandler() + + input := dto.CreateUserInput{ + Name: "Jane Doe", + Email: "jane@example.com", + Password: "StrongP@ss123", + Phone: "+5511981234567", + } + + mockUseCase.On("Execute", input).Return(dto.CreateUserOutput{}, shared.ErrConflictError) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + mockUseCase.AssertExpectations(t) + }) +} diff --git a/backend/internal/interfaces/http/handlers/finish-competition-handler.go b/backend/internal/interfaces/http/handlers/finish-competition-handler.go new file mode 100644 index 0000000..7a9885f --- /dev/null +++ b/backend/internal/interfaces/http/handlers/finish-competition-handler.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/gin-gonic/gin" +) + +type FinishCompetitionHandler struct { + useCase input_ports.FinishCompetitionUseCase +} + +func NewFinishCompetitionHandler(useCase input_ports.FinishCompetitionUseCase) *FinishCompetitionHandler { + return &FinishCompetitionHandler{ + useCase: useCase, + } +} + +func (h *FinishCompetitionHandler) Execute(c *gin.Context) { + if err := h.useCase.Execute(); err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/interfaces/http/handlers/finish-competition-handler_test.go b/backend/internal/interfaces/http/handlers/finish-competition-handler_test.go new file mode 100644 index 0000000..9f13b5b --- /dev/null +++ b/backend/internal/interfaces/http/handlers/finish-competition-handler_test.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockFinishCompetitionUseCase struct { + mock.Mock +} + +func (m *MockFinishCompetitionUseCase) Execute() error { + args := m.Called() + return args.Error(0) +} + +func setupFinishCompetitionTestHandler() (*MockFinishCompetitionUseCase, *gin.Engine) { + mockUseCase := new(MockFinishCompetitionUseCase) + handler := NewFinishCompetitionHandler(mockUseCase) + + router := gin.Default() + router.POST("/admin/competition/finish", handler.Execute) + + return mockUseCase, router +} + +func TestFinishCompetitionHandler_Execute(t *testing.T) { + t.Run("should return 204 when competition is finished successfully", func(t *testing.T) { + mockUseCase, router := setupFinishCompetitionTestHandler() + + mockUseCase.On("Execute").Return(nil) + + req, _ := http.NewRequest(http.MethodPost, "/admin/competition/finish", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNoContent, resp.Code) + mockUseCase.AssertExpectations(t) + }) + + t.Run("should return 500 when use case returns error", func(t *testing.T) { + mockUseCase, router := setupFinishCompetitionTestHandler() + + mockUseCase.On("Execute").Return(assert.AnError) + + req, _ := http.NewRequest(http.MethodPost, "/admin/competition/finish", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusInternalServerError, resp.Code) + mockUseCase.AssertExpectations(t) + }) +} diff --git a/backend/internal/interfaces/http/handlers/load-user-by-id-handler.go b/backend/internal/interfaces/http/handlers/load-user-by-id-handler.go new file mode 100644 index 0000000..fc2272d --- /dev/null +++ b/backend/internal/interfaces/http/handlers/load-user-by-id-handler.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "net/http" + + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/gin-gonic/gin" +) + +type LoadUserByTokenHandler struct { + uc input_ports.LoadUserByTokenUseCase +} + +func NewLoadUserByTokenHandler(uc input_ports.LoadUserByTokenUseCase) *LoadUserByTokenHandler { + return &LoadUserByTokenHandler{uc: uc} +} + +func (h *LoadUserByTokenHandler) Execute(ctx *gin.Context) { + token := ctx.GetHeader("Authorization") + if token == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + + user, err := h.uc.Execute(token) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, user) +} diff --git a/backend/internal/interfaces/http/handlers/load-user-by-id-handler_test.go b/backend/internal/interfaces/http/handlers/load-user-by-id-handler_test.go new file mode 100644 index 0000000..592f745 --- /dev/null +++ b/backend/internal/interfaces/http/handlers/load-user-by-id-handler_test.go @@ -0,0 +1,89 @@ +package handlers_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func validUserDTO() *dto.LoadedUserOutput { + return &dto.LoadedUserOutput{ + ID: "123", + Name: "John Doe", + Email: "valid_email@email.com", + Phone: "+5511987654321", + } +} + +type LoadUserByTokenUseCaseMock struct { + mock.Mock +} + +func (m *LoadUserByTokenUseCaseMock) Execute(token string) (*dto.LoadedUserOutput, error) { + args := m.Called(token) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.LoadedUserOutput), args.Error(1) +} + +func setupLoadUserByTokenHandlerTest() (*LoadUserByTokenUseCaseMock, *gin.Engine) { + mockUseCase := new(LoadUserByTokenUseCaseMock) + handler := handlers.NewLoadUserByTokenHandler(mockUseCase) + + router := gin.Default() + router.GET("/me", handler.Execute) + + return mockUseCase, router +} + +func TestLoadUserByTokenHandler_Execute(t *testing.T) { + + t.Run("should return user when token is valid", func(t *testing.T) { + mockUseCase, router := setupLoadUserByTokenHandlerTest() + + user := validUserDTO() + mockUseCase.On("Execute", "valid-token").Return(user, nil) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "valid-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockUseCase.AssertExpectations(t) + }) + + t.Run("should return 401 if token is missing", func(t *testing.T) { + _, router := setupLoadUserByTokenHandlerTest() + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("should return 401 if token is invalid", func(t *testing.T) { + mockUseCase, router := setupLoadUserByTokenHandlerTest() + + mockUseCase.On("Execute", "invalid-token").Return(nil, errors.New("invalid token")) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "invalid-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + mockUseCase.AssertExpectations(t) + }) +} diff --git a/backend/internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go b/backend/internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go new file mode 100644 index 0000000..ee522d5 --- /dev/null +++ b/backend/internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/gin-gonic/gin" +) + +type LoadUsersOrderedByPointsHandler struct { + uc input_ports.GetUsersRankingUseCase +} + +func NewLoadUsersOrderedByPointsHandler(uc input_ports.GetUsersRankingUseCase) *LoadUsersOrderedByPointsHandler { + return &LoadUsersOrderedByPointsHandler{uc: uc} +} +func (h *LoadUsersOrderedByPointsHandler) Execute(ctx *gin.Context) { + + page := ctx.Query("page") + limit := ctx.Query("limit") + + if page == "" { + page = "0" + } + if limit == "" { + limit = "10" + } + + pageInt, err := strconv.Atoi(page) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"}) + return + } + + limitInt, err := strconv.Atoi(limit) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) + return + } + + input := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: pageInt, + Limit: limitInt, + }, + } + + users, err := h.uc.Execute(input) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) + return + } + + ctx.JSON(http.StatusOK, users) +} diff --git a/backend/internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go b/backend/internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go new file mode 100644 index 0000000..df986d8 --- /dev/null +++ b/backend/internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go @@ -0,0 +1,108 @@ +package handlers_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type GetUsersRankingUseCaseMock struct { + mock.Mock +} + +func (m *GetUsersRankingUseCaseMock) Execute(input dto.GetUsersRankingInput) ([]dto.UserRankingItem, error) { + args := m.Called(input) + return args.Get(0).([]dto.UserRankingItem), args.Error(1) +} + +func validUserRankingList() []dto.UserRankingItem { + return []dto.UserRankingItem{ + {UserID: "1", Name: "Alice", Points: 100}, + {UserID: "2", Name: "Bob", Points: 80}, + } +} + +func setupLoadUsersOrderedByPointsHandlerTest() (*GetUsersRankingUseCaseMock, *gin.Engine) { + mockUseCase := new(GetUsersRankingUseCaseMock) + handler := handlers.NewLoadUsersOrderedByPointsHandler(mockUseCase) + + router := gin.Default() + router.GET("/ranking", handler.Execute) + + return mockUseCase, router +} + +func TestLoadUsersOrderedByPointsHandler_Execute(t *testing.T) { + + t.Run("should return 200 with user ranking list", func(t *testing.T) { + mockUseCase, router := setupLoadUsersOrderedByPointsHandlerTest() + + expectedInput := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + expectedOutput := validUserRankingList() + + mockUseCase.On("Execute", expectedInput).Return(expectedOutput, nil) + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=1&limit=10", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockUseCase.AssertExpectations(t) + }) + + t.Run("should return 400 if page is invalid", func(t *testing.T) { + _, router := setupLoadUsersOrderedByPointsHandlerTest() + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=abc&limit=10", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("should return 400 if limit is invalid", func(t *testing.T) { + _, router := setupLoadUsersOrderedByPointsHandlerTest() + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=1&limit=xyz", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("should return 500 if usecase fails", func(t *testing.T) { + mockUseCase, router := setupLoadUsersOrderedByPointsHandlerTest() + + expectedInput := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + + mockUseCase.On("Execute", expectedInput).Return(nil, errors.New("internal error")) + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=1&limit=10", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + mockUseCase.AssertExpectations(t) + }) +} diff --git a/backend/internal/interfaces/http/handlers/login-handler.go b/backend/internal/interfaces/http/handlers/login-handler.go new file mode 100644 index 0000000..5de3300 --- /dev/null +++ b/backend/internal/interfaces/http/handlers/login-handler.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "net/http" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" +) + +type LoginHandler struct { + uc input_ports.LoginUseCase +} + +func NewLoginHandler(uc input_ports.LoginUseCase) *LoginHandler { + return &LoginHandler{ + uc: uc, + } +} + +func (h *LoginHandler) Execute(ctx *gin.Context) { + var input dto.LoginInput + + if err := ctx.ShouldBindJSON(&input); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + output, err := h.uc.Execute(input) + if err != nil { + switch err { + case shared.ErrNotFound: + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + case shared.ErrAuthorization: + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + case shared.ErrValidation: + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + case shared.ErrConflictError: + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + case shared.ErrInternal: + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + default: + } + } + + ctx.JSON(http.StatusOK, gin.H{ + "token": output.Token, + }) +} diff --git a/backend/internal/interfaces/http/handlers/login-handler_test.go b/backend/internal/interfaces/http/handlers/login-handler_test.go new file mode 100644 index 0000000..c5fd954 --- /dev/null +++ b/backend/internal/interfaces/http/handlers/login-handler_test.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockLoginUserUseCase struct { + mock.Mock +} + +func (m *MockLoginUserUseCase) Execute(input dto.LoginInput) (dto.LoginOutput, error) { + args := m.Called(input) + return args.Get(0).(dto.LoginOutput), args.Error(1) +} + +func setupLoginHandlerTest() (*MockLoginUserUseCase, *gin.Engine) { + mockUseCase := new(MockLoginUserUseCase) + handler := NewLoginHandler(mockUseCase) + + r := gin.Default() + r.POST("/login", handler.Execute) + + return mockUseCase, r +} + +func TestUserHandler_Login(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "john@example.com", Password: "StrongP@ssw0rd"} + expected := dto.LoginOutput{Token: "fake.jwt.token"} + + mockUseCase.On("Execute", input).Return(expected, nil) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), expected.Token) + mockUseCase.AssertExpectations(t) + }) + + t.Run("invalid request body", func(t *testing.T) { + _, router := setupLoginHandlerTest() + + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer([]byte("invalid_json"))) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") + }) + + t.Run("not found error", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "missing@example.com", Password: "123"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrNotFound) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), shared.ErrNotFound.Error()) + }) + + t.Run("unauthorized error", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "user@example.com", Password: "wrong"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrAuthorization) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), shared.ErrAuthorization.Error()) + }) + + t.Run("empty params", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "", Password: ""} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrValidation) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") + }) + + t.Run("internal server error", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "user@example.com", Password: "123456"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrInternal) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), shared.ErrInternal.Error()) + }) + + t.Run("unexpected error - fallback", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "user@example.com", Password: "123456"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, errors.New("unknown error")) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) +} diff --git a/backend/internal/interfaces/http/routes/routes.go b/backend/internal/interfaces/http/routes/routes.go new file mode 100644 index 0000000..dad5b99 --- /dev/null +++ b/backend/internal/interfaces/http/routes/routes.go @@ -0,0 +1,31 @@ +package routes + +import ( + "github.com/cassiusbessa/backend-test/internal/interfaces/http/factory" + "github.com/gin-gonic/gin" +) + +func WithCreateUser(g *gin.RouterGroup) { + handler := factory.BuildCreateUserHandler() + g.POST("/users", handler.Execute) +} + +func WithLogin(g *gin.RouterGroup) { + handler := factory.BuildLoginHandler() + g.POST("/login", handler.Execute) +} + +func WithLoadUserByToken(g *gin.RouterGroup) { + handler := factory.BuildLoadUserByTokenHandler() + g.GET("/me", handler.Execute) +} + +func WithUsersRanking(g *gin.RouterGroup) { + handler := factory.BuildLoadUsersOrderedByPointsHandler() + g.GET("/users/ranking", handler.Execute) +} + +func WithFinishCompetition(g *gin.RouterGroup) { + handler := factory.BuildFinishCompetitionHandler() + g.POST("/admin/competition/finish", handler.Execute) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ba44d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.8" + +services: + backend: + build: ./backend + ports: + - "${APP_PORT}:${APP_PORT}" + depends_on: + - db + env_file: + - .env + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + env_file: + - .env + ports: + - "${FRONTEND_PORT}:3000" + restart: unless-stopped + + + db: + image: postgres:latest + container_name: postgres_db + restart: always + ports: + - "5432:5432" + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..8f322f0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f1f337d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:18-alpine + +WORKDIR /app + +ARG NEXT_PUBLIC_API_URL + +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +COPY . . + +RUN npm install +RUN npm run build + +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..dbcb50e --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Leaf, Copy, Crown } from 'lucide-react'; +import { getMe, getRanking } from '@/lib/services/api'; + +interface User { + id: string; + name: string; + email: string; + phone: string; + points: number; + link_code: string; +} + +interface RankingUser { + user_id: string; + name: string; + points: number; +} + +export default function Dashboard() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [ranking, setRanking] = useState([]); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/'); + return; + } + + const fetchData = async () => { + try { + const [userData, rankingData] = await Promise.all([ + getMe(token), + getRanking(), + ]); + setUser(userData); + setRanking(rankingData); + } catch (err) { + localStorage.removeItem('token'); + router.push('/'); + } + }; + + fetchData(); + }, [router]); + + const handleCopyInviteCode = () => { + if (user) { + navigator.clipboard.writeText(user.link_code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (!user) return null; + + return ( +
+
+
+
+ +

vbio

+
+ +
+
+ +
+
+
+

Seu perfil

+
+

Nome: {user.name}

+

Email: {user.email}

+

Telefone: {user.phone}

+

Pontos: {user.points}

+
+

Seu código de convite:

+ + {copied && ( +

Código copiado!

+ )} +
+
+
+ +
+

+ + Ranking +

+
+ {ranking.map((rank, index) => ( +
+
+ {index + 1}º + {rank.name} +
+ {rank.points} pontos +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..8179b52 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,26 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: rgb(252, 252, 252); + --foreground: rgb(48, 43, 41); + --accent: rgb(176, 122, 10); + --accent-hover: rgb(140, 98, 8); + --muted: rgb(245, 245, 245); + --border: rgb(229, 229, 229); +} + +body { + background-color: var(--background); + color: var(--foreground); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..092954c --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,22 @@ +import './globals.css'; +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..bcc0061 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Leaf } from 'lucide-react'; +import LoginForm from '@/components/LoginForm'; +import RegisterForm from '@/components/RegisterForm'; + +export default function Home() { + const router = useRouter(); + const searchParams = useSearchParams(); + const inviteCode = searchParams.get('invite'); + + const [isLogin, setIsLogin] = useState(true); + + useEffect(() => { + if (inviteCode) { + setIsLogin(false); + } + }, [inviteCode]); + + const handleSuccess = (token: string) => { + localStorage.setItem('token', token); + router.push('/dashboard'); + }; + + return ( +
+
+ +
+
+ +

vbio

+
+ + {isLogin ? ( + + ) : ( + setIsLogin(true)} inviteCode={inviteCode || ''} /> + )} + +
+ +
+
+
+ ); +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..c597462 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/frontend/components/LoginForm.tsx b/frontend/components/LoginForm.tsx new file mode 100644 index 0000000..e7d6250 --- /dev/null +++ b/frontend/components/LoginForm.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState } from 'react'; +import { login } from '@/lib/services/api'; + +export default function LoginForm({ onSuccess }: { onSuccess: (token: string) => void }) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const { token } = await login(email, password); + onSuccess(token); + } catch (err) { + setError('Email ou senha inválidos'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + required + /> +
+ + {error &&

{error}

} + + +
+ ); +} \ No newline at end of file diff --git a/frontend/components/RegisterForm.tsx b/frontend/components/RegisterForm.tsx new file mode 100644 index 0000000..e343442 --- /dev/null +++ b/frontend/components/RegisterForm.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { createUser } from '@/lib/services/api'; + +interface Props { + onSuccess: () => void; + inviteCode?: string; +} + +export default function RegisterForm({ onSuccess, inviteCode }: Props) { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + phone: '', + }); + + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await createUser({ ...formData, invite_code: inviteCode }); + onSuccess(); + } catch (err) { + setError('Erro ao criar conta. Verifique os dados e tente novamente.'); + } finally { + setLoading(false); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {error &&

{error}

} + + +
+ ); +} diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx new file mode 100644 index 0000000..84bf2eb --- /dev/null +++ b/frontend/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..5cba559 --- /dev/null +++ b/frontend/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx new file mode 100644 index 0000000..d2b59cc --- /dev/null +++ b/frontend/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/components/ui/aspect-ratio.tsx b/frontend/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..aaabffb --- /dev/null +++ b/frontend/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +'use client'; + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx new file mode 100644 index 0000000..1346957 --- /dev/null +++ b/frontend/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..2eb790a --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/frontend/components/ui/breadcrumb.tsx b/frontend/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..8b62197 --- /dev/null +++ b/frontend/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>