Sfoglia il codice sorgente

feat: ✨ fat (formulario satisfacao) criado campanhas de avaliacao

criada funcao de campanha de avaliacao. Um adm pode criar campanha e os associados / parceiros poderao avaliar o sistema, com campo para deixar observacao.

fase:dev | origin:escopo
Gustavo Zanatta 22 ore fa
parent
commit
ea60615ba1

+ 33 - 0
app/Enums/EvaluationTargetEnum.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Enums;
+
+use App\Traits\EnumHelper;
+
+enum EvaluationTargetEnum: string
+{
+    use EnumHelper;
+
+    case TODOS = 'todos';
+    case ASSOCIADO = 'associado';
+    case PARCEIRO = 'parceiro';
+
+    /**
+     * Tipos de usuário elegíveis a responder uma campanha com este alvo.
+     *
+     * @return array<int, UserTypeEnum>
+     */
+    public function userTypes(): array
+    {
+        return match ($this) {
+            self::ASSOCIADO => [UserTypeEnum::ASSOCIADO],
+            self::PARCEIRO  => [UserTypeEnum::PARCEIRO],
+            self::TODOS     => [UserTypeEnum::ASSOCIADO, UserTypeEnum::PARCEIRO],
+        };
+    }
+
+    public function accepts(UserTypeEnum $userType): bool
+    {
+        return in_array($userType, $this->userTypes(), true);
+    }
+}

+ 128 - 0
app/Http/Controllers/EvaluationCampaignController.php

@@ -0,0 +1,128 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\EvaluationCampaignRequest;
+use App\Http\Resources\EvaluationCampaignResource;
+use App\Http\Resources\EvaluationResource;
+use App\Services\EvaluationCampaignService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class EvaluationCampaignController extends Controller
+{
+    public function __construct(protected EvaluationCampaignService $service) {}
+
+    public function indexPaginated(Request $request): JsonResponse
+    {
+        $perPage   = min((int) $request->get('per_page', 12), 100);
+        $paginator = $this->service->getAllPaginated($perPage);
+
+        return $this->successResponse(payload: [
+            'data'  => EvaluationCampaignResource::collection($paginator->items()),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem() ?? 0,
+            'to'    => $paginator->lastItem() ?? 0,
+        ]);
+    }
+
+    public function active(): JsonResponse
+    {
+        $campaign = $this->service->getActiveCampaign();
+
+        return $this->successResponse(
+            payload: $campaign ? new EvaluationCampaignResource($campaign) : null,
+        );
+    }
+
+    public function store(EvaluationCampaignRequest $request): JsonResponse
+    {
+        $campaign = $this->service->create($request->validated());
+
+        return $this->successResponse(
+            payload: new EvaluationCampaignResource($campaign),
+            message: __('messages.created'),
+            code: 201,
+        );
+    }
+
+    public function show(int $id): JsonResponse
+    {
+        $campaign = $this->service->findById($id);
+
+        if (!$campaign) {
+            return $this->errorResponse(message: __('messages.not_found'));
+        }
+
+        return $this->successResponse(payload: new EvaluationCampaignResource($campaign));
+    }
+
+    public function update(EvaluationCampaignRequest $request, int $id): JsonResponse
+    {
+        $campaign = $this->service->update($id, $request->validated());
+
+        if (!$campaign) {
+            return $this->errorResponse(message: __('messages.not_found'));
+        }
+
+        return $this->successResponse(
+            payload: new EvaluationCampaignResource($campaign),
+            message: __('messages.updated'),
+        );
+    }
+
+    public function destroy(int $id): JsonResponse
+    {
+        if (!$this->service->delete($id)) {
+            return $this->errorResponse(message: __('messages.not_found'));
+        }
+
+        return $this->successResponse(message: __('messages.deleted'), code: 204);
+    }
+
+    public function finish(int $id): JsonResponse
+    {
+        $result = $this->service->finish($id);
+
+        if (isset($result['error'])) {
+            return match ($result['error']) {
+                'already_finished' => $this->errorResponse(message: __('messages.evaluation.already_finished'), code: 409),
+                default            => $this->errorResponse(message: __('messages.not_found')),
+            };
+        }
+
+        return $this->successResponse(
+            payload: new EvaluationCampaignResource($result['campaign']),
+            message: __('messages.evaluation.campaign_finished'),
+        );
+    }
+
+    public function stats(int $id): JsonResponse
+    {
+        $stats = $this->service->getStats($id);
+
+        if (!$stats) {
+            return $this->errorResponse(message: __('messages.not_found'));
+        }
+
+        return $this->successResponse(payload: $stats);
+    }
+
+    public function evaluations(Request $request, int $id): JsonResponse
+    {
+        $perPage = min((int) $request->get('per_page', 12), 100);
+
+        $paginator = $this->service->getEvaluationsPaginated(
+            campaignId: $id,
+            filters: $request->only(['source', 'min_rating', 'max_rating', 'search']),
+            perPage: $perPage,
+        );
+
+        return $this->successResponse(payload: [
+            'data'  => EvaluationResource::collection($paginator->items()),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem() ?? 0,
+            'to'    => $paginator->lastItem() ?? 0,
+        ]);
+    }
+}

