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
19 changes: 17 additions & 2 deletions app/Http/Controllers/ClarificationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,23 @@ public function store(Request $request)
return redirect()->route('clarifications')->with('error', 'Nenhuma competicao ativa no momento.');
}

// Assuming the first site associated with the contest is the one to use.
$site = DB::table('sites')->where('contest_id', $activeContest->id)->first();
// Use the asking user's own site when they have one -- falling back
// to the contest's first site only for accounts with no site_id.
// Always defaulting to "the first site" mistagged every question
// from a team at any other site, which would have made site-scoped
// clarification answering (issue #18) silently show the wrong
// (or no) questions to that site's coordinator/judges.
// Constrained to $activeContest's id too: nothing deactivates other
// contests when one is activated, so more than one row can have
// is_active=true. If the user's own site happens to belong to a
// different contest than the one resolved here, using it anyway
// would insert a clarification whose contest_id and site_id
// disagree on which contest they belong to.
$siteId = auth()->user()->site_id;
$site = $siteId
? DB::table('sites')->where('id', $siteId)->where('contest_id', $activeContest->id)->first()
: null;
$site ??= DB::table('sites')->where('contest_id', $activeContest->id)->first();
if (!$site) {
return redirect()->route('clarifications')->with('error', 'O concurso ativo nao possui um site configurado.');
}
Expand Down
4 changes: 3 additions & 1 deletion app/Http/Controllers/JudgeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ public function judge(Request $request, Run $run): RedirectResponse
'answer_id' => $answer->id,
'judge_id' => auth()->id(),
'judge_site_id' => auth()->user()->site_id,
'judged_time' => $run->contest->getContestTime(),
// Contest uses SoftDeletes -- a Run can outlive its contest
// being soft-deleted, so ->contest can resolve to null here.
'judged_time' => $run->contest?->getContestTime() ?? 0,
]);

Score::updateScore($run);
Expand Down
207 changes: 207 additions & 0 deletions app/Http/Controllers/SiteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php

namespace App\Http\Controllers;

use App\Models\Clarification;
use App\Models\Contest;
use App\Models\Run;
use App\Models\Task;
use Helium\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;

