Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ export class AuthService {
name: dto.name,
email: dto.email,
password: hashedPassword,
role: dto.role,
});

return this.generateToken(user.id, user.email);
return this.generateToken(user.id, user.email, user.role);
}

async login(dto: LoginDto) {
Expand All @@ -38,11 +39,11 @@ export class AuthService {
throw new UnauthorizedException('Credenciais inválidas');
}

return this.generateToken(user.id, user.email);
return this.generateToken(user.id, user.email, user.role);
}

private generateToken(userId: string, email: string) {
const payload = { sub: userId, email };
private generateToken(userId: string, email: string, role: string) {
const payload = { sub: userId, email, role };
return {
access_token: this.jwtService.sign(payload),
};
Expand Down
8 changes: 8 additions & 0 deletions backend/src/auth/decorators/roles.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../users/enums/user-role.enum';

export const ROLES_KEY = 'roles';

// Decorator para definir os roles permitidos em uma rota
// Exemplo de uso: @Roles(UserRole.NURSE)
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
14 changes: 13 additions & 1 deletion backend/src/auth/dto/register.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger/dist/decorators/api-property.decorator';
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { UserRole } from 'src/users/enums/user-role.enum';

export class RegisterDto {
@IsString()
Expand All @@ -10,4 +12,14 @@ export class RegisterDto {
@IsString()
@MinLength(6, { message: 'A senha deve ter no mínimo 6 caracteres' })
password: string;

@ApiProperty({
enum: UserRole,
example: UserRole.TRAINER,
required: false,
description: 'Padrão: trainer',
})
@IsEnum(UserRole)
@IsOptional()
role?: UserRole;
}
26 changes: 26 additions & 0 deletions backend/src/auth/guards/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../users/enums/user-role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';

// Guard que verifica se o usuário tem o role necessário para acessar a rota
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
// Busca os roles exigidos pela rota via decorator @Roles()
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);

// Se a rota não exige roles específicos, libera o acesso
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}

const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user.role);
}
}
4 changes: 2 additions & 2 deletions backend/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}

async validate(payload: { sub: string; email: string }) {
return { id: payload.sub, email: payload.email };
async validate(payload: { sub: string; email: string; role: string }) {
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
123 changes: 57 additions & 66 deletions backend/src/pokemons/pokemons.controller.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,75 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Request,
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { PokemonsService } from './pokemons.service';
import { CreatePokemonDto } from './dto/create-pokemon.dto';
import { UpdatePokemonDto } from './dto/update-pokemon.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { UserRole } from '../users/enums/user-role.enum';

@ApiTags('Pokémons')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('pokemons')
export class PokemonsController {
constructor(private readonly pokemonsService: PokemonsService) {}
constructor(private readonly pokemonsService: PokemonsService) {}

@Get()
@ApiOperation({ summary: 'Listar todos os pokémons' })
@ApiResponse({
status: 200,
description: 'Lista de pokémons retornada com sucesso',
})
findAll(@Request() req) {
// TODO: depois validar se o tipo de usuário é do tipo treinador ou enfermeira Joy
return this.pokemonsService.findAll(req.user.id);
}
@Get()
@ApiOperation({ summary: 'Listar pokémons' })
@ApiResponse({ status: 200, description: 'Lista retornada com sucesso' })
findAll(@Request() req) {
const isNurse = req.user.role === UserRole.NURSE;
return this.pokemonsService.findAll(isNurse ? undefined : req.user.id);
}

@Get(':id')
@ApiOperation({ summary: 'Buscar um pokémon pelo ID' })
@ApiResponse({ status: 200, description: 'Pokémon encontrado' })
@ApiResponse({ status: 404, description: 'Pokémon não encontrado' })
findOne(@Param('id') id: string) {
return this.pokemonsService.findOne(id);
}
@Get(':id')
@ApiOperation({ summary: 'Buscar um pokémon pelo ID' })
@ApiResponse({ status: 200, description: 'Pokémon encontrado' })
@ApiResponse({ status: 404, description: 'Pokémon não encontrado' })
findOne(@Param('id') id: string) {
return this.pokemonsService.findOne(id);
}

@Post()
@ApiOperation({ summary: 'Cadastrar novo pokémon' })
@ApiResponse({ status: 201, description: 'Pokémon cadastrado com sucesso' })
@ApiResponse({ status: 400, description: 'Dados inválidos' })
create(@Body() dto: CreatePokemonDto, @Request() req) {
return this.pokemonsService.create(dto, req.user.id);
}
@Post()
@Roles(UserRole.TRAINER)
@ApiOperation({ summary: 'Cadastrar novo pokémon' })
@ApiResponse({ status: 201, description: 'Pokémon cadastrado com sucesso' })
@ApiResponse({ status: 403, description: 'Apenas treinadores podem cadastrar pokémons' })
create(@Body() dto: CreatePokemonDto, @Request() req) {
return this.pokemonsService.create(dto, req.user.id);
}

@Put(':id')
@ApiOperation({ summary: 'Atualizar pokémon — apenas o dono pode editar' })
@ApiResponse({ status: 200, description: 'Pokémon atualizado com sucesso' })
@ApiResponse({
status: 403,
description: 'Sem permissão para editar este pokémon',
})
@ApiResponse({ status: 404, description: 'Pokémon não encontrado' })
update(
@Param('id') id: string,
@Body() dto: UpdatePokemonDto,
@Request() req,
) {
return this.pokemonsService.update(id, dto, req.user.id);
}
@Put(':id')
@Roles(UserRole.TRAINER)
@ApiOperation({ summary: 'Atualizar pokémon (apenas o dono pode editar)' })
@ApiResponse({ status: 200, description: 'Pokémon atualizado com sucesso' })
@ApiResponse({ status: 403, description: 'Sem permissão para editar este pokémon' })
update(@Param('id') id: string, @Body() dto: UpdatePokemonDto, @Request() req) {
return this.pokemonsService.update(id, dto, req.user.id);
}

@Delete(':id')
@ApiOperation({ summary: 'Excluir pokémon — apenas o dono pode excluir' })
@ApiResponse({ status: 200, description: 'Pokémon excluído com sucesso' })
@ApiResponse({
status: 403,
description: 'Sem permissão para excluir este pokémon',
})
@ApiResponse({ status: 404, description: 'Pokémon não encontrado' })
remove(@Param('id') id: string, @Request() req) {
return this.pokemonsService.remove(id, req.user.id);
}
}
@Delete(':id')
@Roles(UserRole.TRAINER)
@ApiOperation({ summary: 'Excluir pokémon (apenas o dono pode excluir)' })
@ApiResponse({ status: 200, description: 'Pokémon excluído com sucesso' })
@ApiResponse({ status: 403, description: 'Sem permissão para excluir este pokémon' })
remove(@Param('id') id: string, @Request() req) {
return this.pokemonsService.remove(id, req.user.id);
}
}
9 changes: 9 additions & 0 deletions backend/src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
OneToMany,
} from 'typeorm';