+ 53 - 0
app/Http/Controllers/EvaluationController.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\EvaluationRequest;
+use App\Http\Resources\EvaluationCampaignResource;
+use App\Http\Resources\EvaluationResource;
+use App\Services\EvaluationCampaignService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Auth;
+
+class EvaluationController extends Controller
+{
+    public function __construct(protected EvaluationCampaignService $service) {}
+
+    /**
+     * Campanha que o usuário logado ainda precisa responder (null quando não há pendência).
+     */
+    public function pending(): JsonResponse
+    {
+        $campaign = $this->service->getPendingForUser(Auth::user());
+
+        return $this->successResponse(
+            payload: $campaign ? new EvaluationCampaignResource($campaign) : null,
+        );
+    }
+
+    public function store(EvaluationRequest $request): JsonResponse
+    {
+        $validated = $request->validated();
+
+        $result = $this->service->submit(
+            user: Auth::user(),
+            campaignId: (int) $validated['evaluation_campaign_id'],
+            data: $validated,
+        );
+
+        if (isset($result['error'])) {
+            return match ($result['error']) {
+                'not_found'       => $this->errorResponse(message: __('messages.not_found')),
+                'already_answered' => $this->errorResponse(message: __('messages.evaluation.already_answered'), code: 409),
+                'not_eligible'    => $this->errorResponse(message: __('messages.evaluation.not_eligible'), code: 403),
+                default           => $this->errorResponse(message: __('messages.evaluation.inactive'), code: 422),
+            };
+        }
+
+        return $this->successResponse(
+            payload: new EvaluationResource($result['evaluation']),
+            message: __('messages.evaluation.submitted'),
+            code: 201,
+        );
+    }
+}

+ 26 - 0
app/Http/Requests/EvaluationCampaignRequest.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Enums\EvaluationTargetEnum;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class EvaluationCampaignRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        // Na edição só título e descrição mudam: alvo e ciclo de vida são definidos na criação.
+        $rules = [
+            'title'       => 'sometimes|string|max:255',
+            'description' => 'sometimes|nullable|string',
+        ];
+
+        if ($this->isMethod('post')) {
+            $rules['title']  = 'required|string|max:255';
+            $rules['target'] = ['required', Rule::enum(EvaluationTargetEnum::class)];
+        }
+
+        return $rules;
+    }
+}

+ 17 - 0
app/Http/Requests/EvaluationRequest.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class EvaluationRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'evaluation_campaign_id' => 'required|integer|exists:evaluation_campaigns,id',
+            'rating'                 => 'required|integer|min:1|max:10',
+            'comment'                => 'sometimes|nullable|string|max:2000',
+        ];
+    }
+}

+ 6 - 4
app/Http/Requests/FirstAccessRegisterRequest.php

