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
This commit is contained in:
2026-02-19 14:51:56 -05:00
parent 1592c5be8d
commit 47068dabce
49 changed files with 2426 additions and 809 deletions

View File

@@ -3,6 +3,7 @@
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;
@@ -19,18 +20,22 @@ class PtoController extends Controller
* 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 [
* {
* "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"
* }
* ]
*
* @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
{
@@ -39,7 +44,7 @@ class PtoController extends Controller
'month' => 'nullable|date_format:Y-m',
]);
$query = Pto::where('team_member_id', $data['team_member_id']);
$query = Pto::with('teamMember')->where('team_member_id', $data['team_member_id']);
if (! empty($data['month'])) {
$start = Carbon::createFromFormat('Y-m', $data['month'])->startOfMonth();
@@ -57,7 +62,7 @@ class PtoController extends Controller
$ptos = $query->orderBy('start_date')->get();
return response()->json($ptos);
return $this->wrapResource(PtoResource::collection($ptos));
}
/**
@@ -66,17 +71,21 @@ class PtoController extends Controller
* 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 {
* "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"
* "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
@@ -89,8 +98,9 @@ class PtoController extends Controller
]);
$pto = Pto::create(array_merge($data, ['status' => 'pending']));
$pto->load('teamMember');
return response()->json($pto, 201);
return $this->wrapResource(new PtoResource($pto), 201);
}
/**
@@ -99,15 +109,19 @@ class PtoController extends Controller
* 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 {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "status": "approved"
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "status": "approved"
* }
* }
*/
public function approve(string $id): JsonResponse
{
$pto = Pto::findOrFail($id);
$pto = Pto::with('teamMember')->findOrFail($id);
if ($pto->status !== 'approved') {
$pto->status = 'approved';
@@ -116,7 +130,9 @@ class PtoController extends Controller
$this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months);
}
return response()->json($pto);
$pto->load('teamMember');
return $this->wrapResource(new PtoResource($pto));
}
private function monthsBetween(Carbon|string $start, Carbon|string $end): array