import { UserRole } from '../enums/user-role.enum';

@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
Expand All @@ -21,6 +23,13 @@ export class User {
@Column({ select: false })
password: string;

@Column({
type: 'enum',
enum: UserRole,
default: UserRole.TRAINER,
})
role: UserRole;

@CreateDateColumn()
createdAt: Date;
}
4 changes: 4 additions & 0 deletions backend/src/users/enums/user-role.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum UserRole {
TRAINER = 'trainer',
NURSE = 'nurse',
}
55 changes: 37 additions & 18 deletions frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Pokemon, User, CreatePokemonDto, UpdatePokemonDto } from '@/types';
import { Pokemon, User, UserRole, CreatePokemonDto, UpdatePokemonDto } from '@/types';
import { pokemonService } from '@/services/pokemon.service';
import { authService } from '@/services/auth.service';
import Navbar from '@/components/ui/Navbar';
Expand All @@ -26,7 +26,8 @@ export default function DashboardPage() {
const [deleteTarget, setDeleteTarget] = useState<Pokemon | null>(null);
const [error, setError] = useState<string | null>(null);

// Carrega os pokémons e o usuário logado ao montar o componente
const isNurse = currentUser?.role === UserRole.NURSE;

useEffect(() => {
async function loadData() {
try {
Expand Down Expand Up @@ -62,13 +63,12 @@ export default function DashboardPage() {
}

async function handleEdit(data: CreatePokemonDto | UpdatePokemonDto) {
if (modal.open && modal.mode !== 'edit') return;
const pokemon = (modal as { open: true; mode: 'edit'; pokemon: Pokemon }).pokemon;
if (!modal.open || modal.mode !== 'edit') return;

setIsSubmitting(true);
setError(null);
try {
const updated = await pokemonService.update(pokemon.id, data);
const updated = await pokemonService.update(modal.pokemon.id, data);
setPokemons((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
setModal({ open: false });
} catch (err: any) {
Expand Down Expand Up @@ -97,47 +97,66 @@ export default function DashboardPage() {

return (
<div className="min-h-screen bg-base-200">
<Navbar />
<Navbar user={currentUser} />

<main className="container mx-auto px-4 py-8">

{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">Pokédex</h1>
<h1 className="text-2xl font-bold">
{isNurse ? '🏥 Pokémons no Centro' : '🎒 Meus Pokémons'}
</h1>
<p className="text-base-content/50 text-sm">
{pokemons.length} pokémon{pokemons.length !== 1 ? 's' : ''} cadastrado{pokemons.length !== 1 ? 's' : ''}
{isNurse
? `${pokemons.length} pokémon${pokemons.length !== 1 ? 's' : ''} sob cuidados`
: `${pokemons.length} pokémon${pokemons.length !== 1 ? 's' : ''} cadastrado${pokemons.length !== 1 ? 's' : ''}`
}
</p>
</div>
<button
onClick={() => setModal({ open: true, mode: 'create' })}
className="btn btn-neutral btn-sm"
>
+ Novo Pokémon
</button>

{/* Botão de novo pokémon — apenas para treinadores */}
{!isNurse && (
<button
onClick={() => setModal({ open: true, mode: 'create' })}
className="btn btn-primary btn-sm"
>
+ Novo Pokémon
</button>
)}
</div>

{/* Alerta de erro */}
{error && (
<div role="alert" className="alert alert-error mb-4">
<span>{error}</span>
<button onClick={() => setError(null)} className="btn btn-ghost btn-xs">✕</button>
<button onClick={() => setError(null)} className="btn btn-ghost btn-xs">
</button>
</div>
)}

{/* Lista de pokémons */}
{pokemons.length === 0 ? (
<div className="text-center py-16 text-base-content/50">
<p className="text-lg">Nenhum pokémon cadastrado ainda.</p>
<p className="text-sm mt-1">Clique em &quot;Novo Pokémon&quot; para começar!</p>
<p className="text-lg">
{isNurse
? 'Nenhum pokémon no centro no momento.'
: 'Nenhum pokémon cadastrado ainda.'}
</p>
{!isNurse && (
<p className="text-sm mt-1">
Clique em "Novo Pokémon" para começar!
</p>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{pokemons.map((pokemon) => (
<PokemonCard
key={pokemon.id}
pokemon={pokemon}
currentUserId={currentUser?.id ?? ''}
currentUser={currentUser}
onEdit={(p) => setModal({ open: true, mode: 'edit', pokemon: p })}
onDelete={setDeleteTarget}
/>
Expand Down
Loading