@@ -12,7 +12,8 @@ class FirstAccessRegisterRequest extends FormRequest
 {
     public function rules(): array
     {
-        $userId = $this->existingUserId();
+        $user   = $this->existingUser();
+        $userId = $user?->id;
 
         return [
             'token'        => 'required|string',
@@ -24,17 +25,18 @@ class FirstAccessRegisterRequest extends FormRequest
             'position_id'  => 'required|integer|exists:positions,id',
             'sector_id'    => 'required|integer|exists:sectors,id',
             'password'     => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
-            'photo'        => 'required|image|max:5120',
+            // Obrigatória apenas quando a administração ainda não cadastrou uma foto para o associado.
+            'photo'        => ['nullable', Rule::requiredIf(fn(): bool => !$user?->photo_path), 'image', 'max:5120'],
         ];
     }
 
     /**
      * Associado que já existe com este crachá — as regras de unicidade ignoram o próprio registro.
      */
-    private function existingUserId(): ?int
+    private function existingUser(): ?User
     {
         return User::where('type', UserTypeEnum::ASSOCIADO)
             ->where('registration', $this->input('registration'))
-            ->value('id');
+            ->first();
     }
 }

+ 32 - 0
app/Http/Resources/EvaluationCampaignResource.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class EvaluationCampaignResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                => $this->id,
+            'title'             => $this->title,
+            'description'       => $this->description,
+            'target'            => $this->target,
+            'status'            => $this->status,
+            'started_at'        => $this->started_at?->format('Y-m-d H:i:s'),
+            'finished_at'       => $this->finished_at?->format('Y-m-d H:i:s'),
+            'evaluations_count' => $this->when($this->evaluations_count !== null, (int) $this->evaluations_count),
+            'evaluations_avg_rating' => $this->when(
+                $this->evaluations_avg_rating !== null,
+                fn() => round((float) $this->evaluations_avg_rating, 2),
+            ),
+            'created_by'        => $this->created_by,
+            'created_by_user'   => $this->whenLoaded('createdBy', fn() => new UserResource($this->createdBy)),
+            'created_at'        => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
+            'updated_at'        => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
+        ];
+    }
+}

+ 26 - 0
app/Http/Resources/EvaluationResource.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class EvaluationResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                     => $this->id,
+            'evaluation_campaign_id' => $this->evaluation_campaign_id,
+            'user_id'                => $this->user_id,
+            'user'                   => $this->whenLoaded('user', fn() => new UserResource($this->user)),
+            'user_name'              => $this->whenLoaded('user', fn() => $this->user->name),
+            'source'                 => $this->whenLoaded('user', fn() => $this->user->type),
+            'rating'                 => (int) $this->rating,
+            'comment'                => $this->comment,
+            'created_at'             => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
+            'updated_at'             => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
+        ];
+    }
+}

