feat(api): Implement API Resource Standard compliance

- Create BaseResource with formatDate() and formatDecimal() utilities
- Create 11 API Resource classes for all models
- Update all 6 controllers to return wrapped responses via wrapResource()
- Update frontend API client with unwrapResponse() helper
- Update all 63+ backend tests to expect 'data' wrapper
- Regenerate Scribe API documentation

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

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

Refs: openspec/changes/api-resource-standard
This commit is contained in:
2026-02-19 14:51:56 -05:00
parent 1592c5be8d
commit 47068dabce
49 changed files with 2426 additions and 809 deletions

View File

@@ -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",

View File

@@ -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: ''

View File

@@ -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: ''
-

View File

@@ -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: ''

View File

@@ -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",

View File

@@ -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: ''

View File

@@ -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: ''
-

View File

@@ -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: ''

View File

@@ -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

View File

@@ -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"}

View File

@@ -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,
]));
}
}

View File

@@ -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"
* }

View File

@@ -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));
}
/**

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PtoResource;
use App\Models\Pto;
use App\Services\CapacityService;
use Carbon\Carbon;
@@ -19,18 +20,22 @@ class PtoController extends Controller
* Fetch PTO requests for a team member, optionally constrained to a month.
*
* @group Capacity Planning
*
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
* @response [
* {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
* "start_date": "2026-02-10",
* "end_date": "2026-02-12",
* "status": "pending",
* "reason": "Family travel"
* }
* ]
*
* @response {
* "data": [
* {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
* "start_date": "2026-02-10",
* "end_date": "2026-02-12",
* "status": "pending",
* "reason": "Family travel"
* }
* ]
* }
*/
public function index(Request $request): JsonResponse
{
@@ -39,7 +44,7 @@ class PtoController extends Controller
'month' => 'nullable|date_format:Y-m',
]);
$query = Pto::where('team_member_id', $data['team_member_id']);
$query = Pto::with('teamMember')->where('team_member_id', $data['team_member_id']);
if (! empty($data['month'])) {
$start = Carbon::createFromFormat('Y-m', $data['month'])->startOfMonth();
@@ -57,7 +62,7 @@ class PtoController extends Controller
$ptos = $query->orderBy('start_date')->get();
return response()->json($ptos);
return $this->wrapResource(PtoResource::collection($ptos));
}
/**
@@ -66,17 +71,21 @@ class PtoController extends Controller
* Create a PTO request for a team member and keep it in pending status.
*
* @group Capacity Planning
*
* @bodyParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
* @bodyParam start_date string required The first day of the PTO. Example: 2026-02-10
* @bodyParam end_date string required The final day of the PTO. Example: 2026-02-12
* @bodyParam reason string nullable Optional reason for the PTO.
*
* @response 201 {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
* "start_date": "2026-02-10",
* "end_date": "2026-02-12",
* "status": "pending",
* "reason": "Family travel"
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
* "start_date": "2026-02-10",
* "end_date": "2026-02-12",
* "status": "pending",
* "reason": "Family travel"
* }
* }
*/
public function store(Request $request): JsonResponse
@@ -89,8 +98,9 @@ class PtoController extends Controller
]);
$pto = Pto::create(array_merge($data, ['status' => 'pending']));
$pto->load('teamMember');
return response()->json($pto, 201);
return $this->wrapResource(new PtoResource($pto), 201);
}
/**
@@ -99,15 +109,19 @@ class PtoController extends Controller
* Approve a pending PTO request and refresh the affected capacity caches.
*
* @group Capacity Planning
*
* @urlParam id string required The PTO UUID that needs approval. Example: 550e8400-e29b-41d4-a716-446655440001
*
* @response {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "status": "approved"
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440001",
* "status": "approved"
* }
* }
*/
public function approve(string $id): JsonResponse
{
$pto = Pto::findOrFail($id);
$pto = Pto::with('teamMember')->findOrFail($id);
if ($pto->status !== 'approved') {
$pto->status = 'approved';
@@ -116,7 +130,9 @@ class PtoController extends Controller
$this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months);
}
return response()->json($pto);
$pto->load('teamMember');
return $this->wrapResource(new PtoResource($pto));
}
private function monthsBetween(Carbon|string $start, Carbon|string $end): array

