Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'],
['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'],

// Time entry CRUD endpoints.
['name' => 'time_entry#create', 'url' => '/api/time-entries', 'verb' => 'POST'],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Missing OAS 3.0 OpenAPI specification

The Conduction company-wide conventions require an OAS 3.0 OpenAPI Specification for all new or changed endpoints. This PR adds four new REST endpoints (POST /api/time-entries, GET /api/time-entries, PUT /api/time-entries/{id}, DELETE /api/time-entries/{id}) with no accompanying OpenAPI spec file. Without the spec, downstream consumers, API gateways, and the Common Ground ecosystem cannot discover or validate against these endpoints.

Required action: Add an openapi.yaml documenting all four endpoints — request bodies, query parameters, response schemas, and error responses.

['name' => 'time_entry#index', 'url' => '/api/time-entries', 'verb' => 'GET'],
['name' => 'time_entry#update', 'url' => '/api/time-entries/{id}', 'verb' => 'PUT'],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] PUT used for partial-update semantics — should be PATCH (NL API strategie)

The route registers PUT /api/time-entries/{id} and the service only updates the fields that are present in $data (partial update). Per the NL API strategie (Dutch government API design guidelines) and RFC 7231:

  • PUT replaces the entire resource. The client must send the full representation.
  • PATCH applies a partial modification. Only supplied fields are changed.

The implementation is partial-update behaviour, which means the correct HTTP verb is PATCH.

Required action: Change the route verb from 'PUT' to 'PATCH' in routes.php and update the store (axios.putaxios.patch) accordingly.

['name' => 'time_entry#destroy', 'url' => '/api/time-entries/{id}', 'verb' => 'DELETE'],

// Prometheus metrics endpoint.
['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'],
// Health check endpoint.
Expand Down
234 changes: 234 additions & 0 deletions lib/Controller/TimeEntryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
<?php

/**
* Planix Time Entry Controller
*
* Controller for time entry CRUD operations.
*
* @category Controller
* @package OCA\Planix\Controller
*
* @author Conduction Development Team <dev@conductio.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*/

// SPDX-License-Identifier: EUPL-1.2
// Copyright (C) 2026 Conduction B.V.
declare(strict_types=1);

namespace OCA\Planix\Controller;

use OCA\Planix\AppInfo\Application;
use OCA\Planix\Service\TimeEntryService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;

/**
* Controller for time entry CRUD operations.
*/
class TimeEntryController extends Controller
{
/**
* Constructor for the TimeEntryController.
*
* @param IRequest $request The request object
* @param TimeEntryService $timeEntryService The time entry service
*
* @return void
*/
public function __construct(
IRequest $request,
private TimeEntryService $timeEntryService,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Create a new time entry.
*
* Expects JSON body with: taskId, duration (minutes, > 0), date (ISO 8601), description (optional).
*
* @NoAdminRequired
*
* @return JSONResponse
*/
public function create(): JSONResponse
{
$userId = $this->timeEntryService->getCurrentUserId();
if ($userId === null) {
return new JSONResponse(
['error' => 'Authentication required.'],
Http::STATUS_FORBIDDEN
);
}

try {
$data = $this->request->getParams();
$entry = $this->timeEntryService->createTimeEntry($data);

return new JSONResponse($entry, Http::STATUS_CREATED);
} catch (\InvalidArgumentException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\RuntimeException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}

}//end create()

/**
* List time entries for a task.
*
* Expects query parameter: taskId.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return JSONResponse
*/
public function index(): JSONResponse
{
$userId = $this->timeEntryService->getCurrentUserId();
if ($userId === null) {
return new JSONResponse(
['error' => 'Authentication required.'],
Http::STATUS_FORBIDDEN
);
}

$taskId = $this->request->getParam('taskId', '');
if (empty($taskId) === true) {
return new JSONResponse(
['error' => 'taskId query parameter is required.'],
Http::STATUS_BAD_REQUEST
);
}

if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $taskId) !== 1) {
return new JSONResponse(
['error' => 'taskId must be a valid UUID.'],
Http::STATUS_BAD_REQUEST
);
}

try {
$entries = $this->timeEntryService->listTimeEntries($taskId);

return new JSONResponse($entries);
} catch (\InvalidArgumentException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_NOT_FOUND
);
} catch (\RuntimeException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_FORBIDDEN
);
}

}//end index()

/**
* Update a time entry (owner only).
*
* Expects JSON body with any of: duration (minutes, > 0), date (ISO 8601), description.
*
* @param string $id The time entry UUID
*
* @NoAdminRequired
*
* @return JSONResponse
*/
public function update(string $id): JSONResponse
{
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) !== 1) {
return new JSONResponse(
['error' => 'Invalid identifier format.'],
Http::STATUS_BAD_REQUEST
);
}

$userId = $this->timeEntryService->getCurrentUserId();
if ($userId === null) {
return new JSONResponse(
['error' => 'Authentication required.'],
Http::STATUS_FORBIDDEN
);
}

try {
$data = $this->request->getParams();
$entry = $this->timeEntryService->updateTimeEntry($id, $data);

return new JSONResponse($entry);
} catch (\InvalidArgumentException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\RuntimeException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_FORBIDDEN
);
}

}//end update()

/**
* Delete a time entry (owner only).
*
* @param string $id The time entry UUID
*
* @NoAdminRequired
*
* @return JSONResponse
*/
public function destroy(string $id): JSONResponse
{
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) !== 1) {
return new JSONResponse(
['error' => 'Invalid identifier format.'],
Http::STATUS_BAD_REQUEST
);
}

$userId = $this->timeEntryService->getCurrentUserId();
if ($userId === null) {
return new JSONResponse(
['error' => 'Authentication required.'],
Http::STATUS_FORBIDDEN
);
}

try {
$this->timeEntryService->deleteTimeEntry($id);

return new JSONResponse(['success' => true]);
} catch (\InvalidArgumentException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_NOT_FOUND
);
} catch (\RuntimeException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_FORBIDDEN
);
}

}//end destroy()
}//end class
Loading
Loading