+ 28 - 0
app/Models/Evaluation.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class Evaluation extends Model
+{
+    protected $guarded = ['id'];
+
+    protected function casts(): array
+    {
+        return [
+            'rating' => 'integer',
+        ];
+    }
+
+    public function campaign(): BelongsTo
+    {
+        return $this->belongsTo(EvaluationCampaign::class, 'evaluation_campaign_id');
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 47 - 0
app/Models/EvaluationCampaign.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Models;
+
+use App\Enums\DefaultStatusEnum;
+use App\Enums\EvaluationTargetEnum;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class EvaluationCampaign extends Model
+{
+    use SoftDeletes;
+
+    protected $guarded = ['id'];
+
+    protected function casts(): array
+    {
+        return [
+            'target'      => EvaluationTargetEnum::class,
+            'status'      => DefaultStatusEnum::class,
+            'started_at'  => 'datetime',
+            'finished_at' => 'datetime',
+        ];
+    }
+
+    public function createdBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    public function evaluations(): HasMany
+    {
+        return $this->hasMany(Evaluation::class);
+    }
+
+    public function isActive(): bool
+    {
+        return $this->status === DefaultStatusEnum::ACTIVE;
+    }
+
+    public function isFinished(): bool
+    {
+        return $this->finished_at !== null;
+    }
+}

+ 5 - 0
app/Models/User.php

@@ -168,6 +168,11 @@ class User extends Authenticatable
         return $this->hasMany(UserAccessLog::class);
     }
 
+    public function evaluations(): HasMany
+    {
+        return $this->hasMany(Evaluation::class);
+    }
+
     /**
      * @return BelongsToMany
      */

+ 284 - 0
app/Services/EvaluationCampaignService.php

@@ -0,0 +1,284 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\DefaultStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\Evaluation;
+use App\Models\EvaluationCampaign;
+use App\Models\User;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+
+class EvaluationCampaignService
+{
+    public function getAllPaginated(int $perPage = 12): LengthAwarePaginator
+    {
+        return EvaluationCampaign::with('createdBy')
+            ->withCount('evaluations')
+            ->withAvg('evaluations', 'rating')
+            ->orderByRaw("CASE WHEN status = 'ACTIVE' THEN 0 ELSE 1 END")
+            ->orderBy('created_at', 'desc')
+            ->paginate($perPage);
+    }
+
+    public function findById(int $id): ?EvaluationCampaign
+    {
+        return EvaluationCampaign::with('createdBy')
+            ->withCount('evaluations')
+            ->withAvg('evaluations', 'rating')
+            ->find($id);
+    }
+
+    /**
+     * Toda campanha nasce ativa: a que estiver no ar é encerrada no mesmo instante.
+     */
+    public function create(array $data): EvaluationCampaign
+    {
+        return DB::transaction(function () use ($data) {
+            $this->finishActiveCampaigns();
+
+            $campaign = EvaluationCampaign::create([
+                'title'       => $data['title'],
+                'description' => $data['description'] ?? null,
+                'target'      => $data['target'],
+                'status'      => DefaultStatusEnum::ACTIVE->value,
+                'started_at'  => now(),
+                'created_by'  => Auth::id(),
+            ]);
+
+            return $campaign->load('createdBy');
+        });
+    }
+
+    /**
+     * Apenas título e descrição — alvo e ciclo de vida não mudam depois da criação.
+     */
+    public function update(int $id, array $data): ?EvaluationCampaign
+    {
+        $campaign = EvaluationCampaign::find($id);
+
+        if (!$campaign) {
+            return null;
+        }
+
+        $campaign->update(array_intersect_key($data, array_flip(['title', 'description'])));
+
+        return $campaign->fresh('createdBy');
+    }
+
+    public function delete(int $id): bool
+    {
+        $campaign = EvaluationCampaign::find($id);
+
+        if (!$campaign) {
+            return false;
+        }
+
+        return (bool) $campaign->delete();
+    }
+
+    /**
+     * Encerra a campanha: registra o fim e tira do ar. Não há reabertura.
+     *
+     * @return array{error?: string, campaign?: EvaluationCampaign}
+     */
+    public function finish(int $id): array
+    {
+        $campaign = EvaluationCampaign::find($id);
+
+        if (!$campaign) {
+            return ['error' => 'not_found'];
+        }
+
+        if ($campaign->isFinished()) {
+            return ['error' => 'already_finished'];
+        }
+
+        $campaign->update([
+            'status'      => DefaultStatusEnum::INACTIVE->value,
+            'finished_at' => now(),
+        ]);
+
+        return ['campaign' => $campaign->fresh('createdBy')];
+    }
+
+    private function finishActiveCampaigns(): void
+    {
+        EvaluationCampaign::where('status', DefaultStatusEnum::ACTIVE->value)
+            ->update([
+                'status'      => DefaultStatusEnum::INACTIVE->value,
+                'finished_at' => now(),
+                'updated_at'  => now(),
+            ]);
+    }
+
+    public function getActiveCampaign(): ?EvaluationCampaign
+    {
+        return EvaluationCampaign::where('status', DefaultStatusEnum::ACTIVE->value)->first();
+    }
+
+    /**
+     * Campanha que o usuário ainda precisa responder, ou null se não houver pendência.
+     */
+    public function getPendingForUser(User $user): ?EvaluationCampaign
+    {
+        $campaign = $this->getActiveCampaign();
+
+        if (!$campaign) {
+            return null;
+        }
+
+        if (!$campaign->target->accepts($user->type)) {
+            return null;
+        }
+
+        $alreadyAnswered = Evaluation::where('evaluation_campaign_id', $campaign->id)
+            ->where('user_id', $user->id)
+            ->exists();
+
+        return $alreadyAnswered ? null : $campaign;
+    }
+
+    /**
+     * Registra a avaliação do usuário autenticado na campanha ativa.
+     *
+     * @return array{error?: string, evaluation?: Evaluation}
+     */
+    public function submit(User $user, int $campaignId, array $data): array
+    {
+        $campaign = EvaluationCampaign::find($campaignId);
+
+        if (!$campaign) {
+            return ['error' => 'not_found'];
+        }
+
+        if (!$campaign->isActive()) {
+            return ['error' => 'inactive'];
+        }
+
+        if (!$campaign->target->accepts($user->type)) {
+            return ['error' => 'not_eligible'];
+        }
+
+        $exists = Evaluation::where('evaluation_campaign_id', $campaign->id)
+            ->where('user_id', $user->id)
+            ->exists();
+
+        if ($exists) {
+            return ['error' => 'already_answered'];
+        }
+
+        $evaluation = Evaluation::create([
+            'evaluation_campaign_id' => $campaign->id,
+            'user_id'                => $user->id,
+            'rating'                 => $data['rating'],
+            'comment'                => $data['comment'] ?? null,
+        ]);
+
+        return ['evaluation' => $evaluation->load('user')];
+    }
+
+    /**
+     * Avaliações de uma campanha, com filtros de origem, faixa de nota e busca por nome.
+     */
+    public function getEvaluationsPaginated(int $campaignId, array $filters = [], int $perPage = 12): LengthAwarePaginator
+    {
+        $query = Evaluation::with('user')
+            ->where('evaluation_campaign_id', $campaignId);
+
+        if (!empty($filters['source'])) {
+            $query->whereHas('user', fn($q) => $q->where('type', $filters['source']));
+        }
+
+        if (!empty($filters['min_rating'])) {
+            $query->where('rating', '>=', (int) $filters['min_rating']);
+        }
+
+        if (!empty($filters['max_rating'])) {
+            $query->where('rating', '<=', (int) $filters['max_rating']);
+        }
+
+        if (!empty($filters['search'])) {
+            $term = '%' . mb_strtolower($filters['search']) . '%';
+            $query->where(function ($q) use ($term) {
+                $q->whereHas('user', fn($u) => $u->whereRaw('UNACCENT(LOWER(name)) LIKE UNACCENT(?)', [$term]))
+                    ->orWhereRaw('UNACCENT(LOWER(comment)) LIKE UNACCENT(?)', [$term]);
+            });
+        }
+
+        return $query->orderBy('created_at', 'desc')->paginate($perPage);
+    }
+
+    /**
+     * Blocos de estatística da campanha: total, média, adesão, distribuição e quebra por origem.
+     */
+    public function getStats(int $campaignId): ?array
+    {
+        $campaign = EvaluationCampaign::find($campaignId);
+
+        if (!$campaign) {
+            return null;
+        }
+
+        $totals = Evaluation::where('evaluation_campaign_id', $campaignId)
+            ->selectRaw('COUNT(*) as total, AVG(rating) as average')
+            ->first();
+
+        $total   = (int) ($totals->total ?? 0);
+        $average = $totals->average !== null ? round((float) $totals->average, 2) : null;
+
+        $distributionRaw = Evaluation::where('evaluation_campaign_id', $campaignId)
+            ->selectRaw('rating, COUNT(*) as total')
+            ->groupBy('rating')
+            ->pluck('total', 'rating');
+
+        $distribution = [];
+        for ($rating = 1; $rating <= 10; $rating++) {
+            $distribution[] = [
+                'rating' => $rating,
+                'total'  => (int) ($distributionRaw[$rating] ?? 0),
+            ];
+        }
+
+        $bySourceRaw = Evaluation::where('evaluation_campaign_id', $campaignId)
+            ->join('users', 'users.id', '=', 'evaluations.user_id')
+            ->selectRaw('users.type as source, COUNT(*) as total, AVG(evaluations.rating) as average')
+            ->groupBy('users.type')
+            ->get()
+            ->keyBy('source');
+
+        $bySource = [];
+        foreach ($campaign->target->userTypes() as $userType) {
+            $row = $bySourceRaw->get($userType->value);
+
+            $bySource[] = [
+                'source'  => $userType->value,
+                'total'   => (int) ($row->total ?? 0),
+                'average' => isset($row->average) ? round((float) $row->average, 2) : null,
+            ];
+        }
+
+        $eligible = $this->countEligibleUsers($campaign);
+
+        return [
+            'campaign_id'    => $campaign->id,
+            'total'          => $total,
+            'average'        => $average,
+            'eligible_total' => $eligible,
+            'adherence'      => $eligible > 0 ? round(($total / $eligible) * 100, 1) : 0.0,
+            'distribution'   => $distribution,
+            'by_source'      => $bySource,
+        ];
+    }
+
+    private function countEligibleUsers(EvaluationCampaign $campaign): int
+    {
+        $types = array_map(fn(UserTypeEnum $type) => $type->value, $campaign->target->userTypes());
+
+        return User::whereIn('type', $types)
+            ->whereNull('excluded_at')
+            ->count();
+    }
+}

+ 5 - 2
app/Services/FirstAccessService.php

@@ -48,7 +48,7 @@ class FirstAccessService
     /**
      * Conclui o primeiro acesso: cria o associado (ou completa o existente), define a senha e a foto.
      */
-    public function register(array $data, UploadedFile $photo): User
+    public function register(array $data, ?UploadedFile $photo = null): User
     {
         $registration = $data['registration'];
 
@@ -88,7 +88,10 @@ class FirstAccessService
             $user->first_access_completed_at = now();
             $user->save();
 
-            $this->mediaService->uploadUserAvatar($photo, $user);
+            // Sem arquivo novo, mantém a foto que a administração já havia cadastrado.
+            if ($photo) {
+                $this->mediaService->uploadUserAvatar($photo, $user);
+            }
 
             return $user->fresh();
         });