View File

@@ -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"}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class BaseResource extends JsonResource
{
protected function formatDate($date): ?string
{
return $date?->toIso8601String();
}
protected function formatDecimal($value, int $decimals = 2): ?float
{
return $value !== null ? round((float) $value, $decimals) : null;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
class CapacityResource extends BaseResource
{
public function toArray($request): array
{
return [
'team_member_id' => $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'] ?? [],
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Resources;
class HolidayResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'date' => $this->date?->toDateString(),
'name' => $this->name,
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
class ProjectResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $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),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Resources;
class ProjectStatusResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'order' => $this->order,
'is_active' => $this->is_active,
'is_billable' => $this->is_billable,
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Http\Resources;
class ProjectTypeResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
class PtoResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $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),
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Http\Resources;
class RevenueResource extends BaseResource
{
public function toArray($request): array
{
return [
'month' => $this->resource['month'] ?? null,
'possible_revenue' => $this->resource['possible_revenue'] ?? null,
'member_revenues' => $this->resource['member_revenues'] ?? [],
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
class RoleResource extends BaseResource
{
/**
* Transform the resource into an array.
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Resources;
class TeamCapacityResource extends BaseResource
{
public function toArray($request): array
{
return [
'month' => $this->resource['month'] ?? null,
'total_person_days' => $this->resource['person_days'] ?? null,
'total_hours' => $this->resource['hours'] ?? null,
'members' => $this->resource['members'] ?? [],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
class TeamMemberResource extends BaseResource
{
/**
* Transform the resource into an array.
*/
public function toArray($request): array
{
return [
'id' => $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),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
class UserResource extends BaseResource
{
/**
* Transform the resource into an array.
*/
public function toArray($request): array
{
return [
'id' => $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),
];
}
}

View File

@@ -260,16 +260,19 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;access_token&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...&quot;,
&quot;refresh_token&quot;: &quot;abc123def456&quot;,
&quot;token_type&quot;: &quot;bearer&quot;,
&quot;expires_in&quot;: 3600,
&quot;user&quot;: {
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;Alice Johnson&quot;,
&quot;email&quot;: &quot;user@example.com&quot;,
&quot;role&quot;: &quot;manager&quot;
}
&quot;role&quot;: &quot;manager&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2026-01-01T00:00:00Z&quot;,
&quot;updated_at&quot;: &quot;2026-01-01T00:00:00Z&quot;
},
&quot;access_token&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...&quot;,
&quot;refresh_token&quot;: &quot;abc123def456&quot;,
&quot;token_type&quot;: &quot;bearer&quot;,
&quot;expires_in&quot;: 3600
}</code>
</pre>
<blockquote>
@@ -457,6 +460,15 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;Alice Johnson&quot;,
&quot;email&quot;: &quot;user@example.com&quot;,
&quot;role&quot;: &quot;manager&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2026-01-01T00:00:00Z&quot;,
&quot;updated_at&quot;: &quot;2026-01-01T00:00:00Z&quot;
},
&quot;access_token&quot;: &quot;eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...&quot;,
&quot;refresh_token&quot;: &quot;newtoken123&quot;,
&quot;token_type&quot;: &quot;bearer&quot;,
@@ -758,15 +770,20 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;person_days&quot;: 18.5,
&quot;hours&quot;: 148,
&quot;details&quot;: [
{
&quot;date&quot;: &quot;2026-02-02&quot;,
&quot;availability&quot;: 1,
&quot;is_pto&quot;: false
}
]
&quot;data&quot;: {
&quot;team_member_id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;month&quot;: &quot;2026-02&quot;,
&quot;working_days&quot;: 20,
&quot;person_days&quot;: 18.5,
&quot;hours&quot;: 148,
&quot;details&quot;: [
{
&quot;date&quot;: &quot;2026-02-02&quot;,
&quot;availability&quot;: 1,
&quot;is_pto&quot;: false
}
]
}
}</code>
</pre>
</span>
@@ -944,17 +961,19 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;month&quot;: &quot;2026-02&quot;,
&quot;person_days&quot;: 180.5,
&quot;hours&quot;: 1444,
&quot;members&quot;: [
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;Ada Lovelace&quot;,
&quot;person_days&quot;: 18.5,
&quot;hours&quot;: 148
}
]
&quot;data&quot;: {
&quot;month&quot;: &quot;2026-02&quot;,
&quot;total_person_days&quot;: 180.5,
&quot;total_hours&quot;: 1444,
&quot;members&quot;: [
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;Ada Lovelace&quot;,
&quot;person_days&quot;: 18.5,
&quot;hours&quot;: 148
}
]
}
}</code>
</pre>
</span>
@@ -1108,8 +1127,19 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;month&quot;: &quot;2026-02&quot;,
&quot;possible_revenue&quot;: 21500.25
&quot;data&quot;: {
&quot;month&quot;: &quot;2026-02&quot;,
&quot;possible_revenue&quot;: 21500.25,
&quot;member_revenues&quot;: [
{
&quot;team_member_id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;team_member_name&quot;: &quot;Ada Lovelace&quot;,
&quot;hours&quot;: 148,
&quot;hourly_rate&quot;: 150,
&quot;revenue&quot;: 22200
}
]
}
}</code>
</pre>
</span>
@@ -1262,14 +1292,16 @@ fetch(url, {
</blockquote>
<pre>
<code class="language-json" style="max-height: 300px;">[
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;date&quot;: &quot;2026-02-14&quot;,
&quot;name&quot;: &quot;Company Holiday&quot;,
&quot;description&quot;: &quot;Office closed&quot;
}
]</code>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: [
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;date&quot;: &quot;2026-02-14&quot;,
&quot;name&quot;: &quot;Company Holiday&quot;,
&quot;description&quot;: &quot;Office closed&quot;
}
]
}</code>
</pre>
</span>
<span id="execution-results-GETapi-holidays" hidden>
@@ -1426,10 +1458,12 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;date&quot;: &quot;2026-02-14&quot;,
&quot;name&quot;: &quot;Presidents&#039; Day&quot;,
&quot;description&quot;: &quot;Office closed&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;date&quot;: &quot;2026-02-14&quot;,
&quot;name&quot;: &quot;Presidents&#039; Day&quot;,
&quot;description&quot;: &quot;Office closed&quot;
}
}</code>
</pre>
</span>
@@ -1727,16 +1761,18 @@ fetch(url, {
</blockquote>
<pre>
<code class="language-json" style="max-height: 300px;">[
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440001&quot;,
&quot;team_member_id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;start_date&quot;: &quot;2026-02-10&quot;,
&quot;end_date&quot;: &quot;2026-02-12&quot;,
&quot;status&quot;: &quot;pending&quot;,
&quot;reason&quot;: &quot;Family travel&quot;
}
]</code>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: [
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440001&quot;,
&quot;team_member_id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;start_date&quot;: &quot;2026-02-10&quot;,
&quot;end_date&quot;: &quot;2026-02-12&quot;,
&quot;status&quot;: &quot;pending&quot;,
&quot;reason&quot;: &quot;Family travel&quot;
}
]
}</code>
</pre>
</span>
<span id="execution-results-GETapi-ptos" hidden>
@@ -1919,12 +1955,14 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440001&quot;,
&quot;team_member_id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;start_date&quot;: &quot;2026-02-10&quot;,
&quot;end_date&quot;: &quot;2026-02-12&quot;,
&quot;status&quot;: &quot;pending&quot;,
&quot;reason&quot;: &quot;Family travel&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440001&quot;,
&quot;team_member_id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;start_date&quot;: &quot;2026-02-10&quot;,
&quot;end_date&quot;: &quot;2026-02-12&quot;,
&quot;status&quot;: &quot;pending&quot;,
&quot;reason&quot;: &quot;Family travel&quot;
}
}</code>
</pre>
</span>
@@ -2092,8 +2130,10 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440001&quot;,
&quot;status&quot;: &quot;approved&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440001&quot;,
&quot;status&quot;: &quot;approved&quot;
}
}</code>
</pre>
</span>
@@ -2229,20 +2269,22 @@ fetch(url, {
</blockquote>
<pre>
<code class="language-json" style="max-height: 300px;">[
{
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Project&quot;
},
{
&quot;id&quot;: 2,
&quot;name&quot;: &quot;Support&quot;
},
{
&quot;id&quot;: 3,
&quot;name&quot;: &quot;Engagement&quot;
}
]</code>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: [
{
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Project&quot;
},
{
&quot;id&quot;: 2,
&quot;name&quot;: &quot;Support&quot;
},
{
&quot;id&quot;: 3,
&quot;name&quot;: &quot;Engagement&quot;
}
]
}</code>
</pre>
</span>
<span id="execution-results-GETapi-projects-types" hidden>
@@ -2360,23 +2402,25 @@ fetch(url, {
</blockquote>
<pre>
<code class="language-json" style="max-height: 300px;">[
{
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;,
&quot;order&quot;: 1
},
{
&quot;id&quot;: 2,
&quot;name&quot;: &quot;SOW Approval&quot;,
&quot;order&quot;: 2
},
{
&quot;id&quot;: 3,
&quot;name&quot;: &quot;Gathering Estimates&quot;,
&quot;order&quot;: 3
}
]</code>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: [
{
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;,
&quot;order&quot;: 1
},
{
&quot;id&quot;: 2,
&quot;name&quot;: &quot;SOW Approval&quot;,
&quot;order&quot;: 2
},
{
&quot;id&quot;: 3,
&quot;name&quot;: &quot;Gathering Estimates&quot;,
&quot;order&quot;: 3
}
]
}</code>
</pre>
</span>
<span id="execution-results-GETapi-projects-statuses" hidden>
@@ -2501,31 +2545,31 @@ fetch(url, {
</blockquote>
<pre>
<code class="language-json" style="max-height: 300px;">[
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-001&quot;,
&quot;title&quot;: &quot;Client Dashboard Redesign&quot;,
&quot;status_id&quot;: 1,
&quot;status&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;
},
&quot;type_id&quot;: 2,
&quot;type&quot;: {
&quot;id&quot;: 2,
&quot;name&quot;: &quot;Support&quot;
},
&quot;approved_estimate&quot;: &quot;120.00&quot;,
&quot;forecasted_effort&quot;: {
&quot;2024-02&quot;: 40,
&quot;2024-03&quot;: 60,
&quot;2024-04&quot;: 20
},
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
}
]</code>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: [
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-001&quot;,
&quot;title&quot;: &quot;Client Dashboard Redesign&quot;,
&quot;status&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;
},
&quot;type&quot;: {
&quot;id&quot;: 2,
&quot;name&quot;: &quot;Support&quot;
},
&quot;approved_estimate&quot;: &quot;120.00&quot;,
&quot;forecasted_effort&quot;: {
&quot;2024-02&quot;: 40,
&quot;2024-03&quot;: 60,
&quot;2024-04&quot;: 20
},
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
}
]
}</code>
</pre>
</span>
<span id="execution-results-GETapi-projects" hidden>
@@ -2682,18 +2726,18 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-001&quot;,
&quot;title&quot;: &quot;Client Dashboard Redesign&quot;,
&quot;status_id&quot;: 1,
&quot;status&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;
},
&quot;type_id&quot;: 1,
&quot;type&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Project&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-001&quot;,
&quot;title&quot;: &quot;Client Dashboard Redesign&quot;,
&quot;status&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;
},
&quot;type&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Project&quot;
}
}
}</code>
</pre>
@@ -2868,21 +2912,23 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-001&quot;,
&quot;title&quot;: &quot;Client Dashboard Redesign&quot;,
&quot;status&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;
},
&quot;type&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Project&quot;
},
&quot;approved_estimate&quot;: &quot;120.00&quot;,
&quot;forecasted_effort&quot;: {
&quot;2024-02&quot;: 40,
&quot;2024-03&quot;: 60
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-001&quot;,
&quot;title&quot;: &quot;Client Dashboard Redesign&quot;,
&quot;status&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Pre-sales&quot;
},
&quot;type&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Project&quot;
},
&quot;approved_estimate&quot;: &quot;120.00&quot;,
&quot;forecasted_effort&quot;: {
&quot;2024-02&quot;: 40,
&quot;2024-03&quot;: 60
}
}
}</code>
</pre>
@@ -3038,10 +3084,15 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-002&quot;,
&quot;title&quot;: &quot;Updated Title&quot;,
&quot;type_id&quot;: 2
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;code&quot;: &quot;PROJ-002&quot;,
&quot;title&quot;: &quot;Updated Title&quot;,
&quot;type&quot;: {
&quot;id&quot;: 2,
&quot;name&quot;: &quot;Support&quot;
}
}
}</code>
</pre>
<blockquote>
@@ -3398,10 +3449,12 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;status&quot;: {
&quot;id&quot;: 2,
&quot;name&quot;: &quot;SOW Approval&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;status&quot;: {
&quot;id&quot;: 2,
&quot;name&quot;: &quot;SOW Approval&quot;
}
}
}</code>
</pre>
@@ -3587,8 +3640,10 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;approved_estimate&quot;: &quot;120.00&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;approved_estimate&quot;: &quot;120.00&quot;
}
}</code>
</pre>
<blockquote>
@@ -3779,10 +3834,12 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;forecasted_effort&quot;: {
&quot;2024-02&quot;: 40,
&quot;2024-03&quot;: 60
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;forecasted_effort&quot;: {
&quot;2024-02&quot;: 40,
&quot;2024-03&quot;: 60
}
}
}</code>
</pre>
@@ -3968,21 +4025,22 @@ fetch(url, {
</blockquote>
<pre>
<code class="language-json" style="max-height: 300px;">[
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role_id&quot;: 1,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;150.00&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
}
]</code>
<code class="language-json" style="max-height: 300px;">{
&quot;data&quot;: [
{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;150.00&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
}
]
}</code>
</pre>
</span>
<span id="execution-results-GETapi-team-members" hidden>
@@ -4139,17 +4197,18 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role_id&quot;: 1,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;150.00&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;150.00&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
}
}</code>
</pre>
<blockquote>
@@ -4345,17 +4404,18 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role_id&quot;: 1,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;150.00&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;150.00&quot;,
&quot;active&quot;: true,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;
}
}</code>
</pre>
<blockquote>
@@ -4512,17 +4572,18 @@ fetch(url, {
<pre>
<code class="language-json" style="max-height: 300px;">{
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role_id&quot;: 1,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;175.00&quot;,
&quot;active&quot;: false,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T11:00:00.000000Z&quot;
&quot;data&quot;: {
&quot;id&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
&quot;name&quot;: &quot;John Doe&quot;,
&quot;role&quot;: {
&quot;id&quot;: 1,
&quot;name&quot;: &quot;Backend Developer&quot;
},
&quot;hourly_rate&quot;: &quot;175.00&quot;,
&quot;active&quot;: false,
&quot;created_at&quot;: &quot;2024-01-15T10:00:00.000000Z&quot;,
&quot;updated_at&quot;: &quot;2024-01-15T11:00:00.000000Z&quot;
}
}</code>
</pre>
<blockquote>

View File

@@ -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

View File

@@ -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,
],
]);
}

