Files
headroom/backend/app/Http/Controllers/Api/ProjectController.php
Santhosh Janardhanan 8ed56c9f7c feat(project): Complete Project Lifecycle capability with full TDD workflow
- Implement ProjectController with CRUD, status transitions, estimate/forecast
- Add ProjectService with state machine validation
- Extract ProjectStatusService for reusable state machine logic
- Add ProjectPolicy for role-based authorization
- Create ProjectSeeder with test data
- Implement frontend project management UI with modal forms
- Add projectService API client
- Complete all 9 incomplete unit tests (ProjectModelTest, ProjectForecastTest, ProjectPolicyTest)
- Fix E2E test timing issues with loading state waits
- Add Scribe API documentation annotations
- Improve forecasted effort validation messages with detailed feedback

Test Results:
- Backend: 49 passed (182 assertions)
- Frontend Unit: 32 passed
- E2E: 134 passed (Chromium + Firefox)

Phase 3 Refactor:
- Extract ProjectStatusService for state machine
- Optimize project list query with status joins
- Improve forecasted effort validation messages

Phase 4 Document:
- Add Scribe annotations to ProjectController
- Generate API documentation
2026-02-19 02:43:05 -05:00

391 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Models\ProjectStatus;
use App\Models\ProjectType;
use App\Services\ProjectService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* @group Projects
*
* Endpoints for managing projects.
*/
class ProjectController extends Controller
{
/**
* Project Service instance
*/
protected ProjectService $projectService;
/**
* Constructor
*/
public function __construct(ProjectService $projectService)
{
$this->projectService = $projectService;
}
/**
* List all projects
*
* Get a list of all projects with optional filtering by status and type.
*
* @authenticated
*
* @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"
* }
* ]
*/
public function index(Request $request): JsonResponse
{
$statusId = $request->query('status_id') ? (int) $request->query('status_id') : null;
$typeId = $request->query('type_id') ? (int) $request->query('type_id') : null;
$projects = $this->projectService->getAll($statusId, $typeId);
return response()->json($projects);
}
/**
* Create a new project
*
* Create a new project with code, title, and type.
*
* @authenticated
*
* @bodyParam code string required Project code (must be unique). Example: PROJ-001
* @bodyParam title string required Project title. Example: Client Dashboard Redesign
* @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"}
* }
* @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}
*/
public function store(Request $request): JsonResponse
{
try {
$project = $this->projectService->create($request->all());
return response()->json($project, 201);
} catch (ValidationException $e) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->validator->errors(),
], 422);
}
}
/**
* Get a single project
*
* Get details of a specific project by ID.
*
* @authenticated
*
* @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}
* }
* @response 404 {"message":"Project not found"}
*/
public function show(string $id): JsonResponse
{
$project = $this->projectService->findById($id);
if (! $project) {
return response()->json([
'message' => 'Project not found',
], 404);
}
return response()->json($project);
}
/**
* Update a project
*
* Update details of an existing project.
*
* @authenticated
*
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
*
* @bodyParam code string Project code (must be unique). Example: PROJ-002
* @bodyParam title string Project title. Example: Updated Title
* @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
* }
* @response 404 {"message":"Project not found"}
* @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}
*/
public function update(Request $request, string $id): JsonResponse
{
$project = Project::find($id);
if (! $project) {
return response()->json([
'message' => 'Project not found',
], 404);
}
try {
$project = $this->projectService->update($project, $request->only([
'code', 'title', 'type_id',
]));
return response()->json($project);
} catch (ValidationException $e) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->validator->errors(),
], 422);
}
}
/**
* Transition project status
*
* Transition project to a new status following the state machine rules.
*
* @authenticated
*
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
*
* @bodyParam status_id integer required Target status ID. Example: 2
*
* @response 200 {
* "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"}
*/
public function updateStatus(Request $request, string $id): JsonResponse
{
$project = Project::with('status')->find($id);
if (! $project) {
return response()->json([
'message' => 'Project not found',
], 404);
}
$request->validate([
'status_id' => 'required|integer|exists:project_statuses,id',
]);
try {
$project = $this->projectService->transitionStatus(
$project,
(int) $request->input('status_id')
);
return response()->json($project);
} catch (\RuntimeException $e) {
return response()->json([
'message' => $e->getMessage(),
], 422);
}
}
/**
* Set approved estimate
*
* Set the approved billable hours estimate for a project.
*
* @authenticated
*
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
*
* @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 404 {"message":"Project not found"}
* @response 422 {"message":"Approved estimate must be greater than 0"}
*/
public function setEstimate(Request $request, string $id): JsonResponse
{
$project = Project::find($id);
if (! $project) {
return response()->json([
'message' => 'Project not found',
], 404);
}
$request->validate([
'approved_estimate' => 'required|numeric',
]);
try {
$project = $this->projectService->setApprovedEstimate(
$project,
(float) $request->input('approved_estimate')
);
return response()->json($project);
} catch (\RuntimeException $e) {
return response()->json([
'message' => $e->getMessage(),
], 422);
}
}
/**
* Set forecasted effort
*
* Set the month-by-month forecasted effort breakdown.
*
* @authenticated
*
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
*
* @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 404 {"message":"Project not found"}
* @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"}
*/
public function setForecast(Request $request, string $id): JsonResponse
{
$project = Project::find($id);
if (! $project) {
return response()->json([
'message' => 'Project not found',
], 404);
}
$request->validate([
'forecasted_effort' => 'required|array',
]);
try {
$project = $this->projectService->setForecastedEffort(
$project,
$request->input('forecasted_effort')
);
return response()->json($project);
} catch (\RuntimeException $e) {
return response()->json([
'message' => $e->getMessage(),
], 422);
}
}
/**
* Get all project types
*
* @authenticated
*
* @response 200 [
* {"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);
}
/**
* Get all project statuses
*
* @authenticated
*
* @response 200 [
* {"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);
}
/**
* Delete a project
*
* Delete a project. Cannot delete if project has allocations or actuals.
*
* @authenticated
*
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
*
* @response 200 {"message":"Project deleted successfully"}
* @response 404 {"message":"Project not found"}
* @response 422 {"message":"Cannot delete project with allocations"}
*/
public function destroy(string $id): JsonResponse
{
$project = Project::find($id);
if (! $project) {
return response()->json([
'message' => 'Project not found',
], 404);
}
$canDelete = $this->projectService->canDelete($project);
if (! $canDelete['canDelete']) {
return response()->json([
'message' => "Cannot delete project with {$canDelete['reason']}",
], 422);
}
$project->delete();
return response()->json([
'message' => 'Project deleted successfully',
]);
}
}