+ 34 - 0
database/migrations/2026_08_03_000001_create_evaluation_campaigns_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('evaluation_campaigns', function (Blueprint $table) {
+            $table->id();
+            $table->string('title');
+            $table->text('description')->nullable();
+            $table->string('target');
+            $table->string('status')->default('INACTIVE');
+            $table->timestamp('started_at')->nullable();
+            $table->timestamp('finished_at')->nullable();
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index('status');
+            $table->index('target');
+        });
+
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('evaluation_campaigns');
+    }
+};

+ 28 - 0
database/migrations/2026_08_03_000002_create_evaluations_table.php

@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('evaluations', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('evaluation_campaign_id')->constrained('evaluation_campaigns')->cascadeOnDelete();
+            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+            $table->unsignedTinyInteger('rating');
+            $table->text('comment')->nullable();
+            $table->timestamps();
+
+            $table->unique(['evaluation_campaign_id', 'user_id'], 'evaluations_campaign_user_unique');
+            $table->index('rating');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('evaluations');
+    }
+};

+ 18 - 0
database/seeders/PermissionSeeder.php

@@ -110,6 +110,12 @@ class PermissionSeeder extends Seeder
                         "description" => "Notificações dos Parceiros",
                         "bits" => Permission::CRUD,
                         "children" => [],
