Skip to content
Merged
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
87 changes: 87 additions & 0 deletions app/Http/Controllers/PersonController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Controllers;

use App\Http\Requests\StorePersonRequest;
use App\Http\Resources\PersonResource;
use App\Repositories\PersonRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

/**
* PersonController handles Person resource API endpoints.
*
* All endpoints require:
* - Sanctum authentication (auth:sanctum)
* - Tenant context (SetTenant middleware)
* - Appropriate permissions (person.write, person.read)
*/
class PersonController extends Controller
{
public function __construct(
private readonly PersonRepository $repository
) {}

/**
* Store a new Person.
*
* POST /v1/tenants/{tenant}/persons
*
* @param StorePersonRequest $request Validated request with email_plain, phone_plain, note_enc
* @return JsonResponse Created Person (201) with sanitized data
*/
public function store(StorePersonRequest $request): JsonResponse
{
/** @var int $tenantId */
$tenantId = $request->get('tenant_id');

$person = $this->repository->createOrUpdate($tenantId, [
'email_plain' => $request->input('email_plain'),
'phone_plain' => $request->input('phone_plain'),
'note_enc' => $request->input('note_enc'),
]);

return (new PersonResource($person))
->response()
->setStatusCode(Response::HTTP_CREATED);
}

/**
* Find a Person by email.
*
* GET /v1/tenants/{tenant}/persons/by-email?email=...
*
* @param Request $request Must contain 'email' query parameter
* @return JsonResponse Person (200) or not found (404)
*/
public function byEmail(Request $request): JsonResponse
{
$email = $request->query('email');

if (! $email) {
return response()->json([
'error' => 'Email query parameter is required',
], Response::HTTP_BAD_REQUEST);
}

/** @var int $tenantId */
$tenantId = $request->get('tenant_id');

$person = $this->repository->findByEmail($tenantId, $email);

if (! $person) {
return response()->json([
'error' => 'Person not found',
], Response::HTTP_NOT_FOUND);
}

return (new PersonResource($person))->response();
}
}
58 changes: 58 additions & 0 deletions app/Http/Requests/StorePersonRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/**
* StorePersonRequest validates Person creation/update requests.
*
* Validates:
* - email_plain: required, valid email format
* - phone_plain: optional, string
* - note_enc: optional, string
*/
class StorePersonRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Authorization is handled by middleware (auth:sanctum, SetTenant, permission)
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'email_plain' => ['required', 'email'],
'phone_plain' => ['nullable', 'string'],
'note_enc' => ['nullable', 'string'],
];
}

/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'email_plain.required' => 'Email address is required',
'email_plain.email' => 'Email address must be valid',
];
}
}
45 changes: 45 additions & 0 deletions app/Http/Resources/PersonResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* PersonResource transforms Person models into API responses.
*
* Only exposes safe fields (id, tenant_id, timestamps).
* Encrypted fields and blind indexes are never exposed.
*
* @mixin \App\Models\Person
*/
class PersonResource extends JsonResource
{
/**
* Disable wrapping for single resources.
*
* @var string|null
*/
public static $wrap = null;

/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_id' => $this->tenant_id,
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
3 changes: 2 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
use HasApiTokens, HasFactory, HasRoles, Notifiable;

/**
* The attributes that are mass assignable.
Expand Down
2 changes: 2 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'tenant' => \App\Http\Middleware\SetTenant::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ public function up(): void
Schema::table('person', function (Blueprint $table) {
// Base64 of 32-byte HMAC-SHA256 = 44 chars
$table->string('email_idx', 64)->change();
$table->string('phone_idx', 64)->change();
$table->string('phone_idx', 64)->nullable()->change();
// Encrypted fields also need TEXT (Laravel encrypted cast generates variable-length base64)
$table->text('email_enc')->change();
$table->text('phone_enc')->change();
$table->text('phone_enc')->nullable()->change();
});
}

Expand Down
34 changes: 25 additions & 9 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -51,22 +51,38 @@ parameters:
message: '#Cannot (call method|access property) .* on (App\\Models\\User|Laravel\\Sanctum\\PersonalAccessToken)\|null#'
path: tests/Feature/*

# PEST dynamic properties: $this->tenant is set in beforeEach() but PHPStan cannot infer
# PEST dynamic properties: $this->tenant, $this->user, $this->token set in beforeEach()
-
message: '#Access to an undefined property PHPUnit\\Framework\\TestCase::\$tenant#'
path: tests/Feature/PersonTest.php
message: '#Access to an undefined property PHPUnit\\Framework\\TestCase::\$(tenant|user|token|testPerson)#'
paths:
- tests/Feature/PersonTest.php
- tests/Feature/PersonApiTest.php
-
message: '#Cannot access property .* on App\\Models\\Person\|null#'
path: tests/Feature/PersonTest.php
message: '#Cannot access property .* on (App\\Models\\Person|App\\Models\\TenantKey|mixed)\|null#'
paths:
- tests/Feature/PersonTest.php
- tests/Feature/PersonApiTest.php
-
message: '#Cannot access property \$id on mixed#'
path: tests/Feature/PersonTest.php
paths:
- tests/Feature/PersonTest.php
- tests/Feature/PersonApiTest.php
-
message: '#Property App\\Models\\Person::\$tenant_id \(int\) does not accept mixed#'
path: tests/Feature/PersonTest.php
paths:
- tests/Feature/PersonTest.php
- tests/Feature/PersonApiTest.php
-
message: '#Parameter \#1 \$tenantId of method App\\Repositories\\PersonRepository::(findByEmail|findByPhone|createOrUpdate)\(\) expects int, mixed given#'
path: tests/Feature/PersonTest.php
message: '#Parameter .* expects .*, mixed given#'
paths:
- tests/Feature/PersonTest.php
- tests/Feature/PersonApiTest.php
-
message: '#Part .* of encapsed string cannot be cast to string#'
path: tests/Feature/PersonApiTest.php
-
message: '#Call to an undefined method PHPUnit\\Framework\\TestCase::withToken\(\)#'
path: tests/Feature/PersonApiTest.php

# Laravel Eloquent BelongsTo covariance limitation - known PHPStan issue with template types
-
Expand Down
9 changes: 9 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

use App\Http\Controllers\AuthController;
use App\Http\Controllers\PersonController;
use Illuminate\Support\Facades\Route;

/*
Expand Down Expand Up @@ -36,5 +37,13 @@
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']);
Route::get('/me', [AuthController::class, 'me']);

// Tenant-scoped Person endpoints
Route::prefix('tenants/{tenant}')->middleware('tenant')->group(function () {
Route::post('/persons', [PersonController::class, 'store'])
->middleware('permission:person.write');
Route::get('/persons/by-email', [PersonController::class, 'byEmail'])
->middleware('permission:person.read');
});
});
});
Loading