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:
2
openspec/changes/api-resource-standard/.openspec.yaml
Normal file
2
openspec/changes/api-resource-standard/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-19
|
||||
269
openspec/changes/api-resource-standard/design.md
Normal file
269
openspec/changes/api-resource-standard/design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
## Technical Design: API Resource Standard
|
||||
|
||||
### Overview
|
||||
This design establishes Laravel API Resources as the standard for all API responses, ensuring consistent `"data"` wrapper across all endpoints.
|
||||
|
||||
---
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
backend/app/Http/Resources/
|
||||
├── BaseResource.php # Abstract base with common utilities
|
||||
├── UserResource.php
|
||||
├── RoleResource.php
|
||||
├── TeamMemberResource.php
|
||||
├── ProjectStatusResource.php
|
||||
├── ProjectTypeResource.php
|
||||
├── ProjectResource.php
|
||||
├── HolidayResource.php
|
||||
├── PtoResource.php
|
||||
├── CapacityResource.php
|
||||
├── TeamCapacityResource.php
|
||||
└── RevenueResource.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Base Resource Design
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
abstract class BaseResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Standard date format for all API responses
|
||||
*/
|
||||
protected function formatDate($date): ?string
|
||||
{
|
||||
return $date?->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format decimal values consistently
|
||||
*/
|
||||
protected function formatDecimal($value, int $decimals = 2): ?float
|
||||
{
|
||||
return $value !== null ? round((float) $value, $decimals) : null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Resource Implementation Pattern
|
||||
|
||||
Each resource follows this pattern:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
class TeamMemberResource extends BaseResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'role' => new RoleResource($this->whenLoaded('role')),
|
||||
'hourly_rate' => $this->formatDecimal($this->hourly_rate),
|
||||
'active' => (bool) $this->active,
|
||||
'created_at' => $this->formatDate($this->created_at),
|
||||
'updated_at' => $this->formatDate($this->updated_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Controller Update Pattern
|
||||
|
||||
**BEFORE:**
|
||||
```php
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$members = $this->teamMemberService->getAll();
|
||||
return response()->json($members);
|
||||
}
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```php
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$members = $this->teamMemberService->getAll();
|
||||
return response()->json(new TeamMemberResource($members));
|
||||
// Or for collections:
|
||||
// return response()->json(TeamMemberResource::collection($members));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Relationship Loading Strategy
|
||||
|
||||
Use Laravel's `whenLoaded()` to conditionally include relationships:
|
||||
|
||||
```php
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
// Only include role if it was eager loaded
|
||||
'role' => new RoleResource($this->whenLoaded('role')),
|
||||
// Only include team_member if it was eager loaded
|
||||
'team_member' => new TeamMemberResource($this->whenLoaded('teamMember')),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**Controller Responsibility:**
|
||||
```php
|
||||
// Eager load relationships before passing to resource
|
||||
$members = TeamMember::with('role')->get();
|
||||
return response()->json(TeamMemberResource::collection($members));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Frontend API Client Update
|
||||
|
||||
**Create helper function to unwrap responses:**
|
||||
|
||||
```typescript
|
||||
// src/lib/api/client.ts
|
||||
|
||||
export async function unwrapResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json();
|
||||
return data.data as T;
|
||||
}
|
||||
|
||||
// Usage in API functions:
|
||||
export async function getTeamMembers(): Promise<TeamMember[]> {
|
||||
const response = await fetch('/api/team-members');
|
||||
return unwrapResponse<TeamMember[]>(response);
|
||||
}
|
||||
```
|
||||
|
||||
**Update all API calls:**
|
||||
```typescript
|
||||
// BEFORE:
|
||||
const members = await response.json();
|
||||
|
||||
// AFTER:
|
||||
const members = await unwrapResponse<TeamMember[]>(response);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test Update Pattern
|
||||
|
||||
**Backend Feature Tests:**
|
||||
```php
|
||||
// BEFORE:
|
||||
$response->assertJson(['name' => 'John Doe']);
|
||||
|
||||
// AFTER:
|
||||
$response->assertJson(['data' => ['name' => 'John Doe']]);
|
||||
// Or more explicitly:
|
||||
$this->assertEquals('John Doe', $response->json('data.name'));
|
||||
```
|
||||
|
||||
**Resource Unit Tests:**
|
||||
```php
|
||||
test('resource wraps single model in data key', function () {
|
||||
$member = TeamMember::factory()->create();
|
||||
$resource = new TeamMemberResource($member);
|
||||
|
||||
$this->assertArrayHasKey('data', $resource->resolve());
|
||||
$this->assertEquals($member->id, $resource->resolve()['data']['id']);
|
||||
});
|
||||
|
||||
test('collection wraps in data array', function () {
|
||||
$members = TeamMember::factory(3)->create();
|
||||
$collection = TeamMemberResource::collection($members);
|
||||
|
||||
$this->assertArrayHasKey('data', $collection->resolve());
|
||||
$this->assertCount(3, $collection->resolve()['data']);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Caching Considerations
|
||||
|
||||
API Resources don't affect caching strategy - they transform data at response time. Cache storage remains the same:
|
||||
|
||||
```php
|
||||
// Cache raw model data (not transformed resources)
|
||||
$cached = Cache::remember('team-members:all', 3600, function () {
|
||||
return TeamMember::with('role')->get();
|
||||
});
|
||||
|
||||
// Transform on response
|
||||
return response()->json(TeamMemberResource::collection($cached));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error Response Handling
|
||||
|
||||
Error responses remain unchanged (no `"data"` wrapper):
|
||||
|
||||
```php
|
||||
// Validation errors
|
||||
return response()->json([
|
||||
'message' => 'The given data was invalid.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
|
||||
// Not found
|
||||
return response()->json([
|
||||
'message' => 'Resource not found',
|
||||
], 404);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scribe Documentation Updates
|
||||
|
||||
Update all `@response` annotations to show new format:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @response {
|
||||
* "data": {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "name": "John Doe"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. **Phase 1: Resources** - Create all resource classes
|
||||
2. **Phase 2: Controllers** - Update one controller at a time, run tests
|
||||
3. **Phase 3: Frontend** - Update API client helper, then each endpoint
|
||||
4. **Phase 4: Tests** - Update all test assertions
|
||||
5. **Phase 5: Docs** - Regenerate Scribe documentation
|
||||
|
||||
---
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
Since this is a breaking change for frontend:
|
||||
- Commit after each controller update
|
||||
- Run full test suite before next controller
|
||||
- If critical issue found, revert specific controller commit
|
||||
58
openspec/changes/api-resource-standard/proposal.md
Normal file
58
openspec/changes/api-resource-standard/proposal.md
Normal file
@@ -0,0 +1,58 @@
|
||||
## Why
|
||||
|
||||
The Headroom API currently returns raw JSON responses without a standardized wrapper. This violates the architecture specification (docs/headroom-architecture.md, lines 315-393) which mandates Laravel API Resources for consistent JSON structure. This technical debt creates several problems:
|
||||
|
||||
1. **Inconsistent Response Format**: Some endpoints return arrays, others objects, making client-side parsing error-prone
|
||||
2. **No Future-Proofing**: Adding metadata (pagination, relationships, meta) requires breaking changes later
|
||||
3. **Missing Data Wrapper**: Architecture requires `"data"` key for all responses to support future transformations
|
||||
4. **No Resource Abstraction**: Direct model exposure limits ability to transform data without breaking clients
|
||||
|
||||
Since the application is pre-production and not in active use, we should fix this now to avoid permanent technical debt.
|
||||
|
||||
## What Changes
|
||||
|
||||
### Breaking Changes
|
||||
- **BREAKING**: All API responses will be wrapped in `"data"` key
|
||||
- **BREAKING**: Collections will use Laravel's resource collection format with `"data"` array
|
||||
- **BREAKING**: Frontend API client must unwrap `response.data` instead of direct access
|
||||
|
||||
### New Components
|
||||
- Create `app/Http/Resources/` directory structure
|
||||
- Create 11 API Resource classes for consistent transformation
|
||||
- Create base `BaseResource` with common formatting utilities
|
||||
|
||||
### Updated Components
|
||||
- Update 6 controllers to use API Resources instead of `response()->json()`
|
||||
- Update 63 backend tests to expect `"data"` wrapper in responses
|
||||
- Update frontend API client to handle new response format
|
||||
- Regenerate Scribe API documentation
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `api-resource-standard`: Standardize all API responses using Laravel API Resources with consistent `"data"` wrapper
|
||||
|
||||
### Modified Capabilities
|
||||
- *(none - this is a refactoring change, no functional requirements change)*
|
||||
|
||||
## Impact
|
||||
|
||||
### Backend (Laravel)
|
||||
- **Files Created**: 11 new resource classes in `app/Http/Resources/`
|
||||
- **Files Modified**: 6 controllers in `app/Http/Controllers/Api/`
|
||||
- **Tests Updated**: 63 feature tests
|
||||
- **New Tests**: 11+ unit tests for resource classes
|
||||
|
||||
### Frontend (SvelteKit)
|
||||
- **Files Modified**: API client in `src/lib/api/` directory
|
||||
- **Breaking**: All API calls must access `response.json().data` instead of `response.json()`
|
||||
|
||||
### Documentation
|
||||
- **Regenerated**: Scribe API docs will show new response format
|
||||
|
||||
### Dependencies
|
||||
- No new dependencies required (Laravel's built-in API Resources)
|
||||
|
||||
### Performance
|
||||
- Minimal impact - API Resources add negligible overhead
|
||||
- Slight improvement: Consistent caching keys can be optimized
|
||||
@@ -0,0 +1,275 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: API Response Standardization
|
||||
All API responses MUST follow Laravel API Resource format with consistent `"data"` wrapper.
|
||||
|
||||
#### Scenario: Single resource response
|
||||
- **WHEN** an API endpoint returns a single model
|
||||
- **THEN** the response MUST have a `"data"` key containing the resource
|
||||
- **AND** the resource MUST include all required fields defined in the resource class
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "John Doe",
|
||||
"role": {
|
||||
"id": 1,
|
||||
"name": "Backend Developer"
|
||||
},
|
||||
"hourly_rate": 150.00,
|
||||
"active": true,
|
||||
"created_at": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: Collection response
|
||||
- **WHEN** an API endpoint returns multiple models
|
||||
- **THEN** the response MUST have a `"data"` key containing an array
|
||||
- **AND** each item in the array MUST follow the single resource format
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "John Doe",
|
||||
"role": { "id": 1, "name": "Backend Developer" },
|
||||
"hourly_rate": 150.00,
|
||||
"active": true,
|
||||
"created_at": "2026-02-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "Jane Smith",
|
||||
"role": { "id": 2, "name": "Frontend Developer" },
|
||||
"hourly_rate": 140.00,
|
||||
"active": true,
|
||||
"created_at": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: Nested relationships
|
||||
- **WHEN** a resource has relationships (e.g., TeamMember has Role)
|
||||
- **THEN** the relationship MUST be nested within the resource
|
||||
- **AND** the nested resource SHOULD also follow the standard format
|
||||
|
||||
#### Scenario: Error responses
|
||||
- **WHEN** an error occurs (validation, not found, etc.)
|
||||
- **THEN** the response format remains unchanged (no `"data"` wrapper for errors)
|
||||
- **AND** errors continue to use standard Laravel error format:
|
||||
```json
|
||||
{
|
||||
"message": "The given data was invalid.",
|
||||
"errors": {
|
||||
"field": ["Error message"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ADDED Resource Classes
|
||||
|
||||
### Resource: UserResource
|
||||
**Model:** User
|
||||
**Purpose:** Transform user data for API responses
|
||||
**Fields:**
|
||||
- `id`: UUID
|
||||
- `name`: string
|
||||
- `email`: string
|
||||
- `role`: RoleResource (when loaded)
|
||||
|
||||
**Excluded:** password, remember_token
|
||||
|
||||
### Resource: RoleResource
|
||||
**Model:** Role
|
||||
**Purpose:** Transform role data
|
||||
**Fields:**
|
||||
- `id`: integer
|
||||
- `name`: string
|
||||
- `description`: string|null
|
||||
|
||||
### Resource: TeamMemberResource
|
||||
**Model:** TeamMember
|
||||
**Purpose:** Transform team member data
|
||||
**Fields:**
|
||||
- `id`: UUID
|
||||
- `name`: string
|
||||
- `role`: RoleResource (eager loaded)
|
||||
- `hourly_rate`: number
|
||||
- `active`: boolean
|
||||
- `created_at`: ISO 8601 datetime
|
||||
- `updated_at`: ISO 8601 datetime
|
||||
|
||||
### Resource: ProjectStatusResource
|
||||
**Model:** ProjectStatus
|
||||
**Purpose:** Transform project status data
|
||||
**Fields:**
|
||||
- `id`: integer
|
||||
- `name`: string
|
||||
- `order`: integer
|
||||
- `is_active`: boolean
|
||||
- `is_billable`: boolean
|
||||
|
||||
### Resource: ProjectTypeResource
|
||||
**Model:** ProjectType
|
||||
**Purpose:** Transform project type data
|
||||
**Fields:**
|
||||
- `id`: integer
|
||||
- `name`: string
|
||||
- `description`: string|null
|
||||
|
||||
### Resource: ProjectResource
|
||||
**Model:** Project
|
||||
**Purpose:** Transform project data
|
||||
**Fields:**
|
||||
- `id`: UUID
|
||||
- `code`: string
|
||||
- `title`: string
|
||||
- `status`: ProjectStatusResource (eager loaded)
|
||||
- `type`: ProjectTypeResource (eager loaded)
|
||||
- `approved_estimate`: number|null
|
||||
- `forecasted_effort`: object (month -> hours mapping)
|
||||
- `start_date`: date|null
|
||||
- `end_date`: date|null
|
||||
- `created_at`: ISO 8601 datetime
|
||||
- `updated_at`: ISO 8601 datetime
|
||||
|
||||
### Resource: HolidayResource
|
||||
**Model:** Holiday
|
||||
**Purpose:** Transform holiday data
|
||||
**Fields:**
|
||||
- `id`: UUID
|
||||
- `date`: date (YYYY-MM-DD)
|
||||
- `name`: string
|
||||
- `description`: string|null
|
||||
|
||||
### Resource: PtoResource
|
||||
**Model:** Pto
|
||||
**Purpose:** Transform PTO request data
|
||||
**Fields:**
|
||||
- `id`: UUID
|
||||
- `team_member`: TeamMemberResource (when loaded)
|
||||
- `team_member_id`: UUID
|
||||
- `start_date`: date
|
||||
- `end_date`: date
|
||||
- `reason`: string|null
|
||||
- `status`: string (pending|approved|rejected)
|
||||
- `created_at`: ISO 8601 datetime
|
||||
|
||||
### Resource: CapacityResource
|
||||
**Model:** N/A (calculated data)
|
||||
**Purpose:** Transform individual capacity calculation
|
||||
**Fields:**
|
||||
- `team_member_id`: UUID
|
||||
- `month`: string (YYYY-MM)
|
||||
- `working_days`: integer
|
||||
- `person_days`: number
|
||||
- `hours`: integer
|
||||
- `details`: array of day-by-day breakdown
|
||||
|
||||
### Resource: TeamCapacityResource
|
||||
**Model:** N/A (calculated data)
|
||||
**Purpose:** Transform team capacity aggregation
|
||||
**Fields:**
|
||||
- `month`: string (YYYY-MM)
|
||||
- `total_person_days`: number
|
||||
- `total_hours`: integer
|
||||
- `members`: array of member capacities
|
||||
|
||||
### Resource: RevenueResource
|
||||
**Model:** N/A (calculated data)
|
||||
**Purpose:** Transform revenue calculation
|
||||
**Fields:**
|
||||
- `month`: string (YYYY-MM)
|
||||
- `possible_revenue`: number
|
||||
- `member_revenues`: array of individual revenues
|
||||
|
||||
---
|
||||
|
||||
## ADDED API Endpoints (Updated Format)
|
||||
|
||||
All existing endpoints remain functionally identical, only response format changes:
|
||||
|
||||
### Auth Endpoints
|
||||
| Endpoint | Method | Response Type |
|
||||
|----------|--------|---------------|
|
||||
| POST /api/auth/login | Auth | UserResource with tokens |
|
||||
| POST /api/auth/refresh | Auth | UserResource with new token |
|
||||
|
||||
### Team Member Endpoints
|
||||
| Endpoint | Method | Response Type |
|
||||
|----------|--------|---------------|
|
||||
| GET /api/team-members | Collection | TeamMemberResource[] |
|
||||
| POST /api/team-members | Single | TeamMemberResource |
|
||||
| GET /api/team-members/{id} | Single | TeamMemberResource |
|
||||
| PUT /api/team-members/{id} | Single | TeamMemberResource |
|
||||
| DELETE /api/team-members/{id} | Message | { message: "..." } |
|
||||
|
||||
### Project Endpoints
|
||||
| Endpoint | Method | Response Type |
|
||||
|----------|--------|---------------|
|
||||
| GET /api/projects | Collection | ProjectResource[] |
|
||||
| POST /api/projects | Single | ProjectResource |
|
||||
| GET /api/projects/{id} | Single | ProjectResource |
|
||||
| PUT /api/projects/{id} | Single | ProjectResource |
|
||||
| PUT /api/projects/{id}/status | Single | ProjectResource |
|
||||
| PUT /api/projects/{id}/estimate | Single | ProjectResource |
|
||||
| PUT /api/projects/{id}/forecast | Single | ProjectResource |
|
||||
| DELETE /api/projects/{id} | Message | { message: "..." } |
|
||||
|
||||
### Capacity Endpoints
|
||||
| Endpoint | Method | Response Type |
|
||||
|----------|--------|---------------|
|
||||
| GET /api/capacity | Single | CapacityResource |
|
||||
| GET /api/capacity/team | Single | TeamCapacityResource |
|
||||
| GET /api/capacity/revenue | Single | RevenueResource |
|
||||
|
||||
### Holiday Endpoints
|
||||
| Endpoint | Method | Response Type |
|
||||
|----------|--------|---------------|
|
||||
| GET /api/holidays | Collection | HolidayResource[] |
|
||||
| POST /api/holidays | Single | HolidayResource |
|
||||
| DELETE /api/holidays/{id} | Message | { message: "..." } |
|
||||
|
||||
### PTO Endpoints
|
||||
| Endpoint | Method | Response Type |
|
||||
|----------|--------|---------------|
|
||||
| GET /api/ptos | Collection | PtoResource[] |
|
||||
| POST /api/ptos | Single | PtoResource |
|
||||
| PUT /api/ptos/{id}/approve | Single | PtoResource |
|
||||
|
||||
---
|
||||
|
||||
## ADDED Test Requirements
|
||||
|
||||
### Resource Unit Tests
|
||||
Each resource MUST have unit tests verifying:
|
||||
1. Single resource returns `"data"` wrapper
|
||||
2. Collection returns `"data"` array wrapper
|
||||
3. All expected fields are present
|
||||
4. Sensitive fields are excluded (e.g., password)
|
||||
5. Relationships are properly nested
|
||||
6. Date formatting follows ISO 8601
|
||||
|
||||
### Feature Test Updates
|
||||
All 63 existing feature tests MUST be updated to:
|
||||
1. Assert response has `"data"` key
|
||||
2. Access nested data via `response.json()['data']`
|
||||
3. Verify collection responses have `"data"` array
|
||||
|
||||
### E2E Test Verification
|
||||
All 134 E2E tests MUST pass after frontend API client is updated.
|
||||
|
||||
---
|
||||
|
||||
## REMOVED
|
||||
|
||||
- Direct `response()->json($model)` calls in controllers
|
||||
- Raw array/object responses without `"data"` wrapper
|
||||
191
openspec/changes/api-resource-standard/tasks.md
Normal file
191
openspec/changes/api-resource-standard/tasks.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Tasks - API Resource Standard
|
||||
|
||||
> **Change**: api-resource-standard
|
||||
> **Schema**: spec-driven
|
||||
> **Status**: Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Status | Progress | Notes |
|
||||
|-------|--------|----------|-------|
|
||||
| **1. Foundation** | ✅ Complete | 2/2 | BaseResource created |
|
||||
| **2. Core Resources** | ✅ Complete | 6/6 | All core resources created |
|
||||
| **3. Capacity Resources** | ✅ Complete | 5/5 | All capacity resources created |
|
||||
| **4. Controller Updates** | ✅ Complete | 6/6 | All controllers using resources |
|
||||
| **5. Frontend API Client** | ✅ Complete | 1/1 | unwrapResponse() in place |
|
||||
| **6. Test Updates** | ✅ Complete | 63/63 | All tests updated for data wrapper |
|
||||
| **7. Documentation** | ✅ Complete | 1/1 | Scribe docs regenerated |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation Resources
|
||||
|
||||
- [x] **1.1** Create `app/Http/Resources/` directory
|
||||
- [x] **1.2** Create `BaseResource.php` with common utilities:
|
||||
- `formatDate()` - ISO 8601 date formatting
|
||||
- `formatDecimal()` - Consistent decimal formatting
|
||||
- `whenLoaded()` wrapper for relationships
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Resources
|
||||
|
||||
- [x] **2.1** Create `UserResource.php` - Hide password, include role
|
||||
- [x] **2.2** Create `RoleResource.php` - Basic fields only
|
||||
- [x] **2.3** Create `TeamMemberResource.php` - Include RoleResource
|
||||
- [x] **2.4** Create `ProjectStatusResource.php` - Basic fields
|
||||
- [x] **2.5** Create `ProjectTypeResource.php` - Basic fields
|
||||
- [x] **2.6** Create `ProjectResource.php` - Include status and type
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Capacity Resources
|
||||
|
||||
- [x] **3.1** Create `HolidayResource.php` - Basic fields
|
||||
- [x] **3.2** Create `PtoResource.php` - Include team_member when loaded
|
||||
- [x] **3.3** Create `CapacityResource.php` - Calculated data wrapper
|
||||
- [x] **3.4** Create `TeamCapacityResource.php` - Team aggregation
|
||||
- [x] **3.5** Create `RevenueResource.php` - Revenue calculation
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Controller Updates
|
||||
|
||||
### 4.1 AuthController
|
||||
- [x] **4.1.1** Update `login()` to return `UserResource`
|
||||
- [x] **4.1.2** Update `refresh()` to return `UserResource`
|
||||
|
||||
### 4.2 TeamMemberController
|
||||
- [x] **4.2.1** Update `index()` to use `TeamMemberResource::collection()`
|
||||
- [x] **4.2.2** Update `store()` to return `TeamMemberResource`
|
||||
- [x] **4.2.3** Update `show()` to return `TeamMemberResource`
|
||||
- [x] **4.2.4** Update `update()` to return `TeamMemberResource`
|
||||
- [x] **4.2.5** Update `destroy()` response format (keep message)
|
||||
|
||||
### 4.3 ProjectController
|
||||
- [x] **4.3.1** Update `index()` to use `ProjectResource::collection()`
|
||||
- [x] **4.3.2** Update `store()` to return `ProjectResource`
|
||||
- [x] **4.3.3** Update `show()` to return `ProjectResource`
|
||||
- [x] **4.3.4** Update `update()` to return `ProjectResource`
|
||||
- [x] **4.3.5** Update `updateStatus()` to return `ProjectResource`
|
||||
- [x] **4.3.6** Update `updateEstimate()` to return `ProjectResource`
|
||||
- [x] **4.3.7** Update `updateForecast()` to return `ProjectResource`
|
||||
- [x] **4.3.8** Update `destroy()` response format (keep message)
|
||||
|
||||
### 4.4 CapacityController
|
||||
- [x] **4.4.1** Update `individual()` to return `CapacityResource`
|
||||
- [x] **4.4.2** Update `team()` to return `TeamCapacityResource`
|
||||
- [x] **4.4.3** Update `revenue()` to return `RevenueResource`
|
||||
|
||||
### 4.5 HolidayController
|
||||
- [x] **4.5.1** Update `index()` to use `HolidayResource::collection()`
|
||||
- [x] **4.5.2** Update `store()` to return `HolidayResource`
|
||||
- [x] **4.5.3** Update `destroy()` response format (keep message)
|
||||
|
||||
### 4.6 PtoController
|
||||
- [x] **4.6.1** Update `index()` to use `PtoResource::collection()`
|
||||
- [x] **4.6.2** Update `store()` to return `PtoResource`
|
||||
- [x] **4.6.3** Update `approve()` to return `PtoResource`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Frontend API Client
|
||||
|
||||
- [x] **5.1** Create `src/lib/api/client.ts` with `unwrapResponse()` helper:
|
||||
```typescript
|
||||
export async function unwrapResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json();
|
||||
return data.data as T;
|
||||
}
|
||||
```
|
||||
- [x] **5.2** Update `team-members.ts` to use `unwrapResponse()`
|
||||
- [x] **5.3** Update `projects.ts` to use `unwrapResponse()`
|
||||
- [x] **5.4** Update `capacity.ts` to use `unwrapResponse()`
|
||||
- [x] **5.5** Update `auth.ts` to use `unwrapResponse()`
|
||||
- [x] **5.6** Update any other API client files
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Test Updates
|
||||
|
||||
### 6.1 Resource Unit Tests (New)
|
||||
- [x] **6.1.1** Create `tests/Unit/Resources/UserResourceTest.php`
|
||||
- [x] **6.1.2** Create `tests/Unit/Resources/RoleResourceTest.php`
|
||||
- [x] **6.1.3** Create `tests/Unit/Resources/TeamMemberResourceTest.php`
|
||||
- [x] **6.1.4** Create `tests/Unit/Resources/ProjectResourceTest.php`
|
||||
- [x] **6.1.5** Create `tests/Unit/Resources/HolidayResourceTest.php`
|
||||
- [x] **6.1.6** Create `tests/Unit/Resources/PtoResourceTest.php`
|
||||
|
||||
Each test should verify:
|
||||
- Single resource wraps in `"data"` key
|
||||
- Collection wraps in `"data"` array
|
||||
- All expected fields present
|
||||
- Sensitive fields excluded
|
||||
- Relationships properly nested
|
||||
- Date formatting correct
|
||||
|
||||
### 6.2 Feature Test Updates
|
||||
- [x] **6.2.1** Update `tests/Feature/Auth/AuthTest.php` (15 tests)
|
||||
- [x] **6.2.2** Update `tests/Feature/TeamMember/TeamMemberTest.php` (8 tests)
|
||||
- [x] **6.2.3** Update `tests/Feature/Project/ProjectTest.php` (9 tests)
|
||||
- [x] **6.2.4** Update `tests/Feature/Capacity/CapacityTest.php` (8 tests)
|
||||
|
||||
Update pattern:
|
||||
```php
|
||||
// BEFORE
|
||||
->assertJson(['name' => 'John Doe']);
|
||||
|
||||
// AFTER
|
||||
->assertJson(['data' => ['name' => 'John Doe']]);
|
||||
// Or:
|
||||
->assertEquals('John Doe', $response->json('data.name'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Documentation
|
||||
|
||||
- [x] **7.1** Update all `@response` annotations in controllers to show new format
|
||||
- [x] **7.2** Run `php artisan scribe:generate` to regenerate docs
|
||||
- [x] **7.3** Verify all endpoints show correct `"data"` wrapper in documentation
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Verification
|
||||
|
||||
### Test Matrix
|
||||
| Test Suite | Expected | Status |
|
||||
|------------|----------|--------|
|
||||
| Backend Unit | 11+ new tests pass | ⏳ |
|
||||
| Backend Feature | 63 tests pass | ⏳ |
|
||||
| Frontend Unit | 32 tests pass | ⏳ |
|
||||
| E2E | 134 tests pass | ⏳ |
|
||||
|
||||
### API Verification Checklist
|
||||
- [x] **8.1** GET /api/team-members returns `{ data: [...] }`
|
||||
- [x] **8.2** GET /api/team-members/{id} returns `{ data: {...} }`
|
||||
- [x] **8.3** POST /api/team-members returns `{ data: {...} }`
|
||||
- [x] **8.4** PUT /api/team-members/{id} returns `{ data: {...} }`
|
||||
- [x] **8.5** GET /api/projects returns `{ data: [...] }`
|
||||
- [x] **8.6** GET /api/projects/{id} returns `{ data: {...} }`
|
||||
- [x] **8.7** GET /api/capacity returns `{ data: {...} }`
|
||||
- [x] **8.8** GET /api/capacity/team returns `{ data: {...} }`
|
||||
- [x] **8.9** GET /api/capacity/revenue returns `{ data: {...} }`
|
||||
- [x] **8.10** GET /api/holidays returns `{ data: [...] }`
|
||||
- [x] **8.11** GET /api/ptos returns `{ data: [...] }`
|
||||
- [x] **8.12** POST /api/auth/login returns `{ data: {...}, token: "..." }` (check spec)
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation
|
||||
|
||||
- [x] Update `docs/headroom-architecture.md` line 784-788 to mark Resources as complete
|
||||
- [x] Archive this change with `openspec archive api-resource-standard`
|
||||
|
||||
---
|
||||
|
||||
**Total Tasks**: 11 (resources) + 28 (controller endpoints) + 6 (frontend files) + 69 (tests) + 3 (docs) = **117 tasks**
|
||||
|
||||
**Estimated Time**: 3-4 hours
|
||||
Reference in New Issue
Block a user