+                    ],
+                    [
+                        "scope" => "parceiro.avaliacao",
+                        "description" => "Avaliação do Parceiro",
+                        "bits" => Permission::VIEW | Permission::ADD,
+                        "children" => [],
                     ]
                 ],
             ],
@@ -144,6 +150,12 @@ class PermissionSeeder extends Seeder
                 "bits" => Permission::ALL_PERMS,
                 "children" => [],
             ],
+            [
+                "scope" => "avaliacao",
+                "description" => "Campanhas de Avaliação",
+                "bits" => Permission::ALL_PERMS,
+                "children" => [],
+            ],
             [
                 "scope" => "associado",
                 "description" => "Associado",
@@ -196,6 +208,12 @@ class PermissionSeeder extends Seeder
                         "description" => "Notificações do Associado",
                         "bits" => Permission::VIEW,
                         "children" => [],
+                    ],
+                    [
+                        "scope" => "associado.avaliacao",
+                        "description" => "Avaliação do Associado",
+                        "bits" => Permission::VIEW | Permission::ADD,
+                        "children" => [],
                     ]
                 ],
             ],

+ 2 - 0
database/seeders/UserTypePermissionSeeder.php

@@ -38,6 +38,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'associado.convenio',     'bits' => Permission::VIEW | Permission::MENU],
                         ['scope' => 'associado.dependente',   'bits' => Permission::VIEW | Permission::ADD | Permission::EDIT | Permission::DELETE | Permission::MENU],
                         ['scope' => 'associado.notificacao',  'bits' => Permission::VIEW | Permission::MENU],