View File

@@ -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);

View File

@@ -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];

View File

@@ -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', [

View File

@@ -0,0 +1,32 @@
<?php
use App\Http\Resources\HolidayResource;
use App\Models\Holiday;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('holiday resource wraps data', function () {
$holiday = Holiday::create([
'date' => '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);
});

View File

@@ -0,0 +1,31 @@
<?php
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('project resource includes expected fields inside data wrapper', function () {
$project = Project::factory()->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);
});

View File

@@ -0,0 +1,56 @@
<?php
use App\Http\Resources\PtoResource;
use App\Models\Pto;
use App\Models\Role;
use App\Models\TeamMember;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('pto resource returns wrapped data with team member', function () {
$role = Role::factory()->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);
});

View File

@@ -0,0 +1,28 @@
<?php
use App\Http\Resources\RoleResource;
use App\Models\Role;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('role resource returns wrapped data', function () {
$role = Role::factory()->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);
});

View File

@@ -0,0 +1,32 @@
<?php
use App\Http\Resources\TeamMemberResource;
use App\Models\Role;
use App\Models\TeamMember;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('team member resource wraps data and includes role when loaded', function () {
$role = Role::factory()->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);
});

View File

@@ -0,0 +1,30 @@
<?php
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('user resource wraps response with data', function () {
$user = User::factory()->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');
});