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
14 changes: 9 additions & 5 deletions backend/app/Http/Controllers/Api/V1/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\TimeEntry;
use App\Models\User;
use App\Support\TimezoneAwareDateRange;
use App\Support\WorkedTime;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -118,7 +119,7 @@ public function index(Request $request): JsonResponse
->whereNotNull('ended_at')
->where('type', '!=', 'idle')
->where('approval_status', 'approved')
->selectRaw('user_id, SUM(duration_seconds) as total_seconds')
->selectRaw('user_id, SUM(' . WorkedTime::durationExpr() . ') as total_seconds')
->groupBy('user_id')
->get()
->keyBy('user_id');
Expand Down Expand Up @@ -239,7 +240,8 @@ private function employeeDashboard(User $user, Request $request): JsonResponse
->whereNotNull('ended_at')
->where('type', '!=', 'idle')
->where('approval_status', 'approved')
->sum('duration_seconds');
->selectRaw('COALESCE(SUM(' . WorkedTime::durationExpr() . '), 0) as total_seconds')
->value('total_seconds');

$now = Carbon::now();
if ($now >= Carbon::parse($dateFrom) && $now < Carbon::parse($dateTo) && $timer) {
Expand Down Expand Up @@ -282,7 +284,8 @@ private function employeeDashboard(User $user, Request $request): JsonResponse
->whereNotNull('ended_at')
->where('type', '!=', 'idle')
->where('approval_status', 'approved')
->sum('duration_seconds');
->selectRaw('COALESCE(SUM(' . WorkedTime::durationExpr() . '), 0) as total_seconds')
->value('total_seconds');

// Include current running timer in weekly total only if it started within this week.
// Without the boundary check a timer that started in a previous week would add its
Expand Down Expand Up @@ -312,8 +315,9 @@ private function employeeDashboard(User $user, Request $request): JsonResponse
->where('started_at', '<', $dayEndUtc)
->whereNotNull('ended_at')
->where('type', '!=', 'idle')
->where('approval_status', 'approved')
->sum('duration_seconds');
->where('approval_status', 'approved')
->selectRaw('COALESCE(SUM(' . WorkedTime::durationExpr() . '), 0) as total_seconds')
->value('total_seconds');

// Add running timer elapsed to today's bar
if ($dayStr === $todayLocal && $timer) {
Expand Down
109 changes: 73 additions & 36 deletions backend/app/Http/Controllers/Api/V1/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Services\PermissionService;
use App\Services\ReportService;
use App\Support\ReportExportFormatter;
use App\Support\TimezoneAwareDateRange;
Expand All @@ -25,6 +26,65 @@ private function parseDateRange(Request $request): array
);
}


/**
* Which user(s) the actor is allowed to see, honouring the requested filter.
*
* Returns a single id, a list of ids, or null for "everyone in the organization".
*
* The decision comes from the SCOPE on the actor's `reports.view` grant, never
* from a role name. Two reasons the old `$user->isEmployee()` test was wrong:
*
* - `reports.view` is granted to the `employee` role at scope `own`, so an
* employee legitimately reaches these endpoints. The web client gates the
* user picker on merely HOLDING the permission, so it rendered a full team
* dropdown, sent `user_id`, and the controller then silently clamped every
* request back to the caller. Picking any user — or "All Users" — returned
* the logged-in user's own figures.
* - `isEmployee()` reads the `users.role` STRING, which is only a fallback for
* users with no `user_roles` row, and the role set is open (orgs define custom
* roles). Deciding data visibility from it is wrong in both directions.
*/
/**
* May the actor see anyone other than themselves? Team, projects, payroll and
* attendance reports are inherently about other people, so an `own`-scoped
* actor has nothing to see in them.
*/
private function canViewOthers(Request $request): bool
{
$user = $request->user();

return ($user->getRawOriginal('role') ?? '') === 'owner'
|| app(PermissionService::class)->hasPermission($user, 'reports.view', 'project');
}

private function scopedUserId(Request $request): string|array|null
{
$user = $request->user();
$requested = $request->input('user_id') ?: null;
$scope = app(PermissionService::class)->getScope($user, 'reports.view');

// Owners bypass the permission map entirely, as they do everywhere else.
if ($scope === 'organization' || ($user->getRawOriginal('role') ?? '') === 'owner') {
return $requested;
}

if ($scope === 'project') {
$visible = app(PermissionService::class)->getProjectUserIds($user);

// A requested user outside the actor's team narrows to nothing rather
// than widening the scope — same contract as ProjectTimeReportService.
if ($requested !== null) {
return in_array($requested, $visible, true) ? $requested : $user->id;
}

return $visible;
}

// 'own', or no grant at all.
return $user->id;
}

// REPT-01: Summary
public function summary(Request $request): JsonResponse
{
Expand All @@ -35,11 +95,7 @@ public function summary(Request $request): JsonResponse
]);

$user = $request->user();
$userId = $request->user_id;