+                        ['scope' => 'associado.avaliacao',    'bits' => Permission::VIEW | Permission::ADD],
                         ['scope' => 'associado.agendamento',  'bits' => Permission::VIEW | Permission::ADD | Permission::EDIT | Permission::MENU],
                         ['scope' => 'associado.loja',         'bits' => Permission::VIEW | Permission::MENU],
                         ['scope' => 'categoria',              'bits' => Permission::VIEW],
@@ -57,6 +58,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'parceiro.dados',       'bits' => Permission::VIEW | Permission::EDIT | Permission::MENU],
                         ['scope' => 'parceiro.servico',     'bits' => Permission::VIEW | Permission::ADD | Permission::EDIT | Permission::DELETE],
                         ['scope' => 'parceiro.notificacao', 'bits' => Permission::VIEW | Permission::MENU],
+                        ['scope' => 'parceiro.avaliacao',   'bits' => Permission::VIEW | Permission::ADD],
                         ['scope' => 'categoria',            'bits' => Permission::VIEW],
                         ['scope' => 'config.state',         'bits' => Permission::VIEW],
                         ['scope' => 'config.city',          'bits' => Permission::VIEW],

+ 8 - 0
lang/en/messages.php

@@ -23,6 +23,14 @@ return [
         'register_success'   => 'Association request sent successfully!',
         'registration_taken' => 'There is already an account with this badge. Use the "My First Access" option on the login screen.',
     ],