/**
* Site coordinator screens (issue #18): a read-only view of one physical
* site's submissions/scoreboard, plus site-scoped versions of staff tasks,
* team management, and clarification answering. Everything here is scoped
* to auth()->user()->site_id -- an admin can access these routes too (for
* spot-checking a site) but sees the same site-scoped data a coordinator
* would, not a global view (unlike Judge/Staff/Backend controllers, which
* trust admins with the full contest).
*/
class SiteController extends Controller
{
public function __construct()
{
$this->middleware(['auth', 'role:site,admin']);
}

public function dashboard(): View
{
$site = $this->resolveSite();
$contest = $site?->contest;

$recentRuns = collect();
$scoreboard = collect();

if ($site) {
$recentRuns = Run::where('site_id', $site->id)
->with(['problem:id,short_name,name', 'user:user_id,fullname,username', 'answer:id,name,short_name,is_accepted'])
->orderByDesc('created_at')
->take(20)
->get();

$scoreboard = User::where('site_id', $site->id)
->where('user_type', User::TYPE_TEAM)
->get(['user_id', 'fullname', 'username']);
}

return view('site.dashboard', compact('site', 'contest', 'recentRuns', 'scoreboard'));
}

public function tasks(): View
{
$site = $this->resolveSite();

$tasks = $site
? Task::where('site_id', $site->id)
->where('status', '!=', 'deleted')
->with(['user:user_id,fullname,username', 'staff:user_id,fullname,username'])
->orderByDesc('created_at')
->get()
: collect();

return view('site.tasks', compact('site', 'tasks'));
}

public function completeTask(Task $task): RedirectResponse
{
$this->authorizeSiteAccess($task->site_id, 'tarefas');

$task->update([
'status' => 'done',
// Contest uses SoftDeletes -- a Task can outlive its contest
// being soft-deleted, so ->contest can resolve to null here.
'completed_time' => $task->contest?->getContestTime() ?? 0,
'staff_id' => auth()->id(),
'staff_site_id' => $task->site_id,
]);

return redirect()->route('site.tasks')->with('success', "Tarefa #{$task->task_number} marcada como concluida.");
}

public function teams(): View
{
$site = $this->resolveSite();

$teams = $site
? User::where('site_id', $site->id)->where('user_type', User::TYPE_TEAM)->orderBy('fullname')->get()
: collect();

return view('site.teams', compact('site', 'teams'));
}

public function storeTeam(Request $request): RedirectResponse
{
$site = $this->resolveSite();
abort_if(!$site, 422, 'Sua conta nao esta vinculada a um site.');

$validated = $request->validate([
'fullname' => 'required|string|max:255',
'username' => 'required|string|max:255|unique:users',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
]);

DB::table('users')->insert([
'fullname' => $validated['fullname'],
'username' => $validated['username'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'user_type' => User::TYPE_TEAM,
'site_id' => $site->id,
'contest_id' => $site->contest_id,
'is_enabled' => true,
'created_at' => now(),
'updated_at' => now(),
]);

return redirect()->route('site.teams')->with('success', 'Time criado com sucesso!');
}

public function clarifications(): View
{
$site = $this->resolveSite();

$clarifications = $site
? DB::table('clarifications')
->leftJoin('users', 'clarifications.user_id', '=', 'users.user_id')
->where('clarifications.site_id', $site->id)
->select('clarifications.*', 'users.fullname as team_name')
->orderBy('clarifications.status', 'asc')
->orderBy('clarifications.created_at', 'desc')
->get()
->map(function ($item) {
// Matches Clarification::isAnswered() -- a raw
// status === 'answered' check misses broadcast_site/
// broadcast_all, which would show an already-answered
// (and broadcast) question as pending here, and let a
// resubmit silently overwrite the broadcast answer.
$item->answered = in_array($item->status, ['answered', 'broadcast_site', 'broadcast_all']);
$item->problem = $item->problem_id ? 'Problema #' . $item->problem_id : null;
return $item;
})
: collect();

return view('site.clarifications', compact('site', 'clarifications'));
}

public function answerClarification(Request $request, Clarification $clarification): RedirectResponse
{
$this->authorizeSiteAccess($clarification->site_id, 'clarificacoes');

$request->validate(['answer' => 'required|string']);

$clarification->update([
'answer' => $request->input('answer'),
'status' => 'answered',
'answered_time' => $clarification->contest?->getContestTime() ?? 0,
]);

return redirect()->route('site.clarifications')->with('success', 'Resposta enviada!');
}

/**
* A site coordinator is always locked to their own site_id, regardless
* of any ?site_id= query param -- otherwise they could spot-check (or
* manage) another site's data just by editing the URL. Admins normally
* have no site_id of their own, so they may pick a site to view via
* ?site_id= instead (read-only spot-checking, per this controller's
* class doc); with neither, there's nothing to show.
*/
private function resolveSite(): ?\App\Models\Site
{
$user = auth()->user();

if (!$user->isAdmin()) {
return $user->site;
}

return $user->site ?? \App\Models\Site::find(request()->query('site_id'));
}

/**
* Mirrors JudgeController::authorizeRunAccess()/StaffController::
* authorizeTaskAccess() -- role:site,admin only checks the user's type,
* not which site the target record belongs to. Without this a
* coordinator for site A could complete a task or answer a
* clarification belonging to site B just by guessing/incrementing the
* id. Admins are trusted across sites, matching the rest of the admin
* surface.
*/
private function authorizeSiteAccess(?int $targetSiteId, string $resource): void
{
$user = auth()->user();

if ($user->isAdmin()) {
return;
}

if ($user->site_id !== $targetSiteId) {
abort(403, "Voce nao pode gerenciar {$resource} de outro site.");
}
}
}
4 changes: 3 additions & 1 deletion app/Http/Controllers/StaffController.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ public function complete(Task $task): RedirectResponse

$task->update([
'status' => 'done',
'completed_time' => $task->contest->getContestTime(),
// Contest uses SoftDeletes -- a Task can outlive its contest
// being soft-deleted, so ->contest can resolve to null here.
'completed_time' => $task->contest?->getContestTime() ?? 0,
'staff_id' => auth()->id(),
'staff_site_id' => auth()->user()->site_id,
]);
Expand Down
35 changes: 35 additions & 0 deletions resources/views/layouts/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,41 @@
</div>
@endif

@if(Auth::check() && (Auth::user()->isSite() || Auth::user()->isAdmin()))
<!-- Site Coordinator Section -->
<div class="mt-6 px-3">
<h3 class="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Meu Site
</h3>
<div class="mt-2 space-y-1">
<a href="/site/dashboard" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ Request::is('site/dashboard*') ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' }}">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Painel do Site
</a>
<a href="/site/tasks" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ Request::is('site/tasks*') ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' }}">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Tarefas
</a>
<a href="/site/teams" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ Request::is('site/teams*') ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' }}">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Times
</a>
<a href="/site/clarifications" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ Request::is('site/clarifications*') ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' }}">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Clarificações
</a>
</div>
</div>
@endif

@if(Auth::check() && Auth::user()->isAdmin())
<!-- Admin Section -->
<div class="mt-6 px-3">
Expand Down
Loading