if ($user->isEmployee()) {
$userId = $user->id;
}
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand All @@ -56,7 +112,7 @@ public function summary(Request $request): JsonResponse
// REPT-02: Team
public function team(Request $request): JsonResponse
{
if ($request->user()->isEmployee()) {
if (! $this->canViewOthers($request)) {
return response()->json(['message' => 'Unauthorized.'], 403);
}

Expand Down Expand Up @@ -104,10 +160,7 @@ public function apps(Request $request): JsonResponse
'user_id' => 'nullable|uuid',
]);

$userId = $request->user_id;
if ($request->user()->isEmployee()) {
$userId = $request->user()->id;
}
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand All @@ -130,10 +183,10 @@ public function timeline(Request $request): JsonResponse
'date' => 'required|date',
]);

$userId = $request->user_id;
if ($request->user()->isEmployee()) {
$userId = $request->user()->id;
}
// `user_id` is required here, so the resolver always yields a single id
// (it only returns a list when no specific user was asked for).
$scoped = $this->scopedUserId($request);
$userId = is_array($scoped) ? $request->user()->id : ($scoped ?? $request->user()->id);

$data = $this->reportService->timeline(
$request->user()->organization_id,
Expand All @@ -160,7 +213,7 @@ public function export(Request $request): Response
$orgId = $user->organization_id;

// Employees can only ever export their own data.
$userId = $user->isEmployee() ? $user->id : $request->user_id;
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand Down Expand Up @@ -221,7 +274,7 @@ public function payroll(Request $request): JsonResponse
// REPT-08: Attendance
public function attendance(Request $request): JsonResponse
{
if ($request->user()->isEmployee()) {
if (! $this->canViewOthers($request)) {
return response()->json(['message' => 'Unauthorized.'], 403);
}

Expand Down Expand Up @@ -251,11 +304,7 @@ public function activityByDay(Request $request): JsonResponse
]);

$user = $request->user();
$userId = $request->user_id;

if ($user->isEmployee()) {
$userId = $user->id;
}
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand All @@ -280,11 +329,7 @@ public function timeLogs(Request $request): JsonResponse
]);

$user = $request->user();
$userId = $request->user_id;

if ($user->isEmployee()) {
$userId = $user->id;
}
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand All @@ -309,11 +354,7 @@ public function analytics(Request $request): JsonResponse
]);

$user = $request->user();
$userId = $request->user_id;

if ($user->isEmployee()) {
$userId = $user->id;
}
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand All @@ -338,11 +379,7 @@ public function detailedLogs(Request $request): JsonResponse
]);

$user = $request->user();
$userId = $request->user_id;

if ($user->isEmployee()) {
$userId = $user->id;
}
$userId = $this->scopedUserId($request);

[$dateFrom, $dateTo] = $this->parseDateRange($request);

Expand Down
31 changes: 30 additions & 1 deletion backend/app/Http/Controllers/Api/V1/TimeEntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Services\ManualTimeEntryService;
use App\Services\ReportService;
use App\Support\TimezoneAwareDateRange;
use App\Support\WorkedTime;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

Expand Down Expand Up @@ -51,11 +52,39 @@ public function index(Request $request): JsonResponse
$query->where('is_approved', filter_var($request->is_approved, FILTER_VALIDATE_BOOLEAN));
}

// Totals for the WHOLE filtered set, computed server-side.
//
// The client used to sum the entries it had been handed, which is one page —
// so the "Total Hours" card reported 20 entries' worth of time next to an
// "Entries" count covering every match, and the total changed as you paged.
// Measured with the canonical expression so this card agrees with the
// dashboard and the reports tab instead of trusting `duration_seconds`.
$dur = WorkedTime::durationExpr('time_entries');
$totals = (clone $query)
->withoutEagerLoads()
->reorder()
->selectRaw("
COALESCE(SUM(CASE WHEN time_entries.type <> 'idle'
AND time_entries.approval_status = 'approved'
AND time_entries.ended_at IS NOT NULL THEN {$dur} ELSE 0 END), 0) as worked_seconds,
COALESCE(SUM(CASE WHEN time_entries.type = 'idle'
AND time_entries.ended_at IS NOT NULL THEN {$dur} ELSE 0 END), 0) as idle_seconds,
COALESCE(SUM(CASE WHEN time_entries.type <> 'idle'
AND time_entries.approval_status <> 'approved'
AND time_entries.ended_at IS NOT NULL THEN {$dur} ELSE 0 END), 0) as unapproved_seconds
")
->first();

$entries = $query->orderBy('started_at', 'desc')->paginate(
min((int) $request->input('per_page', 25), 100)
);

return response()->json($entries);
$payload = $entries->toArray();
$payload['total_seconds'] = (int) ($totals->worked_seconds ?? 0);
$payload['idle_seconds'] = (int) ($totals->idle_seconds ?? 0);
$payload['unapproved_seconds'] = (int) ($totals->unapproved_seconds ?? 0);

return response()->json($payload);
}

// TIME-06: Show single entry
Expand Down
Loading
Loading