+    'evaluation'              => [
+        'submitted'            => 'Evaluation submitted successfully!',
+        'already_answered'     => 'You have already answered this evaluation campaign.',
+        'inactive'             => 'This evaluation campaign is no longer available.',
+        'not_eligible'         => 'This evaluation campaign is not intended for your profile.',
+        'campaign_finished'    => 'Campaign finished successfully',
+        'already_finished'     => 'This campaign has already been finished.',
+    ],
     'first_access'            => [
         'register_success' => 'Registration completed successfully!',
         'already_done'     => 'The first access for this badge has already been completed. Please sign in or use "Forgot your password?".',

+ 8 - 0
lang/es/messages.php

@@ -23,6 +23,14 @@ return [
         'register_success'   => '¡Solicitud de asociación enviada con éxito!',
         'registration_taken' => 'Ya existe un registro con esta credencial. Use la opción "Mi Primer Acceso" en el inicio de sesión.',
     ],
+    'evaluation'              => [
+        'submitted'            => '¡Evaluación enviada con éxito!',
+        'already_answered'     => 'Usted ya respondió esta campaña de evaluación.',
+        'inactive'             => 'Esta campaña de evaluación ya no está disponible.',
+        'not_eligible'         => 'Esta campaña de evaluación no está destinada a su perfil.',
+        'campaign_finished'    => 'Campaña finalizada con éxito',
+        'already_finished'     => 'Esta campaña ya fue finalizada.',
+    ],
     'first_access'            => [
         'register_success' => '¡Registro completado con éxito!',
         'already_done'     => 'El primer acceso de esta credencial ya fue realizado. Inicie sesión normalmente o use "¿Olvidó su contraseña?".',

+ 8 - 0
lang/pt/messages.php

@@ -23,6 +23,14 @@ return [
         'register_success'   => 'Solicitação de associação enviada com sucesso!',
         'registration_taken' => 'Já existe um cadastro com este crachá. Use a opção "Meu Primeiro Acesso" no login.',
     ],
+    'evaluation'              => [
+        'submitted'            => 'Avaliação enviada com sucesso!',
+        'already_answered'     => 'Você já respondeu esta campanha de avaliação.',
+        'inactive'             => 'Esta campanha de avaliação não está mais disponível.',
+        'not_eligible'         => 'Esta campanha de avaliação não é destinada ao seu perfil.',
+        'campaign_finished'    => 'Campanha encerrada com sucesso',
+        'already_finished'     => 'Esta campanha já foi encerrada.',
+    ],
     'first_access'            => [
         'register_success' => 'Cadastro concluído com sucesso!',
         'already_done'     => 'O primeiro acesso deste crachá já foi realizado. Faça login normalmente ou use "Esqueceu a senha?".',

+ 9 - 0
routes/authRoutes/associado_evaluation.php

@@ -0,0 +1,9 @@
+<?php
+
+use App\Http\Controllers\EvaluationController;
+use Illuminate\Support\Facades\Route;
+
+Route::controller(EvaluationController::class)->prefix('associado/evaluation')->group(function () {
+    Route::get('/pending', 'pending')->middleware('permission:associado.avaliacao,view');
+    Route::post('/',       'store')  ->middleware('permission:associado.avaliacao,add');
+});

+ 24 - 0
routes/authRoutes/evaluation_campaign.php

@@ -0,0 +1,24 @@
+<?php
+
+use App\Http\Controllers\EvaluationCampaignController;
+use Illuminate\Support\Facades\Route;
+
+Route::controller(EvaluationCampaignController::class)->prefix('evaluation-campaign')->group(function () {
+    Route::get('/paginated', 'indexPaginated')->middleware('permission:avaliacao,view');
+
+    Route::get('/active', 'active')->middleware('permission:avaliacao,view');
+
+    Route::post('/', 'store')->middleware('permission:avaliacao,add');
+
+    Route::get('/{id}', 'show')->middleware('permission:avaliacao,view');
+
+    Route::put('/{id}', 'update')->middleware('permission:avaliacao,edit');
+
+    Route::delete('/{id}', 'destroy')->middleware('permission:avaliacao,delete');
+
+    Route::patch('/{id}/finish', 'finish')->middleware('permission:avaliacao,edit');
+
+    Route::get('/{id}/stats', 'stats')->middleware('permission:avaliacao,view');
+
+    Route::get('/{id}/evaluations', 'evaluations')->middleware('permission:avaliacao,view');
+});

+ 9 - 0
routes/authRoutes/parceiro_evaluation.php

@@ -0,0 +1,9 @@
+<?php
+
+use App\Http\Controllers\EvaluationController;
+use Illuminate\Support\Facades\Route;
+
+Route::controller(EvaluationController::class)->prefix('parceiro/evaluation')->group(function () {
+    Route::get('/pending', 'pending')->middleware('permission:parceiro.avaliacao,view');
+    Route::post('/',       'store')  ->middleware('permission:parceiro.avaliacao,add');
+});