Files
headroom/backend/app/Http/Controllers/Api/PtoController.php
Santhosh Janardhanan 47068dabce feat(api): Implement API Resource Standard compliance
- Create BaseResource with formatDate() and formatDecimal() utilities
- Create 11 API Resource classes for all models
- Update all 6 controllers to return wrapped responses via wrapResource()
- Update frontend API client with unwrapResponse() helper
- Update all 63+ backend tests to expect 'data' wrapper
- Regenerate Scribe API documentation

BREAKING CHANGE: All API responses now wrap data in 'data' key per architecture spec.

Backend Tests: 70 passed, 5 failed (unrelated to data wrapper)
Frontend Unit: 10 passed
E2E Tests: 102 passed, 20 skipped
API Docs: Generated successfully

Refs: openspec/changes/api-resource-standard
2026-02-19 14:51:56 -05:00

152 lines
4.9 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PtoResource;
use App\Models\Pto;
use App\Services\CapacityService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PtoController extends Controller
{
public function __construct(protected CapacityService $capacityService) {}
/**
* List PTO Requests
*
* Fetch PTO requests for a team member, optionally constrained to a month.
*
* @group Capacity Planning
*
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
*
* @response {
* "data": [
* {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
* "start_date": "2026-02-10",
* "end_date": "2026-02-12",
* "status": "pending",
* "reason": "Family travel"
* }
* ]
* }
*/
public function index(Request $request): JsonResponse
{
$data = $request->validate([
'team_member_id' => 'required|exists:team_members,id',
'month' => 'nullable|date_format:Y-m',
]);
$query = Pto::with('teamMember')->where('team_member_id', $data['team_member_id']);
if (! empty($data['month'])) {
$start = Carbon::createFromFormat('Y-m', $data['month'])->startOfMonth();
$end = $start->copy()->endOfMonth();
$query->where(function ($statement) use ($start, $end): void {
$statement->whereBetween('start_date', [$start, $end])
->orWhereBetween('end_date', [$start, $end])
->orWhere(function ($nested) use ($start, $end): void {
$nested->where('start_date', '<=', $start)
->where('end_date', '>=', $end);
});
});
}
$ptos = $query->orderBy('start_date')->get();
return $this->wrapResource(PtoResource::collection($ptos));
}
/**
* Request PTO
*
* Create a PTO request for a team member and keep it in pending status.
*
* @group Capacity Planning
*
* @bodyParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
* @bodyParam start_date string required The first day of the PTO. Example: 2026-02-10
* @bodyParam end_date string required The final day of the PTO. Example: 2026-02-12
* @bodyParam reason string nullable Optional reason for the PTO.
*
* @response 201 {
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
* "start_date": "2026-02-10",
* "end_date": "2026-02-12",
* "status": "pending",
* "reason": "Family travel"
* }
* }
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'team_member_id' => 'required|exists:team_members,id',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'reason' => 'nullable|string',
]);
$pto = Pto::create(array_merge($data, ['status' => 'pending']));
$pto->load('teamMember');
return $this->wrapResource(new PtoResource($pto), 201);
}
/**
* Approve PTO
*
* Approve a pending PTO request and refresh the affected capacity caches.
*
* @group Capacity Planning
*
* @urlParam id string required The PTO UUID that needs approval. Example: 550e8400-e29b-41d4-a716-446655440001
*
* @response {
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "status": "approved"
* }
* }
*/
public function approve(string $id): JsonResponse
{
$pto = Pto::with('teamMember')->findOrFail($id);
if ($pto->status !== 'approved') {
$pto->status = 'approved';
$pto->save();
$months = $this->monthsBetween($pto->start_date, $pto->end_date);
$this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months);
}
$pto->load('teamMember');
return $this->wrapResource(new PtoResource($pto));
}
private function monthsBetween(Carbon|string $start, Carbon|string $end): array
{
$startMonth = Carbon::create($start)->copy()->startOfMonth();
$endMonth = Carbon::create($end)->copy()->startOfMonth();
$months = [];
while ($startMonth <= $endMonth) {
$months[] = $startMonth->format('Y-m');
$startMonth->addMonth();
}
return $months;
}
}