mdn-backend / app /Domain /Files /FileService.php
internationalscholarsprogram's picture
feat(care): Peter b104020 — expand Care lifecycle, 17 new routes, migration
bdec7a7
Raw
History Blame Contribute Delete
10.8 kB
<?php
namespace App\Domain\Files;
use App\Domain\Audit\AuditService;
use App\Infrastructure\Database\TransactionManager;
use App\Infrastructure\Ids\UuidGenerator;
use App\Shared\Exceptions\ApiException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class FileService
{
private const CLASSIFICATIONS = [
'APPLICATION_DOCUMENT',
'ISP_EVIDENCE',
'MEMBERSHIP_CONTRACT',
'PRODUCT_CONTRACT',
'CARE_BENEFICIARY_DOCUMENT',
'REAL_ESTATE_DUE_DILIGENCE',
'CONNECT_ATTACHMENT',
'CONCIERGE_ATTACHMENT',
'MARKETPLACE_VENDOR_DOCUMENT',
'SUPPORT_ATTACHMENT',
];
private const MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
private const MAX_BYTES = 26214400;
private const TARGET_TABLES = [
'application_id' => 'applications',
'membership_id' => 'memberships',
'product_request_id' => 'product_requests',
'contract_id' => 'contracts',
'support_case_id' => 'support_cases',
'care_enrollment_id' => 'care_enrollments',
'care_person_id' => 'care_people',
'care_claim_id' => 'care_claims',
'care_beneficiary_id' => 'care_beneficiaries',
'real_estate_partner_id' => 'real_estate_partners',
'marketplace_vendor_id' => 'marketplace_vendors',
];
public function __construct(
private readonly FileRepository $files,
private readonly FileLinkRepository $links,
private readonly FileAccessPolicy $policy,
private readonly UuidGenerator $ids,
private readonly TransactionManager $transactions,
private readonly AuditService $audit,
private readonly SignedFileUrlFactory $fileUrls,
) {
}
public function register(array $input, string $actorUserId): array
{
$classification = strtoupper($input['classification']);
$mimeType = strtolower($input['mime_type']);
$size = (int) $input['file_size_bytes'];
if (! in_array($classification, self::CLASSIFICATIONS, true)) {
throw new ApiException('UNSUPPORTED_FILE_CLASSIFICATION', 'File classification is not supported.', [], 422);
}
if (! in_array($mimeType, self::MIME_TYPES, true)) {
throw new ApiException('UNSUPPORTED_FILE_TYPE', 'File type is not supported.', [], 422);
}
if ($size < 1 || $size > self::MAX_BYTES) {
throw new ApiException('FILE_SIZE_NOT_ALLOWED', 'File size is outside allowed limits.', [], 422);
}
$id = $this->ids->generate();
$storageKey = $input['storage_key'] ?? strtolower($classification).'/'.$id.'/'.Str::slug(pathinfo($input['original_filename'], PATHINFO_FILENAME)).'.'.pathinfo($input['original_filename'], PATHINFO_EXTENSION);
$this->files->create([
'id' => $id,
'classification' => $classification,
'owner_user_id' => $actorUserId,
'storage_provider' => 'LOCAL',
'storage_bucket' => 'mdn-files',
'storage_key' => $storageKey,
'original_filename' => $input['original_filename'],
'mime_type' => $mimeType,
'file_size_bytes' => $size,
'checksum_sha256' => $input['checksum_sha256'] ?? null,
'virus_scan_status' => $input['virus_scan_status'] ?? 'PENDING',
'retention_until' => $input['retention_until'] ?? null,
]);
$this->audit->record('FILE_REGISTERED', $actorUserId, null, 'stored_files', $id, 'SUCCESS', [
'classification' => $classification,
'mime_type' => $mimeType,
]);
return ['file' => $this->payload($this->files->find($id))];
}
public function link(string $fileId, array $input, string $actorUserId): array
{
$file = $this->requireFile($fileId);
$this->authorizeManage($file, $actorUserId);
$targets = array_filter(
array_intersect_key($input, array_flip(FileLinkRepository::TARGET_COLUMNS)),
fn ($value) => $value !== null && $value !== ''
);
if (count($targets) !== 1) {
throw new ApiException('INVALID_FILE_LINK_TARGET', 'Exactly one file link target is required.', [], 422);
}
$targetColumn = array_key_first($targets);
$targetId = $targets[$targetColumn];
$this->ensureTargetExists($targetColumn, $targetId, $actorUserId);
$linkId = $this->links->create(array_merge(
[
'id' => $this->ids->generate(),
'file_id' => $fileId,
'link_purpose' => $input['link_purpose'] ?? 'GENERAL',
'linked_by_user_id' => $actorUserId,
],
array_fill_keys(FileLinkRepository::TARGET_COLUMNS, null),
[$targetColumn => $targetId],
));
$this->audit->record('FILE_LINKED', $actorUserId, null, 'file_links', $linkId, 'SUCCESS', [
'file_id' => $fileId,
'target_column' => $targetColumn,
]);
return [
'file_link' => [
'id' => $linkId,
'file_id' => $fileId,
'target_column' => $targetColumn,
'target_id' => $targetId,
],
];
}
public function get(string $fileId, string $actorUserId): array
{
$file = $this->requireFile($fileId);
$this->authorizeAccess($file, $actorUserId);
$this->audit->record('SENSITIVE_RECORD_ACCESSED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS', [
'access_type' => 'metadata_read',
]);
return ['file' => $this->payload($file)];
}
public function downloadUrl(string $fileId, string $actorUserId): array
{
$file = $this->requireFile($fileId);
$this->authorizeAccess($file, $actorUserId);
$this->audit->record('SENSITIVE_RECORD_ACCESSED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS', [
'access_type' => 'download_url',
]);
return [
'download' => [
'file_id' => $fileId,
'url' => $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $fileId, $expiresAt = Carbon::now()->addMinutes(10))['url'],
'expires_at' => $expiresAt->toIso8601String(),
],
];
}
public function download(string $fileId, string $actorUserId): array
{
$file = $this->requireFile($fileId);
$this->authorizeAccess($file, $actorUserId);
$this->audit->record('SENSITIVE_RECORD_ACCESSED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS', [
'access_type' => 'signed_download',
]);
return ['file' => $this->payload($file), 'storage_provider' => $file->storage_provider];
}
public function archive(string $fileId, string $actorUserId): array
{
$file = $this->requireFile($fileId);
$this->authorizeManage($file, $actorUserId);
$this->files->archive($fileId);
$this->audit->record('FILE_ARCHIVED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS');
return ['file' => $this->payload($this->files->find($fileId))];
}
public function delete(string $fileId, string $actorUserId): array
{
$file = $this->requireFile($fileId);
$this->authorizeManage($file, $actorUserId);
$this->files->softDelete($fileId);
$this->audit->record('FILE_DELETED', $actorUserId, null, 'stored_files', $fileId, 'SUCCESS');
return ['deleted' => true];
}
private function requireFile(string $fileId): object
{
$file = $this->files->find($fileId);
if ($file === null) {
throw new ApiException('FILE_NOT_FOUND', 'File was not found.', [], 404);
}
return $file;
}
private function authorizeAccess(object $file, string $actorUserId): void
{
if (! $this->policy->canAccess($file, $actorUserId)) {
throw new ApiException('FORBIDDEN', 'You are not authorized to access this file.', [], 403);
}
}
private function authorizeManage(object $file, string $actorUserId): void
{
if (! $this->policy->canManage($file, $actorUserId)) {
throw new ApiException('FORBIDDEN', 'You are not authorized to manage this file.', [], 403);
}
}
private function ensureTargetExists(string $targetColumn, string $targetId, string $actorUserId): void
{
$table = self::TARGET_TABLES[$targetColumn] ?? null;
if ($table === null || ! DB::table($table)->where('id', $targetId)->whereNull('deleted_at')->exists()) {
throw new ApiException('FILE_LINK_TARGET_NOT_FOUND', 'File link target was not found.', [], 404);
}
if ($targetColumn === 'application_id') {
$application = DB::table('applications')->where('id', $targetId)->first();
if ($application?->user_id !== null && $application->user_id !== $actorUserId && ! $this->policy->canAccess((object) ['owner_user_id' => null], $actorUserId)) {
throw new ApiException('FORBIDDEN', 'You are not authorized to link to this application.', [], 403);
}
}
}
private function payload(object $file): array
{
return [
'id' => $file->id,
'classification' => $file->classification,
'owner_user_id' => $file->owner_user_id,
'storage_provider' => $file->storage_provider,
'storage_bucket' => $file->storage_bucket,
'storage_key_reference' => hash('sha256', $file->storage_key),
'original_filename' => $file->original_filename,
'mime_type' => $file->mime_type,
'file_size_bytes' => (int) $file->file_size_bytes,
'checksum_sha256' => $file->checksum_sha256,
'virus_scan_status' => $file->virus_scan_status,
'retention_until' => $file->retention_until,
'archived_at' => $file->archived_at,
'created_at' => $file->created_at,
'updated_at' => $file->updated_at,
'download_url' => $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $file->id, Carbon::now()->addMinutes(10))['url'],
'stream_url' => $this->fileUrls->temporaryFileStreamUrl('applicant.files.stream', $file->id, Carbon::now()->addMinutes(10))['url'],
'secure_link' => $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $file->id, Carbon::now()->addMinutes(10))['url'],
];
}
}