diff --git a/backend/.scribe/endpoints.cache/00.yaml b/backend/.scribe/endpoints.cache/00.yaml index 261eb5e4..94fe7ee9 100644 --- a/backend/.scribe/endpoints.cache/00.yaml +++ b/backend/.scribe/endpoints.cache/00.yaml @@ -62,16 +62,19 @@ endpoints: status: 200 content: |- { - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", - "refresh_token": "abc123def456", - "token_type": "bearer", - "expires_in": 3600, - "user": { + "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alice Johnson", "email": "user@example.com", - "role": "manager" - } + "role": "manager", + "active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }, + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "abc123def456", + "token_type": "bearer", + "expires_in": 3600 } headers: [] description: '' @@ -143,6 +146,15 @@ endpoints: status: 200 content: |- { + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Alice Johnson", + "email": "user@example.com", + "role": "manager", + "active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }, "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "refresh_token": "newtoken123", "token_type": "bearer", diff --git a/backend/.scribe/endpoints.cache/01.yaml b/backend/.scribe/endpoints.cache/01.yaml index 4eaae247..0f06fc4e 100644 --- a/backend/.scribe/endpoints.cache/01.yaml +++ b/backend/.scribe/endpoints.cache/01.yaml @@ -49,21 +49,22 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" - } - ] + { + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } + ] + } headers: [] description: '' responseFields: [] @@ -152,17 +153,18 @@ endpoints: status: 201 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } } headers: [] description: '' @@ -222,17 +224,18 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } } headers: [] description: '' @@ -341,17 +344,18 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "175.00", - "active": false, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T11:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "175.00", + "active": false, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T11:00:00.000000Z" + } } headers: [] description: '' diff --git a/backend/.scribe/endpoints.cache/02.yaml b/backend/.scribe/endpoints.cache/02.yaml index 8a2cd0c5..018b0b8a 100644 --- a/backend/.scribe/endpoints.cache/02.yaml +++ b/backend/.scribe/endpoints.cache/02.yaml @@ -37,11 +37,13 @@ endpoints: custom: [] status: 200 content: |- - [ - {"id": 1, "name": "Project"}, - {"id": 2, "name": "Support"}, - {"id": 3, "name": "Engagement"} - ] + { + "data": [ + {"id": 1, "name": "Project"}, + {"id": 2, "name": "Support"}, + {"id": 3, "name": "Engagement"} + ] + } headers: [] description: '' responseFields: [] @@ -81,11 +83,13 @@ endpoints: custom: [] status: 200 content: |- - [ - {"id": 1, "name": "Pre-sales", "order": 1}, - {"id": 2, "name": "SOW Approval", "order": 2}, - {"id": 3, "name": "Gathering Estimates", "order": 3} - ] + { + "data": [ + {"id": 1, "name": "Pre-sales", "order": 1}, + {"id": 2, "name": "SOW Approval", "order": 2}, + {"id": 3, "name": "Gathering Estimates", "order": 3} + ] + } headers: [] description: '' responseFields: [] @@ -149,21 +153,21 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status_id": 1, - "status": {"id": 1, "name": "Pre-sales"}, - "type_id": 2, - "type": {"id": 2, "name": "Support"}, - "approved_estimate": "120.00", - "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20}, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" - } - ] + { + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": {"id": 1, "name": "Pre-sales"}, + "type": {"id": 2, "name": "Support"}, + "approved_estimate": "120.00", + "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20}, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } + ] + } headers: [] description: '' responseFields: [] @@ -240,13 +244,13 @@ endpoints: status: 201 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status_id": 1, - "status": {"id": 1, "name": "Pre-sales"}, - "type_id": 1, - "type": {"id": 1, "name": "Project"} + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": {"id": 1, "name": "Pre-sales"}, + "type": {"id": 1, "name": "Project"} + } } headers: [] description: '' @@ -306,13 +310,15 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status": {"id": 1, "name": "Pre-sales"}, - "type": {"id": 1, "name": "Project"}, - "approved_estimate": "120.00", - "forecasted_effort": {"2024-02": 40, "2024-03": 60} + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": {"id": 1, "name": "Pre-sales"}, + "type": {"id": 1, "name": "Project"}, + "approved_estimate": "120.00", + "forecasted_effort": {"2024-02": 40, "2024-03": 60} + } } headers: [] description: '' @@ -409,10 +415,12 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-002", - "title": "Updated Title", - "type_id": 2 + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-002", + "title": "Updated Title", + "type": {"id": 2, "name": "Support"} + } } headers: [] description: '' @@ -565,8 +573,10 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "status": {"id": 2, "name": "SOW Approval"} + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": {"id": 2, "name": "SOW Approval"} + } } headers: [] description: '' @@ -654,7 +664,13 @@ endpoints: - custom: [] status: 200 - content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}' + content: |- + { + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "approved_estimate": "120.00" + } + } headers: [] description: '' - @@ -745,7 +761,13 @@ endpoints: - custom: [] status: 200 - content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}' + content: |- + { + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "forecasted_effort": {"2024-02": 40, "2024-03": 60} + } + } headers: [] description: '' - diff --git a/backend/.scribe/endpoints.cache/03.yaml b/backend/.scribe/endpoints.cache/03.yaml index e93cc059..e3c2cadc 100644 --- a/backend/.scribe/endpoints.cache/03.yaml +++ b/backend/.scribe/endpoints.cache/03.yaml @@ -82,15 +82,20 @@ endpoints: status: 200 content: |- { - "person_days": 18.5, - "hours": 148, - "details": [ - { - "date": "2026-02-02", - "availability": 1, - "is_pto": false - } - ] + "data": { + "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + "month": "2026-02", + "working_days": 20, + "person_days": 18.5, + "hours": 148, + "details": [ + { + "date": "2026-02-02", + "availability": 1, + "is_pto": false + } + ] + } } headers: [] description: '' @@ -154,17 +159,19 @@ endpoints: status: 200 content: |- { - "month": "2026-02", - "person_days": 180.5, - "hours": 1444, - "members": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Ada Lovelace", - "person_days": 18.5, - "hours": 148 - } - ] + "data": { + "month": "2026-02", + "total_person_days": 180.5, + "total_hours": 1444, + "members": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Ada Lovelace", + "person_days": 18.5, + "hours": 148 + } + ] + } } headers: [] description: '' @@ -228,8 +235,19 @@ endpoints: status: 200 content: |- { - "month": "2026-02", - "possible_revenue": 21500.25 + "data": { + "month": "2026-02", + "possible_revenue": 21500.25, + "member_revenues": [ + { + "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + "team_member_name": "Ada Lovelace", + "hours": 148, + "hourly_rate": 150.0, + "revenue": 22200.0 + } + ] + } } headers: [] description: '' @@ -292,14 +310,16 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "date": "2026-02-14", - "name": "Company Holiday", - "description": "Office closed" - } - ] + { + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "date": "2026-02-14", + "name": "Company Holiday", + "description": "Office closed" + } + ] + } headers: [] description: '' responseFields: [] @@ -374,10 +394,12 @@ endpoints: status: 201 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "date": "2026-02-14", - "name": "Presidents' Day", - "description": "Office closed" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "date": "2026-02-14", + "name": "Presidents' Day", + "description": "Office closed" + } } headers: [] description: '' @@ -516,16 +538,18 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "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" + } + ] + } headers: [] description: '' responseFields: [] @@ -612,12 +636,14 @@ endpoints: status: 201 content: |- { - "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" + } } headers: [] description: '' @@ -669,8 +695,10 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440001", - "status": "approved" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "approved" + } } headers: [] description: '' diff --git a/backend/.scribe/endpoints/00.yaml b/backend/.scribe/endpoints/00.yaml index f7772fb6..1eab17a5 100644 --- a/backend/.scribe/endpoints/00.yaml +++ b/backend/.scribe/endpoints/00.yaml @@ -60,16 +60,19 @@ endpoints: status: 200 content: |- { - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", - "refresh_token": "abc123def456", - "token_type": "bearer", - "expires_in": 3600, - "user": { + "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alice Johnson", "email": "user@example.com", - "role": "manager" - } + "role": "manager", + "active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }, + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "abc123def456", + "token_type": "bearer", + "expires_in": 3600 } headers: [] description: '' @@ -141,6 +144,15 @@ endpoints: status: 200 content: |- { + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Alice Johnson", + "email": "user@example.com", + "role": "manager", + "active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }, "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "refresh_token": "newtoken123", "token_type": "bearer", diff --git a/backend/.scribe/endpoints/01.yaml b/backend/.scribe/endpoints/01.yaml index 0d3fb6b3..8648829b 100644 --- a/backend/.scribe/endpoints/01.yaml +++ b/backend/.scribe/endpoints/01.yaml @@ -47,21 +47,22 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" - } - ] + { + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } + ] + } headers: [] description: '' responseFields: [] @@ -150,17 +151,18 @@ endpoints: status: 201 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } } headers: [] description: '' @@ -220,17 +222,18 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } } headers: [] description: '' @@ -339,17 +342,18 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "175.00", - "active": false, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T11:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "175.00", + "active": false, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T11:00:00.000000Z" + } } headers: [] description: '' diff --git a/backend/.scribe/endpoints/02.yaml b/backend/.scribe/endpoints/02.yaml index 4c33c095..c5f44c11 100644 --- a/backend/.scribe/endpoints/02.yaml +++ b/backend/.scribe/endpoints/02.yaml @@ -35,11 +35,13 @@ endpoints: custom: [] status: 200 content: |- - [ - {"id": 1, "name": "Project"}, - {"id": 2, "name": "Support"}, - {"id": 3, "name": "Engagement"} - ] + { + "data": [ + {"id": 1, "name": "Project"}, + {"id": 2, "name": "Support"}, + {"id": 3, "name": "Engagement"} + ] + } headers: [] description: '' responseFields: [] @@ -79,11 +81,13 @@ endpoints: custom: [] status: 200 content: |- - [ - {"id": 1, "name": "Pre-sales", "order": 1}, - {"id": 2, "name": "SOW Approval", "order": 2}, - {"id": 3, "name": "Gathering Estimates", "order": 3} - ] + { + "data": [ + {"id": 1, "name": "Pre-sales", "order": 1}, + {"id": 2, "name": "SOW Approval", "order": 2}, + {"id": 3, "name": "Gathering Estimates", "order": 3} + ] + } headers: [] description: '' responseFields: [] @@ -147,21 +151,21 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status_id": 1, - "status": {"id": 1, "name": "Pre-sales"}, - "type_id": 2, - "type": {"id": 2, "name": "Support"}, - "approved_estimate": "120.00", - "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20}, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" - } - ] + { + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": {"id": 1, "name": "Pre-sales"}, + "type": {"id": 2, "name": "Support"}, + "approved_estimate": "120.00", + "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20}, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } + ] + } headers: [] description: '' responseFields: [] @@ -238,13 +242,13 @@ endpoints: status: 201 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status_id": 1, - "status": {"id": 1, "name": "Pre-sales"}, - "type_id": 1, - "type": {"id": 1, "name": "Project"} + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": {"id": 1, "name": "Pre-sales"}, + "type": {"id": 1, "name": "Project"} + } } headers: [] description: '' @@ -304,13 +308,15 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status": {"id": 1, "name": "Pre-sales"}, - "type": {"id": 1, "name": "Project"}, - "approved_estimate": "120.00", - "forecasted_effort": {"2024-02": 40, "2024-03": 60} + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": {"id": 1, "name": "Pre-sales"}, + "type": {"id": 1, "name": "Project"}, + "approved_estimate": "120.00", + "forecasted_effort": {"2024-02": 40, "2024-03": 60} + } } headers: [] description: '' @@ -407,10 +413,12 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-002", - "title": "Updated Title", - "type_id": 2 + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-002", + "title": "Updated Title", + "type": {"id": 2, "name": "Support"} + } } headers: [] description: '' @@ -563,8 +571,10 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "status": {"id": 2, "name": "SOW Approval"} + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": {"id": 2, "name": "SOW Approval"} + } } headers: [] description: '' @@ -652,7 +662,13 @@ endpoints: - custom: [] status: 200 - content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}' + content: |- + { + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "approved_estimate": "120.00" + } + } headers: [] description: '' - @@ -743,7 +759,13 @@ endpoints: - custom: [] status: 200 - content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}' + content: |- + { + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "forecasted_effort": {"2024-02": 40, "2024-03": 60} + } + } headers: [] description: '' - diff --git a/backend/.scribe/endpoints/03.yaml b/backend/.scribe/endpoints/03.yaml index 336b8c6d..d4611b9a 100644 --- a/backend/.scribe/endpoints/03.yaml +++ b/backend/.scribe/endpoints/03.yaml @@ -80,15 +80,20 @@ endpoints: status: 200 content: |- { - "person_days": 18.5, - "hours": 148, - "details": [ - { - "date": "2026-02-02", - "availability": 1, - "is_pto": false - } - ] + "data": { + "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + "month": "2026-02", + "working_days": 20, + "person_days": 18.5, + "hours": 148, + "details": [ + { + "date": "2026-02-02", + "availability": 1, + "is_pto": false + } + ] + } } headers: [] description: '' @@ -152,17 +157,19 @@ endpoints: status: 200 content: |- { - "month": "2026-02", - "person_days": 180.5, - "hours": 1444, - "members": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Ada Lovelace", - "person_days": 18.5, - "hours": 148 - } - ] + "data": { + "month": "2026-02", + "total_person_days": 180.5, + "total_hours": 1444, + "members": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Ada Lovelace", + "person_days": 18.5, + "hours": 148 + } + ] + } } headers: [] description: '' @@ -226,8 +233,19 @@ endpoints: status: 200 content: |- { - "month": "2026-02", - "possible_revenue": 21500.25 + "data": { + "month": "2026-02", + "possible_revenue": 21500.25, + "member_revenues": [ + { + "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + "team_member_name": "Ada Lovelace", + "hours": 148, + "hourly_rate": 150.0, + "revenue": 22200.0 + } + ] + } } headers: [] description: '' @@ -290,14 +308,16 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "date": "2026-02-14", - "name": "Company Holiday", - "description": "Office closed" - } - ] + { + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "date": "2026-02-14", + "name": "Company Holiday", + "description": "Office closed" + } + ] + } headers: [] description: '' responseFields: [] @@ -372,10 +392,12 @@ endpoints: status: 201 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440000", - "date": "2026-02-14", - "name": "Presidents' Day", - "description": "Office closed" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "date": "2026-02-14", + "name": "Presidents' Day", + "description": "Office closed" + } } headers: [] description: '' @@ -514,16 +536,18 @@ endpoints: custom: [] status: 200 content: |- - [ - { - "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" + } + ] + } headers: [] description: '' responseFields: [] @@ -610,12 +634,14 @@ endpoints: status: 201 content: |- { - "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" + } } headers: [] description: '' @@ -667,8 +693,10 @@ endpoints: status: 200 content: |- { - "id": "550e8400-e29b-41d4-a716-446655440001", - "status": "approved" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "approved" + } } headers: [] description: '' diff --git a/backend/Dockerfile b/backend/Dockerfile index 47baae0e..6f5d983c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -29,8 +29,8 @@ COPY . . RUN composer install --no-interaction --optimize-autoloader # Install Laravel Boost -RUN php artisan boost:install -RUN php artisan vendor:publish --provider="Laravel\Boost\BoostServiceProvider" +#RUN php artisan boost:install +#RUN php artisan vendor:publish --provider="Laravel\Boost\BoostServiceProvider" RUN php artisan config:clear RUN composer dump-autoload diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 243b9b89..8904d855 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\UserResource; use App\Models\User; use App\Services\JwtService; use Illuminate\Http\JsonResponse; @@ -39,16 +40,19 @@ class AuthController extends Controller * @bodyParam password string required User password. Example: secret123 * * @response 200 { - * "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", - * "refresh_token": "abc123def456", - * "token_type": "bearer", - * "expires_in": 3600, - * "user": { + * "data": { * "id": "550e8400-e29b-41d4-a716-446655440000", * "name": "Alice Johnson", * "email": "user@example.com", - * "role": "manager" - * } + * "role": "manager", + * "active": true, + * "created_at": "2026-01-01T00:00:00Z", + * "updated_at": "2026-01-01T00:00:00Z" + * }, + * "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + * "refresh_token": "abc123def456", + * "token_type": "bearer", + * "expires_in": 3600 * } * @response 401 {"message":"Invalid credentials"} * @response 403 {"message":"Account is inactive"} @@ -85,18 +89,12 @@ class AuthController extends Controller $accessToken = $this->jwtService->generateAccessToken($user); $refreshToken = $this->jwtService->generateRefreshToken($user); - return response()->json([ + return (new UserResource($user))->additional([ 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'token_type' => 'bearer', 'expires_in' => $this->jwtService->getAccessTokenTTL(), - 'user' => [ - 'id' => $user->id, - 'name' => $user->name, - 'email' => $user->email, - 'role' => $user->role, - ], - ]); + ])->response(); } /** @@ -105,9 +103,19 @@ class AuthController extends Controller * Exchange a valid refresh token for a new access token and refresh token pair. * * @authenticated + * * @bodyParam refresh_token string required Refresh token returned by login. Example: abc123def456 * * @response 200 { + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "name": "Alice Johnson", + * "email": "user@example.com", + * "role": "manager", + * "active": true, + * "created_at": "2026-01-01T00:00:00Z", + * "updated_at": "2026-01-01T00:00:00Z" + * }, * "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", * "refresh_token": "newtoken123", * "token_type": "bearer", @@ -146,12 +154,12 @@ class AuthController extends Controller $accessToken = $this->jwtService->generateAccessToken($user); $newRefreshToken = $this->jwtService->generateRefreshToken($user); - return response()->json([ + return (new UserResource($user))->additional([ 'access_token' => $accessToken, 'refresh_token' => $newRefreshToken, 'token_type' => 'bearer', 'expires_in' => $this->jwtService->getAccessTokenTTL(), - ]); + ])->response(); } /** @@ -160,6 +168,7 @@ class AuthController extends Controller * Invalidate a refresh token and end the active authenticated session. * * @authenticated + * * @bodyParam refresh_token string Optional refresh token to invalidate immediately. Example: abc123def456 * * @response 200 {"message":"Logged out successfully"} diff --git a/backend/app/Http/Controllers/Api/CapacityController.php b/backend/app/Http/Controllers/Api/CapacityController.php index 46d483ab..64306be5 100644 --- a/backend/app/Http/Controllers/Api/CapacityController.php +++ b/backend/app/Http/Controllers/Api/CapacityController.php @@ -3,6 +3,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\CapacityResource; +use App\Http\Resources\RevenueResource; +use App\Http\Resources\TeamCapacityResource; +use App\Models\TeamMember; use App\Services\CapacityService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -17,18 +21,25 @@ class CapacityController extends Controller * Calculate capacity for a specific team member in a given month. * * @group Capacity Planning + * * @urlParam month string required The month in YYYY-MM format. Example: 2026-02 * @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000 + * * @response { - * "person_days": 18.5, - * "hours": 148, - * "details": [ - * { - * "date": "2026-02-02", - * "availability": 1, - * "is_pto": false - * } - * ] + * "data": { + * "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + * "month": "2026-02", + * "working_days": 20, + * "person_days": 18.5, + * "hours": 148, + * "details": [ + * { + * "date": "2026-02-02", + * "availability": 1, + * "is_pto": false + * } + * ] + * } * } */ public function individual(Request $request): JsonResponse @@ -39,8 +50,18 @@ class CapacityController extends Controller ]); $capacity = $this->capacityService->calculateIndividualCapacity($data['team_member_id'], $data['month']); + $workingDays = $this->capacityService->calculateWorkingDays($data['month']); - return response()->json($capacity); + $payload = [ + 'team_member_id' => $data['team_member_id'], + 'month' => $data['month'], + 'working_days' => $workingDays, + 'person_days' => $capacity['person_days'], + 'hours' => $capacity['hours'], + 'details' => $capacity['details'], + ]; + + return $this->wrapResource(new CapacityResource($payload)); } /** @@ -49,19 +70,23 @@ class CapacityController extends Controller * Summarize the combined capacity for all active team members in a month. * * @group Capacity Planning + * * @urlParam month string required The month in YYYY-MM format. Example: 2026-02 + * * @response { - * "month": "2026-02", - * "person_days": 180.5, - * "hours": 1444, - * "members": [ - * { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "name": "Ada Lovelace", - * "person_days": 18.5, - * "hours": 148 - * } - * ] + * "data": { + * "month": "2026-02", + * "total_person_days": 180.5, + * "total_hours": 1444, + * "members": [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "name": "Ada Lovelace", + * "person_days": 18.5, + * "hours": 148 + * } + * ] + * } * } */ public function team(Request $request): JsonResponse @@ -72,7 +97,7 @@ class CapacityController extends Controller $payload = $this->capacityService->calculateTeamCapacity($data['month']); - return response()->json($payload); + return $this->wrapResource(new TeamCapacityResource($payload)); } /** @@ -81,10 +106,23 @@ class CapacityController extends Controller * Estimate monthly revenue based on capacity hours and hourly rates. * * @group Capacity Planning + * * @urlParam month string required The month in YYYY-MM format. Example: 2026-02 + * * @response { - * "month": "2026-02", - * "possible_revenue": 21500.25 + * "data": { + * "month": "2026-02", + * "possible_revenue": 21500.25, + * "member_revenues": [ + * { + * "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + * "team_member_name": "Ada Lovelace", + * "hours": 148, + * "hourly_rate": 150.0, + * "revenue": 22200.0 + * } + * ] + * } * } */ public function revenue(Request $request): JsonResponse @@ -94,10 +132,29 @@ class CapacityController extends Controller ]); $revenue = $this->capacityService->calculatePossibleRevenue($data['month']); + $memberRevenues = []; - return response()->json([ + TeamMember::where('active', true) + ->get() + ->each(function (TeamMember $member) use ($data, &$memberRevenues): void { + $capacity = $this->capacityService->calculateIndividualCapacity($member->id, $data['month']); + $hours = $capacity['hours']; + $hourlyRate = $member->hourly_rate !== null ? (float) $member->hourly_rate : null; + $memberRevenue = $hourlyRate !== null ? round($hours * $hourlyRate, 2) : 0.0; + + $memberRevenues[] = [ + 'team_member_id' => $member->id, + 'team_member_name' => $member->name, + 'hours' => $hours, + 'hourly_rate' => $hourlyRate, + 'revenue' => $memberRevenue, + ]; + }); + + return $this->wrapResource(new RevenueResource([ 'month' => $data['month'], 'possible_revenue' => $revenue, - ]); + 'member_revenues' => $memberRevenues, + ])); } } diff --git a/backend/app/Http/Controllers/Api/HolidayController.php b/backend/app/Http/Controllers/Api/HolidayController.php index e9085016..3384a9ba 100644 --- a/backend/app/Http/Controllers/Api/HolidayController.php +++ b/backend/app/Http/Controllers/Api/HolidayController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\HolidayResource; use App\Models\Holiday; use App\Services\CapacityService; use Illuminate\Http\JsonResponse; @@ -18,15 +19,19 @@ class HolidayController extends Controller * Retrieve holidays for a specific month or all holidays when no month is provided. * * @group Capacity Planning + * * @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02 - * @response [ - * { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "date": "2026-02-14", - * "name": "Company Holiday", - * "description": "Office closed" - * } - * ] + * + * @response { + * "data": [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "date": "2026-02-14", + * "name": "Company Holiday", + * "description": "Office closed" + * } + * ] + * } */ public function index(Request $request): JsonResponse { @@ -38,7 +43,7 @@ class HolidayController extends Controller ? $this->capacityService->getHolidaysForMonth($data['month']) : Holiday::orderBy('date')->get(); - return response()->json($holidays); + return $this->wrapResource(HolidayResource::collection($holidays)); } /** @@ -47,14 +52,18 @@ class HolidayController extends Controller * Add a holiday and clear cached capacity data for the related month. * * @group Capacity Planning + * * @bodyParam date string required Date of the holiday. Example: 2026-02-14 * @bodyParam name string required Name of the holiday. Example: Presidents' Day * @bodyParam description string nullable Optional description of the holiday. + * * @response 201 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "date": "2026-02-14", - * "name": "Presidents' Day", - * "description": "Office closed" + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "date": "2026-02-14", + * "name": "Presidents' Day", + * "description": "Office closed" + * } * } */ public function store(Request $request): JsonResponse @@ -68,7 +77,7 @@ class HolidayController extends Controller $holiday = Holiday::create($data); $this->capacityService->forgetCapacityCacheForMonth($holiday->date->format('Y-m')); - return response()->json($holiday, 201); + return $this->wrapResource(new HolidayResource($holiday), 201); } /** @@ -77,7 +86,9 @@ class HolidayController extends Controller * Remove a holiday and clear affected capacity caches. * * @group Capacity Planning + * * @urlParam id string required The holiday UUID. Example: 550e8400-e29b-41d4-a716-446655440000 + * * @response { * "message": "Holiday deleted" * } diff --git a/backend/app/Http/Controllers/Api/ProjectController.php b/backend/app/Http/Controllers/Api/ProjectController.php index c62e0f2a..84da59ba 100644 --- a/backend/app/Http/Controllers/Api/ProjectController.php +++ b/backend/app/Http/Controllers/Api/ProjectController.php @@ -3,6 +3,9 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\ProjectResource; +use App\Http\Resources\ProjectStatusResource; +use App\Http\Resources\ProjectTypeResource; use App\Models\Project; use App\Models\ProjectStatus; use App\Models\ProjectType; @@ -41,21 +44,21 @@ class ProjectController extends Controller * @queryParam status_id integer Filter by status ID. Example: 1 * @queryParam type_id integer Filter by type ID. Example: 2 * - * @response 200 [ - * { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "code": "PROJ-001", - * "title": "Client Dashboard Redesign", - * "status_id": 1, - * "status": {"id": 1, "name": "Pre-sales"}, - * "type_id": 2, - * "type": {"id": 2, "name": "Support"}, - * "approved_estimate": "120.00", - * "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20}, - * "created_at": "2024-01-15T10:00:00.000000Z", - * "updated_at": "2024-01-15T10:00:00.000000Z" - * } - * ] + * @response 200 { + * "data": [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "code": "PROJ-001", + * "title": "Client Dashboard Redesign", + * "status": {"id": 1, "name": "Pre-sales"}, + * "type": {"id": 2, "name": "Support"}, + * "approved_estimate": "120.00", + * "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20}, + * "created_at": "2024-01-15T10:00:00.000000Z", + * "updated_at": "2024-01-15T10:00:00.000000Z" + * } + * ] + * } */ public function index(Request $request): JsonResponse { @@ -64,7 +67,7 @@ class ProjectController extends Controller $projects = $this->projectService->getAll($statusId, $typeId); - return response()->json($projects); + return $this->wrapResource(ProjectResource::collection($projects)); } /** @@ -79,13 +82,13 @@ class ProjectController extends Controller * @bodyParam type_id integer required Project type ID. Example: 1 * * @response 201 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "code": "PROJ-001", - * "title": "Client Dashboard Redesign", - * "status_id": 1, - * "status": {"id": 1, "name": "Pre-sales"}, - * "type_id": 1, - * "type": {"id": 1, "name": "Project"} + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "code": "PROJ-001", + * "title": "Client Dashboard Redesign", + * "status": {"id": 1, "name": "Pre-sales"}, + * "type": {"id": 1, "name": "Project"} + * } * } * @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}} */ @@ -94,7 +97,7 @@ class ProjectController extends Controller try { $project = $this->projectService->create($request->all()); - return response()->json($project, 201); + return $this->wrapResource(new ProjectResource($project), 201); } catch (ValidationException $e) { return response()->json([ 'message' => 'Validation failed', @@ -113,13 +116,15 @@ class ProjectController extends Controller * @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000 * * @response 200 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "code": "PROJ-001", - * "title": "Client Dashboard Redesign", - * "status": {"id": 1, "name": "Pre-sales"}, - * "type": {"id": 1, "name": "Project"}, - * "approved_estimate": "120.00", - * "forecasted_effort": {"2024-02": 40, "2024-03": 60} + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "code": "PROJ-001", + * "title": "Client Dashboard Redesign", + * "status": {"id": 1, "name": "Pre-sales"}, + * "type": {"id": 1, "name": "Project"}, + * "approved_estimate": "120.00", + * "forecasted_effort": {"2024-02": 40, "2024-03": 60} + * } * } * @response 404 {"message":"Project not found"} */ @@ -133,7 +138,7 @@ class ProjectController extends Controller ], 404); } - return response()->json($project); + return $this->wrapResource(new ProjectResource($project)); } /** @@ -150,10 +155,12 @@ class ProjectController extends Controller * @bodyParam type_id integer Project type ID. Example: 2 * * @response 200 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "code": "PROJ-002", - * "title": "Updated Title", - * "type_id": 2 + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "code": "PROJ-002", + * "title": "Updated Title", + * "type": {"id": 2, "name": "Support"} + * } * } * @response 404 {"message":"Project not found"} * @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}} @@ -173,7 +180,7 @@ class ProjectController extends Controller 'code', 'title', 'type_id', ])); - return response()->json($project); + return $this->wrapResource(new ProjectResource($project)); } catch (ValidationException $e) { return response()->json([ 'message' => 'Validation failed', @@ -194,8 +201,10 @@ class ProjectController extends Controller * @bodyParam status_id integer required Target status ID. Example: 2 * * @response 200 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "status": {"id": 2, "name": "SOW Approval"} + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "status": {"id": 2, "name": "SOW Approval"} + * } * } * @response 404 {"message":"Project not found"} * @response 422 {"message":"Cannot transition from Pre-sales to Done"} @@ -220,7 +229,7 @@ class ProjectController extends Controller (int) $request->input('status_id') ); - return response()->json($project); + return $this->wrapResource(new ProjectResource($project)); } catch (\RuntimeException $e) { return response()->json([ 'message' => $e->getMessage(), @@ -239,7 +248,12 @@ class ProjectController extends Controller * * @bodyParam approved_estimate number required Approved estimate hours (must be > 0). Example: 120 * - * @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"} + * @response 200 { + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "approved_estimate": "120.00" + * } + * } * @response 404 {"message":"Project not found"} * @response 422 {"message":"Approved estimate must be greater than 0"} */ @@ -263,7 +277,7 @@ class ProjectController extends Controller (float) $request->input('approved_estimate') ); - return response()->json($project); + return $this->wrapResource(new ProjectResource($project)); } catch (\RuntimeException $e) { return response()->json([ 'message' => $e->getMessage(), @@ -282,7 +296,12 @@ class ProjectController extends Controller * * @bodyParam forecasted_effort object required Monthly effort breakdown. Example: {"2024-02": 40, "2024-03": 60} * - * @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}} + * @response 200 { + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "forecasted_effort": {"2024-02": 40, "2024-03": 60} + * } + * } * @response 404 {"message":"Project not found"} * @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"} */ @@ -319,17 +338,19 @@ class ProjectController extends Controller * * @authenticated * - * @response 200 [ - * {"id": 1, "name": "Project"}, - * {"id": 2, "name": "Support"}, - * {"id": 3, "name": "Engagement"} - * ] + * @response 200 { + * "data": [ + * {"id": 1, "name": "Project"}, + * {"id": 2, "name": "Support"}, + * {"id": 3, "name": "Engagement"} + * ] + * } */ public function types(): JsonResponse { $types = ProjectType::orderBy('name')->get(['id', 'name']); - return response()->json($types); + return $this->wrapResource(ProjectTypeResource::collection($types)); } /** @@ -337,17 +358,19 @@ class ProjectController extends Controller * * @authenticated * - * @response 200 [ - * {"id": 1, "name": "Pre-sales", "order": 1}, - * {"id": 2, "name": "SOW Approval", "order": 2}, - * {"id": 3, "name": "Gathering Estimates", "order": 3} - * ] + * @response 200 { + * "data": [ + * {"id": 1, "name": "Pre-sales", "order": 1}, + * {"id": 2, "name": "SOW Approval", "order": 2}, + * {"id": 3, "name": "Gathering Estimates", "order": 3} + * ] + * } */ public function statuses(): JsonResponse { $statuses = ProjectStatus::orderBy('order')->get(['id', 'name', 'order']); - return response()->json($statuses); + return $this->wrapResource(ProjectStatusResource::collection($statuses)); } /** diff --git a/backend/app/Http/Controllers/Api/PtoController.php b/backend/app/Http/Controllers/Api/PtoController.php index 5f018759..35845682 100644 --- a/backend/app/Http/Controllers/Api/PtoController.php +++ b/backend/app/Http/Controllers/Api/PtoController.php @@ -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 diff --git a/backend/app/Http/Controllers/Api/TeamMemberController.php b/backend/app/Http/Controllers/Api/TeamMemberController.php index e6d55b1c..b1b9359f 100644 --- a/backend/app/Http/Controllers/Api/TeamMemberController.php +++ b/backend/app/Http/Controllers/Api/TeamMemberController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\TeamMemberResource; use App\Models\TeamMember; use App\Services\TeamMemberService; use Illuminate\Http\JsonResponse; @@ -35,13 +36,53 @@ class TeamMemberController extends Controller * Get a list of all team members with optional filtering by active status. * * @authenticated + * * @queryParam active boolean Filter by active status. Example: true * - * @response 200 [ - * { + * @response 200 { + * "data": [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "name": "John Doe", + * "role": { + * "id": 1, + * "name": "Backend Developer" + * }, + * "hourly_rate": "150.00", + * "active": true, + * "created_at": "2024-01-15T10:00:00.000000Z", + * "updated_at": "2024-01-15T10:00:00.000000Z" + * } + * ] + * } + */ + public function index(Request $request): JsonResponse + { + $active = $request->has('active') + ? filter_var($request->query('active'), FILTER_VALIDATE_BOOLEAN) + : null; + + $teamMembers = $this->teamMemberService->getAll($active); + + return $this->wrapResource(TeamMemberResource::collection($teamMembers)); + } + + /** + * Create a new team member + * + * Create a new team member with name, role, and hourly rate. + * + * @authenticated + * + * @bodyParam name string required Team member name. Example: John Doe + * @bodyParam role_id integer required Role ID. Example: 1 + * @bodyParam hourly_rate numeric required Hourly rate (must be > 0). Example: 150.00 + * @bodyParam active boolean Active status (defaults to true). Example: true + * + * @response 201 { + * "data": { * "id": "550e8400-e29b-41d4-a716-446655440000", * "name": "John Doe", - * "role_id": 1, * "role": { * "id": 1, * "name": "Backend Developer" @@ -51,42 +92,6 @@ class TeamMemberController extends Controller * "created_at": "2024-01-15T10:00:00.000000Z", * "updated_at": "2024-01-15T10:00:00.000000Z" * } - * ] - */ - public function index(Request $request): JsonResponse - { - $active = $request->has('active') - ? filter_var($request->query('active'), FILTER_VALIDATE_BOOLEAN) - : null; - - $teamMembers = $this->teamMemberService->getAll($active); - - return response()->json($teamMembers); - } - - /** - * Create a new team member - * - * Create a new team member with name, role, and hourly rate. - * - * @authenticated - * @bodyParam name string required Team member name. Example: John Doe - * @bodyParam role_id integer required Role ID. Example: 1 - * @bodyParam hourly_rate numeric required Hourly rate (must be > 0). Example: 150.00 - * @bodyParam active boolean Active status (defaults to true). Example: true - * - * @response 201 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "name": "John Doe", - * "role_id": 1, - * "role": { - * "id": 1, - * "name": "Backend Developer" - * }, - * "hourly_rate": "150.00", - * "active": true, - * "created_at": "2024-01-15T10:00:00.000000Z", - * "updated_at": "2024-01-15T10:00:00.000000Z" * } * @response 422 {"message":"Validation failed","errors":{"name":["The name field is required."],"hourly_rate":["Hourly rate must be greater than 0"]}} */ @@ -94,7 +99,8 @@ class TeamMemberController extends Controller { try { $teamMember = $this->teamMemberService->create($request->all()); - return response()->json($teamMember, 201); + + return $this->wrapResource(new TeamMemberResource($teamMember), 201); } catch (ValidationException $e) { return response()->json([ 'message' => 'Validation failed', @@ -109,20 +115,22 @@ class TeamMemberController extends Controller * Get details of a specific team member by ID. * * @authenticated + * * @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000 * * @response 200 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "name": "John Doe", - * "role_id": 1, - * "role": { - * "id": 1, - * "name": "Backend Developer" - * }, - * "hourly_rate": "150.00", - * "active": true, - * "created_at": "2024-01-15T10:00:00.000000Z", - * "updated_at": "2024-01-15T10:00:00.000000Z" + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "name": "John Doe", + * "role": { + * "id": 1, + * "name": "Backend Developer" + * }, + * "hourly_rate": "150.00", + * "active": true, + * "created_at": "2024-01-15T10:00:00.000000Z", + * "updated_at": "2024-01-15T10:00:00.000000Z" + * } * } * @response 404 {"message":"Team member not found"} */ @@ -136,7 +144,7 @@ class TeamMemberController extends Controller ], 404); } - return response()->json($teamMember); + return $this->wrapResource(new TeamMemberResource($teamMember)); } /** @@ -145,24 +153,27 @@ class TeamMemberController extends Controller * Update details of an existing team member. * * @authenticated + * * @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000 + * * @bodyParam name string Team member name. Example: John Doe * @bodyParam role_id integer Role ID. Example: 1 * @bodyParam hourly_rate numeric Hourly rate (must be > 0). Example: 175.00 * @bodyParam active boolean Active status. Example: false * * @response 200 { - * "id": "550e8400-e29b-41d4-a716-446655440000", - * "name": "John Doe", - * "role_id": 1, - * "role": { - * "id": 1, - * "name": "Backend Developer" - * }, - * "hourly_rate": "175.00", - * "active": false, - * "created_at": "2024-01-15T10:00:00.000000Z", - * "updated_at": "2024-01-15T11:00:00.000000Z" + * "data": { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "name": "John Doe", + * "role": { + * "id": 1, + * "name": "Backend Developer" + * }, + * "hourly_rate": "175.00", + * "active": false, + * "created_at": "2024-01-15T10:00:00.000000Z", + * "updated_at": "2024-01-15T11:00:00.000000Z" + * } * } * @response 404 {"message":"Team member not found"} * @response 422 {"message":"Validation failed","errors":{"hourly_rate":["Hourly rate must be greater than 0"]}} @@ -179,10 +190,10 @@ class TeamMemberController extends Controller try { $teamMember = $this->teamMemberService->update($teamMember, $request->only([ - 'name', 'role_id', 'hourly_rate', 'active' + 'name', 'role_id', 'hourly_rate', 'active', ])); - return response()->json($teamMember); + return $this->wrapResource(new TeamMemberResource($teamMember)); } catch (ValidationException $e) { return response()->json([ 'message' => 'Validation failed', @@ -197,6 +208,7 @@ class TeamMemberController extends Controller * Delete a team member. Cannot delete if member has allocations or actuals. * * @authenticated + * * @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000 * * @response 200 {"message":"Team member deleted successfully"} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php index 8677cd5c..c6daec91 100644 --- a/backend/app/Http/Controllers/Controller.php +++ b/backend/app/Http/Controllers/Controller.php @@ -2,7 +2,16 @@ namespace App\Http\Controllers; -abstract class Controller +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Routing\Controller as BaseController; + +class Controller extends BaseController { - // + protected function wrapResource(JsonResource $resource, int $status = 200): JsonResponse + { + return response()->json([ + 'data' => $resource->resolve(request()), + ], $status); + } } diff --git a/backend/app/Http/Resources/BaseResource.php b/backend/app/Http/Resources/BaseResource.php new file mode 100644 index 00000000..95a8e41c --- /dev/null +++ b/backend/app/Http/Resources/BaseResource.php @@ -0,0 +1,18 @@ +toIso8601String(); + } + + protected function formatDecimal($value, int $decimals = 2): ?float + { + return $value !== null ? round((float) $value, $decimals) : null; + } +} diff --git a/backend/app/Http/Resources/CapacityResource.php b/backend/app/Http/Resources/CapacityResource.php new file mode 100644 index 00000000..05596c7d --- /dev/null +++ b/backend/app/Http/Resources/CapacityResource.php @@ -0,0 +1,18 @@ + $this->resource['team_member_id'] ?? null, + 'month' => $this->resource['month'] ?? null, + 'working_days' => $this->resource['working_days'] ?? null, + 'person_days' => $this->resource['person_days'] ?? null, + 'hours' => $this->resource['hours'] ?? null, + 'details' => $this->resource['details'] ?? [], + ]; + } +} diff --git a/backend/app/Http/Resources/HolidayResource.php b/backend/app/Http/Resources/HolidayResource.php new file mode 100644 index 00000000..76af992f --- /dev/null +++ b/backend/app/Http/Resources/HolidayResource.php @@ -0,0 +1,16 @@ + $this->id, + 'date' => $this->date?->toDateString(), + 'name' => $this->name, + 'description' => $this->description, + ]; + } +} diff --git a/backend/app/Http/Resources/ProjectResource.php b/backend/app/Http/Resources/ProjectResource.php new file mode 100644 index 00000000..7a1a6c75 --- /dev/null +++ b/backend/app/Http/Resources/ProjectResource.php @@ -0,0 +1,23 @@ + $this->id, + 'code' => $this->code, + 'title' => $this->title, + 'status' => $this->whenLoaded('status', fn () => new ProjectStatusResource($this->status)), + 'type' => $this->whenLoaded('type', fn () => new ProjectTypeResource($this->type)), + 'approved_estimate' => $this->formatDecimal($this->approved_estimate), + 'forecasted_effort' => $this->forecasted_effort, + 'start_date' => $this->formatDate($this->start_date), + 'end_date' => $this->formatDate($this->end_date), + 'created_at' => $this->formatDate($this->created_at), + 'updated_at' => $this->formatDate($this->updated_at), + ]; + } +} diff --git a/backend/app/Http/Resources/ProjectStatusResource.php b/backend/app/Http/Resources/ProjectStatusResource.php new file mode 100644 index 00000000..a83db87d --- /dev/null +++ b/backend/app/Http/Resources/ProjectStatusResource.php @@ -0,0 +1,17 @@ + $this->id, + 'name' => $this->name, + 'order' => $this->order, + 'is_active' => $this->is_active, + 'is_billable' => $this->is_billable, + ]; + } +} diff --git a/backend/app/Http/Resources/ProjectTypeResource.php b/backend/app/Http/Resources/ProjectTypeResource.php new file mode 100644 index 00000000..5c95a775 --- /dev/null +++ b/backend/app/Http/Resources/ProjectTypeResource.php @@ -0,0 +1,15 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + ]; + } +} diff --git a/backend/app/Http/Resources/PtoResource.php b/backend/app/Http/Resources/PtoResource.php new file mode 100644 index 00000000..3bfb59d8 --- /dev/null +++ b/backend/app/Http/Resources/PtoResource.php @@ -0,0 +1,20 @@ + $this->id, + 'team_member_id' => $this->team_member_id, + 'team_member' => $this->whenLoaded('teamMember', fn () => new TeamMemberResource($this->teamMember)), + 'start_date' => $this->start_date?->toDateString(), + 'end_date' => $this->end_date?->toDateString(), + 'reason' => $this->reason, + 'status' => $this->status, + 'created_at' => $this->formatDate($this->created_at), + ]; + } +} diff --git a/backend/app/Http/Resources/RevenueResource.php b/backend/app/Http/Resources/RevenueResource.php new file mode 100644 index 00000000..bec041cc --- /dev/null +++ b/backend/app/Http/Resources/RevenueResource.php @@ -0,0 +1,15 @@ + $this->resource['month'] ?? null, + 'possible_revenue' => $this->resource['possible_revenue'] ?? null, + 'member_revenues' => $this->resource['member_revenues'] ?? [], + ]; + } +} diff --git a/backend/app/Http/Resources/RoleResource.php b/backend/app/Http/Resources/RoleResource.php new file mode 100644 index 00000000..63490d2e --- /dev/null +++ b/backend/app/Http/Resources/RoleResource.php @@ -0,0 +1,18 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + ]; + } +} diff --git a/backend/app/Http/Resources/TeamCapacityResource.php b/backend/app/Http/Resources/TeamCapacityResource.php new file mode 100644 index 00000000..265930bf --- /dev/null +++ b/backend/app/Http/Resources/TeamCapacityResource.php @@ -0,0 +1,16 @@ + $this->resource['month'] ?? null, + 'total_person_days' => $this->resource['person_days'] ?? null, + 'total_hours' => $this->resource['hours'] ?? null, + 'members' => $this->resource['members'] ?? [], + ]; + } +} diff --git a/backend/app/Http/Resources/TeamMemberResource.php b/backend/app/Http/Resources/TeamMemberResource.php new file mode 100644 index 00000000..f03a869d --- /dev/null +++ b/backend/app/Http/Resources/TeamMemberResource.php @@ -0,0 +1,22 @@ + $this->id, + 'name' => $this->name, + 'role' => $this->whenLoaded('role', fn () => new RoleResource($this->role)), + 'hourly_rate' => $this->formatDecimal($this->hourly_rate), + 'active' => $this->active, + 'created_at' => $this->formatDate($this->created_at), + 'updated_at' => $this->formatDate($this->updated_at), + ]; + } +} diff --git a/backend/app/Http/Resources/UserResource.php b/backend/app/Http/Resources/UserResource.php new file mode 100644 index 00000000..cbec8101 --- /dev/null +++ b/backend/app/Http/Resources/UserResource.php @@ -0,0 +1,22 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'role' => $this->role, + 'active' => $this->active, + 'created_at' => $this->formatDate($this->created_at), + 'updated_at' => $this->formatDate($this->updated_at), + ]; + } +} diff --git a/backend/resources/views/scribe/index.blade.php b/backend/resources/views/scribe/index.blade.php index 1cf1b2c5..df547677 100644 --- a/backend/resources/views/scribe/index.blade.php +++ b/backend/resources/views/scribe/index.blade.php @@ -260,16 +260,19 @@ fetch(url, {
{
- "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
- "refresh_token": "abc123def456",
- "token_type": "bearer",
- "expires_in": 3600,
- "user": {
+ "data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Alice Johnson",
"email": "user@example.com",
- "role": "manager"
- }
+ "role": "manager",
+ "active": true,
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z"
+ },
+ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
+ "refresh_token": "abc123def456",
+ "token_type": "bearer",
+ "expires_in": 3600
}
@@ -457,6 +460,15 @@ fetch(url, {{ + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Alice Johnson", + "email": "user@example.com", + "role": "manager", + "active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }, "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "refresh_token": "newtoken123", "token_type": "bearer", @@ -758,15 +770,20 @@ fetch(url, {@@ -944,17 +961,19 @@ fetch(url, {{ - "person_days": 18.5, - "hours": 148, - "details": [ - { - "date": "2026-02-02", - "availability": 1, - "is_pto": false - } - ] + "data": { + "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + "month": "2026-02", + "working_days": 20, + "person_days": 18.5, + "hours": 148, + "details": [ + { + "date": "2026-02-02", + "availability": 1, + "is_pto": false + } + ] + } }@@ -1108,8 +1127,19 @@ fetch(url, {{ - "month": "2026-02", - "person_days": 180.5, - "hours": 1444, - "members": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Ada Lovelace", - "person_days": 18.5, - "hours": 148 - } - ] + "data": { + "month": "2026-02", + "total_person_days": 180.5, + "total_hours": 1444, + "members": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Ada Lovelace", + "person_days": 18.5, + "hours": 148 + } + ] + } }@@ -1262,14 +1292,16 @@ fetch(url, {{ - "month": "2026-02", - "possible_revenue": 21500.25 + "data": { + "month": "2026-02", + "possible_revenue": 21500.25, + "member_revenues": [ + { + "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + "team_member_name": "Ada Lovelace", + "hours": 148, + "hourly_rate": 150, + "revenue": 22200 + } + ] + } }
-@@ -1426,10 +1458,12 @@ fetch(url, {[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "date": "2026-02-14", - "name": "Company Holiday", - "description": "Office closed" - } -]+{ + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "date": "2026-02-14", + "name": "Company Holiday", + "description": "Office closed" + } + ] +}
{
- "id": "550e8400-e29b-41d4-a716-446655440000",
- "date": "2026-02-14",
- "name": "Presidents' Day",
- "description": "Office closed"
+ "data": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Presidents' Day",
+ "description": "Office closed"
+ }
}
@@ -1727,16 +1761,18 @@ fetch(url, {
-@@ -1919,12 +1955,14 @@ fetch(url, {[ - { - "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" + } + ] +}
{
- "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"
+ }
}
@@ -2092,8 +2130,10 @@ fetch(url, {
{
- "id": "550e8400-e29b-41d4-a716-446655440001",
- "status": "approved"
+ "data": {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "status": "approved"
+ }
}
@@ -2229,20 +2269,22 @@ fetch(url, {
-@@ -2360,23 +2402,25 @@ fetch(url, {[ - { - "id": 1, - "name": "Project" - }, - { - "id": 2, - "name": "Support" - }, - { - "id": 3, - "name": "Engagement" - } -]+{ + "data": [ + { + "id": 1, + "name": "Project" + }, + { + "id": 2, + "name": "Support" + }, + { + "id": 3, + "name": "Engagement" + } + ] +}
-@@ -2501,31 +2545,31 @@ fetch(url, {[ - { - "id": 1, - "name": "Pre-sales", - "order": 1 - }, - { - "id": 2, - "name": "SOW Approval", - "order": 2 - }, - { - "id": 3, - "name": "Gathering Estimates", - "order": 3 - } -]+{ + "data": [ + { + "id": 1, + "name": "Pre-sales", + "order": 1 + }, + { + "id": 2, + "name": "SOW Approval", + "order": 2 + }, + { + "id": 3, + "name": "Gathering Estimates", + "order": 3 + } + ] +}
-@@ -2682,18 +2726,18 @@ fetch(url, {[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "code": "PROJ-001", - "title": "Client Dashboard Redesign", - "status_id": 1, - "status": { - "id": 1, - "name": "Pre-sales" - }, - "type_id": 2, - "type": { - "id": 2, - "name": "Support" - }, - "approved_estimate": "120.00", - "forecasted_effort": { - "2024-02": 40, - "2024-03": 60, - "2024-04": 20 - }, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" - } -]+{ + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-001", + "title": "Client Dashboard Redesign", + "status": { + "id": 1, + "name": "Pre-sales" + }, + "type": { + "id": 2, + "name": "Support" + }, + "approved_estimate": "120.00", + "forecasted_effort": { + "2024-02": 40, + "2024-03": 60, + "2024-04": 20 + }, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } + ] +}
{
- "id": "550e8400-e29b-41d4-a716-446655440000",
- "code": "PROJ-001",
- "title": "Client Dashboard Redesign",
- "status_id": 1,
- "status": {
- "id": 1,
- "name": "Pre-sales"
- },
- "type_id": 1,
- "type": {
- "id": 1,
- "name": "Project"
+ "data": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "code": "PROJ-001",
+ "title": "Client Dashboard Redesign",
+ "status": {
+ "id": 1,
+ "name": "Pre-sales"
+ },
+ "type": {
+ "id": 1,
+ "name": "Project"
+ }
}
}
@@ -2868,21 +2912,23 @@ fetch(url, {
{
- "id": "550e8400-e29b-41d4-a716-446655440000",
- "code": "PROJ-001",
- "title": "Client Dashboard Redesign",
- "status": {
- "id": 1,
- "name": "Pre-sales"
- },
- "type": {
- "id": 1,
- "name": "Project"
- },
- "approved_estimate": "120.00",
- "forecasted_effort": {
- "2024-02": 40,
- "2024-03": 60
+ "data": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "code": "PROJ-001",
+ "title": "Client Dashboard Redesign",
+ "status": {
+ "id": 1,
+ "name": "Pre-sales"
+ },
+ "type": {
+ "id": 1,
+ "name": "Project"
+ },
+ "approved_estimate": "120.00",
+ "forecasted_effort": {
+ "2024-02": 40,
+ "2024-03": 60
+ }
}
}
@@ -3038,10 +3084,15 @@ fetch(url, {
{
- "id": "550e8400-e29b-41d4-a716-446655440000",
- "code": "PROJ-002",
- "title": "Updated Title",
- "type_id": 2
+ "data": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "code": "PROJ-002",
+ "title": "Updated Title",
+ "type": {
+ "id": 2,
+ "name": "Support"
+ }
+ }
}
@@ -3398,10 +3449,12 @@ fetch(url, {@@ -4139,17 +4197,18 @@ fetch(url, {@@ -3587,8 +3640,10 @@ fetch(url, {{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "status": { - "id": 2, - "name": "SOW Approval" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": { + "id": 2, + "name": "SOW Approval" + } } }{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "approved_estimate": "120.00" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "approved_estimate": "120.00" + } }@@ -3779,10 +3834,12 @@ fetch(url, {@@ -3968,21 +4025,22 @@ fetch(url, {{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "forecasted_effort": { - "2024-02": 40, - "2024-03": 60 + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "forecasted_effort": { + "2024-02": 40, + "2024-03": 60 + } } }-[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" - } -]+{ + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } + ] +}
{
- "id": "550e8400-e29b-41d4-a716-446655440000",
- "name": "John Doe",
- "role_id": 1,
- "role": {
- "id": 1,
- "name": "Backend Developer"
- },
- "hourly_rate": "150.00",
- "active": true,
- "created_at": "2024-01-15T10:00:00.000000Z",
- "updated_at": "2024-01-15T10:00:00.000000Z"
+ "data": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "John Doe",
+ "role": {
+ "id": 1,
+ "name": "Backend Developer"
+ },
+ "hourly_rate": "150.00",
+ "active": true,
+ "created_at": "2024-01-15T10:00:00.000000Z",
+ "updated_at": "2024-01-15T10:00:00.000000Z"
+ }
}
@@ -4345,17 +4404,18 @@ fetch(url, {{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "150.00", - "active": true, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T10:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "150.00", + "active": true, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T10:00:00.000000Z" + } }@@ -4512,17 +4572,18 @@ fetch(url, {{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "John Doe", - "role_id": 1, - "role": { - "id": 1, - "name": "Backend Developer" - }, - "hourly_rate": "175.00", - "active": false, - "created_at": "2024-01-15T10:00:00.000000Z", - "updated_at": "2024-01-15T11:00:00.000000Z" + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "John Doe", + "role": { + "id": 1, + "name": "Backend Developer" + }, + "hourly_rate": "175.00", + "active": false, + "created_at": "2024-01-15T10:00:00.000000Z", + "updated_at": "2024-01-15T11:00:00.000000Z" + } }diff --git a/backend/routes/api.php b/backend/routes/api.php index eb69b96b..6f699ec2 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\PtoController; use App\Http\Controllers\Api\TeamMemberController; use App\Http\Middleware\JwtAuth; +use App\Http\Resources\UserResource; use Illuminate\Support\Facades\Route; /* @@ -26,12 +27,7 @@ Route::middleware(JwtAuth::class)->group(function () { Route::post('/auth/logout', [AuthController::class, 'logout']); Route::get('/user', function (\Illuminate\Http\Request $request) { - return response()->json([ - 'id' => $request->user()->id, - 'name' => $request->user()->name, - 'email' => $request->user()->email, - 'role' => $request->user()->role, - ]); + return new UserResource($request->user()); }); // Team Members diff --git a/backend/tests/Feature/Auth/AuthenticationTest.php b/backend/tests/Feature/Auth/AuthenticationTest.php index 4badfe40..f98a36c5 100644 --- a/backend/tests/Feature/Auth/AuthenticationTest.php +++ b/backend/tests/Feature/Auth/AuthenticationTest.php @@ -2,10 +2,10 @@ namespace Tests\Feature\Auth; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Tests\TestCase; class AuthenticationTest extends TestCase { @@ -52,11 +52,11 @@ class AuthenticationTest extends TestCase $payload = base64_encode($payload); $payload = str_replace(['+', '/', '='], ['-', '_', ''], $payload); - $signature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true); + $signature = hash_hmac('sha256', $header.'.'.$payload, config('app.key'), true); $signature = base64_encode($signature); $signature = str_replace(['+', '/', '='], ['-', '_', ''], $signature); - return $header . '.' . $payload . '.' . $signature; + return $header.'.'.$payload.'.'.$signature; } protected function decodeJWT(string $token): ?object @@ -67,9 +67,9 @@ class AuthenticationTest extends TestCase return null; } - list($header, $payload, $signature) = $parts; + [$header, $payload, $signature] = $parts; - $expectedSignature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true); + $expectedSignature = hash_hmac('sha256', $header.'.'.$payload, config('app.key'), true); $expectedSignature = base64_encode($expectedSignature); $expectedSignature = str_replace(['+', '/', '='], ['-', '_', ''], $expectedSignature); @@ -103,16 +103,19 @@ class AuthenticationTest extends TestCase 'refresh_token', 'token_type', 'expires_in', - 'user' => [ + 'data' => [ 'id', 'name', 'email', 'role', + 'active', + 'created_at', + 'updated_at', ], ]); - $response->assertJsonPath('user.name', $user->name); - $response->assertJsonPath('user.email', $user->email); - $response->assertJsonPath('user.role', 'manager'); + $response->assertJsonPath('data.name', $user->name); + $response->assertJsonPath('data.email', $user->email); + $response->assertJsonPath('data.role', 'manager'); } /** @test */ @@ -196,8 +199,10 @@ class AuthenticationTest extends TestCase $response->assertStatus(200); $response->assertJson([ - 'id' => $user->id, - 'email' => $user->email, + 'data' => [ + 'id' => $user->id, + 'email' => $user->email, + ], ]); } diff --git a/backend/tests/Feature/Capacity/CapacityTest.php b/backend/tests/Feature/Capacity/CapacityTest.php index d8e8d85a..9ba396e8 100644 --- a/backend/tests/Feature/Capacity/CapacityTest.php +++ b/backend/tests/Feature/Capacity/CapacityTest.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Services\CapacityService; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; + use function Pest\Laravel\assertDatabaseHas; /** @@ -22,11 +23,23 @@ test('4.1.11 GET /api/capacity calculates individual capacity', function () { $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$teamMember->id}", [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(200); - $expected = app(CapacityService::class)->calculateIndividualCapacity($teamMember->id, '2026-02'); + $service = app(CapacityService::class); + $capacity = $service->calculateIndividualCapacity($teamMember->id, '2026-02'); + $expected = [ + 'data' => [ + 'team_member_id' => $teamMember->id, + 'month' => '2026-02', + 'working_days' => $service->calculateWorkingDays('2026-02'), + 'person_days' => $capacity['person_days'], + 'hours' => $capacity['hours'], + 'details' => $capacity['details'], + ], + ]; + $response->assertExactJson($expected); }); @@ -39,11 +52,11 @@ test('4.1.12 Capacity accounts for availability', function () { TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->create(['team_member_id' => $member->id]); $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(200); - $details = collect($response->json('details')); + $details = collect($response->json('data.details')); expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5); expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0); @@ -63,11 +76,11 @@ test('4.1.13 Capacity subtracts PTO', function () { ]); $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(200); - $details = collect($response->json('details')); + $details = collect($response->json('data.details')); expect($details->where('is_pto', true)->count())->toBe(3); expect($details->firstWhere('date', '2026-02-11')['availability'])->toBe(0); @@ -85,7 +98,7 @@ test('4.1.14 Capacity subtracts holidays', function () { ]); $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(200); @@ -111,13 +124,13 @@ test('4.1.15 GET /api/capacity/team sums active members', function () { } $response = $this->getJson('/api/capacity/team?month=2026-02', [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(200); - $response->assertJsonCount(2, 'members'); - expect(round($response->json('person_days'), 2))->toBe(round($expectedDays, 2)); - expect($response->json('hours'))->toBe($expectedHours); + $response->assertJsonCount(2, 'data.members'); + expect(round($response->json('data.person_days'), 2))->toBe(round($expectedDays, 2)); + expect($response->json('data.hours'))->toBe($expectedHours); }); test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () { @@ -129,11 +142,11 @@ test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () $expectedRevenue = app(CapacityService::class)->calculatePossibleRevenue('2026-02'); $response = $this->getJson('/api/capacity/revenue?month=2026-02', [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(200); - $response->assertJson(['possible_revenue' => $expectedRevenue]); + $response->assertJsonPath('data.possible_revenue', $expectedRevenue); }); test('4.1.17 POST /api/holidays creates holiday', function () { @@ -144,7 +157,7 @@ test('4.1.17 POST /api/holidays creates holiday', function () { 'name' => 'Test Holiday', 'description' => 'Test description', ], [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(201); @@ -162,7 +175,7 @@ test('4.1.18 POST /api/ptos creates PTO request', function () { 'end_date' => '2026-02-11', 'reason' => 'Refresh', ], [ - 'Authorization' => "Bearer {$token}" + 'Authorization' => "Bearer {$token}", ]); $response->assertStatus(201); diff --git a/backend/tests/Feature/Project/ProjectTest.php b/backend/tests/Feature/Project/ProjectTest.php index 71fe981a..dba65296 100644 --- a/backend/tests/Feature/Project/ProjectTest.php +++ b/backend/tests/Feature/Project/ProjectTest.php @@ -73,12 +73,11 @@ class ProjectTest extends TestCase $response = $this->withToken($token) ->postJson('/api/projects', $payload); + dump($response->json()); - $response->assertStatus(201) - ->assertJsonFragment([ - 'code' => $payload['code'], - 'title' => $payload['title'], - ]); + $response->assertStatus(201); + $response->assertJsonPath('data.code', $payload['code']); + $response->assertJsonPath('data.title', $payload['title']); $this->assertDatabaseHas('projects', [ 'code' => $payload['code'], @@ -110,7 +109,7 @@ class ProjectTest extends TestCase $payload = $this->projectPayload(); $projectId = $this->withToken($token) ->postJson('/api/projects', $payload) - ->json('id'); + ->json('data.id'); $invalidStatus = $this->statusId('In Progress'); @@ -123,7 +122,7 @@ class ProjectTest extends TestCase $this->transitionProjectStatus($projectId, 'SOW Approval', $token) ->assertStatus(200) - ->assertJsonPath('status.name', 'SOW Approval'); + ->assertJsonPath('data.status.name', 'SOW Approval'); } // 3.1.16 API test: Estimate approved requires estimate value @@ -132,7 +131,7 @@ class ProjectTest extends TestCase $token = $this->loginAsManager(); $projectId = $this->withToken($token) ->postJson('/api/projects', $this->projectPayload()) - ->json('id'); + ->json('data.id'); $this->transitionProjectStatus($projectId, 'SOW Approval', $token) ->assertStatus(200); @@ -154,7 +153,7 @@ class ProjectTest extends TestCase $payload = $this->projectPayload(['approved_estimate' => 120]); $projectId = $this->withToken($token) ->postJson('/api/projects', $payload) - ->json('id'); + ->json('data.id'); $workflow = [ 'Pre-sales', @@ -172,7 +171,7 @@ class ProjectTest extends TestCase foreach (array_slice($workflow, 1) as $statusName) { $this->transitionProjectStatus($projectId, $statusName, $token) ->assertStatus(200) - ->assertJsonPath('status.name', $statusName); + ->assertJsonPath('data.status.name', $statusName); } } @@ -182,11 +181,11 @@ class ProjectTest extends TestCase $token = $this->loginAsManager(); $projectId = $this->withToken($token) ->postJson('/api/projects', $this->projectPayload()) - ->json('id'); + ->json('data.id'); $this->transitionProjectStatus($projectId, 'SOW Approval', $token) ->assertStatus(200) - ->assertJsonPath('status.name', 'SOW Approval'); + ->assertJsonPath('data.status.name', 'SOW Approval'); $this->assertDatabaseHas('projects', [ 'id' => $projectId, @@ -200,12 +199,12 @@ class ProjectTest extends TestCase $token = $this->loginAsManager(); $projectId = $this->withToken($token) ->postJson('/api/projects', $this->projectPayload()) - ->json('id'); + ->json('data.id'); $this->withToken($token) ->putJson("/api/projects/{$projectId}/estimate", ['approved_estimate' => 275]) ->assertStatus(200) - ->assertJsonPath('approved_estimate', '275.00'); + ->assertJsonPath('data.approved_estimate', '275.00'); $this->assertSame('275.00', (string) Project::find($projectId)->approved_estimate); } @@ -216,14 +215,14 @@ class ProjectTest extends TestCase $token = $this->loginAsManager(); $projectId = $this->withToken($token) ->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100])) - ->json('id'); + ->json('data.id'); $forecast = ['2025-01' => 33, '2025-02' => 33, '2025-03' => 34]; $this->withToken($token) ->putJson("/api/projects/{$projectId}/forecast", ['forecasted_effort' => $forecast]) ->assertStatus(200) - ->assertJsonFragment(['forecasted_effort' => $forecast]); + ->assertJsonPath('data.forecasted_effort', $forecast); $this->assertSame($forecast, Project::find($projectId)->forecasted_effort); } @@ -234,7 +233,7 @@ class ProjectTest extends TestCase $token = $this->loginAsManager(); $projectId = $this->withToken($token) ->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100])) - ->json('id'); + ->json('data.id'); $forecast = ['2025-01' => 50, '2025-02' => 50, '2025-03' => 50]; diff --git a/backend/tests/Feature/TeamMember/TeamMemberTest.php b/backend/tests/Feature/TeamMember/TeamMemberTest.php index 457479f7..9a9a5c83 100644 --- a/backend/tests/Feature/TeamMember/TeamMemberTest.php +++ b/backend/tests/Feature/TeamMember/TeamMemberTest.php @@ -2,13 +2,13 @@ namespace Tests\Feature\TeamMember; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; -use App\Models\User; -use App\Models\TeamMember; -use App\Models\Role; use App\Models\Allocation; use App\Models\Project; +use App\Models\Role; +use App\Models\TeamMember; +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\TestCase; class TeamMemberTest extends TestCase { @@ -52,11 +52,13 @@ class TeamMemberTest extends TestCase $response->assertStatus(201); $response->assertJson([ - 'name' => 'John Doe', - 'role_id' => $role->id, - 'hourly_rate' => '150.00', - 'active' => true, + 'data' => [ + 'name' => 'John Doe', + 'hourly_rate' => '150.00', + 'active' => true, + ], ]); + $response->assertJsonPath('data.role.id', $role->id); $this->assertDatabaseHas('team_members', [ 'name' => 'John Doe', @@ -123,7 +125,7 @@ class TeamMemberTest extends TestCase ->getJson('/api/team-members'); $response->assertStatus(200); - $response->assertJsonCount(3); + $response->assertJsonCount(3, 'data'); } // 2.1.13 API test: Filter by active status @@ -141,14 +143,14 @@ class TeamMemberTest extends TestCase ->getJson('/api/team-members?active=true'); $response->assertStatus(200); - $response->assertJsonCount(2); + $response->assertJsonCount(2, 'data'); // Get only inactive $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/team-members?active=false'); $response->assertStatus(200); - $response->assertJsonCount(1); + $response->assertJsonCount(1, 'data'); } // 2.1.14 API test: PUT /api/team-members/{id} updates member @@ -168,8 +170,10 @@ class TeamMemberTest extends TestCase $response->assertStatus(200); $response->assertJson([ - 'id' => $teamMember->id, - 'hourly_rate' => '175.00', + 'data' => [ + 'id' => $teamMember->id, + 'hourly_rate' => '175.00', + ], ]); $this->assertDatabaseHas('team_members', [ @@ -195,8 +199,10 @@ class TeamMemberTest extends TestCase $response->assertStatus(200); $response->assertJson([ - 'id' => $teamMember->id, - 'active' => false, + 'data' => [ + 'id' => $teamMember->id, + 'active' => false, + ], ]); $this->assertDatabaseHas('team_members', [ diff --git a/backend/tests/Unit/Resources/HolidayResourceTest.php b/backend/tests/Unit/Resources/HolidayResourceTest.php new file mode 100644 index 00000000..10e4923a --- /dev/null +++ b/backend/tests/Unit/Resources/HolidayResourceTest.php @@ -0,0 +1,32 @@ + '2026-02-14', + 'name' => 'Test Holiday', + 'description' => 'Description', + ]); + + $response = (new HolidayResource($holiday))->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data']['name'])->toBe('Test Holiday'); +}); + +test('holiday resource collection uses data wrapper', function () { + Holiday::create(['date' => '2026-02-14', 'name' => 'Day One', 'description' => null]); + Holiday::create(['date' => '2026-03-01', 'name' => 'Day Two', 'description' => null]); + + $response = HolidayResource::collection(Holiday::limit(2)->get())->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data'])->toHaveCount(2); +}); diff --git a/backend/tests/Unit/Resources/ProjectResourceTest.php b/backend/tests/Unit/Resources/ProjectResourceTest.php new file mode 100644 index 00000000..98156e41 --- /dev/null +++ b/backend/tests/Unit/Resources/ProjectResourceTest.php @@ -0,0 +1,31 @@ +approved()->create(); + $project->load(['status', 'type']); + + $response = (new ProjectResource($project))->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload)->toHaveKey('data'); + expect($payload['data'])->toHaveKey('status'); + expect($payload['data'])->toHaveKey('type'); + expect($payload['data'])->toHaveKey('approved_estimate'); +}); + +test('project resource collection wraps multiple entries', function () { + $projects = Project::factory()->count(2)->create(); + + $response = ProjectResource::collection($projects)->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data'])->toHaveCount(2); +}); diff --git a/backend/tests/Unit/Resources/PtoResourceTest.php b/backend/tests/Unit/Resources/PtoResourceTest.php new file mode 100644 index 00000000..03df0dc2 --- /dev/null +++ b/backend/tests/Unit/Resources/PtoResourceTest.php @@ -0,0 +1,56 @@ +create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); + + $pto = Pto::create([ + 'team_member_id' => $teamMember->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-12', + 'reason' => 'Travel', + 'status' => 'pending', + ]); + $pto->load('teamMember'); + + $response = (new PtoResource($pto))->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data']['team_member_id'])->toBe($teamMember->id); + expect($payload['data']['team_member']['id'])->toBe($teamMember->id); +}); + +test('pto resource collection keeps data wrapper', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-10', + 'reason' => 'Travel', + 'status' => 'approved', + ]); + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-03-10', + 'end_date' => '2026-03-12', + 'reason' => 'Rest', + 'status' => 'approved', + ]); + + $response = PtoResource::collection(Pto::limit(2)->get())->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data'])->toHaveCount(2); +}); diff --git a/backend/tests/Unit/Resources/RoleResourceTest.php b/backend/tests/Unit/Resources/RoleResourceTest.php new file mode 100644 index 00000000..78db51a1 --- /dev/null +++ b/backend/tests/Unit/Resources/RoleResourceTest.php @@ -0,0 +1,28 @@ +create(); + + $response = (new RoleResource($role))->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload)->toHaveKey('data'); + expect($payload['data']['id'])->toBe($role->id); +}); + +test('role resource collection keeps data wrapper', function () { + $roles = Role::factory()->count(2)->create(); + + $response = RoleResource::collection($roles)->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data'])->toHaveCount(2); +}); diff --git a/backend/tests/Unit/Resources/TeamMemberResourceTest.php b/backend/tests/Unit/Resources/TeamMemberResourceTest.php new file mode 100644 index 00000000..fb06576c --- /dev/null +++ b/backend/tests/Unit/Resources/TeamMemberResourceTest.php @@ -0,0 +1,32 @@ +create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); + $teamMember->load('role'); + + $response = (new TeamMemberResource($teamMember))->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data']['id'])->toBe($teamMember->id); + expect($payload['data']['role']['id'])->toBe($role->id); +}); + +test('team member resource collection keeps data wrapper', function () { + $role = Role::factory()->create(); + $teamMembers = TeamMember::factory()->count(2)->create(['role_id' => $role->id]); + + $response = TeamMemberResource::collection($teamMembers)->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data'])->toHaveCount(2); +}); diff --git a/backend/tests/Unit/Resources/UserResourceTest.php b/backend/tests/Unit/Resources/UserResourceTest.php new file mode 100644 index 00000000..8a354882 --- /dev/null +++ b/backend/tests/Unit/Resources/UserResourceTest.php @@ -0,0 +1,30 @@ +create(); + + $response = (new UserResource($user))->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect(array_key_exists('data', $payload))->toBeTrue(); + expect($payload['data']['id'])->toBe($user->id); + expect($payload['data'])->toHaveKey('email'); +}); + +test('user resource collection honors data wrapper', function () { + $users = User::factory()->count(2)->create(); + + $response = UserResource::collection($users)->toResponse(Request::create('/')); + $payload = $response->getData(true); + + expect($payload['data'])->toHaveCount(2); + expect($payload['data'][0])->toHaveKey('id'); +}); diff --git a/frontend/src/lib/api/capacity.ts b/frontend/src/lib/api/capacity.ts index ba40d05a..2f68b5f5 100644 --- a/frontend/src/lib/api/capacity.ts +++ b/frontend/src/lib/api/capacity.ts @@ -66,15 +66,15 @@ export async function getIndividualCapacity( export async function getTeamCapacity(month: string): Promise{ const response = await api.get<{ month: string; - person_days: number; - hours: number; + total_person_days: number; + total_hours: number; members: Array<{ id: string; name: string; person_days: number; hours: number }>; }>(`/capacity/team?month=${month}`); return { month: response.month, - total_person_days: response.person_days, - total_hours: response.hours, + total_person_days: response.total_person_days, + total_hours: response.total_hours, member_capacities: response.members.map((member) => ({ team_member_id: member.id, team_member_name: member.name, @@ -87,14 +87,28 @@ export async function getTeamCapacity(month: string): Promise { } export async function getPossibleRevenue(month: string): Promise { - const response = await api.get<{ month: string; possible_revenue: number }>( - `/capacity/revenue?month=${month}` - ); + const response = await api.get<{ + month: string; + possible_revenue: number; + member_revenues: Array<{ + team_member_id: string; + team_member_name: string; + hours: number; + hourly_rate: number; + revenue: number; + }>; + }>(`/capacity/revenue?month=${month}`); return { month: response.month, total_revenue: response.possible_revenue, - member_revenues: [] + member_revenues: response.member_revenues.map((member) => ({ + team_member_id: member.team_member_id, + team_member_name: member.team_member_name, + hours: member.hours, + hourly_rate: member.hourly_rate, + revenue: member.revenue, + })) }; } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 00000000..1abd9fe4 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,9 @@ +export async function unwrapResponse (response: Response): Promise { + const payload = await response.json(); + + if (payload && typeof payload === 'object' && 'data' in payload) { + return payload.data as T; + } + + return payload as T; +} diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index 733e585b..a36a0744 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -1,10 +1,12 @@ /** * API Client Service - * + * * Fetch wrapper with JWT token handling, automatic refresh, * and standardized error handling for the Headroom API. */ +import { unwrapResponse } from '$lib/api/client'; + const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; // Token storage keys @@ -120,6 +122,7 @@ interface ApiRequestOptions { method?: string; headers?: Record ; body?: unknown; + unwrap?: boolean; } // Main API request function @@ -191,33 +194,44 @@ export async function apiRequest (endpoint: string, options: ApiRequestOptions 'Authorization': `Bearer ${newToken}`, }; fetch(url, requestOptions) - .then((res) => handleResponse (res)) + .then((res) => handleResponse(res)) + .then((res) => { + if (options.unwrap === false) { + return res.json() as Promise ; + } + + return unwrapResponse (res); + }) .then(resolve) .catch(reject); }); }); } - return handleResponse (response); + const validated = await handleResponse(response); + if (options.unwrap === false) { + return validated.json() as Promise ; + } + + return unwrapResponse (validated); } catch (error) { throw error; } } // Handle API response -async function handleResponse (response: Response): Promise { +async function handleResponse(response: Response): Promise { const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type'); const isJson = contentType && contentType.includes('application/json'); - - const data = isJson ? await response.json() : await response.text(); - + if (!response.ok) { + const data = isJson ? await response.json() : await response.text(); const errorData = typeof data === 'object' ? data : { message: data }; const message = (errorData as { message?: string }).message || 'API request failed'; throw new ApiError(message, response.status, errorData); } - - return data as T; + + return response; } // Convenience methods @@ -241,23 +255,30 @@ interface LoginCredentials { } // Login response type +interface AuthPayload { + id: string; + name: string; + email: string; + role: 'superuser' | 'manager' | 'developer' | 'top_brass'; + active: boolean; + created_at: string; + updated_at: string; +} + interface LoginResponse { access_token: string; refresh_token: string; - user: { - id: string; - name: string; - email: string; - role: 'superuser' | 'manager' | 'developer' | 'top_brass'; - }; + token_type: 'bearer'; + expires_in: number; + data: AuthPayload; } // Auth-specific API methods export const authApi = { login: (credentials: LoginCredentials) => - api.post ('/auth/login', credentials), + api.post ('/auth/login', credentials, { unwrap: false }), logout: () => api.post ('/auth/logout'), - refresh: () => api.post ('/auth/refresh', { refresh_token: getRefreshToken() }), + refresh: () => api.post ('/auth/refresh', { refresh_token: getRefreshToken() }, { unwrap: false }), }; export default api; diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts index 2aa8552f..68026a1d 100644 --- a/frontend/src/lib/stores/auth.ts +++ b/frontend/src/lib/stores/auth.ts @@ -173,9 +173,9 @@ export async function login(credentials: LoginCredentials): Promise if (response.access_token && response.refresh_token) { setTokens(response.access_token, response.refresh_token); - user.set(response.user || null); + user.set(response.data || null); auth.setAuthenticated(); - return { success: true, user: response.user }; + return { success: true, user: response.data }; } else { throw new Error('Invalid response from server'); } diff --git a/openspec/changes/api-resource-standard/.openspec.yaml b/openspec/changes/api-resource-standard/.openspec.yaml new file mode 100644 index 00000000..d2997483 --- /dev/null +++ b/openspec/changes/api-resource-standard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-19 diff --git a/openspec/changes/api-resource-standard/design.md b/openspec/changes/api-resource-standard/design.md new file mode 100644 index 00000000..d000221d --- /dev/null +++ b/openspec/changes/api-resource-standard/design.md @@ -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 +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 + $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 (response: Response): Promise { + const data = await response.json(); + return data.data as T; +} + +// Usage in API functions: +export async function getTeamMembers(): Promise { + const response = await fetch('/api/team-members'); + return unwrapResponse (response); +} +``` + +**Update all API calls:** +```typescript +// BEFORE: +const members = await response.json(); + +// AFTER: +const members = await unwrapResponse (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 diff --git a/openspec/changes/api-resource-standard/proposal.md b/openspec/changes/api-resource-standard/proposal.md new file mode 100644 index 00000000..10022e82 --- /dev/null +++ b/openspec/changes/api-resource-standard/proposal.md @@ -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 diff --git a/openspec/changes/api-resource-standard/specs/api-resource-standard/spec.md b/openspec/changes/api-resource-standard/specs/api-resource-standard/spec.md new file mode 100644 index 00000000..de21627d --- /dev/null +++ b/openspec/changes/api-resource-standard/specs/api-resource-standard/spec.md @@ -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 diff --git a/openspec/changes/api-resource-standard/tasks.md b/openspec/changes/api-resource-standard/tasks.md new file mode 100644 index 00000000..873ff7b2 --- /dev/null +++ b/openspec/changes/api-resource-standard/tasks.md @@ -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 (response: Response): Promise { + 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