=== DOCUMENT API ROUTES === 1-use(new \BCOS\Http\Middleware\TrustedProxyMiddleware());$router->use(new \BCOS\Http\Middleware\RateLimitMiddleware($db));$router->use(new \BCOS\Http\Middleware\MaintenanceModeMiddleware($db));$router->use(new \BCOS\Http\Middleware\SecurityLoggingMiddleware($db)); 8- 9- 10- $router->add('GET','/health',fn(Request $r)=>$platform->health($r)); 11- $router->add('GET','/ready',fn(Request $r)=>$platform->ready($r)); 12- $router->add('POST','/api/files/upload',fn(Request $r)=>$platform->upload($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'files.upload')]); 13- $router->add('POST','/api/files/scan',fn(Request $r)=>$platform->scan($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'files.scan')]); 14- $router->add('GET','/api/files/download',fn(Request $r)=>$launch->download($r),[$authenticated,new AuthorizationMiddleware($db,'files.download')]); 15- $router->add('GET','/api/search',fn(Request $r)=>$admin23->search($r),[$authenticated,new AuthorizationMiddleware($db,'enterprise_search.use')]); 16- $router->add('GET','/api/search/saved',fn(Request $r)=>$admin23->saved($r),[$authenticated,new AuthorizationMiddleware($db,'enterprise_search.use')]); 17- $router->add('POST','/api/search/saved',fn(Request $r)=>$admin23->saveSearch($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'enterprise_search.use')]); 18- $router->add('GET','/api/administration/dashboard',fn(Request $r)=>$admin23->dashboard($r),[$authenticated,new AuthorizationMiddleware($db,'administration.view')]); 19- $router->add('POST','/api/administration/settings',fn(Request $r)=>$admin23->setting($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); -- 169- $router->add('GET','/api/finance/project',fn(Request $r)=>$finance->dashboard($r),[$authenticated,new AuthorizationMiddleware($db,'finance.view')]); 170- $router->add('GET','/api/finance/client-summary',fn(Request $r)=>$finance->clientSummary($r),[$authenticated,new AuthorizationMiddleware($db,'finance.view_client_summary')]); 171- $router->add('POST','/api/finance/budgets',fn(Request $r)=>$finance->budget($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_budgets')]); 172- $router->add('POST','/api/finance/budgets/approve',fn(Request $r)=>$finance->approveBudget($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.approve_budgets')]); 173- $router->add('POST','/api/finance/expenses',fn(Request $r)=>$finance->expense($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_expenses')]); 174- $router->add('POST','/api/finance/expenses/decide',fn(Request $r)=>$finance->decideExpense($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.approve_expenses')]); 175- $router->add('POST','/api/finance/invoices',fn(Request $r)=>$finance->invoice($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_invoices')]); 176- $router->add('POST','/api/finance/funding',fn(Request $r)=>$finance->funding($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_payments')]); 177- $router->add('POST','/api/finance/payments',fn(Request $r)=>$finance->payment($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_payments')]); 178- $router->add('POST','/api/finance/payments/confirm',fn(Request $r)=>$finance->confirmPayment($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_payments')]); 179- $router->add('POST','/api/finance/refunds',fn(Request $r)=>$finance->refund($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_refunds')]); 180- $router->add('POST','/api/finance/exchange-rates',fn(Request $r)=>$finance->rate($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'finance.manage_rates')]); 181: $router->add('GET','/api/documents/show',fn(Request $r)=>$documents->show($r),[$authenticated,new AuthorizationMiddleware($db,'documents.view')]); 182: $router->add('POST','/api/documents',fn(Request $r)=>$documents->create($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'documents.create')]); 183: $router->add('POST','/api/documents/version',fn(Request $r)=>$documents->version($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'documents.create')]); 184: $router->add('POST','/api/documents/approve',fn(Request $r)=>$documents->approve($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'documents.approve')]); 185: $router->add('POST','/api/documents/distribute',fn(Request $r)=>$documents->distribute($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'documents.distribute')]); 186- $router->add('POST','/api/media-permissions',fn(Request $r)=>$documents->mediaPermission($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'media_permissions.manage')]); 187- $router->add('POST','/api/reports/executive-project',fn(Request $r)=>$documents->executiveReport($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'reports.generate')]); 188- $router->add('GET','/api/admin/users',fn(Request $r)=>$users->index($r),[$authenticated,new AuthorizationMiddleware($db,'users.view')]);$router->add('POST','/api/admin/users',fn(Request $r)=>$users->create($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'users.create')]);$router->add('POST','/api/admin/users/update',fn(Request $r)=>$users->update($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'users.update')]);$router->add('POST','/api/admin/users/archive',fn(Request $r)=>$users->archive($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'users.archive')]);$router->add('POST','/api/admin/users/assign-role',fn(Request $r)=>$users->assignRole($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'roles.manage')]); 189- $router->add('GET','/api/admin/security/roles',fn(Request $r)=>$sec->roles($r),[$authenticated,new AuthorizationMiddleware($db,'roles.manage')]);$router->add('POST','/api/admin/security/role-permissions',fn(Request $r)=>$sec->setRolePermissions($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'permissions.manage')]);$router->add('POST','/api/security/privileged-access/request',fn(Request $r)=>$sec->requestAccess($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'privileged_access.request')]);$router->add('POST','/api/security/privileged-access/decide',fn(Request $r)=>$sec->decideAccess($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'privileged_access.approve')]);return new self($router,$root); } 190- public function run(): never 191- { 192- RequestId::init(); 193- SecurityHeaders::apply(); 194- 195- $privateLog = $this->root . '/storage/logs/php-error.log'; 196- 197- set_exception_handler(function (Throwable $e) use ($privateLog): void { === DOCUMENT SERVICES AND CONTROLLERS === app/Core/App.php-1-use(new \BCOS\Http\Middleware\TrustedProxyMiddleware());$router->use(new \BCOS\Http\Middleware\RateLimitMiddleware($db));$router->use(new \BCOS\Http\Middleware\MaintenanceModeMiddleware($db));$router->use(new \BCOS\Http\Middleware\SecurityLoggingMiddleware($db)); app/Core/App.php-8- app/Core/App.php-9- app/Core/App.php-10- $router->add('GET','/health',fn(Request $r)=>$platform->health($r)); app/Core/App.php-11- $router->add('GET','/ready',fn(Request $r)=>$platform->ready($r)); app/Core/App.php-12- $router->add('POST','/api/files/upload',fn(Request $r)=>$platform->upload($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'files.upload')]); app/Core/App.php-13- $router->add('POST','/api/files/scan',fn(Request $r)=>$platform->scan($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'files.scan')]); app/Core/App.php-14- $router->add('GET','/api/files/download',fn(Request $r)=>$launch->download($r),[$authenticated,new AuthorizationMiddleware($db,'files.download')]); app/Core/App.php-15- $router->add('GET','/api/search',fn(Request $r)=>$admin23->search($r),[$authenticated,new AuthorizationMiddleware($db,'enterprise_search.use')]); app/Core/App.php-16- $router->add('GET','/api/search/saved',fn(Request $r)=>$admin23->saved($r),[$authenticated,new AuthorizationMiddleware($db,'enterprise_search.use')]); app/Core/App.php-17- $router->add('POST','/api/search/saved',fn(Request $r)=>$admin23->saveSearch($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'enterprise_search.use')]); app/Core/App.php-18- $router->add('GET','/api/administration/dashboard',fn(Request $r)=>$admin23->dashboard($r),[$authenticated,new AuthorizationMiddleware($db,'administration.view')]); app/Core/App.php-19- $router->add('POST','/api/administration/settings',fn(Request $r)=>$admin23->setting($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-20- $router->add('POST','/api/administration/reference-data',fn(Request $r)=>$admin23->reference($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-21- $router->add('POST','/api/administration/legal-entities',fn(Request $r)=>$admin23->entity($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-22- $router->add('POST','/api/administration/legal-entities/update',fn(Request $r)=>$admin23->updateEntity($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-23- $router->add('GET','/api/administration/legal-profiles',fn(Request $r)=>$admin23->legalProfiles($r),[$authenticated,new AuthorizationMiddleware($db,'administration.view')]); app/Core/App.php-24- $router->add('POST','/api/administration/legal-profiles',fn(Request $r)=>$admin23->saveLegalProfile($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-25- $router->add('POST','/api/administration/offices',fn(Request $r)=>$admin23->office($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-26- $router->add('POST','/api/administration/departments',fn(Request $r)=>$admin23->department($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-27- $router->add('POST','/api/administration/maintenance',fn(Request $r)=>$admin23->maintenance($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'administration.manage')]); app/Core/App.php-28- $router->add('POST','/api/backups/restore-plans',fn(Request $r)=>$admin23->planRestore($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'restore.plan')]); app/Core/App.php-29- $router->add('GET','/api/audit/search',fn(Request $r)=>$admin23->audit($r),[$authenticated,new AuthorizationMiddleware($db,'audit.search')]); app/Core/App.php-30- $router->add('GET','/api/notifications',fn(Request $r)=>$notify->notifications($r),[$authenticated,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-31- $router->add('GET','/api/notifications/summary',fn(Request $r)=>$notify->notificationSummary($r),[$authenticated,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-32- $router->add('GET','/api/notifications/preferences',fn(Request $r)=>$notify->preferences($r),[$authenticated,new AuthorizationMiddleware($db,'notifications.view')]); -- app/Http/Controllers/DocumentController.php-1-s->create($r->body,(string)$r->attributes['user_id']),'Document created.',201);} app/Http/Controllers/DocumentController.php-7- public function version(Request $r):never{Response::ok($this->s->newVersion((string)($r->body['id']??''),(array)($r->body['content']??[]),(string)($r->body['change_summary']??''),(string)$r->attributes['user_id']),'Version created.',201);} app/Http/Controllers/DocumentController.php-8- public function show(Request $r):never{Response::ok($this->s->show((string)($r->query['id']??'')));} app/Http/Controllers/DocumentController.php:9: public function approve(Request $r):never{$this->s->approve((string)($r->body['id']??''),(string)$r->attributes['user_id']);Response::ok(null,'Document approved.');} app/Http/Controllers/DocumentController.php-10- public function distribute(Request $r):never{Response::ok($this->s->distribute((string)($r->body['id']??''),$r->body,(string)$r->attributes['user_id']),'Distribution recorded.');} app/Http/Controllers/DocumentController.php-11- public function mediaPermission(Request $r):never{Response::ok($this->s->mediaPermission($r->body,(string)$r->attributes['user_id']),'Media permission created.',201);} app/Http/Controllers/DocumentController.php-12- public function executiveReport(Request $r):never{Response::ok($this->s->executiveReport((string)($r->body['project_id']??''),(string)$r->attributes['user_id']),'Executive report generated.',201);} app/Http/Controllers/DocumentController.php-13-} -- app/Http/Controllers/NotificationCollaborationController.php-6-use BCOS\Core\Response; app/Http/Controllers/NotificationCollaborationController.php-7-use BCOS\Modules\NotificationsCollaboration\CollaborationService; app/Http/Controllers/NotificationCollaborationController.php-8-use BCOS\Modules\NotificationsCollaboration\EmailQueueService; app/Http/Controllers/NotificationCollaborationController.php-9-use BCOS\Modules\NotificationsCollaboration\NotificationService; app/Http/Controllers/NotificationCollaborationController.php-10-use BCOS\Modules\NotificationsCollaboration\SchedulerService; app/Http/Controllers/NotificationCollaborationController.php-11- app/Http/Controllers/NotificationCollaborationController.php-12-final class NotificationCollaborationController app/Http/Controllers/NotificationCollaborationController.php-13-{ app/Http/Controllers/NotificationCollaborationController.php-14- public function __construct( app/Http/Controllers/NotificationCollaborationController.php-15- private NotificationService $notifications, app/Http/Controllers/NotificationCollaborationController.php-16- private CollaborationService $collaboration, app/Http/Controllers/NotificationCollaborationController.php-17- private EmailQueueService $email, app/Http/Controllers/NotificationCollaborationController.php-18- private SchedulerService $scheduler app/Http/Controllers/NotificationCollaborationController.php-19- ) {} app/Http/Controllers/NotificationCollaborationController.php-20- app/Http/Controllers/NotificationCollaborationController.php-21- private function user(Request $r): string { return (string) $r->attributes['user_id']; } app/Http/Controllers/NotificationCollaborationController.php-22- app/Http/Controllers/NotificationCollaborationController.php-23- public function notifications(Request $r): never { Response::ok($this->notifications->list($this->user($r),$r->query)); } app/Http/Controllers/NotificationCollaborationController.php-24- public function notificationSummary(Request $r): never { Response::ok($this->notifications->summary($this->user($r))); } app/Http/Controllers/NotificationCollaborationController.php-25- public function preferences(Request $r): never { Response::ok($this->notifications->preferences($this->user($r))); } app/Http/Controllers/NotificationCollaborationController.php-26- public function updatePreferences(Request $r): never { Response::ok($this->notifications->updatePreferences($this->user($r),$r->body),'Notification preferences updated.'); } app/Http/Controllers/NotificationCollaborationController.php-27- public function markRead(Request $r): never { Response::ok(['updated'=>$this->notifications->markRead($this->user($r),(array)($r->body['ids']??[]))],'Notifications marked as read.'); } app/Http/Controllers/NotificationCollaborationController.php-28- public function archive(Request $r): never { Response::ok(['updated'=>$this->notifications->archive($this->user($r),(array)($r->body['ids']??[]))],'Notifications archived.'); } app/Http/Controllers/NotificationCollaborationController.php-29- public function createNotification(Request $r): never { Response::ok($this->notifications->create($r->body,$this->user($r)),'Notification created.',201); } app/Http/Controllers/NotificationCollaborationController.php-30- public function notifyReport(Request $r): never { Response::ok($this->notifications->notifyReport((string)($r->body['report_id']??''),(array)($r->body['recipient_user_ids']??[]),$this->user($r)),'Report notifications created.'); } app/Http/Controllers/NotificationCollaborationController.php:31: public function notifyDocument(Request $r): never { Response::ok($this->notifications->notifyDocument((string)($r->body['document_id']??''),(array)($r->body['recipient_user_ids']??[]),$this->user($r)),'Document notifications created.'); } app/Http/Controllers/NotificationCollaborationController.php-32- public function notifyApproval(Request $r): never { Response::ok($this->notifications->notifyApproval($r->body,$this->user($r)),'Approval notifications created.'); } app/Http/Controllers/NotificationCollaborationController.php-33- app/Http/Controllers/NotificationCollaborationController.php-34- public function conversations(Request $r): never { Response::ok($this->collaboration->conversations($this->user($r),(int)($r->query['limit']??50))); } app/Http/Controllers/NotificationCollaborationController.php-35- public function messages(Request $r): never { Response::ok($this->collaboration->messages($this->user($r),(string)($r->query['conversation_id']??''),(int)($r->query['limit']??100))); } app/Http/Controllers/NotificationCollaborationController.php-36- public function createConversation(Request $r): never { Response::ok($this->collaboration->createConversation($this->user($r),$r->body),'Conversation created.',201); } app/Http/Controllers/NotificationCollaborationController.php-37- public function addParticipants(Request $r): never { Response::ok(['added'=>$this->collaboration->addParticipants($this->user($r),$r->body)],'Participants added.'); } app/Http/Controllers/NotificationCollaborationController.php-38- public function postMessage(Request $r): never { Response::ok($this->collaboration->postMessage($this->user($r),$r->body),'Message posted.',201); } app/Http/Controllers/NotificationCollaborationController.php-39- public function attachMessage(Request $r): never { Response::ok($this->collaboration->attach($this->user($r),$r->body),'Attachment linked.',201); } app/Http/Controllers/NotificationCollaborationController.php-40- app/Http/Controllers/NotificationCollaborationController.php-41- public function emailTemplates(Request $r): never { Response::ok($this->email->templates()); } app/Http/Controllers/NotificationCollaborationController.php-42- public function emailConfigs(Request $r): never { Response::ok($this->email->configs()); } app/Http/Controllers/NotificationCollaborationController.php-43- public function emailQueue(Request $r): never { Response::ok($this->email->queue((int)($r->query['limit']??100))); } app/Http/Controllers/NotificationCollaborationController.php-44- public function saveEmailConfig(Request $r): never { Response::ok($this->email->saveConfig($r->body,$this->user($r)),'SMTP configuration saved.'); } app/Http/Controllers/NotificationCollaborationController.php-45- public function testEmailConfig(Request $r): never { Response::ok($this->email->testConfig((string)($r->body['config_id']??''),(string)($r->body['recipient_email']??''),$this->user($r)),'SMTP test completed.'); } app/Http/Controllers/NotificationCollaborationController.php-46- public function queueEmail(Request $r): never { Response::ok(['id'=>$this->email->queueRaw($r->body+['created_by'=>$this->user($r)])],'Email queued.',201); } app/Http/Controllers/NotificationCollaborationController.php-47- public function processEmailQueue(Request $r): never { Response::ok($this->email->processBatch((int)($r->body['limit']??20),$this->user($r)),'Email queue processed.'); } app/Http/Controllers/NotificationCollaborationController.php-48- app/Http/Controllers/NotificationCollaborationController.php-49- public function reminders(Request $r): never { Response::ok($this->scheduler->reminders($this->user($r),(int)($r->query['limit']??100))); } app/Http/Controllers/NotificationCollaborationController.php-50- public function createReminder(Request $r): never { Response::ok($this->scheduler->createReminder($this->user($r),$r->body),'Reminder created.',201); } app/Http/Controllers/NotificationCollaborationController.php-51- public function runScheduler(Request $r): never { Response::ok($this->scheduler->run($this->user($r),(int)($r->body['email_limit']??25)),'Scheduler run completed.'); } app/Http/Controllers/NotificationCollaborationController.php-52-} -- app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-1-all($sql,$p);} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-8- public function show(string $id):array{$a=$this->one('SELECT * FROM vw_technical_assessment_dashboard WHERE id=:id',['id'=>$id]);if(!$a)throw new RuntimeException('Assessment unavailable.');$a['findings']=$this->all('SELECT * FROM assessment_findings WHERE assessment_id=:id ORDER BY FIELD(severity,"CRITICAL","HIGH","MEDIUM","LOW"),created_at',['id'=>$id]);$a['risks']=$this->all('SELECT * FROM assessment_risks WHERE assessment_id=:id ORDER BY risk_score DESC',['id'=>$id]);$a['recommendations']=$this->all('SELECT * FROM assessment_recommendations WHERE assessment_id=:id ORDER BY sequence_no',['id'=>$id]);$a['actions']=$this->all('SELECT * FROM corrective_actions WHERE assessment_id=:id ORDER BY due_date',['id'=>$id]);$a['evidence']=$this->all('SELECT * FROM assessment_evidence WHERE assessment_id=:id ORDER BY created_at DESC',['id'=>$id]);return $a;} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-9- public function createTemplate(string $u,array $d):array{$id=Uuid::v4();$ref=$this->ref('BC-ATM');$this->db->connection()->prepare('INSERT INTO assessment_templates(id,reference_no,name,item_type,description,version_no,status,scoring_method,pass_mark,created_by) VALUES(:id,:r,:n,:t,:d,1,:s,:m,:p,:u)')->execute(['id'=>$id,'r'=>$ref,'n'=>$d['name']??'Assessment Template','t'=>$d['item_type']??'OTHER','d'=>$d['description']??null,'s'=>$d['status']??'DRAFT','m'=>$d['scoring_method']??'WEIGHTED','p'=>$d['pass_mark']??70,'u'=>$u]);$this->audit->record('assessment.template_created',$u,['template_id'=>$id]);return ['id'=>$id,'reference_no'=>$ref];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-10- public function addSection(string $u,array $d):array{return $this->insert('assessment_sections',['template_id'=>$d['template_id']??throw new RuntimeException('Template required.'),'sequence_no'=>$d['sequence_no']??1,'title'=>$d['title']??'Section','description'=>$d['description']??null,'weight_percent'=>$d['weight_percent']??0],'assessment.section_created',$u);} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-11- public function addQuestion(string $u,array $d):array{return $this->insert('assessment_questions',['section_id'=>$d['section_id']??throw new RuntimeException('Section required.'),'sequence_no'=>$d['sequence_no']??1,'question_text'=>$d['question_text']??'Question','response_type'=>$d['response_type']??'TEXT','options_json'=>isset($d['options'])?json_encode($d['options']):null,'required_flag'=>!empty($d['required_flag'])?1:0,'weight_percent'=>$d['weight_percent']??0,'critical_flag'=>!empty($d['critical_flag'])?1:0,'guidance'=>$d['guidance']??null],'assessment.question_created',$u);} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-12- public function createItem(string $u,array $d):array{$id=Uuid::v4();$ref=$this->ref('BC-ITM');$this->db->connection()->prepare('INSERT INTO technical_items(id,reference_no,organization_id,project_id,item_type,name,manufacturer,model,serial_or_vin,year_manufactured,location,specifications_json,created_by) VALUES(:id,:r,:o,:p,:t,:n,:m,:model,:s,:y,:l,:j,:u)')->execute(['id'=>$id,'r'=>$ref,'o'=>$d['organization_id']??null,'p'=>$d['project_id']??null,'t'=>$d['item_type']??'OTHER','n'=>$d['name']??'Item','m'=>$d['manufacturer']??null,'model'=>$d['model']??null,'s'=>$d['serial_or_vin']??null,'y'=>$d['year_manufactured']??null,'l'=>$d['location']??null,'j'=>isset($d['specifications'])?json_encode($d['specifications']):null,'u'=>$u]);$this->audit->record('assessment.item_created',$u,['item_id'=>$id]);return ['id'=>$id,'reference_no'=>$ref];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-13- public function createAssessment(string $u,array $d):array{$id=Uuid::v4();$ref=$this->ref('BC-TIA');$this->db->connection()->prepare('INSERT INTO technical_assessments(id,reference_no,template_id,item_id,project_id,client_organization_id,lead_assessor_user_id,assessment_date,status,recommendation,executive_summary,client_visible) VALUES(:id,:r,:t,:i,:p,:c,:u,:dt,:s,:rec,:e,:v)')->execute(['id'=>$id,'r'=>$ref,'t'=>$d['template_id']??throw new RuntimeException('Template required.'),'i'=>$d['item_id']??throw new RuntimeException('Item required.'),'p'=>$d['project_id']??null,'c'=>$d['client_organization_id']??null,'u'=>$u,'dt'=>$d['assessment_date']??gmdate('Y-m-d'),'s'=>$d['status']??'DRAFT','rec'=>$d['recommendation']??'PENDING','e'=>$d['executive_summary']??null,'v'=>!empty($d['client_visible'])?1:0]);$this->audit->record('assessment.created',$u,['assessment_id'=>$id]);return ['id'=>$id,'reference_no'=>$ref];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-14- public function saveResponse(string $u,array $d):array{$id=Uuid::v4();$this->db->connection()->prepare('INSERT INTO assessment_responses(id,assessment_id,question_id,responder_user_id,response_text,response_number,response_json,score_awarded) VALUES(:id,:a,:q,:u,:t,:n,:j,:s) ON DUPLICATE KEY UPDATE responder_user_id=VALUES(responder_user_id),response_text=VALUES(response_text),response_number=VALUES(response_number),response_json=VALUES(response_json),score_awarded=VALUES(score_awarded),answered_at=UTC_TIMESTAMP(6)')->execute(['id'=>$id,'a'=>$d['assessment_id']??'','q'=>$d['question_id']??'','u'=>$u,'t'=>$d['response_text']??null,'n'=>$d['response_number']??null,'j'=>isset($d['response'])?json_encode($d['response']):null,'s'=>$d['score_awarded']??null]);return ['id'=>$id];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-15- public function addFinding(string $u,array $d):array{$id=Uuid::v4();$ref=$this->ref('BC-FND');$this->db->connection()->prepare('INSERT INTO assessment_findings(id,assessment_id,reference_no,title,description,severity,category,status,owner_user_id,due_date,created_by) VALUES(:id,:a,:r,:t,:d,:s,:c,:st,:o,:due,:u)')->execute(['id'=>$id,'a'=>$d['assessment_id']??'','r'=>$ref,'t'=>$d['title']??'Finding','d'=>$d['description']??'','s'=>$d['severity']??'MEDIUM','c'=>$d['category']??null,'st'=>$d['status']??'OPEN','o'=>$d['owner_user_id']??null,'due'=>$d['due_date']??null,'u'=>$u]);$this->audit->record('assessment.finding_created',$u,['finding_id'=>$id]);return ['id'=>$id,'reference_no'=>$ref];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php:16: public function addEvidence(string $u,array $d):array{$hash=$d['integrity_hash']??hash('sha256',json_encode($d));return $this->insert('assessment_evidence',['assessment_id'=>$d['assessment_id']??'','finding_id'=>$d['finding_id']??null,'file_id'=>$d['file_id']??null,'evidence_type'=>$d['evidence_type']??'OTHER','caption'=>$d['caption']??null,'captured_at'=>$d['captured_at']??gmdate('Y-m-d H:i:s'),'latitude'=>$d['latitude']??null,'longitude'=>$d['longitude']??null,'integrity_hash'=>$hash,'client_visible'=>!empty($d['client_visible'])?1:0,'uploaded_by'=>$u],'assessment.evidence_added',$u);} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php:17: public function addThirdParty(string $u,array $d):array{return $this->insert('third_party_inspections',['assessment_id'=>$d['assessment_id']??'','provider_organization_id'=>$d['provider_organization_id']??null,'inspector_name'=>$d['inspector_name']??'Independent Inspector','inspection_date'=>$d['inspection_date']??gmdate('Y-m-d'),'report_document_id'=>$d['report_document_id']??null,'conclusion'=>$d['conclusion']??null,'verification_status'=>'PENDING'],'assessment.third_party_added',$u);} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-18- public function addAction(string $u,array $d):array{return $this->insert('corrective_actions',['assessment_id'=>$d['assessment_id']??'','finding_id'=>$d['finding_id']??null,'action_text'=>$d['action_text']??'Corrective action','owner_user_id'=>$d['owner_user_id']??null,'due_date'=>$d['due_date']??null,'status'=>'OPEN'],'assessment.corrective_action_created',$u);} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-19- public function syncDraft(string $u,array $d):array{$payload=$d['payload']??[];$hash=hash('sha256',json_encode($payload));$id=Uuid::v4();$this->db->connection()->prepare('INSERT INTO offline_draft_manifests(id,user_id,device_id,draft_type,local_reference,payload_hash,encrypted_flag,sync_status,server_object_id,synced_at) VALUES(:id,:u,:dev,:type,:ref,:h,1,"SYNCED",:obj,UTC_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE payload_hash=VALUES(payload_hash),sync_status="SYNCED",server_object_id=VALUES(server_object_id),synced_at=UTC_TIMESTAMP(6)')->execute(['id'=>$id,'u'=>$u,'dev'=>$d['device_id']??'unknown','type'=>$d['draft_type']??'ASSESSMENT','ref'=>$d['local_reference']??Uuid::v4(),'h'=>$hash,'obj'=>$d['server_object_id']??null]);return ['id'=>$id,'payload_hash'=>$hash,'sync_status'=>'SYNCED'];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php:20: public function executiveReport(string $id):array{$a=$this->show($id);return ['assessment'=>$a,'generated_at'=>gmdate(DATE_ATOM),'report_title'=>'Executive Technical Assessment Report','integrity_hash'=>hash('sha256',json_encode($a))];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-21- private function insert(string $table,array $data,string $event,string $u):array{$id=Uuid::v4();$data=['id'=>$id]+$data;$cols=array_keys($data);$this->db->connection()->prepare('INSERT INTO '.$table.'('.implode(',',$cols).') VALUES('.implode(',',array_map(fn($c)=>':'.$c,$cols)).')')->execute($data);$this->audit->record($event,$u,['record_id'=>$id]);return ['id'=>$id];} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-22- private function ref(string $p):string{return $p.'-'.gmdate('Y').'-'.str_pad((string)random_int(1,999999),6,'0',STR_PAD_LEFT);}private function all(string $q,array $p=[]):array{$s=$this->db->connection()->prepare($q);$s->execute($p);return $s->fetchAll();}private function one(string $q,array $p=[]):array|false{$s=$this->db->connection()->prepare($q);$s->execute($p);return $s->fetch();} app/Modules/TechnicalAssessment/TechnicalAssessmentService.php-23-} -- app/Modules/ClientPortal/ClientPortalService.php-1-db->connection()->prepare("SELECT m.*,o.legal_name organization_name FROM client_portal_memberships m JOIN organizations o ON o.id=m.organization_id WHERE m.user_id=:u AND m.status='ACTIVE' ORDER BY m.activated_at DESC LIMIT 1");$s->execute(['u'=>$userId]);$m=$s->fetch();if(!$m)throw new RuntimeException('No active client portal membership.');return $m;} app/Modules/ClientPortal/ClientPortalService.php-8- public function dashboard(string $userId):array{$m=$this->context($userId);$org=$m['organization_id'];$projectSql='SELECT vp.* FROM vw_client_portal_projects vp WHERE vp.client_organization_id=:o';$params=['o'=>$org];if(!in_array($m['portal_role'],['CLIENT_EXECUTIVE','CLIENT_FINANCE'],true)){$projectSql.=' AND EXISTS(SELECT 1 FROM project_client_visibility v WHERE v.project_id=vp.id AND v.contact_id=:c AND v.can_view=1 AND v.revoked_at IS NULL)';$params['c']=$m['contact_id'];}$projectSql.=' ORDER BY vp.target_end_date IS NULL,vp.target_end_date';$financial=in_array($m['portal_role'],['CLIENT_EXECUTIVE','CLIENT_FINANCE'],true)?($this->one('SELECT * FROM vw_client_financial_summary WHERE client_id=:o',['o'=>$org])??[]):[];return ['membership'=>$m,'projects'=>$this->all($projectSql,$params),'financial'=>$financial,'pending_approvals'=>$this->all("SELECT * FROM client_portal_approval_requests WHERE organization_id=:o AND status='PENDING' AND (requested_from_user_id IS NULL OR requested_from_user_id=:u) ORDER BY requested_at",['o'=>$org,'u'=>$userId]),'recent_messages'=>$this->all("SELECT * FROM client_portal_messages WHERE organization_id=:o AND visibility<>'INTERNAL_ONLY' ORDER BY created_at DESC LIMIT 10",['o'=>$org]),'learning'=>$this->all("SELECT * FROM client_learning_resources WHERE status='PUBLISHED' AND (organization_id IS NULL OR organization_id=:o) ORDER BY published_at DESC LIMIT 12",['o'=>$org])];} app/Modules/ClientPortal/ClientPortalService.php:9: public function project(string $userId,string $projectId):array{$m=$this->context($userId);$org=$m['organization_id'];$accessSql='SELECT vp.* FROM vw_client_portal_projects vp WHERE vp.id=:p AND vp.client_organization_id=:o';$params=['p'=>$projectId,'o'=>$org];if(!in_array($m['portal_role'],['CLIENT_EXECUTIVE','CLIENT_FINANCE'],true)){$accessSql.=' AND EXISTS(SELECT 1 FROM project_client_visibility v WHERE v.project_id=vp.id AND v.contact_id=:c AND v.can_view=1 AND v.revoked_at IS NULL)';$params['c']=$m['contact_id'];}$p=$this->one($accessSql,$params);if(!$p)throw new RuntimeException('Project is unavailable.');$p['milestones']=$this->all('SELECT id,reference_no,name,description,status,due_date,completed_at FROM milestones WHERE project_id=:p ORDER BY due_date',['p'=>$projectId]);$p['deliverables']=$this->all("SELECT id,reference_no,title,description,status,due_date,accepted_at FROM deliverables WHERE project_id=:p AND status NOT IN ('DRAFT','CANCELLED','SUPERSEDED') ORDER BY due_date",['p'=>$projectId]);$p['financial']=$this->one('SELECT * FROM vw_project_financial_position WHERE project_id=:p',['p'=>$projectId])??[];$p['documents']=$this->all('SELECT * FROM vw_client_portal_documents WHERE project_id=:p ORDER BY updated_at DESC',['p'=>$projectId]);$p['contracts']=$this->all('SELECT * FROM vw_client_portal_contracts WHERE project_id=:p ORDER BY effective_date DESC',['p'=>$projectId]);$p['invoices']=$this->all('SELECT * FROM vw_client_portal_invoices WHERE project_id=:p ORDER BY issue_date DESC',['p'=>$projectId]);$p['receipts']=$this->all('SELECT * FROM vw_client_portal_receipts WHERE project_id=:p ORDER BY issued_at DESC',['p'=>$projectId]);return $p;} app/Modules/ClientPortal/ClientPortalService.php-10- public function message(string $userId,array $d):array{$m=$this->context($userId);$project=$d['project_id']??null;if($project&&!$this->one('SELECT id FROM projects WHERE id=:p AND client_organization_id=:o',['p'=>$project,'o'=>$m['organization_id']]))throw new RuntimeException('Project is outside client scope.');$id=Uuid::v4();$this->db->connection()->prepare("INSERT INTO client_portal_messages(id,organization_id,project_id,sender_user_id,subject,body,visibility,parent_message_id) VALUES(:id,:o,:p,:u,:s,:b,'CLIENT_AND_TEAM',:parent)")->execute(['id'=>$id,'o'=>$m['organization_id'],'p'=>$project,'u'=>$userId,'s'=>trim((string)($d['subject']??'Message')),'b'=>trim((string)($d['body']??'')),'parent'=>$d['parent_message_id']??null]);$this->audit->record('client_portal.message_created',$userId,['message_id'=>$id,'organization_id'=>$m['organization_id']]);return ['id'=>$id];} app/Modules/ClientPortal/ClientPortalService.php-11- public function decide(string $userId,array $d):void{$m=$this->context($userId);$id=(string)($d['id']??'');$decision=strtoupper((string)($d['decision']??''));if(!in_array($decision,['APPROVED','REJECTED','CHANGES_REQUESTED'],true))throw new RuntimeException('Invalid decision.');$s=$this->db->connection()->prepare("UPDATE client_portal_approval_requests SET status=:st,decided_by=:decided_by,decided_at=UTC_TIMESTAMP(),decision_notes=:n WHERE id=:id AND organization_id=:o AND status='PENDING' AND (requested_from_user_id IS NULL OR requested_from_user_id=:requested_user)");$s->execute(['st'=>$decision,'decided_by'=>$userId,'n'=>$d['notes']??null,'id'=>$id,'o'=>$m['organization_id'],'requested_user'=>$userId]);if($s->rowCount()!==1)throw new RuntimeException('Approval request unavailable.');$this->audit->record('client_portal.approval_decided',$userId,['approval_id'=>$id,'decision'=>$decision]);} app/Modules/ClientPortal/ClientPortalService.php-12- private function all(string $sql,array $p=[]):array{$s=$this->db->connection()->prepare($sql);$s->execute($p);return $s->fetchAll();}private function one(string $sql,array $p=[]):array|false{$s=$this->db->connection()->prepare($sql);$s->execute($p);return $s->fetch();} app/Modules/ClientPortal/ClientPortalService.php-13-} -- app/Modules/Reports/ReportDataService.php-1-$this->executive(), 'PROJECT'=>$this->project($this->need($contextId)), 'ASSESSMENT'=>$this->assessment($this->need($contextId)), app/Modules/Reports/ReportDataService.php-14- 'FINANCIAL'=>$this->financial($this->need($contextId),(string)($options['context_type']??'PROJECT')), app/Modules/Reports/ReportDataService.php-15- 'COMPLIANCE'=>$this->compliance(), 'RISK'=>$this->risk(), 'AUDIT'=>$this->audit($this->need($contextId)), 'CLIENT'=>$this->client($this->need($contextId))}; app/Modules/Reports/ReportDataService.php-16- } app/Modules/Reports/ReportDataService.php-17- private function executive():array {$e=$this->one('SELECT * FROM vw_governance_executive_summary')?:[];$health=$this->one('SELECT * FROM vw_executive_project_health')?:[];$p=$this->all("SELECT id,reference_no project_reference,name,status,health,priority,progress_percent,overdue_tasks,high_risks FROM vw_project_dashboard ORDER BY CASE health WHEN 'CRITICAL' THEN 1 WHEN 'AT_RISK' THEN 2 ELSE 3 END,overdue_tasks DESC LIMIT 25");$f=$this->one('SELECT * FROM vw_executive_financial_health')?:[];$k=$this->one('SELECT * FROM vw_executive_knowledge_learning')?:[];$charts=[['title'=>'Project health profile','type'=>'bar','labels'=>['Critical','At risk','Other active'],'values'=>[(int)($health['critical_projects']??0),(int)($health['at_risk_projects']??0),max(0,(int)($health['total_active_projects']??0)-(int)($health['critical_projects']??0)-(int)($health['at_risk_projects']??0))]]];return $this->doc('Executive Enterprise Briefing',[['title'=>'Executive summary','content'=>$e+['project_health'=>$health]],['title'=>'Financial position','content'=>$f],['title'=>'Project portfolio requiring attention','content'=>$p],['title'=>'Knowledge and learning','content'=>$k]],$charts,[['title'=>'Project portfolio','rows'=>$p]]);} app/Modules/Reports/ReportDataService.php-18- private function project(string $id):array {$p=$this->one('SELECT * FROM vw_project_dashboard WHERE id=?',[$id]);if(!$p)throw new RuntimeException('Project not found.');$m=$this->all('SELECT * FROM milestones WHERE project_id=? ORDER BY due_date',[$id]);$d=$this->all('SELECT * FROM deliverables WHERE project_id=? ORDER BY due_date',[$id]);$r=$this->all('SELECT * FROM project_risks WHERE project_id=? ORDER BY risk_score DESC',[$id]);$f=$this->one('SELECT * FROM vw_project_financial_position WHERE project_id=?',[$id])?:[];return $this->doc('Project Performance Report',[['title'=>'Project overview','content'=>$p],['title'=>'Financial position','content'=>$f],['title'=>'Milestones','content'=>$m],['title'=>'Deliverables','content'=>$d],['title'=>'Risks','content'=>$r],['title'=>'Management recommendations','content'=>'Resolve overdue tasks, confirm client decisions, control financial commitments and capture lessons learned before closure.']],[['title'=>'Project delivery status','type'=>'bar','labels'=>['Progress','Milestones completed','Deliverables accepted'],'values'=>[(float)($p['progress_percent']??0),(float)($p['completed_milestones']??0),(float)($p['accepted_deliverables']??0)]]],[['title'=>'Milestones','rows'=>$m],['title'=>'Deliverables','rows'=>$d],['title'=>'Risk register','rows'=>$r]]);} app/Modules/Reports/ReportDataService.php:19: private function assessment(string $id):array {$a=$this->one('SELECT * FROM vw_technical_assessment_dashboard WHERE id=?',[$id]);if(!$a)throw new RuntimeException('Assessment not found.');$find=$this->all('SELECT * FROM assessment_findings WHERE assessment_id=? ORDER BY severity DESC',[$id]);$risks=$this->all('SELECT * FROM assessment_risks WHERE assessment_id=? ORDER BY risk_score DESC',[$id]);$actions=$this->all('SELECT * FROM corrective_actions WHERE assessment_id=? ORDER BY due_date',[$id]);$evidence=$this->all('SELECT id,evidence_type,caption,captured_at,client_visible,integrity_hash FROM assessment_evidence WHERE assessment_id=?',[$id]);$sev=['LOW'=>0,'MEDIUM'=>0,'HIGH'=>0,'CRITICAL'=>0];foreach($find as $x)$sev[$x['severity']]++;return $this->doc('Technical Item Assessment Report',[['title'=>'Assessment overview','content'=>$a],['title'=>'Findings','content'=>$find],['title'=>'Risks','content'=>$risks],['title'=>'Corrective actions','content'=>$actions],['title'=>'Recommendation','content'=>$a['recommendation']??'Pending']],[['title'=>'Findings by severity','type'=>'bar','labels'=>array_keys($sev),'values'=>array_values($sev)]],[['title'=>'Evidence register','rows'=>$evidence],['title'=>'Finding register','rows'=>$find]]);} app/Modules/Reports/ReportDataService.php-20- private function financial(string $id,string $context):array {$context=strtoupper($context);if($context==='CLIENT'){$s=$this->all('SELECT * FROM vw_client_financial_live_summary WHERE client_organization_id=?',[$id]);$title='Client Financial Statement';}else{$s=$this->one('SELECT * FROM vw_project_financial_position WHERE project_id=?',[$id]);if(!$s)throw new RuntimeException('Financial context not found.');$s=[$s];$title='Project Financial Statement';}$first=$s[0]??[];$labels=['Budget','Funds received','Expenditure','Available','Outstanding'];$values=[(float)($first['approved_budget']??0),(float)($first['client_funds_received']??0),(float)($first['approved_expenditure']??0),(float)($first['available_balance']??0),(float)($first['outstanding_funding']??0)];return $this->doc($title,[['title'=>'Financial summary','content'=>$s],['title'=>'Financial interpretation','content'=>'Balances are generated from approved and confirmed BCOS records. Reconcile with bank and accounting records before statutory use.']],[['title'=>'Financial position','type'=>'bar','labels'=>$labels,'values'=>$values]],[['title'=>'Financial detail','rows'=>$s]]);} app/Modules/Reports/ReportDataService.php-21- private function compliance():array {$cal=$this->all('SELECT * FROM vw_compliance_calendar ORDER BY due_date LIMIT 200');$counts=['CRITICAL'=>0,'HIGH'=>0,'MEDIUM'=>0,'LOW'=>0];foreach($cal as $x){$p=$x['priority']??'MEDIUM';if(isset($counts[$p]))$counts[$p]++;}return $this->doc('Compliance Status Report',[['title'=>'Compliance executive summary','content'=>$this->one('SELECT * FROM vw_governance_executive_summary')?:[]],['title'=>'Compliance calendar','content'=>$cal]],[['title'=>'Compliance obligations by priority','type'=>'bar','labels'=>array_keys($counts),'values'=>array_values($counts)]],[['title'=>'Compliance calendar','rows'=>$cal]]);} app/Modules/Reports/ReportDataService.php-22- private function risk():array {$r=$this->all('SELECT * FROM vw_risk_control_summary ORDER BY COALESCE(residual_score,inherent_score) DESC LIMIT 200');$top=array_slice($r,0,20);return $this->doc('Enterprise Risk Report',[['title'=>'Risk executive summary','content'=>['total_risks'=>count($r),'high_exposure'=>count(array_filter($r,fn($x)=>(int)($x['residual_score']??$x['inherent_score']??0)>=15))]],['title'=>'Top risks','content'=>$top],['title'=>'Control and treatment guidance','content'=>'Prioritize risks outside appetite, test key controls, verify corrective actions and record management acceptance.']],[['title'=>'Top residual risk scores','type'=>'bar','labels'=>array_map(fn($x)=>(string)($x['reference_no']??''),$top),'values'=>array_map(fn($x)=>(int)($x['residual_score']??$x['inherent_score']??0),$top)]],[['title'=>'Risk register','rows'=>$r]]);} app/Modules/Reports/ReportDataService.php-23- private function audit(string $id):array {$a=$this->one('SELECT * FROM audits WHERE id=?',[$id]);if(!$a)throw new RuntimeException('Audit not found.');$f=$this->all('SELECT * FROM audit_findings WHERE audit_id=? ORDER BY severity DESC',[$id]);$sev=['LOW'=>0,'MEDIUM'=>0,'HIGH'=>0,'CRITICAL'=>0];foreach($f as $x)$sev[$x['severity']]++;return $this->doc('Audit Report',[['title'=>'Audit overview','content'=>$a],['title'=>'Findings','content'=>$f],['title'=>'Management conclusion','content'=>'Management should agree owners and deadlines, verify closure evidence and retain the complete audit trail.']],[['title'=>'Audit findings by severity','type'=>'bar','labels'=>array_keys($sev),'values'=>array_values($sev)]],[['title'=>'Audit finding register','rows'=>$f]]);} app/Modules/Reports/ReportDataService.php-24- private function client(string $id):array {$org=$this->one('SELECT * FROM organizations WHERE id=?',[$id]);if(!$org)throw new RuntimeException('Client not found.');$projects=$this->all('SELECT * FROM vw_client_portal_projects WHERE client_organization_id=?',[$id]);$fin=$this->all('SELECT * FROM vw_client_financial_live_summary WHERE client_organization_id=?',[$id]);return $this->doc('Client Engagement Report',[['title'=>'Client overview','content'=>$org],['title'=>'Project portfolio','content'=>$projects],['title'=>'Financial transparency','content'=>$fin],['title'=>'Capability and learning','content'=>'This report summarizes delivery evidence, financial stewardship, knowledge transfer and recommended capability-building priorities.']],[['title'=>'Project progress','type'=>'bar','labels'=>array_map(fn($x)=>(string)($x['project_reference']??''),$projects),'values'=>array_map(fn($x)=>(float)($x['progress_percent']??0),$projects)]],[['title'=>'Project portfolio','rows'=>$projects],['title'=>'Financial summary','rows'=>$fin]]);} app/Modules/Reports/ReportDataService.php-25- private function doc(string $title,array $sections,array $charts,array $appendices):array{return compact('title','sections','charts','appendices');} app/Modules/Reports/ReportDataService.php-26- private function need(?string $id):string{if(!$id)throw new RuntimeException('A context ID is required.');return $id;} app/Modules/Reports/ReportDataService.php-27- private function all(string $sql,array $params=[]):array{try{$s=$this->db->connection()->prepare($sql);$s->execute($params);return $s->fetchAll();}catch(\Throwable){return [];}} app/Modules/Reports/ReportDataService.php-28- private function one(string $sql,array $params=[]):array|false{try{$s=$this->db->connection()->prepare($sql);$s->execute($params);return $s->fetch();}catch(\Throwable){return false;}} app/Modules/Reports/ReportDataService.php-29-} -- app/Modules/Reports/ReportExportService.php-1-pdf=new PdfExporter($root,$charts,$qr);$this->docx=new DocxExporter($root,$charts,$qr);$this->xlsx=new XlsxExporter($root,$charts,$qr);} app/Modules/Reports/ReportExportService.php-20- public function templates():array{return $this->all('SELECT * FROM vw_report_template_catalog ORDER BY report_type,title');} app/Modules/Reports/ReportExportService.php-21- public function runs(int $limit=50):array{$limit=max(1,min(200,$limit));return $this->all('SELECT * FROM vw_report_run_summary ORDER BY generated_at DESC LIMIT '.$limit);} app/Modules/Reports/ReportExportService.php-22- public function show(string $id):array{$r=$this->one('SELECT * FROM report_runs WHERE id=?',[$id]);if(!$r)throw new RuntimeException('Report not found.');$r['outputs']=$this->all('SELECT id,output_format,file_name,mime_type,file_size,file_hash,generated_at FROM report_outputs WHERE report_run_id=? ORDER BY output_format',[$id]);$r['approvals']=$this->all('SELECT decision,comments,reviewer_user_id,decided_at FROM report_approvals WHERE report_run_id=? ORDER BY decided_at',[$id]);$r['distributions']=$this->all('SELECT channel,recipient_name,recipient_address,purpose,distributed_at FROM report_distributions WHERE report_run_id=? ORDER BY distributed_at DESC',[$id]);return $r;} app/Modules/Reports/ReportExportService.php-23- public function generate(array $input,string $user):array { app/Modules/Reports/ReportExportService.php-24- $type=strtoupper((string)($input['report_type']??''));$formats=array_values(array_unique(array_map('strtoupper',(array)($input['formats']??['PDF']))));$formats=array_values(array_intersect($formats,['PDF','DOCX','XLSX']));if(!$formats)throw new RuntimeException('At least one output format is required.'); app/Modules/Reports/ReportExportService.php:25: $contextId=isset($input['context_id'])?(string)$input['context_id']:null;$built=$this->data->build($type,$contextId,$input);$reference=$this->nextReference();$token=bin2hex(random_bytes(24));$classification=strtoupper((string)($input['classification']??'CONFIDENTIAL'));$watermark=(string)($input['watermark_text']??($classification==='PUBLIC'?'CONTROLLED COPY':'CONFIDENTIAL')); app/Modules/Reports/ReportExportService.php-26- $snapshot=['report_type'=>$type,'context_id'=>$contextId,'title'=>$input['title']??$built['title'],'sections'=>$built['sections'],'charts'=>!empty($input['include_charts'])||!array_key_exists('include_charts',$input)?$built['charts']:[],'appendices'=>!empty($input['include_appendices'])||!array_key_exists('include_appendices',$input)?$built['appendices']:[]]; app/Modules/Reports/ReportExportService.php-27- $contentHash=hash('sha256',json_encode($snapshot,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR));$base=rtrim((string)Env::get('APP_URL','https://portal.baffoes.com'),'/');$verificationUrl=$base.'/api/reports/verify?token='.rawurlencode($token).'&hash='.$contentHash; app/Modules/Reports/ReportExportService.php-28- $doc=$snapshot+['reference'=>$reference,'classification'=>$classification,'watermark'=>$watermark,'content_hash'=>$contentHash,'verification_url'=>$verificationUrl];$id=Uuid::v4();$dir=$this->root.'/storage/reports/'.gmdate('Y').'/'.$reference;if(!is_dir($dir)&&!mkdir($dir,0770,true)&&!is_dir($dir))throw new RuntimeException('Unable to create report storage directory.');$outputs=[]; app/Modules/Reports/ReportExportService.php-29- try{foreach($formats as $format){$safe=preg_replace('/[^A-Za-z0-9._-]+/','-',(string)$doc['title']);$file=$reference.'-'.$safe.'.'.strtolower($format);$path=$dir.'/'.$file;$result=match($format){'PDF'=>$this->pdf->export($doc,$path),'DOCX'=>$this->docx->export($doc,$path),'XLSX'=>$this->xlsx->export($doc,$path)};$outputs[$format]=$result+['file_name'=>$file];}} app/Modules/Reports/ReportExportService.php-30- catch(\Throwable $e){$this->removeTree($dir);throw $e;} app/Modules/Reports/ReportExportService.php:31: $pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO report_runs(id,reference_no,template_id,report_type,title,context_type,context_id,classification,status,watermark_text,content_hash,verification_token_hash,generated_by,options_json,content_snapshot_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')->execute([$id,$reference,$input['template_id']??null,$type,(string)$doc['title'],strtoupper((string)($input['context_type']??$type)),$contextId,$classification,'GENERATED',$watermark,$contentHash,hash('sha256',$token),$user,json_encode($input,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),json_encode($doc,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)]);$seq=1;foreach($doc['sections'] as $s){$pdo->prepare('INSERT INTO report_sections(id,report_run_id,section_key,title,sequence_no,content_json,source_label) VALUES(?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,'section_'.$seq,(string)$s['title'],$seq,json_encode($s['content'],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),'BCOS governed records']);$seq++;}foreach($outputs as $format=>$o){$pdo->prepare('INSERT INTO report_outputs(id,report_run_id,output_format,file_name,storage_path,mime_type,file_size,file_hash) VALUES(?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$format,$o['file_name'],$this->relative($o['path']),$o['mime'],$o['size'],$o['sha256']]);}$pdo->commit();}catch(\Throwable $e){$pdo->rollBack();$this->removeTree($dir);throw $e;} app/Modules/Reports/ReportExportService.php-32- $this->audit->record('report.generated',$user,['report_id'=>$id,'reference_no'=>$reference,'formats'=>$formats,'content_hash'=>$contentHash]);return ['id'=>$id,'reference_no'=>$reference,'status'=>'GENERATED','content_hash'=>$contentHash,'verification_url'=>$verificationUrl,'outputs'=>array_map(fn($o)=>['format'=>strtoupper(pathinfo($o['path'],PATHINFO_EXTENSION)),'file_name'=>$o['file_name'],'size'=>$o['size'],'sha256'=>$o['sha256']],$outputs)]; app/Modules/Reports/ReportExportService.php-33- } app/Modules/Reports/ReportExportService.php-34- public function approve(string $id,string $decision,?string $comments,string $user):void {$decision=strtoupper($decision);if(!in_array($decision,['APPROVED','REJECTED','CHANGES_REQUESTED'],true))throw new RuntimeException('Invalid approval decision.');$r=$this->one('SELECT generated_by,status FROM report_runs WHERE id=?',[$id]);if(!$r)throw new RuntimeException('Report not found.');if($decision==='APPROVED'&&$r['generated_by']===$user)throw new RuntimeException('The report author cannot approve the same report.');$pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO report_approvals(id,report_run_id,reviewer_user_id,decision,comments) VALUES(?,?,?,?,?)')->execute([Uuid::v4(),$id,$user,$decision,$comments]);$status=$decision==='APPROVED'?'APPROVED':($decision==='REJECTED'?'REJECTED':'UNDER_REVIEW');$pdo->prepare('UPDATE report_runs SET status=?,approved_by=?,approved_at=IF(?="APPROVED",UTC_TIMESTAMP(),NULL) WHERE id=?')->execute([$status,$decision==='APPROVED'?$user:null,$decision,$id]);$pdo->commit();}catch(\Throwable $e){$pdo->rollBack();throw $e;}$this->audit->record('report.approval_decided',$user,['report_id'=>$id,'decision'=>$decision]);} app/Modules/Reports/ReportExportService.php-35- public function distribute(string $id,array $d,string $user):void {$r=$this->one('SELECT status FROM report_runs WHERE id=?',[$id]);if(!$r)throw new RuntimeException('Report not found.');if(!in_array($r['status'],['APPROVED','RELEASED'],true))throw new RuntimeException('Only approved reports may be distributed.');$this->db->connection()->prepare('INSERT INTO report_distributions(id,report_run_id,output_id,channel,recipient_name,recipient_address,purpose,policy_notice_acknowledged,distributed_by,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$d['output_id']??null,strtoupper((string)($d['channel']??'PORTAL')),$d['recipient_name']??null,$d['recipient_address']??null,$d['purpose']??null,!empty($d['policy_notice_acknowledged'])?1:0,$user,$d['expires_at']??null]);$this->db->connection()->prepare("UPDATE report_runs SET status='RELEASED',released_at=COALESCE(released_at,UTC_TIMESTAMP()) WHERE id=?")->execute([$id]);$this->audit->record('report.distributed',$user,['report_id'=>$id,'channel'=>$d['channel']??'PORTAL']);} app/Modules/Reports/ReportExportService.php-36- public function output(string $outputId):array {$o=$this->one('SELECT o.*,r.reference_no,r.title,r.status FROM report_outputs o JOIN report_runs r ON r.id=o.report_run_id WHERE o.id=?',[$outputId]);if(!$o)throw new RuntimeException('Report output not found.');$path=$this->root.'/'.ltrim((string)$o['storage_path'],'/');$real=realpath($path);$base=realpath($this->root.'/storage/reports');if(!$real||!$base||!str_starts_with($real,$base.DIRECTORY_SEPARATOR)||!is_file($real))throw new RuntimeException('Report file is unavailable.');$o['absolute_path']=$real;return $o;} app/Modules/Reports/ReportExportService.php-37- public function verify(string $token,?string $hash,array $server=[]):array {$tokenHash=hash('sha256',$token);$r=$this->one('SELECT id,reference_no,title,status,classification,content_hash,generated_at,approved_at,released_at FROM report_runs WHERE verification_token_hash=?',[$tokenHash]);if(!$r)return ['valid'=>false,'message'=>'Verification token is invalid.'];$valid=$hash===null||hash_equals((string)$r['content_hash'],$hash);$this->db->connection()->prepare('INSERT INTO report_verification_events(id,report_run_id,supplied_hash,result,ip_address,user_agent) VALUES(?,?,?,?,?,?)')->execute([Uuid::v4(),$r['id'],$hash,$valid?'VALID':'INVALID',$server['REMOTE_ADDR']??null,$server['HTTP_USER_AGENT']??null]);return ['valid'=>$valid,'reference_no'=>$r['reference_no'],'title'=>$r['title'],'status'=>$r['status'],'classification'=>$r['classification'],'content_hash'=>$r['content_hash'],'generated_at'=>$r['generated_at'],'approved_at'=>$r['approved_at'],'released_at'=>$r['released_at']];} app/Modules/Reports/ReportExportService.php-38- private function nextReference():string {$pdo=$this->db->connection();$pdo->beginTransaction();try{$year=(int)gmdate('Y');$q=$pdo->prepare('SELECT next_value FROM report_number_sequences WHERE year_no=? FOR UPDATE');$q->execute([$year]);$n=$q->fetchColumn();if($n===false){$n=1;$pdo->prepare('INSERT INTO report_number_sequences(year_no,next_value) VALUES(?,2)')->execute([$year]);}else{$pdo->prepare('UPDATE report_number_sequences SET next_value=next_value+1 WHERE year_no=?')->execute([$year]);}$pdo->commit();return sprintf('BC-RPT-%d-%06d',$year,(int)$n);}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/Reports/ReportExportService.php-39- private function all(string $sql,array $p=[]):array{$s=$this->db->connection()->prepare($sql);$s->execute($p);return $s->fetchAll();}private function one(string $sql,array $p=[]):array|false{$s=$this->db->connection()->prepare($sql);$s->execute($p);return $s->fetch();} app/Modules/Reports/ReportExportService.php-40- private function relative(string $path):string{return ltrim(str_replace($this->root,'',$path),'/');}private function removeTree(string $dir):void{if(!is_dir($dir))return;foreach(scandir($dir)?:[] as $f){if($f==='.'||$f==='..')continue;$p=$dir.'/'.$f;is_dir($p)?$this->removeTree($p):@unlink($p);}@rmdir($dir);} app/Modules/Reports/ReportExportService.php-41-} -- app/Modules/NotificationsCollaboration/NotificationService.php-67- { app/Modules/NotificationsCollaboration/NotificationService.php-68- $recipient=(string)($data['recipient_user_id']??'');$user=$this->one("SELECT id,email,display_name,locale,timezone FROM users WHERE id=:id AND status='ACTIVE'",['id'=>$recipient]); app/Modules/NotificationsCollaboration/NotificationService.php-69- if(!$user)throw new RuntimeException('Notification recipient is unavailable.'); app/Modules/NotificationsCollaboration/NotificationService.php-70- $id=Uuid::v4();$priority=$this->priority((string)($data['priority']??'NORMAL'));$policy=$this->policy((string)($data['delivery_policy']??'EMAIL_ALLOWED')); app/Modules/NotificationsCollaboration/NotificationService.php-71- $this->db->connection()->prepare('INSERT INTO notifications(id,recipient_user_id,category,notification_type,title,message,priority,source_type,source_id,action_url,delivery_policy,dedupe_key,created_by,scheduled_for) VALUES(:id,:recipient,:category,:type,:title,:message,:priority,:source_type,:source_id,:url,:policy,:dedupe,:actor,:scheduled)')->execute([ app/Modules/NotificationsCollaboration/NotificationService.php-72- 'id'=>$id,'recipient'=>$recipient,'category'=>strtoupper((string)($data['category']??'GENERAL')),'type'=>(string)($data['notification_type']??'GENERAL'), app/Modules/NotificationsCollaboration/NotificationService.php-73- 'title'=>Text::limit(trim((string)($data['title']??'BCOS notification')),255),'message'=>(string)($data['message']??''),'priority'=>$priority, app/Modules/NotificationsCollaboration/NotificationService.php-74- 'source_type'=>$data['source_type']??null,'source_id'=>$data['source_id']??null,'url'=>$data['action_url']??null,'policy'=>$policy,'dedupe'=>$data['dedupe_key']??null, app/Modules/NotificationsCollaboration/NotificationService.php-75- 'actor'=>$actorId,'scheduled'=>$data['scheduled_for']??null, app/Modules/NotificationsCollaboration/NotificationService.php-76- ]); app/Modules/NotificationsCollaboration/NotificationService.php-77- $emailQueueId=null; app/Modules/NotificationsCollaboration/NotificationService.php-78- if(!empty($data['email_template_code'])&&$this->emailAllowed($recipient,$policy,(string)($data['category']??'GENERAL'))){ app/Modules/NotificationsCollaboration/NotificationService.php-79- $vars=(array)($data['email_variables']??[]);$vars['display_name']=$vars['display_name']??$user['display_name']; app/Modules/NotificationsCollaboration/NotificationService.php-80- $emailQueueId=$this->email->queueTemplate((string)$data['email_template_code'],$recipient,(string)$user['email'],(string)$user['display_name'],$vars,$id,$data['source_type']??null,$data['source_id']??null,$priority,$actorId,$this->nextEmailTime($recipient),$user['locale']?:'en',(array)($data['safe_html_keys']??[])); app/Modules/NotificationsCollaboration/NotificationService.php-81- } app/Modules/NotificationsCollaboration/NotificationService.php-82- $this->audit->record('notifications.created',$actorId,['notification_id'=>$id,'recipient_user_id'=>$recipient,'email_queue_id'=>$emailQueueId]); app/Modules/NotificationsCollaboration/NotificationService.php-83- return['id'=>$id,'email_queue_id'=>$emailQueueId]; app/Modules/NotificationsCollaboration/NotificationService.php-84- } app/Modules/NotificationsCollaboration/NotificationService.php-85- app/Modules/NotificationsCollaboration/NotificationService.php-86- public function notifyReport(string $reportId,array $recipientIds,string $actorId):array app/Modules/NotificationsCollaboration/NotificationService.php-87- { app/Modules/NotificationsCollaboration/NotificationService.php-88- $report=$this->one('SELECT id,reference_no,title,status FROM report_runs WHERE id=:id',['id'=>$reportId]);if(!$report)throw new RuntimeException('Report not found.'); app/Modules/NotificationsCollaboration/NotificationService.php-89- return $this->notifyMany($recipientIds,fn(string $u)=>['recipient_user_id'=>$u,'category'=>'REPORT','notification_type'=>'REPORT_READY','title'=>'Controlled report ready: '.$report['title'],'message'=>$report['reference_no'].' is available with status '.$report['status'].'.','priority'=>'NORMAL','source_type'=>'REPORT','source_id'=>$reportId,'action_url'=>'/reports/'.$reportId,'delivery_policy'=>'EMAIL_ALLOWED','dedupe_key'=>'report:'.$reportId.':'.$report['status'],'email_template_code'=>'report_ready','email_variables'=>['report_reference'=>$report['reference_no'],'report_title'=>$report['title'],'status'=>$report['status'],'action_url'=>$this->absolute('/reports/'.$reportId)]],$actorId); app/Modules/NotificationsCollaboration/NotificationService.php-90- } app/Modules/NotificationsCollaboration/NotificationService.php-91- app/Modules/NotificationsCollaboration/NotificationService.php:92: public function notifyDocument(string $documentId,array $recipientIds,string $actorId):array app/Modules/NotificationsCollaboration/NotificationService.php-93- { app/Modules/NotificationsCollaboration/NotificationService.php-94- $doc=$this->one('SELECT id,document_reference reference_no,title,status FROM controlled_documents WHERE id=:id',['id'=>$documentId]); app/Modules/NotificationsCollaboration/NotificationService.php-95- if(!$doc)$doc=$this->one('SELECT id,reference_no,title,status FROM documents WHERE id=:id',['id'=>$documentId]); app/Modules/NotificationsCollaboration/NotificationService.php-96- if(!$doc)throw new RuntimeException('Document not found.'); app/Modules/NotificationsCollaboration/NotificationService.php-97- return $this->notifyMany($recipientIds,fn(string $u)=>['recipient_user_id'=>$u,'category'=>'DOCUMENT','notification_type'=>'DOCUMENT_UPDATE','title'=>'Document update: '.$doc['title'],'message'=>$doc['reference_no'].' is now '.$doc['status'].'.','priority'=>'NORMAL','source_type'=>'DOCUMENT','source_id'=>$documentId,'action_url'=>'/documents/'.$documentId,'delivery_policy'=>'EMAIL_ALLOWED','dedupe_key'=>'document:'.$documentId.':'.$doc['status'],'email_template_code'=>'document_notice','email_variables'=>['document_reference'=>$doc['reference_no'],'document_title'=>$doc['title'],'status'=>$doc['status'],'action_url'=>$this->absolute('/documents/'.$documentId)]],$actorId); app/Modules/NotificationsCollaboration/NotificationService.php-98- } app/Modules/NotificationsCollaboration/NotificationService.php-99- app/Modules/NotificationsCollaboration/NotificationService.php-100- public function notifyApproval(array $data,string $actorId):array app/Modules/NotificationsCollaboration/NotificationService.php-101- { app/Modules/NotificationsCollaboration/NotificationService.php-102- $recipients=(array)($data['recipient_user_ids']??[]);$title=trim((string)($data['item_title']??'Approval item'));$due=(string)($data['due_date']??'Not specified');$url=(string)($data['action_url']??'/approvals'); app/Modules/NotificationsCollaboration/NotificationService.php-103- return $this->notifyMany($recipients,fn(string $u)=>['recipient_user_id'=>$u,'category'=>'APPROVAL','notification_type'=>'APPROVAL_REQUIRED','title'=>'Approval required: '.$title,'message'=>$title.' requires your review. Due: '.$due,'priority'=>$data['priority']??'HIGH','source_type'=>$data['source_type']??'APPROVAL','source_id'=>$data['source_id']??null,'action_url'=>$url,'delivery_policy'=>'EMAIL_ALLOWED','dedupe_key'=>'approval:'.($data['source_type']??'item').':'.($data['source_id']??hash('sha256',$title.$due)),'email_template_code'=>'approval_required','email_variables'=>['item_title'=>$title,'due_date'=>$due,'action_url'=>$this->absolute($url)]],$actorId); app/Modules/NotificationsCollaboration/NotificationService.php-104- } app/Modules/NotificationsCollaboration/NotificationService.php-105- app/Modules/NotificationsCollaboration/NotificationService.php-106- private function notifyMany(array $recipientIds,callable $factory,string $actorId):array app/Modules/NotificationsCollaboration/NotificationService.php-107- { app/Modules/NotificationsCollaboration/NotificationService.php-108- $result=[];foreach(array_values(array_unique(array_filter(array_map('strval',$recipientIds))))as$u){try{$result[]=$this->create($factory($u),$actorId);}catch(Throwable $e){$result[]=['recipient_user_id'=>$u,'error'=>$e->getMessage()];}}return$result; app/Modules/NotificationsCollaboration/NotificationService.php-109- } app/Modules/NotificationsCollaboration/NotificationService.php-110- app/Modules/NotificationsCollaboration/NotificationService.php-111- private function emailAllowed(string $userId,string $policy,string $category):bool app/Modules/NotificationsCollaboration/NotificationService.php-112- { app/Modules/NotificationsCollaboration/NotificationService.php-113- if($policy==='IN_APP_ONLY'||$policy==='DIGEST_ONLY')return false;if($policy==='EMAIL_REQUIRED')return true; app/Modules/NotificationsCollaboration/NotificationService.php-114- $p=$this->preferences($userId);if(empty($p['email_enabled']))return false; app/Modules/NotificationsCollaboration/NotificationService.php-115- $categories=json_decode((string)($p['category_preferences_json']??''),true);if(is_array($categories)&&array_key_exists($category,$categories)&&isset($categories[$category]['email']))return(bool)$categories[$category]['email']; app/Modules/NotificationsCollaboration/NotificationService.php-116- return true; app/Modules/NotificationsCollaboration/NotificationService.php-117- } -- app/Modules/GovernanceRisk/GovernanceRiskService.php-1-$this->one('SELECT * FROM vw_governance_executive_summary')?:[],'quality'=>$this->one('SELECT * FROM vw_quality_assurance_summary')?:[],'calendar'=>$this->all('SELECT * FROM vw_compliance_calendar WHERE due_date IS NOT NULL ORDER BY due_date LIMIT 30'),'risks'=>$this->all('SELECT * FROM vw_risk_control_summary ORDER BY COALESCE(residual_score,inherent_score) DESC LIMIT 20'),'incidents'=>$this->all('SELECT id,reference_no,incident_type,title,severity,status,detected_at FROM incidents_register ORDER BY detected_at DESC LIMIT 10')];} app/Modules/GovernanceRisk/GovernanceRiskService.php-8- public function legal(string $u,array $d):array{return $this->insert('corporate_legal_registers','BC-LEG',$u,$d,['register_type','title'],['legal_entity_id','register_type','title','jurisdiction','description','owner_user_id','status','confidentiality','opened_at','next_review_at'],'legal.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php:9: public function policy(string $u,array $d):array{ $id=Uuid::v4();$ref=$this->ref('BC-POL');$hash=hash('sha256',(string)($d['content']??''));$pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO policy_register(id,reference_no,title,category,owner_user_id,status,classification,current_version_no,effective_date,review_date,created_by) VALUES(:id,:r,:t,:c,:o,:s,:cl,1,:e,:rv,:u)')->execute(['id'=>$id,'r'=>$ref,'t'=>$this->req($d,'title'),'c'=>$this->req($d,'category'),'o'=>$d['owner_user_id']??null,'s'=>$d['status']??'DRAFT','cl'=>$d['classification']??'INTERNAL','e'=>$d['effective_date']??null,'rv'=>$d['review_date']??null,'u'=>$u]);$pdo->prepare('INSERT INTO policy_versions(id,policy_id,version_no,content,change_summary,integrity_hash,created_by) VALUES(:id,:p,1,:c,:s,:h,:u)')->execute(['id'=>Uuid::v4(),'p'=>$id,'c'=>$this->req($d,'content'),'s'=>$d['change_summary']??'Initial version','h'=>$hash,'u'=>$u]);$pdo->commit();$this->audit->record('policy.created',$u,['policy_id'=>$id,'reference_no'=>$ref]);return compact('id')+['reference_no'=>$ref,'integrity_hash'=>$hash];}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/GovernanceRisk/GovernanceRiskService.php:10: public function compliance(string $u,array $d):array{return $this->insert('compliance_obligations_v2','BC-CMP',$u,$d,['jurisdiction','category','title'],['legal_entity_id','office_id','jurisdiction','category','title','description','authority_name','frequency','due_date','owner_user_id','risk_level','status','evidence_document_id'],'compliance.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php:11: public function licence(string $u,array $d):array{return $this->insert('licences_register','BC-LIC',$u,$d,['title'],['legal_entity_id','title','issuing_authority','licence_number','jurisdiction','issue_date','expiry_date','owner_user_id','status','document_id'],'licence.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php:12: public function filing(string $u,array $d):array{return $this->insert('statutory_filings','BC-FIL',$u,$d,['filing_type','due_date'],['legal_entity_id','filing_type','authority_name','reporting_period','due_date','filed_at','status','owner_user_id','evidence_document_id'],'filing.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-13- public function conflict(string $u,array $d):array{$d['declarant_user_id']=$u;return $this->simple('conflict_declarations',$u,$d,['declaration_type'],['declarant_user_id','project_id','organization_id','declaration_type','details','mitigation','status'],'ethics.conflict_declared');} app/Modules/GovernanceRisk/GovernanceRiskService.php-14- public function gift(string $u,array $d):array{$d['declarant_user_id']=$u;return $this->simple('gifts_hospitality_register',$u,$d,['direction','description','event_date'],['declarant_user_id','organization_id','project_id','direction','description','estimated_value','currency','event_date','status'],'ethics.gift_declared');} app/Modules/GovernanceRisk/GovernanceRiskService.php-15- public function whistleblowing(string $u,array $d):array{if(($d['anonymous_flag']??false))$u='';$id=Uuid::v4();$ref=$this->ref('BC-WB');$this->db->connection()->prepare('INSERT INTO whistleblowing_cases(id,reference_no,reporter_user_id,anonymous_flag,category,summary,confidentiality,status,case_owner_user_id) VALUES(:id,:r,:u,:a,:c,:s,:cl,:st,:o)')->execute(['id'=>$id,'r'=>$ref,'u'=>$u?:null,'a'=>!empty($d['anonymous_flag'])?1:0,'c'=>$this->req($d,'category'),'s'=>$this->req($d,'summary'),'cl'=>$d['confidentiality']??'RESTRICTED','st'=>'RECEIVED','o'=>$d['case_owner_user_id']??null]);$this->audit->record('whistleblowing.received',$u?:null,['case_id'=>$id,'reference_no'=>$ref,'anonymous'=>!empty($d['anonymous_flag'])]);return ['id'=>$id,'reference_no'=>$ref];} app/Modules/GovernanceRisk/GovernanceRiskService.php-16- public function privacy(string $u,array $d):array{return $this->insert('privacy_processing_register','BC-PRV',$u,$d,['process_name','purpose'],['process_name','business_owner_user_id','purpose','data_subjects','personal_data_categories','lawful_basis','retention_category_id','recipients','international_transfer_flag','risk_level','status'],'privacy.processing_created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-17- public function risk(string $u,array $d):array{$p=(int)($d['probability']??0);$i=(int)($d['impact']??0);if($p<1||$p>5||$i<1||$i>5)throw new RuntimeException('Probability and impact must be 1 to 5.');$d['inherent_score']=$p*$i;return $this->insert('enterprise_risks','BC-RSK',$u,$d,['category','title','probability','impact'],['project_id','legal_entity_id','category','title','description','probability','impact','inherent_score','residual_score','appetite_status','treatment','mitigation','owner_user_id','status','review_date'],'risk.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-18- public function control(string $u,array $d):array{return $this->insert('control_register','BC-CTL',$u,$d,['title','control_type'],['risk_id','title','description','control_type','frequency','owner_user_id','design_effectiveness','operating_effectiveness','status'],'control.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-19- public function auditRecord(string $u,array $d):array{return $this->insert('audits','BC-AUD',$u,$d,['title','audit_type'],['title','audit_type','scope','lead_auditor_user_id','start_date','end_date','status','rating'],'audit.created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-20- public function finding(string $u,array $d):array{return $this->insert('audit_findings','BC-AUF',$u,$d,['audit_id','title','description','severity'],['audit_id','title','description','severity','root_cause','recommendation','owner_user_id','due_date','status'],'audit.finding_created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-21- public function correctiveAction(string $u,array $d):array{return $this->insert('governance_corrective_actions','BC-CAR',$u,$d,['source_type','source_id','action_text'],['source_type','source_id','action_text','owner_user_id','due_date','status','verification_notes'],'quality.corrective_action_created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-22- public function qualityGate(string $u,array $d):array{$d['criteria_json']=json_encode($d['criteria']??[],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);return $this->insert('quality_gates','BC-QGT',$u,$d,['gate_name','criteria_json'],['project_id','deliverable_id','gate_name','criteria_json','result','reviewer_user_id','reviewed_at','notes'],'quality.gate_created');} app/Modules/GovernanceRisk/GovernanceRiskService.php-23- public function complaint(string $u,array $d):array{return $this->insert('complaints_register','BC-CMPA',$u,$d,['category','summary'],['organization_id','project_id','complainant_name','channel','category','summary','severity','status','owner_user_id'],'complaint.created',false);} app/Modules/GovernanceRisk/GovernanceRiskService.php-24- public function incident(string $u,array $d):array{$d['reported_by']=$u;return $this->insert('incidents_register','BC-INC',$u,$d,['incident_type','title','description','severity'],['incident_type','project_id','title','description','severity','occurred_at','owner_user_id','status','root_cause','resolution','reported_by'],'incident.created',false);} app/Modules/GovernanceRisk/GovernanceRiskService.php:25: public function continuity(string $u,array $d):array{return $this->insert('business_continuity_plans','BC-BCP',$u,$d,['title','critical_process'],['legal_entity_id','office_id','title','critical_process','maximum_tolerable_downtime_hours','recovery_time_objective_hours','recovery_point_objective_hours','owner_user_id','status','last_tested_at','next_test_date','plan_document_id'],'continuity.plan_created');} app/Modules/GovernanceRisk/GovernanceRiskService.php:26: public function recoveryTest(string $u,array $d):array{return $this->insert('disaster_recovery_tests','BC-DRT',$u,$d,['test_type','test_date','scenario','result'],['plan_id','test_type','test_date','scenario','result','actual_rto_hours','actual_rpo_hours','findings','corrective_actions','led_by_user_id','evidence_document_id'],'continuity.test_created',false);} app/Modules/GovernanceRisk/GovernanceRiskService.php-27- private function insert(string $table,string $prefix,string $u,array $d,array $required,array $fields,string $event,bool $createdBy=true):array{foreach($required as $k)$this->req($d,$k);$id=Uuid::v4();$ref=$this->ref($prefix);$cols=['id','reference_no'];$vals=[':id',':reference_no'];$p=['id'=>$id,'reference_no'=>$ref];foreach($fields as $f){$cols[]=$f;$vals[]=':'.$f;$p[$f]=$d[$f]??null;}if($createdBy){$cols[]='created_by';$vals[]=':created_by';$p['created_by']=$u;}$sql='INSERT INTO '.$table.'('.implode(',',$cols).') VALUES('.implode(',',$vals).')';$this->db->connection()->prepare($sql)->execute($p);$this->audit->record($event,$u,['id'=>$id,'reference_no'=>$ref]);return ['id'=>$id,'reference_no'=>$ref];} app/Modules/GovernanceRisk/GovernanceRiskService.php-28- private function simple(string $table,string $u,array $d,array $required,array $fields,string $event):array{foreach($required as $k)$this->req($d,$k);$id=Uuid::v4();$cols=['id'];$vals=[':id'];$p=['id'=>$id];foreach($fields as $f){$cols[]=$f;$vals[]=':'.$f;$p[$f]=$d[$f]??null;}$this->db->connection()->prepare('INSERT INTO '.$table.'('.implode(',',$cols).') VALUES('.implode(',',$vals).')')->execute($p);$this->audit->record($event,$u,['id'=>$id]);return ['id'=>$id];} app/Modules/GovernanceRisk/GovernanceRiskService.php-29- private function ref(string $prefix):string{return sprintf('%s-%s-%06d',$prefix,date('Y'),random_int(1,999999));} app/Modules/GovernanceRisk/GovernanceRiskService.php-30- private function req(array $d,string $k):string{if(!isset($d[$k])||$d[$k]==='')throw new RuntimeException($k.' is required.');return (string)$d[$k];} app/Modules/GovernanceRisk/GovernanceRiskService.php-31- private function all(string $q,array $p=[]):array{$s=$this->db->connection()->prepare($q);$s->execute($p);return $s->fetchAll();} app/Modules/GovernanceRisk/GovernanceRiskService.php-32- private function one(string $q,array $p=[]):array|false{$s=$this->db->connection()->prepare($q);$s->execute($p);return $s->fetch();} app/Modules/GovernanceRisk/GovernanceRiskService.php-33-} -- app/Modules/Documents/DocumentService.php-1-db->connection();$pdo->beginTransaction();try{$year=(int)gmdate('Y');$q=$pdo->prepare('SELECT id,last_value,prefix FROM document_number_sequences WHERE entity_type=? AND jurisdiction_code=? AND year_value=? FOR UPDATE');$q->execute([$type,$jurisdiction,$year]);$row=$q->fetch(PDO::FETCH_ASSOC);$prefix=$this->prefix($type,$jurisdiction);if(!$row){$id=Uuid::v4();$pdo->prepare('INSERT INTO document_number_sequences(id,entity_type,jurisdiction_code,year_value,last_value,prefix) VALUES(?,?,?,?,1,?)')->execute([$id,$type,$jurisdiction,$year,$prefix]);$n=1;}else{$n=((int)$row['last_value'])+1;$pdo->prepare('UPDATE document_number_sequences SET last_value=? WHERE id=?')->execute([$n,$row['id']]);$prefix=(string)$row['prefix'];}$pdo->commit();return sprintf('%s-%d-%06d',$prefix,$year,$n);}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/Documents/DocumentService.php:8: private function prefix(string $type,string $jurisdiction):string{$map=['PROPOSAL'=>'PRO','QUOTATION'=>'QUO','PRO_FORMA_INVOICE'=>'PFI','INVOICE'=>'INV','RECEIPT'=>'REC','CREDIT_NOTE'=>'CRN','ENGAGEMENT_LETTER'=>'ENG','SERVICE_AGREEMENT'=>'SVA','NDA'=>'NDA','NCNDA'=>'NCNDA','CONTRACT'=>'CON','PURCHASE_ORDER'=>'PO','REPORT'=>'RPT','MEDIA_PERMISSION'=>'MED','DOCUMENT'=>'DOC'];return 'BCOS-'.($map[$type]??'DOC').'-'.$jurisdiction;} app/Modules/Documents/DocumentService.php:9: public function create(array $d,string $user):array{$type=strtoupper((string)($d['document_type']??'DOCUMENT'));$jur=strtoupper((string)($d['jurisdiction_code']??'INT'));$ref=$this->nextReference($type,$jur);$id=Uuid::v4();$content=is_array($d['content']??null)?$d['content']:[];$entityId=(string)($d['issuing_legal_entity_id']??'');if($entityId!=='')$content['_issuing_entity_snapshot']=$this->legalEntitySnapshot($entityId);$json=json_encode($content,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);$meta=$d;$meta['document_type']=$type;$meta['issuing_legal_entity']=$content['_issuing_entity_snapshot']??null;$html=$this->renderHtml($meta,$ref,1,$content);$hash=hash('sha256',$html);$pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO controlled_documents(id,document_reference,document_type,title,project_id,client_organization_id,issuing_legal_entity_id,language_mode,classification,status,current_version,watermark_text,owner_user_id,retention_category) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)')->execute([$id,$ref,$type,(string)($d['title']??$type),$d['project_id']??null,$d['client_organization_id']??null,$entityId!==''?$entityId:null,$d['language_mode']??'EN',$d['classification']??'CONFIDENTIAL','DRAFT',1,$d['watermark_text']??'DRAFT',$user,$d['retention_category']??'CLIENT_PROJECT']);$pdo->prepare('INSERT INTO controlled_document_versions(id,document_id,version_number,content_json,rendered_html,content_hash,change_summary,created_by) VALUES(?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,1,$json,$html,$hash,'Initial version',$user]);$pdo->commit();$this->audit->record('document.created',$user,['document_id'=>$id,'reference'=>$ref,'document_type'=>$type]);return ['id'=>$id,'reference'=>$ref,'version'=>1,'hash'=>$hash,'html'=>$html];}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}} app/Modules/Documents/DocumentService.php:10: public function newVersion(string $id,array $content,string $summary,string $user):array{$pdo=$this->db->connection();$pdo->beginTransaction();try{$q=$pdo->prepare('SELECT * FROM controlled_documents WHERE id=? FOR UPDATE');$q->execute([$id]);$doc=$q->fetch(PDO::FETCH_ASSOC);if(!$doc)throw new RuntimeException('Document not found.');$version=((int)$doc['current_version'])+1;$html=$this->renderHtml($doc,(string)$doc['document_reference'],$version,$content);$json=json_encode($content,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);$hash=hash('sha256',$html);$pdo->prepare('INSERT INTO controlled_document_versions(id,document_id,version_number,content_json,rendered_html,content_hash,change_summary,created_by) VALUES(?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$version,$json,$html,$hash,$summary,$user]);$pdo->prepare("UPDATE controlled_documents SET current_version=?,status='DRAFT',updated_at=UTC_TIMESTAMP() WHERE id=?")->execute([$version,$id]);$pdo->commit();$this->audit->record('document.version_created',$user,['document_id'=>$id,'version'=>$version]);return ['id'=>$id,'version'=>$version,'hash'=>$hash,'html'=>$html];}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/Documents/DocumentService.php:11: public function approve(string $id,string $user):void{$this->db->connection()->prepare("UPDATE controlled_documents SET status='APPROVED',approved_by=?,approved_at=UTC_TIMESTAMP() WHERE id=?")->execute([$user,$id]);$this->audit->record('document.approved',$user,['document_id'=>$id]);} app/Modules/Documents/DocumentService.php:12: public function show(string $id):array{$q=$this->db->connection()->prepare('SELECT d.*,v.rendered_html,v.content_hash,v.version_number FROM controlled_documents d JOIN controlled_document_versions v ON v.document_id=d.id AND v.version_number=d.current_version WHERE d.id=?');$q->execute([$id]);$row=$q->fetch(PDO::FETCH_ASSOC);if(!$row)throw new RuntimeException('Document not found.');return $row;} app/Modules/Documents/DocumentService.php:13: public function distribute(string $id,array $d,string $user):array{$token=bin2hex(random_bytes(24));$hash=hash('sha256',$token);$doc=$this->show($id);$this->db->connection()->prepare('INSERT INTO document_distribution_events(id,document_id,version_number,channel,recipient_name,recipient_address,purpose,policy_notice_acknowledged,tracking_token_hash,expires_at,distributed_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$doc['version_number'],$d['channel']??'PORTAL',$d['recipient_name']??null,$d['recipient_address']??null,$d['purpose']??null,!empty($d['policy_notice_acknowledged'])?1:0,$hash,$d['expires_at']??null,$user]);$this->audit->record('document.distributed',$user,['document_id'=>$id,'channel'=>$d['channel']??'PORTAL']);return ['access_token'=>$token,'policy_notice'=>'Access is transparently logged under the BCOS Document Distribution and Privacy Policy.'];} app/Modules/Documents/DocumentService.php:14: public function mediaPermission(array $d,string $user):array{$id=Uuid::v4();$ref=$this->nextReference('MEDIA_PERMISSION',(string)($d['jurisdiction_code']??'INT'));$this->db->connection()->prepare('INSERT INTO media_permissions(id,permission_reference,project_id,client_organization_id,media_type,owner_name,owner_contact,permitted_channels,permitted_territories,attribution_requirement,purpose,valid_from,valid_until,status,consent_document_id,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')->execute([$id,$ref,$d['project_id']??null,$d['client_organization_id']??null,$d['media_type']??'IMAGE',$d['owner_name']??'', $d['owner_contact']??null,json_encode($d['permitted_channels']??[]),json_encode($d['permitted_territories']??[]),$d['attribution_requirement']??null,$d['purpose']??'', $d['valid_from']??null,$d['valid_until']??null,$d['status']??'PENDING',$d['consent_document_id']??null,$user]);$this->audit->record('media_permission.created',$user,['media_permission_id'=>$id,'reference'=>$ref]);return ['id'=>$id,'reference'=>$ref];} app/Modules/Documents/DocumentService.php:15: public function executiveReport(string $projectId,string $user):array{$pdo=$this->db->connection();$q=$pdo->prepare('SELECT p.*,o.legal_name client_name FROM projects p LEFT JOIN organizations o ON o.id=p.client_organization_id WHERE p.id=?');$q->execute([$projectId]);$p=$q->fetch(PDO::FETCH_ASSOC);if(!$p)throw new RuntimeException('Project not found.');$fq=$pdo->prepare('SELECT * FROM vw_project_financial_position WHERE project_id=?');$fq->execute([$projectId]);$f=$fq->fetch(PDO::FETCH_ASSOC)?:[];$rq=$pdo->prepare("SELECT COUNT(*) total, SUM(status='CLOSED') closed FROM project_risks WHERE project_id=?");$rq->execute([$projectId]);$risks=$rq->fetch(PDO::FETCH_ASSOC)?:[];$content=['summary'=>'This executive report consolidates verified BCOS project records for management review.','project'=>$p,'financials'=>$f,'risk_summary'=>$risks,'recommendations'=>['Review outstanding milestones and high-priority risks.','Confirm financial commitments before new expenditure.','Capture lessons learned before project closure.']];return $this->create(['document_type'=>'REPORT','jurisdiction_code'=>'INT','title'=>'Executive Project Report — '.$p['name'],'project_id'=>$projectId,'client_organization_id'=>$p['client_organization_id'],'language_mode'=>'EN','classification'=>'CONFIDENTIAL','watermark_text'=>'CONFIDENTIAL','content'=>$content],$user);} app/Modules/Documents/DocumentService.php-16- private function legalEntitySnapshot(string $id):array{$q=$this->db->connection()->prepare('SELECT le.id,le.entity_code,le.legal_name,COALESCE(le.registration_number,le.registration_no) registration_number,le.jurisdiction,le.country_code,le.tax_number,le.base_currency,lp.registration_place,lp.registration_authority,lp.entity_type,lp.registration_date,lp.registered_office_address,lp.official_business_address,lp.official_digital_address,lp.tax_identification_number,lp.vat_registration_status,lp.vat_registration_number,lp.bank_name,lp.beneficiary_name,lp.account_number_ciphertext,lp.iban_ciphertext,lp.swift_bic,lp.correspondent_bank_details_ciphertext,lp.payment_instructions,lp.governing_law_default,lp.dispute_resolution_default,lp.verification_status,lp.effective_from FROM legal_entities le JOIN legal_entity_profiles lp ON lp.legal_entity_id=le.id AND lp.status="ACTIVE" AND lp.verification_status="VERIFIED" WHERE le.id=:id AND le.status="ACTIVE" LIMIT 1');$q->execute(['id'=>$id]);$row=$q->fetch(PDO::FETCH_ASSOC);if(!$row)throw new RuntimeException('Issuing legal entity is unavailable.');foreach(['account_number_ciphertext'=>'account_number','iban_ciphertext'=>'iban','correspondent_bank_details_ciphertext'=>'correspondent_bank_details'] as $cipher=>$plain){$row[$plain]=null;if(!empty($row[$cipher]))$row[$plain]=$this->crypto->decrypt((string)$row[$cipher]);unset($row[$cipher]);}$row['snapshot_at']=gmdate(DATE_ATOM);return $row;} app/Modules/Documents/DocumentService.php:17: private function renderHtml(array $meta,string $ref,int $version,array $content):string{$logo='/assets/branding/baffoe-b-mark-transparent.png';$title=htmlspecialchars((string)($meta['title']??'Controlled Document'),ENT_QUOTES,'UTF-8');$water=htmlspecialchars((string)($meta['watermark_text']??'DRAFT'),ENT_QUOTES,'UTF-8');$lang=(string)($meta['language_mode']??'EN');$entity=is_array($content['_issuing_entity_snapshot']??null)?$content['_issuing_entity_snapshot']:[];$entityName=htmlspecialchars((string)($entity['legal_name']??'Baffoe Consulting Management Company Limited'),ENT_QUOTES,'UTF-8');$registration=htmlspecialchars((string)($entity['registration_number']??''),ENT_QUOTES,'UTF-8');$entityLine=$registration!==''?'Registration: '.$registration:'';$body=$this->renderContent(array_filter($content,fn($k)=>$k!=='_issuing_entity_snapshot',ARRAY_FILTER_USE_KEY),$lang);return ''.$title.'
'.$water.'

'.$title.'

'.$entityName.'
'.$entityLine.'

Reference: '.htmlspecialchars($ref).'   Version: '.$version.'   Classification: '.htmlspecialchars((string)($meta['classification']??'CONFIDENTIAL')).'

'.$body.'';} app/Modules/Documents/DocumentService.php-18- private function renderContent(array $c,string $lang):string{$out='';foreach($c as $k=>$v){$h=ucwords(str_replace('_',' ',(string)$k));$out.='

'.htmlspecialchars($h).'

';if(is_array($v)){$out.='
'.htmlspecialchars(json_encode($v,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)).'
';}else{$out.='

'.nl2br(htmlspecialchars((string)$v)).'

';}$out.='
';}return $out;} app/Modules/Documents/DocumentService.php-19-} -- app/Services/Export/XlsxExporter.php-1-summaryRows($doc);$data=$this->dataRows($doc);$apps=$this->appendixRows($doc);$chartSpec=(array)(($doc['charts']??[])[0]??['labels'=>['No data'],'values'=>[0]]);$chart=$this->charts->fromSpec($chartSpec)->png();$qr=$this->qr->png((string)($doc['verification_url']??'')); app/Services/Export/XlsxExporter.php-8- $zip->add('[Content_Types].xml',$this->types((bool)$qr));$zip->add('_rels/.rels',$this->rootRels());$zip->add('docProps/core.xml',$this->core((string)($doc['title']??'BCOS Report')));$zip->add('docProps/app.xml',$this->app());$zip->add('xl/workbook.xml',$this->workbook());$zip->add('xl/_rels/workbook.xml.rels',$this->workbookRels());$zip->add('xl/styles.xml',$this->styles());$zip->add('xl/worksheets/sheet1.xml',$this->sheet($summary,true));$zip->add('xl/worksheets/_rels/sheet1.xml.rels',$this->sheetRels());$zip->add('xl/worksheets/sheet2.xml',$this->sheet($data,false));$zip->add('xl/worksheets/sheet3.xml',$this->sheet($apps,false));$zip->add('xl/drawings/drawing1.xml',$this->drawing((bool)$qr));$zip->add('xl/drawings/_rels/drawing1.xml.rels',$this->drawingRels((bool)$qr));$zip->addFile('xl/media/logo.jpg',$this->root.'/public/assets/baffoe-logo.jpg');$zip->add('xl/media/chart.png',$chart);if($qr)$zip->add('xl/media/verification-qr.png',$qr);$zip->save($path);$bytes=file_get_contents($path);return ['path'=>$path,'mime'=>'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','size'=>strlen($bytes),'sha256'=>hash('sha256',$bytes)];} app/Services/Export/XlsxExporter.php-9- private function summaryRows(array $d):array{$rows=[['BAFFOE CONSULTING MANAGEMENT COMPANY LIMITED'],[$d['title']??'BCOS Report'],['Reference',$d['reference']??''],['Classification',$d['classification']??'CONFIDENTIAL'],['Content hash',$d['content_hash']??''],['Verification URL',$d['verification_url']??''],[]];foreach((array)($d['sections']??[]) as $s){$rows[]=[strtoupper((string)($s['title']??'SECTION'))];$c=$s['content']??'';if(is_array($c)){foreach($this->rows($c) as $r)$rows[]=$r;}else$rows[]=[(string)$c];$rows[]=[];}return $rows;} app/Services/Export/XlsxExporter.php-10- private function dataRows(array $d):array{$rows=[['Chart category','Value']];$s=(array)(($d['charts']??[])[0]??[]);foreach(array_map(null,(array)($s['labels']??[]),(array)($s['values']??[])) as $r)$rows[]=$r;return $rows;} app/Services/Export/XlsxExporter.php-11- private function appendixRows(array $d):array{$rows=[['Appendix','Field','Value']];foreach((array)($d['appendices']??[]) as $a){foreach($this->rows((array)($a['rows']??[])) as $r)$rows[]=[(string)($a['title']??'Appendix'),...$r];}return $rows;} app/Services/Export/XlsxExporter.php:12: private function sheet(array $rows,bool $drawing):string{$xml='';foreach($rows as $ri=>$row){$r=$ri+1;$xml.='';foreach((array)$row as $ci=>$v){$ref=$this->col($ci+1).$r;$style=$ri<2?1:($ri===2?2:0);if(is_numeric($v)&&!is_string($v))$xml.=''.$v.'';else$xml.=''.$this->x((string)$v).'';}$xml.='';}$xml.=''.($drawing?'':'').'';return $xml;} app/Services/Export/XlsxExporter.php:13: private function drawing(bool $qr):string{$anchors=$this->anchor('rId1',0,0,2,4,2,4,'Logo').$this->anchor('rId2',0,9,8,30,8,21,'Chart');if($qr)$anchors.=$this->anchor('rId3',6,0,8,8,2,8,'Verification QR');return ''.$anchors.'';} app/Services/Export/XlsxExporter.php-14- private function anchor(string $rid,int $fc,int $fr,int $tc,int $tr,int $cx,int $cy,string $name):string{return ''.$fc.'0'.$fr.'0'.$tc.'0'.$tr.'0';} app/Services/Export/XlsxExporter.php:15: private function types(bool $qr):string{return '';} app/Services/Export/XlsxExporter.php:16: private function rootRels():string{return '';} app/Services/Export/XlsxExporter.php:17: private function workbook():string{return '';} app/Services/Export/XlsxExporter.php:18: private function workbookRels():string{return '';} app/Services/Export/XlsxExporter.php:19: private function sheetRels():string{return '';} app/Services/Export/XlsxExporter.php:20: private function drawingRels(bool $qr):string{return ''.($qr?'':'').'';} app/Services/Export/XlsxExporter.php-21- private function styles():string{return '';} app/Services/Export/XlsxExporter.php-22- private function core(string $title):string{return ''.$this->x($title).'Baffoe Consulting Management Company Limited'.gmdate('Y-m-d\TH:i:s\Z').'';} app/Services/Export/XlsxExporter.php:23: private function app():string{return 'BCOS Report FactoryBaffoe Consulting Management Company Limited';} app/Services/Export/XlsxExporter.php-24- private function rows(array $d):array{if(!$d)return [];if(array_is_list($d)){if(isset($d[0])&&is_array($d[0])){$keys=array_keys($d[0]);return [$keys,...array_map(fn($r)=>array_map(fn($k)=>$r[$k]??'', $keys),$d)];}return array_map(fn($v)=>[$v],$d);}return array_map(fn($k,$v)=>[$k,is_scalar($v)?$v:json_encode($v,JSON_UNESCAPED_UNICODE)],array_keys($d),array_values($d));} app/Services/Export/XlsxExporter.php-25- private function col(int $n):string{$s='';while($n>0){$n--;$s=chr(65+$n%26).$s;$n=intdiv($n,26);}return $s;}private function x(string $s):string{return htmlspecialchars($s,ENT_XML1|ENT_QUOTES,'UTF-8');} app/Services/Export/XlsxExporter.php-26-} -- app/Services/Export/DocxExporter.php-1-root.'/public/assets/baffoe-logo.jpg';if(is_file($logo)){$rels[]=['id'=>'rId'.$rid,'type'=>'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image','target'=>'media/logo.jpg'];$media['word/media/logo.jpg']=file_get_contents($logo);$body.=$this->image('rId'.$rid,914400,914400,'Company logo');$rid++;} app/Services/Export/DocxExporter.php-8- $body.=$this->p('BAFFOE CONSULTING MANAGEMENT COMPANY LIMITED','Title');$body.=$this->p((string)($doc['title']??'BCOS Report'),'Title');$body.=$this->p('Reference: '.($doc['reference']??'').' | Classification: '.($doc['classification']??'CONFIDENTIAL'),'Subtitle'); app/Services/Export/DocxExporter.php-9- foreach((array)($doc['sections']??[]) as $s){$body.=$this->p((string)($s['title']??'Section'),'Heading1');$content=$s['content']??'';if(is_array($content))$body.=$this->table($this->rows($content));else foreach(preg_split('/\r?\n/',(string)$content)?:[] as $line)$body.=$this->p($line,'Normal');} app/Services/Export/DocxExporter.php-10- foreach((array)($doc['charts']??[]) as $i=>$spec){$body.=$this->p((string)($spec['title']??'Chart'),'Heading2');$png=$this->charts->fromSpec($spec)->png();$name='chart'.($i+1).'.png';$rels[]=['id'=>'rId'.$rid,'type'=>'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image','target'=>'media/'.$name];$media['word/media/'.$name]=$png;$body.=$this->image('rId'.$rid,5486400,2550000,'Report chart');$rid++;$body.=$this->table([['Category','Value'],...array_map(fn($l,$v)=>[$l,$v],(array)($spec['labels']??[]),(array)($spec['values']??[]))]);} app/Services/Export/DocxExporter.php-11- if(!empty($doc['appendices'])){$body.=$this->p('Appendices','Heading1');foreach((array)$doc['appendices'] as $app){$body.=$this->p((string)($app['title']??'Appendix'),'Heading2');$body.=$this->table($this->rows((array)($app['rows']??[])));}} app/Services/Export/DocxExporter.php-12- $qrPng=$this->qr->png((string)($doc['verification_url']??''));if($qrPng){$rels[]=['id'=>'rId'.$rid,'type'=>'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image','target'=>'media/verification-qr.png'];$media['word/media/verification-qr.png']=$qrPng;$body.=$this->p('Verification','Heading2').$this->image('rId'.$rid,1371600,1371600,'Verification QR code');$rid++;} app/Services/Export/DocxExporter.php-13- $body.=$this->p('Verification URL: '.($doc['verification_url']??''),'Normal').$this->p('Content hash: '.($doc['content_hash']??''),'Normal'); app/Services/Export/DocxExporter.php-14- $zip->add('[Content_Types].xml',$this->contentTypes());$zip->add('_rels/.rels',$this->rootRels());$zip->add('word/document.xml',$this->document($body));$zip->add('word/styles.xml',$this->styles());$zip->add('word/header1.xml',$this->header((string)($doc['watermark']??'CONFIDENTIAL')));$zip->add('word/_rels/document.xml.rels',$this->rels($rels));foreach($media as $n=>$d)$zip->add($n,$d);$zip->add('docProps/core.xml',$this->core((string)($doc['title']??'BCOS Report')));$zip->add('docProps/app.xml',$this->app());$zip->save($path);$data=file_get_contents($path);return ['path'=>$path,'mime'=>'application/vnd.openxmlformats-officedocument.wordprocessingml.document','size'=>strlen($data),'sha256'=>hash('sha256',$data)];} app/Services/Export/DocxExporter.php:15: private function document(string $body):string{return ''.$body.'';} app/Services/Export/DocxExporter.php-16- private function header(string $watermark):string{return '';} app/Services/Export/DocxExporter.php-17- private function p(string $text,string $style):string{return ''.$this->x($text).'';} app/Services/Export/DocxExporter.php-18- private function table(array $rows):string{if(!$rows)return '';$out='';foreach($rows as $ri=>$row){$out.='';foreach((array)$row as $v)$out.=''.$this->p(is_scalar($v)?(string)$v:json_encode($v,JSON_UNESCAPED_UNICODE),'Normal').'';$out.='';}$out.='';return $out;} app/Services/Export/DocxExporter.php-19- private function image(string $rid,int $cx,int $cy,string $name):string{return '';} app/Services/Export/DocxExporter.php-20- private function rows(array $d):array{if(!$d)return [];if(array_is_list($d)){if(isset($d[0])&&is_array($d[0])){$keys=array_keys($d[0]);return [$keys,...array_map(fn($r)=>array_map(fn($k)=>$r[$k]??'', $keys),$d)];}return array_map(fn($v)=>[$v],$d);}return [['Field','Value'],...array_map(fn($k,$v)=>[$k,is_scalar($v)?$v:json_encode($v,JSON_UNESCAPED_UNICODE)],array_keys($d),array_values($d))];} app/Services/Export/DocxExporter.php:21: private function rels(array $rels):string{$x='';foreach($rels as $r)$x.='';$x.='';return $x;} app/Services/Export/DocxExporter.php:22: private function contentTypes():string{return '';} app/Services/Export/DocxExporter.php:23: private function rootRels():string{return '';} app/Services/Export/DocxExporter.php-24- private function styles():string{return '';} app/Services/Export/DocxExporter.php-25- private function core(string $title):string{return ''.$this->x($title).'Baffoe Consulting Management Company LimitedBCOS'.gmdate('Y-m-d\TH:i:s\Z').'';} app/Services/Export/DocxExporter.php:26: private function app():string{return 'BCOS Report FactoryBaffoe Consulting Management Company Limited';} app/Services/Export/DocxExporter.php-27- private function x(string $s):string{return htmlspecialchars($s,ENT_XML1|ENT_QUOTES,'UTF-8');} app/Services/Export/DocxExporter.php-28-} === PDF SOFTWARE AVAILABLE === app/Modules/Reports/ReportExportService.php-1-pdf=new PdfExporter($root,$charts,$qr);$this->docx=new DocxExporter($root,$charts,$qr);$this->xlsx=new XlsxExporter($root,$charts,$qr);} app/Modules/Reports/ReportExportService.php-20- public function templates():array{return $this->all('SELECT * FROM vw_report_template_catalog ORDER BY report_type,title');} app/Modules/Reports/ReportExportService.php-21- public function runs(int $limit=50):array{$limit=max(1,min(200,$limit));return $this->all('SELECT * FROM vw_report_run_summary ORDER BY generated_at DESC LIMIT '.$limit);} app/Modules/Reports/ReportExportService.php-22- public function show(string $id):array{$r=$this->one('SELECT * FROM report_runs WHERE id=?',[$id]);if(!$r)throw new RuntimeException('Report not found.');$r['outputs']=$this->all('SELECT id,output_format,file_name,mime_type,file_size,file_hash,generated_at FROM report_outputs WHERE report_run_id=? ORDER BY output_format',[$id]);$r['approvals']=$this->all('SELECT decision,comments,reviewer_user_id,decided_at FROM report_approvals WHERE report_run_id=? ORDER BY decided_at',[$id]);$r['distributions']=$this->all('SELECT channel,recipient_name,recipient_address,purpose,distributed_at FROM report_distributions WHERE report_run_id=? ORDER BY distributed_at DESC',[$id]);return $r;} app/Modules/Reports/ReportExportService.php-23- public function generate(array $input,string $user):array { app/Modules/Reports/ReportExportService.php:24: $type=strtoupper((string)($input['report_type']??''));$formats=array_values(array_unique(array_map('strtoupper',(array)($input['formats']??['PDF']))));$formats=array_values(array_intersect($formats,['PDF','DOCX','XLSX']));if(!$formats)throw new RuntimeException('At least one output format is required.'); app/Modules/Reports/ReportExportService.php-25- $contextId=isset($input['context_id'])?(string)$input['context_id']:null;$built=$this->data->build($type,$contextId,$input);$reference=$this->nextReference();$token=bin2hex(random_bytes(24));$classification=strtoupper((string)($input['classification']??'CONFIDENTIAL'));$watermark=(string)($input['watermark_text']??($classification==='PUBLIC'?'CONTROLLED COPY':'CONFIDENTIAL')); app/Modules/Reports/ReportExportService.php-26- $snapshot=['report_type'=>$type,'context_id'=>$contextId,'title'=>$input['title']??$built['title'],'sections'=>$built['sections'],'charts'=>!empty($input['include_charts'])||!array_key_exists('include_charts',$input)?$built['charts']:[],'appendices'=>!empty($input['include_appendices'])||!array_key_exists('include_appendices',$input)?$built['appendices']:[]]; app/Modules/Reports/ReportExportService.php-27- $contentHash=hash('sha256',json_encode($snapshot,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR));$base=rtrim((string)Env::get('APP_URL','https://portal.baffoes.com'),'/');$verificationUrl=$base.'/api/reports/verify?token='.rawurlencode($token).'&hash='.$contentHash; app/Modules/Reports/ReportExportService.php-28- $doc=$snapshot+['reference'=>$reference,'classification'=>$classification,'watermark'=>$watermark,'content_hash'=>$contentHash,'verification_url'=>$verificationUrl];$id=Uuid::v4();$dir=$this->root.'/storage/reports/'.gmdate('Y').'/'.$reference;if(!is_dir($dir)&&!mkdir($dir,0770,true)&&!is_dir($dir))throw new RuntimeException('Unable to create report storage directory.');$outputs=[]; app/Modules/Reports/ReportExportService.php:29: try{foreach($formats as $format){$safe=preg_replace('/[^A-Za-z0-9._-]+/','-',(string)$doc['title']);$file=$reference.'-'.$safe.'.'.strtolower($format);$path=$dir.'/'.$file;$result=match($format){'PDF'=>$this->pdf->export($doc,$path),'DOCX'=>$this->docx->export($doc,$path),'XLSX'=>$this->xlsx->export($doc,$path)};$outputs[$format]=$result+['file_name'=>$file];}} app/Modules/Reports/ReportExportService.php-30- catch(\Throwable $e){$this->removeTree($dir);throw $e;} app/Modules/Reports/ReportExportService.php-31- $pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO report_runs(id,reference_no,template_id,report_type,title,context_type,context_id,classification,status,watermark_text,content_hash,verification_token_hash,generated_by,options_json,content_snapshot_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')->execute([$id,$reference,$input['template_id']??null,$type,(string)$doc['title'],strtoupper((string)($input['context_type']??$type)),$contextId,$classification,'GENERATED',$watermark,$contentHash,hash('sha256',$token),$user,json_encode($input,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),json_encode($doc,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)]);$seq=1;foreach($doc['sections'] as $s){$pdo->prepare('INSERT INTO report_sections(id,report_run_id,section_key,title,sequence_no,content_json,source_label) VALUES(?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,'section_'.$seq,(string)$s['title'],$seq,json_encode($s['content'],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),'BCOS governed records']);$seq++;}foreach($outputs as $format=>$o){$pdo->prepare('INSERT INTO report_outputs(id,report_run_id,output_format,file_name,storage_path,mime_type,file_size,file_hash) VALUES(?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$format,$o['file_name'],$this->relative($o['path']),$o['mime'],$o['size'],$o['sha256']]);}$pdo->commit();}catch(\Throwable $e){$pdo->rollBack();$this->removeTree($dir);throw $e;} app/Modules/Reports/ReportExportService.php-32- $this->audit->record('report.generated',$user,['report_id'=>$id,'reference_no'=>$reference,'formats'=>$formats,'content_hash'=>$contentHash]);return ['id'=>$id,'reference_no'=>$reference,'status'=>'GENERATED','content_hash'=>$contentHash,'verification_url'=>$verificationUrl,'outputs'=>array_map(fn($o)=>['format'=>strtoupper(pathinfo($o['path'],PATHINFO_EXTENSION)),'file_name'=>$o['file_name'],'size'=>$o['size'],'sha256'=>$o['sha256']],$outputs)]; app/Modules/Reports/ReportExportService.php-33- } app/Modules/Reports/ReportExportService.php-34- public function approve(string $id,string $decision,?string $comments,string $user):void {$decision=strtoupper($decision);if(!in_array($decision,['APPROVED','REJECTED','CHANGES_REQUESTED'],true))throw new RuntimeException('Invalid approval decision.');$r=$this->one('SELECT generated_by,status FROM report_runs WHERE id=?',[$id]);if(!$r)throw new RuntimeException('Report not found.');if($decision==='APPROVED'&&$r['generated_by']===$user)throw new RuntimeException('The report author cannot approve the same report.');$pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO report_approvals(id,report_run_id,reviewer_user_id,decision,comments) VALUES(?,?,?,?,?)')->execute([Uuid::v4(),$id,$user,$decision,$comments]);$status=$decision==='APPROVED'?'APPROVED':($decision==='REJECTED'?'REJECTED':'UNDER_REVIEW');$pdo->prepare('UPDATE report_runs SET status=?,approved_by=?,approved_at=IF(?="APPROVED",UTC_TIMESTAMP(),NULL) WHERE id=?')->execute([$status,$decision==='APPROVED'?$user:null,$decision,$id]);$pdo->commit();}catch(\Throwable $e){$pdo->rollBack();throw $e;}$this->audit->record('report.approval_decided',$user,['report_id'=>$id,'decision'=>$decision]);} app/Modules/Reports/ReportExportService.php-35- public function distribute(string $id,array $d,string $user):void {$r=$this->one('SELECT status FROM report_runs WHERE id=?',[$id]);if(!$r)throw new RuntimeException('Report not found.');if(!in_array($r['status'],['APPROVED','RELEASED'],true))throw new RuntimeException('Only approved reports may be distributed.');$this->db->connection()->prepare('INSERT INTO report_distributions(id,report_run_id,output_id,channel,recipient_name,recipient_address,purpose,policy_notice_acknowledged,distributed_by,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$d['output_id']??null,strtoupper((string)($d['channel']??'PORTAL')),$d['recipient_name']??null,$d['recipient_address']??null,$d['purpose']??null,!empty($d['policy_notice_acknowledged'])?1:0,$user,$d['expires_at']??null]);$this->db->connection()->prepare("UPDATE report_runs SET status='RELEASED',released_at=COALESCE(released_at,UTC_TIMESTAMP()) WHERE id=?")->execute([$id]);$this->audit->record('report.distributed',$user,['report_id'=>$id,'channel'=>$d['channel']??'PORTAL']);} app/Modules/Reports/ReportExportService.php-36- public function output(string $outputId):array {$o=$this->one('SELECT o.*,r.reference_no,r.title,r.status FROM report_outputs o JOIN report_runs r ON r.id=o.report_run_id WHERE o.id=?',[$outputId]);if(!$o)throw new RuntimeException('Report output not found.');$path=$this->root.'/'.ltrim((string)$o['storage_path'],'/');$real=realpath($path);$base=realpath($this->root.'/storage/reports');if(!$real||!$base||!str_starts_with($real,$base.DIRECTORY_SEPARATOR)||!is_file($real))throw new RuntimeException('Report file is unavailable.');$o['absolute_path']=$real;return $o;} app/Modules/Reports/ReportExportService.php-37- public function verify(string $token,?string $hash,array $server=[]):array {$tokenHash=hash('sha256',$token);$r=$this->one('SELECT id,reference_no,title,status,classification,content_hash,generated_at,approved_at,released_at FROM report_runs WHERE verification_token_hash=?',[$tokenHash]);if(!$r)return ['valid'=>false,'message'=>'Verification token is invalid.'];$valid=$hash===null||hash_equals((string)$r['content_hash'],$hash);$this->db->connection()->prepare('INSERT INTO report_verification_events(id,report_run_id,supplied_hash,result,ip_address,user_agent) VALUES(?,?,?,?,?,?)')->execute([Uuid::v4(),$r['id'],$hash,$valid?'VALID':'INVALID',$server['REMOTE_ADDR']??null,$server['HTTP_USER_AGENT']??null]);return ['valid'=>$valid,'reference_no'=>$r['reference_no'],'title'=>$r['title'],'status'=>$r['status'],'classification'=>$r['classification'],'content_hash'=>$r['content_hash'],'generated_at'=>$r['generated_at'],'approved_at'=>$r['approved_at'],'released_at'=>$r['released_at']];} app/Modules/Reports/ReportExportService.php-38- private function nextReference():string {$pdo=$this->db->connection();$pdo->beginTransaction();try{$year=(int)gmdate('Y');$q=$pdo->prepare('SELECT next_value FROM report_number_sequences WHERE year_no=? FOR UPDATE');$q->execute([$year]);$n=$q->fetchColumn();if($n===false){$n=1;$pdo->prepare('INSERT INTO report_number_sequences(year_no,next_value) VALUES(?,2)')->execute([$year]);}else{$pdo->prepare('UPDATE report_number_sequences SET next_value=next_value+1 WHERE year_no=?')->execute([$year]);}$pdo->commit();return sprintf('BC-RPT-%d-%06d',$year,(int)$n);}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/Reports/ReportExportService.php-39- private function all(string $sql,array $p=[]):array{$s=$this->db->connection()->prepare($sql);$s->execute($p);return $s->fetchAll();}private function one(string $sql,array $p=[]):array|false{$s=$this->db->connection()->prepare($sql);$s->execute($p);return $s->fetch();} app/Modules/Reports/ReportExportService.php-40- private function relative(string $path):string{return ltrim(str_replace($this->root,'',$path),'/');}private function removeTree(string $dir):void{if(!is_dir($dir))return;foreach(scandir($dir)?:[] as $f){if($f==='.'||$f==='..')continue;$p=$dir.'/'.$f;is_dir($p)?$this->removeTree($p):@unlink($p);}@rmdir($dir);} app/Modules/Reports/ReportExportService.php-41-} -- app/Services/Export/PdfExporter.php-1-root.'/public/assets/baffoe-logo.jpg';$logoData=is_file($logo)?file_get_contents($logo):false;$logoSize=$logoData?getimagesize($logo):false; app/Services/Export/PdfExporter.php-11- $verification=(string)($doc['verification_url']??'');$qrMatrix=$verification!==''?$this->qr->matrix($verification):null; app/Services/Export/PdfExporter.php-12- $pages=$this->layout($doc,$qrMatrix);$objects=[];$fontId=$this->obj($objects,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>');$boldId=$this->obj($objects,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>'); app/Services/Export/PdfExporter.php-13- $logoId=0;if($logoData&&$logoSize){$logoId=$this->streamObj($objects,'<< /Type /XObject /Subtype /Image /Width '.$logoSize[0].' /Height '.$logoSize[1].' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length '.strlen($logoData).' >>',$logoData,false);} app/Services/Export/PdfExporter.php-14- $chartIds=[];foreach((array)($doc['charts']??[]) as $i=>$spec){$canvas=$this->charts->fromSpec($spec);$raw=gzcompress($canvas->rawRgb(),8);$chartIds[$i]=$this->streamObj($objects,'<< /Type /XObject /Subtype /Image /Width '.$canvas->width.' /Height '.$canvas->height.' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length '.strlen($raw).' >>',$raw,false);} app/Services/Export/PdfExporter.php-15- $pagesId=$this->obj($objects,'');$pageIds=[]; app/Services/Export/PdfExporter.php-16- foreach($pages as $page){$contentId=$this->streamObj($objects,'<< /Length '.strlen($page).' >>',$page,false);$xobj=[];if($logoId)$xobj[]='/Logo '.$logoId.' 0 R';foreach($chartIds as $i=>$id)$xobj[]='/Chart'.$i.' '.$id.' 0 R';$resources='<< /Font << /F1 '.$fontId.' 0 R /F2 '.$boldId.' 0 R >>'.($xobj?' /XObject << '.implode(' ',$xobj).' >>':'').' >>';$pageIds[]=$this->obj($objects,'<< /Type /Page /Parent '.$pagesId.' 0 R /MediaBox [0 0 595 842] /Resources '.$resources.' /Contents '.$contentId.' 0 R >>');} app/Services/Export/PdfExporter.php-17- $kids=implode(' ',array_map(fn($id)=>$id.' 0 R',$pageIds));$objects[$pagesId-1]='<< /Type /Pages /Kids [ '.$kids.' ] /Count '.count($pageIds).' >>';$catalogId=$this->obj($objects,'<< /Type /Catalog /Pages '.$pagesId.' 0 R >>'); app/Services/Export/PdfExporter.php:18: $pdf="%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";$offsets=[0];foreach($objects as $i=>$body){$offsets[]=strlen($pdf);$pdf.=($i+1)." 0 obj\n".$body."\nendobj\n";}$xref=strlen($pdf);$pdf.="xref\n0 ".(count($objects)+1)."\n0000000000 65535 f \n";for($i=1;$i<=count($objects);$i++)$pdf.=sprintf('%010d 00000 n ', $offsets[$i])."\n";$pdf.='trailer << /Size '.(count($objects)+1).' /Root '.$catalogId." 0 R >>\nstartxref\n".$xref."\n%%EOF\n"; app/Services/Export/PdfExporter.php:19: $this->write($path,$pdf);return ['path'=>$path,'mime'=>'application/pdf','size'=>strlen($pdf),'sha256'=>hash('sha256',$pdf)]; app/Services/Export/PdfExporter.php-20- } app/Services/Export/PdfExporter.php:21: /** @return array */ private function layout(array $doc,?array $qr):array {$pages=[];$c='';$y=780;$pageNo=1;$start=function()use(&$c,&$y,$doc,&$pageNo){$c="q\n";$water=$this->pdfText((string)($doc['watermark']??'CONFIDENTIAL'));$c.="0.93 g BT /F2 48 Tf 0.707 0.707 -0.707 0.707 155 330 Tm ($water) Tj ET 0 g\n";$c.="BT /F2 15 Tf 90 795 Td (BAFFOE CONSULTING MANAGEMENT COMPANY LIMITED) Tj ET\n";$c.="BT /F1 8 Tf 90 781 Td (Management Consulting | Strategic Sourcing | Business Development) Tj ET\n";$c.="0.71 0.55 0.17 RG 50 770 m 545 770 l S 0 G\n";$y=744;if(is_file($this->root.'/public/assets/baffoe-logo.jpg'))$c.="q 34 0 0 34 50 780 cm /Logo Do Q\n";};$finish=function()use(&$c,&$pages,&$pageNo,$doc,$qr){$ref=$this->pdfText((string)($doc['reference']??''));$hash=$this->pdfText(substr((string)($doc['content_hash']??''),0,32));$c.="0.8 G 45 40 m 550 40 l S 0 G BT /F1 7 Tf 48 27 Td (CONFIDENTIAL - Reference: $ref - Hash: $hash - Page $pageNo) Tj ET\n";if($qr){$n=count($qr);$s=1.4;$ox=510-$n*$s;$oy=48;foreach($qr as $yy=>$row)foreach($row as $xx=>$dark)if($dark)$c.=sprintf("0 0 0 rg %.2f %.2f %.2f %.2f re f\n",$ox+$xx*$s,$oy+($n-1-$yy)*$s,$s,$s);}$c.="Q\n";$pages[]=$c;$pageNo++;};$newPage=function()use(&$start,&$finish,&$y){$finish();$start();};$start(); app/Services/Export/PdfExporter.php-22- $title=(string)($doc['title']??'BCOS Report');$this->text($c,$title,50,$y,20,true);$y-=26;$this->text($c,'Reference: '.($doc['reference']??'').' | Classification: '.($doc['classification']??'CONFIDENTIAL'),50,$y,9,false);$y-=22; app/Services/Export/PdfExporter.php-23- foreach((array)($doc['sections']??[]) as $section){if($y<150)$newPage();$this->text($c,(string)($section['title']??'Section'),50,$y,14,true);$y-=20;$content=$section['content']??'';if(is_array($content)){$rows=$this->tableRows($content);foreach($rows as $row){if($y<90)$newPage();$line=implode(' | ',array_map(fn($v)=>is_scalar($v)?(string)$v:json_encode($v),$row));foreach($this->wrap($line,92) as $ln){$this->text($c,$ln,55,$y,8,false);$y-=12;}$y-=2;}}else{foreach($this->wrap((string)$content,98) as $ln){if($y<85)$newPage();$this->text($c,$ln,55,$y,9,false);$y-=13;}}$y-=10;} app/Services/Export/PdfExporter.php-24- foreach((array)($doc['charts']??[]) as $i=>$spec){if($y<300)$newPage();$this->text($c,(string)($spec['title']??'Chart'),50,$y,13,true);$y-=225;$c.=sprintf("q 480 0 0 220 55 %.2f cm /Chart%d Do Q\n",$y,$i);$y-=25;} app/Services/Export/PdfExporter.php-25- if(!empty($doc['appendices'])){if($y<180)$newPage();$this->text($c,'Appendices',50,$y,16,true);$y-=24;foreach((array)$doc['appendices'] as $app){if($y<100)$newPage();$this->text($c,(string)($app['title']??'Appendix'),55,$y,12,true);$y-=17;foreach($this->tableRows((array)($app['rows']??[])) as $row){foreach($this->wrap(implode(' | ',array_map('strval',$row)),92) as $ln){$this->text($c,$ln,58,$y,8,false);$y-=11;}}$y-=8;}} app/Services/Export/PdfExporter.php-26- $finish();return $pages;} app/Services/Export/PdfExporter.php:27: private function text(string &$c,string $text,float $x,float $y,int $size,bool $bold):void{$t=$this->pdfText($text);$c.='BT /F'.($bold?'2':'1').' '.$size.' Tf '.sprintf('%.2f %.2f Td',$x,$y).' ('.$t.") Tj ET\n";} app/Services/Export/PdfExporter.php-28- private function wrap(string $text,int $limit):array{$text=preg_replace('/\s+/u',' ',trim($text))??'';if($text==='')return [''];$words=preg_split('/\s+/u',$text)?:[];$lines=[];$line='';foreach($words as $w){if(strlen($line.' '.$w)>$limit&&$line!==''){$lines[]=$line;$line=$w;}else{$line=trim($line.' '.$w);}}if($line!=='')$lines[]=$line;return $lines;} app/Services/Export/PdfExporter.php-29- private function tableRows(array $data):array {if(!$data)return [];if(array_is_list($data))return array_map(fn($r)=>is_array($r)?$r:[$r],$data);$rows=[];foreach($data as $k=>$v)$rows[]=[$k,is_scalar($v)?$v:json_encode($v,JSON_UNESCAPED_UNICODE)];return $rows;} app/Services/Export/PdfExporter.php:30: private function pdfText(string $s):string{$s=iconv('UTF-8','Windows-1252//TRANSLIT//IGNORE',$s)?:$s;return str_replace(['\\','(',')',"\r","\n"],['\\\\','\\(','\\)',' ',' '],$s);} app/Services/Export/PdfExporter.php-31- private function obj(array &$objects,string $body):int{$objects[]=$body;return count($objects);}private function streamObj(array &$objects,string $dict,string $stream,bool $compress=false):int{if($compress){$stream=gzcompress($stream);$dict=str_replace('>>',' /Filter /FlateDecode >>',$dict);}return $this->obj($objects,$dict."\nstream\n".$stream."\nendstream");} app/Services/Export/PdfExporter.php:32: private function write(string $path,string $data):void{$dir=dirname($path);if(!is_dir($dir)&&!mkdir($dir,0770,true)&&!is_dir($dir))throw new RuntimeException('Cannot create report directory.');if(file_put_contents($path,$data,LOCK_EX)===false)throw new RuntimeException('Cannot write PDF.');} app/Services/Export/PdfExporter.php-33-} -- app/Services/SecureUploadService.php-1-$max)throw new RuntimeException('File exceeds size limit.');$tmp=$file['tmp_name']??'';if(!is_uploaded_file($tmp))throw new RuntimeException('Invalid upload source.');$mime=(new \finfo(FILEINFO_MIME_TYPE))->file($tmp)?:'application/octet-stream';if(!in_array($mime,self::ALLOWED,true))throw new RuntimeException('File type not allowed.');$id=Uuid::v4();$name=preg_replace('/[^A-Za-z0-9._-]/','_',basename((string)($file['name']??'upload.bin')));$qdir=$this->root.'/storage/quarantine/'.date('Y/m');if(!is_dir($qdir)&&!mkdir($qdir,0700,true)&&!is_dir($qdir))throw new RuntimeException('Cannot create quarantine.');$path=$qdir.'/'.$id.'-'.$name;if(!move_uploaded_file($tmp,$path))throw new RuntimeException('Cannot quarantine upload.');chmod($path,0600);$hash=hash_file('sha256',$path);$q=$this->db->connection()->prepare("INSERT INTO secure_files(id,original_name,stored_path,mime_type,size_bytes,sha256,scan_status,uploaded_by,uploaded_at) VALUES(:id,:n,:p,:m,:s,:h,'QUARANTINED',:u,UTC_TIMESTAMP(6))");$q->execute(['id'=>$id,'n'=>$name,'p'=>$path,'m'=>$mime,'s'=>(int)$file['size'],'h'=>$hash,'u'=>$userId]);return ['id'=>$id,'name'=>$name,'mime'=>$mime,'size'=>(int)$file['size'],'sha256'=>$hash,'scan_status'=>'QUARANTINED']; } app/Services/SecureUploadService.php-8- public function scan(string $id): array { $q=$this->db->connection()->prepare('SELECT * FROM secure_files WHERE id=:id');$q->execute(['id'=>$id]);$f=$q->fetch();if(!$f)throw new RuntimeException('File not found.');$status='PENDING';$detail='ClamAV not configured';$clam=Env::get('CLAMAV_COMMAND','');if($clam!==''){exec(escapeshellcmd($clam).' '.escapeshellarg($f['stored_path']).' 2>&1',$out,$code);$status=$code===0?'CLEAN':($code===1?'INFECTED':'ERROR');$detail=implode("\n",$out);if($status==='CLEAN'){$safe=$this->root.'/storage/private/uploads/'.date('Y/m');if(!is_dir($safe))mkdir($safe,0700,true);$new=$safe.'/'.basename($f['stored_path']);rename($f['stored_path'],$new);$f['stored_path']=$new;}}$u=$this->db->connection()->prepare('UPDATE secure_files SET scan_status=:s,scan_details=:d,stored_path=:p,scanned_at=UTC_TIMESTAMP(6) WHERE id=:id');$u->execute(['s'=>$status,'d'=>mb_substr($detail,0,4000),'p'=>$f['stored_path'],'id'=>$id]);return ['id'=>$id,'scan_status'=>$status]; } app/Services/SecureUploadService.php-9-} === EMAIL SOFTWARE AVAILABLE === app/Core/App.php-31- $router->add('GET','/api/notifications/summary',fn(Request $r)=>$notify->notificationSummary($r),[$authenticated,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-32- $router->add('GET','/api/notifications/preferences',fn(Request $r)=>$notify->preferences($r),[$authenticated,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-33- $router->add('POST','/api/notifications/preferences',fn(Request $r)=>$notify->updatePreferences($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-34- $router->add('POST','/api/notifications/read',fn(Request $r)=>$notify->markRead($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-35- $router->add('POST','/api/notifications/archive',fn(Request $r)=>$notify->archive($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-36- $router->add('POST','/api/notifications/create',fn(Request $r)=>$notify->createNotification($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.manage')]); app/Core/App.php-37- $router->add('POST','/api/notifications/report',fn(Request $r)=>$notify->notifyReport($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.manage')]); app/Core/App.php-38- $router->add('POST','/api/notifications/document',fn(Request $r)=>$notify->notifyDocument($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.manage')]); app/Core/App.php-39- $router->add('POST','/api/notifications/approval',fn(Request $r)=>$notify->notifyApproval($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'notifications.manage')]); app/Core/App.php-40- $router->add('GET','/api/collaboration/conversations',fn(Request $r)=>$notify->conversations($r),[$authenticated,new AuthorizationMiddleware($db,'collaboration.view')]); app/Core/App.php-41- $router->add('GET','/api/collaboration/messages',fn(Request $r)=>$notify->messages($r),[$authenticated,new AuthorizationMiddleware($db,'collaboration.view')]); app/Core/App.php-42- $router->add('POST','/api/collaboration/conversations',fn(Request $r)=>$notify->createConversation($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'collaboration.message')]); app/Core/App.php-43- $router->add('POST','/api/collaboration/participants',fn(Request $r)=>$notify->addParticipants($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'collaboration.manage')]); app/Core/App.php-44- $router->add('POST','/api/collaboration/messages',fn(Request $r)=>$notify->postMessage($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'collaboration.message')]); app/Core/App.php-45- $router->add('POST','/api/collaboration/attachments',fn(Request $r)=>$notify->attachMessage($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'collaboration.message')]); app/Core/App.php-46- $router->add('GET','/api/email/templates',fn(Request $r)=>$notify->emailTemplates($r),[$authenticated,new AuthorizationMiddleware($db,'email.manage_templates')]); app/Core/App.php-47- $router->add('GET','/api/email/configs',fn(Request $r)=>$notify->emailConfigs($r),[$authenticated,new AuthorizationMiddleware($db,'email.manage_config')]); app/Core/App.php-48- $router->add('GET','/api/email/queue',fn(Request $r)=>$notify->emailQueue($r),[$authenticated,new AuthorizationMiddleware($db,'email.manage_queue')]); app/Core/App.php-49- $router->add('POST','/api/email/configs',fn(Request $r)=>$notify->saveEmailConfig($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'email.manage_config')]); app/Core/App.php-50- $router->add('POST','/api/email/configs/test',fn(Request $r)=>$notify->testEmailConfig($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'email.manage_config')]); app/Core/App.php:51: $router->add('POST','/api/email/queue',fn(Request $r)=>$notify->queueEmail($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'email.manage_queue')]); app/Core/App.php-52- $router->add('POST','/api/email/queue/process',fn(Request $r)=>$notify->processEmailQueue($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'email.manage_queue')]); app/Core/App.php-53- $router->add('GET','/api/reminders',fn(Request $r)=>$notify->reminders($r),[$authenticated,new AuthorizationMiddleware($db,'notifications.view')]); app/Core/App.php-54- $router->add('POST','/api/reminders',fn(Request $r)=>$notify->createReminder($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'reminders.manage')]); app/Core/App.php-55- $router->add('POST','/api/scheduler/run',fn(Request $r)=>$notify->runScheduler($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'scheduler.run')]); app/Core/App.php-56- $router->add('GET','/api/reports/templates',fn(Request $r)=>$reports->templates($r),[$authenticated,new AuthorizationMiddleware($db,'reports.view')]); app/Core/App.php-57- $router->add('GET','/api/reports/runs',fn(Request $r)=>$reports->index($r),[$authenticated,new AuthorizationMiddleware($db,'reports.view')]); app/Core/App.php-58- $router->add('GET','/api/reports/run',fn(Request $r)=>$reports->show($r),[$authenticated,new AuthorizationMiddleware($db,'reports.view')]); app/Core/App.php-59- $router->add('GET','/api/reports/download',fn(Request $r)=>$reports->download($r),[$authenticated,new AuthorizationMiddleware($db,'reports.download')]); app/Core/App.php-60- $router->add('GET','/api/reports/verify',fn(Request $r)=>$reports->verify($r)); app/Core/App.php-61- $router->add('POST','/api/reports/generate',fn(Request $r)=>$reports->generate($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'reports.generate')]); app/Core/App.php-62- $router->add('POST','/api/reports/approve',fn(Request $r)=>$reports->approve($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'reports.approve')]); app/Core/App.php-63- $router->add('POST','/api/reports/distribute',fn(Request $r)=>$reports->distribute($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'reports.distribute')]); app/Core/App.php-64- $router->add('GET','/api/governance/dashboard',fn(Request $r)=>$governance->dashboard($r),[$authenticated,new AuthorizationMiddleware($db,'legal.view')]); app/Core/App.php-65- $router->add('POST','/api/governance/legal',fn(Request $r)=>$governance->legal($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'legal.manage')]); app/Core/App.php-66- $router->add('POST','/api/governance/policies',fn(Request $r)=>$governance->policy($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'policies.manage')]); app/Core/App.php-67- $router->add('POST','/api/governance/compliance',fn(Request $r)=>$governance->compliance($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'compliance.manage')]); app/Core/App.php-68- $router->add('POST','/api/governance/licences',fn(Request $r)=>$governance->licence($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'compliance.manage')]); app/Core/App.php-69- $router->add('POST','/api/governance/filings',fn(Request $r)=>$governance->filing($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'compliance.manage')]); app/Core/App.php-70- $router->add('POST','/api/governance/conflicts',fn(Request $r)=>$governance->conflict($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'ethics.declare')]); app/Core/App.php-71- $router->add('POST','/api/governance/gifts',fn(Request $r)=>$governance->gift($r),[$authenticated,$csrf,new AuthorizationMiddleware($db,'ethics.declare')]); -- app/Http/Controllers/NotificationCollaborationController.php-24- public function notificationSummary(Request $r): never { Response::ok($this->notifications->summary($this->user($r))); } app/Http/Controllers/NotificationCollaborationController.php-25- public function preferences(Request $r): never { Response::ok($this->notifications->preferences($this->user($r))); } app/Http/Controllers/NotificationCollaborationController.php-26- public function updatePreferences(Request $r): never { Response::ok($this->notifications->updatePreferences($this->user($r),$r->body),'Notification preferences updated.'); } app/Http/Controllers/NotificationCollaborationController.php-27- public function markRead(Request $r): never { Response::ok(['updated'=>$this->notifications->markRead($this->user($r),(array)($r->body['ids']??[]))],'Notifications marked as read.'); } app/Http/Controllers/NotificationCollaborationController.php-28- public function archive(Request $r): never { Response::ok(['updated'=>$this->notifications->archive($this->user($r),(array)($r->body['ids']??[]))],'Notifications archived.'); } app/Http/Controllers/NotificationCollaborationController.php-29- public function createNotification(Request $r): never { Response::ok($this->notifications->create($r->body,$this->user($r)),'Notification created.',201); } app/Http/Controllers/NotificationCollaborationController.php-30- public function notifyReport(Request $r): never { Response::ok($this->notifications->notifyReport((string)($r->body['report_id']??''),(array)($r->body['recipient_user_ids']??[]),$this->user($r)),'Report notifications created.'); } app/Http/Controllers/NotificationCollaborationController.php-31- public function notifyDocument(Request $r): never { Response::ok($this->notifications->notifyDocument((string)($r->body['document_id']??''),(array)($r->body['recipient_user_ids']??[]),$this->user($r)),'Document notifications created.'); } app/Http/Controllers/NotificationCollaborationController.php-32- public function notifyApproval(Request $r): never { Response::ok($this->notifications->notifyApproval($r->body,$this->user($r)),'Approval notifications created.'); } app/Http/Controllers/NotificationCollaborationController.php-33- app/Http/Controllers/NotificationCollaborationController.php-34- public function conversations(Request $r): never { Response::ok($this->collaboration->conversations($this->user($r),(int)($r->query['limit']??50))); } app/Http/Controllers/NotificationCollaborationController.php-35- public function messages(Request $r): never { Response::ok($this->collaboration->messages($this->user($r),(string)($r->query['conversation_id']??''),(int)($r->query['limit']??100))); } app/Http/Controllers/NotificationCollaborationController.php-36- public function createConversation(Request $r): never { Response::ok($this->collaboration->createConversation($this->user($r),$r->body),'Conversation created.',201); } app/Http/Controllers/NotificationCollaborationController.php-37- public function addParticipants(Request $r): never { Response::ok(['added'=>$this->collaboration->addParticipants($this->user($r),$r->body)],'Participants added.'); } app/Http/Controllers/NotificationCollaborationController.php-38- public function postMessage(Request $r): never { Response::ok($this->collaboration->postMessage($this->user($r),$r->body),'Message posted.',201); } app/Http/Controllers/NotificationCollaborationController.php-39- public function attachMessage(Request $r): never { Response::ok($this->collaboration->attach($this->user($r),$r->body),'Attachment linked.',201); } app/Http/Controllers/NotificationCollaborationController.php-40- app/Http/Controllers/NotificationCollaborationController.php-41- public function emailTemplates(Request $r): never { Response::ok($this->email->templates()); } app/Http/Controllers/NotificationCollaborationController.php-42- public function emailConfigs(Request $r): never { Response::ok($this->email->configs()); } app/Http/Controllers/NotificationCollaborationController.php-43- public function emailQueue(Request $r): never { Response::ok($this->email->queue((int)($r->query['limit']??100))); } app/Http/Controllers/NotificationCollaborationController.php:44: public function saveEmailConfig(Request $r): never { Response::ok($this->email->saveConfig($r->body,$this->user($r)),'SMTP configuration saved.'); } app/Http/Controllers/NotificationCollaborationController.php:45: public function testEmailConfig(Request $r): never { Response::ok($this->email->testConfig((string)($r->body['config_id']??''),(string)($r->body['recipient_email']??''),$this->user($r)),'SMTP test completed.'); } app/Http/Controllers/NotificationCollaborationController.php:46: public function queueEmail(Request $r): never { Response::ok(['id'=>$this->email->queueRaw($r->body+['created_by'=>$this->user($r)])],'Email queued.',201); } app/Http/Controllers/NotificationCollaborationController.php-47- public function processEmailQueue(Request $r): never { Response::ok($this->email->processBatch((int)($r->body['limit']??20),$this->user($r)),'Email queue processed.'); } app/Http/Controllers/NotificationCollaborationController.php-48- app/Http/Controllers/NotificationCollaborationController.php-49- public function reminders(Request $r): never { Response::ok($this->scheduler->reminders($this->user($r),(int)($r->query['limit']??100))); } app/Http/Controllers/NotificationCollaborationController.php-50- public function createReminder(Request $r): never { Response::ok($this->scheduler->createReminder($this->user($r),$r->body),'Reminder created.',201); } app/Http/Controllers/NotificationCollaborationController.php-51- public function runScheduler(Request $r): never { Response::ok($this->scheduler->run($this->user($r),(int)($r->body['email_limit']??25)),'Scheduler run completed.'); } app/Http/Controllers/NotificationCollaborationController.php-52-} -- app/Modules/NotificationsCollaboration/EmailQueueService.php-1-renderer ??= new EmailTemplateRenderer(); app/Modules/NotificationsCollaboration/EmailQueueService.php:29: $this->transport ??= new SmtpTransport(); app/Modules/NotificationsCollaboration/EmailQueueService.php-30- } app/Modules/NotificationsCollaboration/EmailQueueService.php-31- app/Modules/NotificationsCollaboration/EmailQueueService.php-32- public function templates(): array app/Modules/NotificationsCollaboration/EmailQueueService.php-33- { app/Modules/NotificationsCollaboration/EmailQueueService.php-34- return $this->all("SELECT id,template_code,template_name,category,locale,version_no,status,created_at,updated_at FROM email_templates ORDER BY category,template_code,version_no DESC"); app/Modules/NotificationsCollaboration/EmailQueueService.php-35- } app/Modules/NotificationsCollaboration/EmailQueueService.php-36- app/Modules/NotificationsCollaboration/EmailQueueService.php-37- public function configs(): array app/Modules/NotificationsCollaboration/EmailQueueService.php-38- { app/Modules/NotificationsCollaboration/EmailQueueService.php:39: return $this->all("SELECT id,config_name,smtp_host,smtp_port,encryption_mode,auth_mode,username,from_email,from_name,reply_to_email,timeout_seconds,is_active,last_test_status,last_tested_at,last_test_error,created_at,updated_at FROM email_smtp_configs ORDER BY is_active DESC,config_name"); app/Modules/NotificationsCollaboration/EmailQueueService.php-40- } app/Modules/NotificationsCollaboration/EmailQueueService.php-41- app/Modules/NotificationsCollaboration/EmailQueueService.php-42- public function queue(int $limit = 100): array app/Modules/NotificationsCollaboration/EmailQueueService.php-43- { app/Modules/NotificationsCollaboration/EmailQueueService.php-44- $limit = max(1, min(500, $limit)); app/Modules/NotificationsCollaboration/EmailQueueService.php-45- return $this->all("SELECT id,notification_id,template_code,locale,to_email,to_name,subject_line,source_type,source_id,priority,status,attempt_count,max_attempts,scheduled_at,next_attempt_at,sent_at,last_error,created_at,updated_at FROM email_queue ORDER BY created_at DESC LIMIT {$limit}"); app/Modules/NotificationsCollaboration/EmailQueueService.php-46- } app/Modules/NotificationsCollaboration/EmailQueueService.php-47- app/Modules/NotificationsCollaboration/EmailQueueService.php-48- public function saveConfig(array $data, string $actorId): array app/Modules/NotificationsCollaboration/EmailQueueService.php-49- { app/Modules/NotificationsCollaboration/EmailQueueService.php-50- $id = trim((string) ($data['id'] ?? '')) ?: Uuid::v4(); app/Modules/NotificationsCollaboration/EmailQueueService.php:51: $host = trim((string) ($data['smtp_host'] ?? '')); app/Modules/NotificationsCollaboration/EmailQueueService.php:52: $port = (int) ($data['smtp_port'] ?? 0); app/Modules/NotificationsCollaboration/EmailQueueService.php-53- $fromEmail = trim((string) ($data['from_email'] ?? '')); app/Modules/NotificationsCollaboration/EmailQueueService.php-54- if ($host === '' || $port < 1 || $port > 65535 || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) { app/Modules/NotificationsCollaboration/EmailQueueService.php:55: throw new RuntimeException('Valid SMTP host, port and sender email are required.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-56- } app/Modules/NotificationsCollaboration/EmailQueueService.php-57- $mode = strtoupper((string) ($data['encryption_mode'] ?? 'TLS')); app/Modules/NotificationsCollaboration/EmailQueueService.php-58- $auth = strtoupper((string) ($data['auth_mode'] ?? 'LOGIN')); app/Modules/NotificationsCollaboration/EmailQueueService.php-59- if (!in_array($mode, ['TLS','SSL','NONE'], true) || !in_array($auth, ['LOGIN','NONE'], true)) { app/Modules/NotificationsCollaboration/EmailQueueService.php:60: throw new RuntimeException('Unsupported SMTP encryption or authentication mode.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-61- } app/Modules/NotificationsCollaboration/EmailQueueService.php-62- if (strtolower((string) Env::get('APP_ENV','production')) === 'production' && ($mode === 'NONE' || $auth === 'NONE')) { app/Modules/NotificationsCollaboration/EmailQueueService.php:63: throw new RuntimeException('Production SMTP must use TLS or SSL with LOGIN authentication.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-64- } app/Modules/NotificationsCollaboration/EmailQueueService.php-65- $active = !empty($data['is_active']) ? 1 : 0; app/Modules/NotificationsCollaboration/EmailQueueService.php-66- $pdo = $this->db->connection(); app/Modules/NotificationsCollaboration/EmailQueueService.php-67- $pdo->beginTransaction(); app/Modules/NotificationsCollaboration/EmailQueueService.php-68- try { app/Modules/NotificationsCollaboration/EmailQueueService.php:69: if ($active === 1) $pdo->exec('UPDATE email_smtp_configs SET is_active=0'); app/Modules/NotificationsCollaboration/EmailQueueService.php:70: $existing = $this->one('SELECT id,password_ciphertext FROM email_smtp_configs WHERE id=:id', ['id'=>$id]); app/Modules/NotificationsCollaboration/EmailQueueService.php-71- $passwordCiphertext = $existing['password_ciphertext'] ?? null; app/Modules/NotificationsCollaboration/EmailQueueService.php-72- if (array_key_exists('password', $data) && (string) $data['password'] !== '') { app/Modules/NotificationsCollaboration/EmailQueueService.php-73- $passwordCiphertext = $this->crypto->encrypt((string) $data['password']); app/Modules/NotificationsCollaboration/EmailQueueService.php-74- } app/Modules/NotificationsCollaboration/EmailQueueService.php-75- if ($auth === 'LOGIN' && (!$passwordCiphertext || trim((string) ($data['username'] ?? '')) === '')) { app/Modules/NotificationsCollaboration/EmailQueueService.php:76: throw new RuntimeException('SMTP username and password are required for LOGIN authentication.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-77- } app/Modules/NotificationsCollaboration/EmailQueueService.php-78- $params = [ app/Modules/NotificationsCollaboration/EmailQueueService.php:79: 'id'=>$id,'name'=>trim((string) ($data['config_name'] ?? 'Primary SMTP')),'host'=>$host,'port'=>$port, app/Modules/NotificationsCollaboration/EmailQueueService.php-80- 'enc'=>$mode,'auth'=>$auth,'username'=>$data['username']??null,'password'=>$passwordCiphertext, app/Modules/NotificationsCollaboration/EmailQueueService.php-81- 'from_email'=>$fromEmail,'from_name'=>trim((string) ($data['from_name'] ?? 'Baffoe Consulting')), app/Modules/NotificationsCollaboration/EmailQueueService.php-82- 'reply'=>$data['reply_to_email']??null,'timeout'=>max(5,min(120,(int)($data['timeout_seconds']??20))), app/Modules/NotificationsCollaboration/EmailQueueService.php-83- 'active'=>$active,'created_by'=>$actorId,'updated_by'=>$actorId, app/Modules/NotificationsCollaboration/EmailQueueService.php-84- ]; app/Modules/NotificationsCollaboration/EmailQueueService.php-85- if ($existing) { app/Modules/NotificationsCollaboration/EmailQueueService.php:86: $sql='UPDATE email_smtp_configs SET config_name=:name,smtp_host=:host,smtp_port=:port,encryption_mode=:enc,auth_mode=:auth,username=:username,password_ciphertext=:password,from_email=:from_email,from_name=:from_name,reply_to_email=:reply,timeout_seconds=:timeout,is_active=:active,updated_by=:actor WHERE id=:id'; app/Modules/NotificationsCollaboration/EmailQueueService.php-87- } else { app/Modules/NotificationsCollaboration/EmailQueueService.php:88: $sql='INSERT INTO email_smtp_configs(id,config_name,smtp_host,smtp_port,encryption_mode,auth_mode,username,password_ciphertext,from_email,from_name,reply_to_email,timeout_seconds,is_active,created_by,updated_by) VALUES(:id,:name,:host,:port,:enc,:auth,:username,:password,:from_email,:from_name,:reply,:timeout,:active,:created_by,:updated_by)'; app/Modules/NotificationsCollaboration/EmailQueueService.php-89- } app/Modules/NotificationsCollaboration/EmailQueueService.php-90- $pdo->prepare($sql)->execute($params); app/Modules/NotificationsCollaboration/EmailQueueService.php-91- $pdo->commit(); app/Modules/NotificationsCollaboration/EmailQueueService.php-92- } catch (Throwable $e) { app/Modules/NotificationsCollaboration/EmailQueueService.php-93- if ($pdo->inTransaction()) $pdo->rollBack(); app/Modules/NotificationsCollaboration/EmailQueueService.php-94- throw $e; app/Modules/NotificationsCollaboration/EmailQueueService.php-95- } app/Modules/NotificationsCollaboration/EmailQueueService.php:96: $this->audit->record('email.smtp_config_saved', $actorId, ['smtp_config_id'=>$id,'active'=>$active]); app/Modules/NotificationsCollaboration/EmailQueueService.php-97- return ['id'=>$id,'is_active'=>(bool)$active]; app/Modules/NotificationsCollaboration/EmailQueueService.php-98- } app/Modules/NotificationsCollaboration/EmailQueueService.php-99- app/Modules/NotificationsCollaboration/EmailQueueService.php-100- public function testConfig(string $configId, string $recipientEmail, string $actorId): array app/Modules/NotificationsCollaboration/EmailQueueService.php-101- { app/Modules/NotificationsCollaboration/EmailQueueService.php-102- if (!filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) throw new RuntimeException('A valid test-recipient email is required.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-103- $config = $this->loadConfig($configId); app/Modules/NotificationsCollaboration/EmailQueueService.php-104- $message = [ app/Modules/NotificationsCollaboration/EmailQueueService.php-105- 'to_email'=>$recipientEmail, app/Modules/NotificationsCollaboration/EmailQueueService.php:106: 'to_name'=>'BCOS SMTP Test', app/Modules/NotificationsCollaboration/EmailQueueService.php:107: 'subject'=>'BCOS authenticated SMTP test', app/Modules/NotificationsCollaboration/EmailQueueService.php:108: 'html'=>'

This message confirms that the authenticated BCOS SMTP connection completed successfully.

No external notification API was used.

', app/Modules/NotificationsCollaboration/EmailQueueService.php:109: 'text'=>'This message confirms that the authenticated BCOS SMTP connection completed successfully. No external notification API was used.', app/Modules/NotificationsCollaboration/EmailQueueService.php-110- 'cc'=>[], 'bcc'=>[], app/Modules/NotificationsCollaboration/EmailQueueService.php-111- ]; app/Modules/NotificationsCollaboration/EmailQueueService.php-112- try { app/Modules/NotificationsCollaboration/EmailQueueService.php-113- $result = $this->transport->send($config, $message); app/Modules/NotificationsCollaboration/EmailQueueService.php:114: $this->db->connection()->prepare("UPDATE email_smtp_configs SET last_test_status='PASSED',last_tested_at=UTC_TIMESTAMP(6),last_test_error=NULL WHERE id=:id")->execute(['id'=>$configId]); app/Modules/NotificationsCollaboration/EmailQueueService.php:115: $this->audit->record('email.smtp_test_passed',$actorId,['smtp_config_id'=>$configId,'recipient'=>$recipientEmail]); app/Modules/NotificationsCollaboration/EmailQueueService.php-116- return $result; app/Modules/NotificationsCollaboration/EmailQueueService.php-117- } catch (Throwable $e) { app/Modules/NotificationsCollaboration/EmailQueueService.php:118: $this->db->connection()->prepare("UPDATE email_smtp_configs SET last_test_status='FAILED',last_tested_at=UTC_TIMESTAMP(6),last_test_error=:error WHERE id=:id")->execute(['id'=>$configId,'error'=>Text::limit($e->getMessage(),1000)]); app/Modules/NotificationsCollaboration/EmailQueueService.php:119: $this->audit->record('email.smtp_test_failed',$actorId,['smtp_config_id'=>$configId,'error'=>$e->getMessage()]); app/Modules/NotificationsCollaboration/EmailQueueService.php-120- throw $e; app/Modules/NotificationsCollaboration/EmailQueueService.php-121- } app/Modules/NotificationsCollaboration/EmailQueueService.php-122- } app/Modules/NotificationsCollaboration/EmailQueueService.php-123- app/Modules/NotificationsCollaboration/EmailQueueService.php-124- /** app/Modules/NotificationsCollaboration/EmailQueueService.php-125- * @param array $variables app/Modules/NotificationsCollaboration/EmailQueueService.php-126- * @param list $safeHtmlKeys app/Modules/NotificationsCollaboration/EmailQueueService.php-127- */ app/Modules/NotificationsCollaboration/EmailQueueService.php-128- public function queueTemplate( app/Modules/NotificationsCollaboration/EmailQueueService.php-129- string $templateCode, app/Modules/NotificationsCollaboration/EmailQueueService.php-130- ?string $recipientUserId, app/Modules/NotificationsCollaboration/EmailQueueService.php-131- ?string $toEmail, app/Modules/NotificationsCollaboration/EmailQueueService.php-132- ?string $toName, app/Modules/NotificationsCollaboration/EmailQueueService.php-133- array $variables, app/Modules/NotificationsCollaboration/EmailQueueService.php-134- ?string $notificationId, app/Modules/NotificationsCollaboration/EmailQueueService.php-135- ?string $sourceType, app/Modules/NotificationsCollaboration/EmailQueueService.php-136- ?string $sourceId, app/Modules/NotificationsCollaboration/EmailQueueService.php-137- string $priority = 'NORMAL', app/Modules/NotificationsCollaboration/EmailQueueService.php-138- ?string $createdBy = null, app/Modules/NotificationsCollaboration/EmailQueueService.php-139- ?string $scheduledAt = null, -- app/Modules/NotificationsCollaboration/EmailQueueService.php-185- public function processBatch(int $limit = 20, ?string $actorId = null): array app/Modules/NotificationsCollaboration/EmailQueueService.php-186- { app/Modules/NotificationsCollaboration/EmailQueueService.php-187- $limit = max(1, min(100, $limit)); app/Modules/NotificationsCollaboration/EmailQueueService.php-188- $pdo = $this->db->connection(); app/Modules/NotificationsCollaboration/EmailQueueService.php-189- $pdo->exec("UPDATE email_queue SET status='PENDING',lock_token=NULL,locked_at=NULL,last_error=CONCAT(COALESCE(last_error,''),' | Recovered stale processing lock') WHERE status='PROCESSING' AND locked_at0,'sent'=>0,'retried'=>0,'failed'=>0,'skipped'=>0]; app/Modules/NotificationsCollaboration/EmailQueueService.php-191- for ($i=0;$i<$limit;$i++) { app/Modules/NotificationsCollaboration/EmailQueueService.php-192- $job = $this->claimOne(); app/Modules/NotificationsCollaboration/EmailQueueService.php-193- if (!$job) { $stats['skipped']++; break; } app/Modules/NotificationsCollaboration/EmailQueueService.php-194- $stats['processed']++; app/Modules/NotificationsCollaboration/EmailQueueService.php-195- $configId = null; app/Modules/NotificationsCollaboration/EmailQueueService.php-196- $started = microtime(true); app/Modules/NotificationsCollaboration/EmailQueueService.php-197- try { app/Modules/NotificationsCollaboration/EmailQueueService.php-198- $config = $this->activeConfig(); app/Modules/NotificationsCollaboration/EmailQueueService.php-199- $configId = (string) $config['id']; app/Modules/NotificationsCollaboration/EmailQueueService.php-200- $result = $this->transport->send($config, [ app/Modules/NotificationsCollaboration/EmailQueueService.php-201- 'to_email'=>$job['to_email'],'to_name'=>$job['to_name'],'cc'=>$this->jsonArray($job['cc_json']),'bcc'=>$this->jsonArray($job['bcc_json']), app/Modules/NotificationsCollaboration/EmailQueueService.php-202- 'subject'=>$job['subject_line'],'html'=>$job['html_body'],'text'=>$job['text_body'], app/Modules/NotificationsCollaboration/EmailQueueService.php-203- ]); app/Modules/NotificationsCollaboration/EmailQueueService.php-204- $pdo->prepare("UPDATE email_queue SET status='SENT',sent_at=UTC_TIMESTAMP(6),attempt_count=attempt_count+1,lock_token=NULL,locked_at=NULL,last_error=NULL WHERE id=:id")->execute(['id'=>$job['id']]); app/Modules/NotificationsCollaboration/EmailQueueService.php:205: $this->logAttempt((string)$job['id'],$configId,(int)$job['attempt_count']+1,'SENT',$result['smtp_code'],$result['smtp_response'],$result['message_id'],$result['duration_ms']); app/Modules/NotificationsCollaboration/EmailQueueService.php-206- $stats['sent']++; app/Modules/NotificationsCollaboration/EmailQueueService.php-207- } catch (Throwable $e) { app/Modules/NotificationsCollaboration/EmailQueueService.php-208- $attempt = (int)$job['attempt_count']+1; app/Modules/NotificationsCollaboration/EmailQueueService.php:209: $permanent = $e instanceof SmtpException && $e->permanent; app/Modules/NotificationsCollaboration/EmailQueueService.php-210- $failed = $permanent || $attempt >= (int)$job['max_attempts']; app/Modules/NotificationsCollaboration/EmailQueueService.php-211- if ($failed) { app/Modules/NotificationsCollaboration/EmailQueueService.php-212- $pdo->prepare("UPDATE email_queue SET status='FAILED',attempt_count=:attempt,lock_token=NULL,locked_at=NULL,last_error=:error WHERE id=:id")->execute(['attempt'=>$attempt,'error'=>Text::limit($e->getMessage(),2000),'id'=>$job['id']]); app/Modules/NotificationsCollaboration/EmailQueueService.php-213- $stats['failed']++; app/Modules/NotificationsCollaboration/EmailQueueService.php-214- } else { app/Modules/NotificationsCollaboration/EmailQueueService.php-215- $pdo->prepare("UPDATE email_queue SET status='PENDING',attempt_count=:attempt,next_attempt_at=:next,lock_token=NULL,locked_at=NULL,last_error=:error WHERE id=:id")->execute(['attempt'=>$attempt,'next'=>RetryPolicy::nextAttemptAt($attempt),'error'=>Text::limit($e->getMessage(),2000),'id'=>$job['id']]); app/Modules/NotificationsCollaboration/EmailQueueService.php-216- $stats['retried']++; app/Modules/NotificationsCollaboration/EmailQueueService.php-217- } app/Modules/NotificationsCollaboration/EmailQueueService.php:218: $code=$e instanceof SmtpException?$e->smtpCode:null;$resp=$e instanceof SmtpException?$e->smtpResponse:$e->getMessage(); app/Modules/NotificationsCollaboration/EmailQueueService.php-219- $this->logAttempt((string)$job['id'],$configId,$attempt,$failed?'PERMANENT_FAILURE':'TEMPORARY_FAILURE',$code,$resp,null,(int)round((microtime(true)-$started)*1000)); app/Modules/NotificationsCollaboration/EmailQueueService.php-220- } app/Modules/NotificationsCollaboration/EmailQueueService.php-221- } app/Modules/NotificationsCollaboration/EmailQueueService.php-222- $this->audit->record('email.queue_processed',$actorId,$stats); app/Modules/NotificationsCollaboration/EmailQueueService.php-223- return $stats; app/Modules/NotificationsCollaboration/EmailQueueService.php-224- } app/Modules/NotificationsCollaboration/EmailQueueService.php-225- app/Modules/NotificationsCollaboration/EmailQueueService.php-226- private function claimOne(): array|false app/Modules/NotificationsCollaboration/EmailQueueService.php-227- { app/Modules/NotificationsCollaboration/EmailQueueService.php-228- $pdo=$this->db->connection();$pdo->beginTransaction(); app/Modules/NotificationsCollaboration/EmailQueueService.php-229- try { app/Modules/NotificationsCollaboration/EmailQueueService.php-230- $s=$pdo->query("SELECT * FROM email_queue WHERE status='PENDING' AND scheduled_at<=UTC_TIMESTAMP(6) AND next_attempt_at<=UTC_TIMESTAMP(6) ORDER BY FIELD(priority,'CRITICAL','HIGH','NORMAL','LOW'),created_at LIMIT 1 FOR UPDATE SKIP LOCKED"); app/Modules/NotificationsCollaboration/EmailQueueService.php-231- $job=$s->fetch(); app/Modules/NotificationsCollaboration/EmailQueueService.php-232- if(!$job){$pdo->commit();return false;} app/Modules/NotificationsCollaboration/EmailQueueService.php-233- $token=bin2hex(random_bytes(32)); app/Modules/NotificationsCollaboration/EmailQueueService.php-234- $pdo->prepare("UPDATE email_queue SET status='PROCESSING',lock_token=:token,locked_at=UTC_TIMESTAMP(6) WHERE id=:id AND status='PENDING'")->execute(['token'=>$token,'id'=>$job['id']]); app/Modules/NotificationsCollaboration/EmailQueueService.php-235- $pdo->commit();$job['lock_token']=$token;return $job; app/Modules/NotificationsCollaboration/EmailQueueService.php-236- } catch(Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} app/Modules/NotificationsCollaboration/EmailQueueService.php-237- } app/Modules/NotificationsCollaboration/EmailQueueService.php-238- app/Modules/NotificationsCollaboration/EmailQueueService.php-239- private function activeConfig(): array app/Modules/NotificationsCollaboration/EmailQueueService.php-240- { app/Modules/NotificationsCollaboration/EmailQueueService.php:241: $row=$this->one("SELECT * FROM email_smtp_configs WHERE is_active=1 ORDER BY updated_at DESC LIMIT 1"); app/Modules/NotificationsCollaboration/EmailQueueService.php:242: if(!$row)throw new RuntimeException('No active SMTP configuration is available.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-243- $row['password']=$row['password_ciphertext']?$this->crypto->decrypt((string)$row['password_ciphertext']):''; app/Modules/NotificationsCollaboration/EmailQueueService.php-244- unset($row['password_ciphertext']);return $row; app/Modules/NotificationsCollaboration/EmailQueueService.php-245- } app/Modules/NotificationsCollaboration/EmailQueueService.php-246- app/Modules/NotificationsCollaboration/EmailQueueService.php-247- private function loadConfig(string $id): array app/Modules/NotificationsCollaboration/EmailQueueService.php-248- { app/Modules/NotificationsCollaboration/EmailQueueService.php:249: $row=$this->one('SELECT * FROM email_smtp_configs WHERE id=:id',['id'=>$id]); app/Modules/NotificationsCollaboration/EmailQueueService.php:250: if(!$row)throw new RuntimeException('SMTP configuration was not found.'); app/Modules/NotificationsCollaboration/EmailQueueService.php-251- $row['password']=$row['password_ciphertext']?$this->crypto->decrypt((string)$row['password_ciphertext']):''; app/Modules/NotificationsCollaboration/EmailQueueService.php-252- unset($row['password_ciphertext']);return $row; app/Modules/NotificationsCollaboration/EmailQueueService.php-253- } app/Modules/NotificationsCollaboration/EmailQueueService.php-254- app/Modules/NotificationsCollaboration/EmailQueueService.php-255- private function logAttempt(string $queueId,?string $configId,int $attempt,string $outcome,?string $code,?string $response,?string $messageId,int $duration):void app/Modules/NotificationsCollaboration/EmailQueueService.php-256- { app/Modules/NotificationsCollaboration/EmailQueueService.php:257: $this->db->connection()->prepare('INSERT INTO email_delivery_logs(id,email_queue_id,smtp_config_id,attempt_no,outcome,smtp_response_code,smtp_response_text,message_id,duration_ms) VALUES(:id,:queue,:smtp,:attempt,:outcome,:code,:response,:message,:duration)')->execute([ app/Modules/NotificationsCollaboration/EmailQueueService.php:258: 'id'=>Uuid::v4(),'queue'=>$queueId,'smtp'=>$configId,'attempt'=>$attempt,'outcome'=>$outcome,'code'=>$code,'response'=>$response?Text::limit($response,1000):null,'message'=>$messageId,'duration'=>$duration, app/Modules/NotificationsCollaboration/EmailQueueService.php-259- ]); app/Modules/NotificationsCollaboration/EmailQueueService.php-260- } app/Modules/NotificationsCollaboration/EmailQueueService.php-261- app/Modules/NotificationsCollaboration/EmailQueueService.php-262- private function jsonArray(mixed $json): array app/Modules/NotificationsCollaboration/EmailQueueService.php-263- { app/Modules/NotificationsCollaboration/EmailQueueService.php-264- if(!$json)return[];if(is_array($json))return$json;$decoded=json_decode((string)$json,true);return is_array($decoded)?$decoded:[]; app/Modules/NotificationsCollaboration/EmailQueueService.php-265- } app/Modules/NotificationsCollaboration/EmailQueueService.php-266- private function priority(string $priority):string{$p=strtoupper($priority);return in_array($p,['LOW','NORMAL','HIGH','CRITICAL'],true)?$p:'NORMAL';} app/Modules/NotificationsCollaboration/EmailQueueService.php-267- private function all(string $sql,array $params=[]):array{$s=$this->db->connection()->prepare($sql);$s->execute($params);return$s->fetchAll();} app/Modules/NotificationsCollaboration/EmailQueueService.php-268- private function one(string $sql,array $params=[]):array|false{$s=$this->db->connection()->prepare($sql);$s->execute($params);return$s->fetch();} app/Modules/NotificationsCollaboration/EmailQueueService.php-269-} -- app/Services/Mail/SmtpTransport.php-1-builder ??= new SmtpMessageBuilder(); app/Services/Mail/SmtpTransport.php-10- } app/Services/Mail/SmtpTransport.php-11- app/Services/Mail/SmtpTransport.php-12- /** app/Services/Mail/SmtpTransport.php-13- * @param array $config app/Services/Mail/SmtpTransport.php-14- * @param array $message app/Services/Mail/SmtpTransport.php:15: * @return array{message_id:string,smtp_code:string,smtp_response:string,duration_ms:int} app/Services/Mail/SmtpTransport.php-16- */ app/Services/Mail/SmtpTransport.php-17- public function send(array $config, array $message): array app/Services/Mail/SmtpTransport.php-18- { app/Services/Mail/SmtpTransport.php-19- $start = microtime(true); app/Services/Mail/SmtpTransport.php:20: $host = trim((string) ($config['smtp_host'] ?? '')); app/Services/Mail/SmtpTransport.php:21: $port = (int) ($config['smtp_port'] ?? 0); app/Services/Mail/SmtpTransport.php-22- $mode = strtoupper((string) ($config['encryption_mode'] ?? 'TLS')); app/Services/Mail/SmtpTransport.php-23- $timeout = max(5, min(120, (int) ($config['timeout_seconds'] ?? 20))); app/Services/Mail/SmtpTransport.php-24- if ($host === '' || $port < 1 || $port > 65535) { app/Services/Mail/SmtpTransport.php:25: throw new SmtpException('SMTP host or port is invalid.', true); app/Services/Mail/SmtpTransport.php-26- } app/Services/Mail/SmtpTransport.php-27- if (!in_array($mode, ['TLS', 'SSL', 'NONE'], true)) { app/Services/Mail/SmtpTransport.php:28: throw new SmtpException('SMTP encryption mode is invalid.', true); app/Services/Mail/SmtpTransport.php-29- } app/Services/Mail/SmtpTransport.php-30- app/Services/Mail/SmtpTransport.php-31- $target = ($mode === 'SSL' ? 'ssl://' : 'tcp://') . $host . ':' . $port; app/Services/Mail/SmtpTransport.php-32- $context = stream_context_create([ app/Services/Mail/SmtpTransport.php-33- 'ssl' => [ app/Services/Mail/SmtpTransport.php-34- 'verify_peer' => true, app/Services/Mail/SmtpTransport.php-35- 'verify_peer_name' => true, app/Services/Mail/SmtpTransport.php-36- 'allow_self_signed' => false, app/Services/Mail/SmtpTransport.php-37- 'SNI_enabled' => true, app/Services/Mail/SmtpTransport.php-38- 'peer_name' => $host, app/Services/Mail/SmtpTransport.php-39- ], app/Services/Mail/SmtpTransport.php-40- ]); app/Services/Mail/SmtpTransport.php-41- $errno = 0; $errstr = ''; app/Services/Mail/SmtpTransport.php-42- $socket = @stream_socket_client($target, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $context); app/Services/Mail/SmtpTransport.php-43- if (!is_resource($socket)) { app/Services/Mail/SmtpTransport.php:44: throw new SmtpException('Unable to connect to SMTP server: ' . $errstr, false); app/Services/Mail/SmtpTransport.php-45- } app/Services/Mail/SmtpTransport.php-46- stream_set_timeout($socket, $timeout); app/Services/Mail/SmtpTransport.php-47- app/Services/Mail/SmtpTransport.php-48- try { app/Services/Mail/SmtpTransport.php-49- $this->expect($socket, [220]); app/Services/Mail/SmtpTransport.php-50- $hostname = gethostname() ?: 'bcos.local'; app/Services/Mail/SmtpTransport.php-51- $this->command($socket, 'EHLO ' . preg_replace('/[^a-zA-Z0-9.-]/', '', $hostname), [250]); app/Services/Mail/SmtpTransport.php-52- if ($mode === 'TLS') { app/Services/Mail/SmtpTransport.php-53- $this->command($socket, 'STARTTLS', [220]); app/Services/Mail/SmtpTransport.php-54- $ok = @stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); app/Services/Mail/SmtpTransport.php:55: if ($ok !== true) throw new SmtpException('Unable to establish SMTP TLS encryption.', false); app/Services/Mail/SmtpTransport.php-56- $this->command($socket, 'EHLO ' . preg_replace('/[^a-zA-Z0-9.-]/', '', $hostname), [250]); app/Services/Mail/SmtpTransport.php-57- } app/Services/Mail/SmtpTransport.php-58- app/Services/Mail/SmtpTransport.php-59- $authMode = strtoupper((string) ($config['auth_mode'] ?? 'LOGIN')); app/Services/Mail/SmtpTransport.php-60- if ($authMode === 'LOGIN') { app/Services/Mail/SmtpTransport.php-61- $username = (string) ($config['username'] ?? ''); app/Services/Mail/SmtpTransport.php-62- $password = (string) ($config['password'] ?? ''); app/Services/Mail/SmtpTransport.php:63: if ($username === '' || $password === '') throw new SmtpException('SMTP credentials are missing.', true); app/Services/Mail/SmtpTransport.php-64- $this->command($socket, 'AUTH LOGIN', [334]); app/Services/Mail/SmtpTransport.php-65- $this->command($socket, base64_encode($username), [334], false); app/Services/Mail/SmtpTransport.php-66- $this->command($socket, base64_encode($password), [235], false); app/Services/Mail/SmtpTransport.php-67- } elseif ($authMode !== 'NONE') { app/Services/Mail/SmtpTransport.php:68: throw new SmtpException('Unsupported SMTP authentication mode.', true); app/Services/Mail/SmtpTransport.php-69- } app/Services/Mail/SmtpTransport.php-70- app/Services/Mail/SmtpTransport.php-71- $built = $this->builder->build([ app/Services/Mail/SmtpTransport.php-72- 'from_email' => (string) $config['from_email'], app/Services/Mail/SmtpTransport.php-73- 'from_name' => (string) $config['from_name'], app/Services/Mail/SmtpTransport.php-74- 'reply_to_email' => $config['reply_to_email'] ?? null, app/Services/Mail/SmtpTransport.php-75- ], $message); app/Services/Mail/SmtpTransport.php-76- $this->command($socket, 'MAIL FROM:<' . $built['envelope_from'] . '>', [250]); app/Services/Mail/SmtpTransport.php-77- foreach ($built['recipients'] as $recipient) { app/Services/Mail/SmtpTransport.php-78- $this->command($socket, 'RCPT TO:<' . $recipient['email'] . '>', [250, 251]); app/Services/Mail/SmtpTransport.php-79- } app/Services/Mail/SmtpTransport.php-80- $this->command($socket, 'DATA', [354]); app/Services/Mail/SmtpTransport.php-81- $payload = preg_replace('/(?m)^\./', '..', $built['raw']) ?? $built['raw']; app/Services/Mail/SmtpTransport.php-82- fwrite($socket, rtrim($payload, "\r\n") . "\r\n.\r\n"); app/Services/Mail/SmtpTransport.php-83- [$code, $response] = $this->expect($socket, [250]); app/Services/Mail/SmtpTransport.php:84: try { $this->command($socket, 'QUIT', [221]); } catch (SmtpException) {} app/Services/Mail/SmtpTransport.php-85- app/Services/Mail/SmtpTransport.php-86- return [ app/Services/Mail/SmtpTransport.php-87- 'message_id' => $built['message_id'], app/Services/Mail/SmtpTransport.php:88: 'smtp_code' => (string) $code, app/Services/Mail/SmtpTransport.php:89: 'smtp_response' => $response, app/Services/Mail/SmtpTransport.php-90- 'duration_ms' => (int) round((microtime(true) - $start) * 1000), app/Services/Mail/SmtpTransport.php-91- ]; app/Services/Mail/SmtpTransport.php-92- } finally { app/Services/Mail/SmtpTransport.php-93- fclose($socket); app/Services/Mail/SmtpTransport.php-94- } app/Services/Mail/SmtpTransport.php-95- } app/Services/Mail/SmtpTransport.php-96- app/Services/Mail/SmtpTransport.php-97- /** @return array{0:int,1:string} */ app/Services/Mail/SmtpTransport.php-98- private function command($socket, string $command, array $expected, bool $loggable = true): array app/Services/Mail/SmtpTransport.php-99- { app/Services/Mail/SmtpTransport.php-100- if (fwrite($socket, $command . "\r\n") === false) { app/Services/Mail/SmtpTransport.php:101: throw new SmtpException('Failed to write to SMTP connection.', false); app/Services/Mail/SmtpTransport.php-102- } app/Services/Mail/SmtpTransport.php-103- return $this->expect($socket, $expected, $loggable ? $command : '[credential]'); app/Services/Mail/SmtpTransport.php-104- } app/Services/Mail/SmtpTransport.php-105- app/Services/Mail/SmtpTransport.php-106- /** @return array{0:int,1:string} */ app/Services/Mail/SmtpTransport.php-107- private function expect($socket, array $expected, string $command = ''): array app/Services/Mail/SmtpTransport.php-108- { app/Services/Mail/SmtpTransport.php-109- $lines = []; app/Services/Mail/SmtpTransport.php-110- while (($line = fgets($socket, 4096)) !== false) { app/Services/Mail/SmtpTransport.php-111- $lines[] = rtrim($line, "\r\n"); app/Services/Mail/SmtpTransport.php-112- if (preg_match('/^(\d{3})\s/', $line, $match)) { app/Services/Mail/SmtpTransport.php-113- $code = (int) $match[1]; app/Services/Mail/SmtpTransport.php-114- $response = implode(' | ', $lines); app/Services/Mail/SmtpTransport.php-115- if (!in_array($code, $expected, true)) { app/Services/Mail/SmtpTransport.php-116- $permanent = $code >= 500 && $code < 600; app/Services/Mail/SmtpTransport.php:117: throw new SmtpException('SMTP command failed' . ($command !== '' ? " after {$command}" : '') . ': ' . $response, $permanent, (string) $code, $response); app/Services/Mail/SmtpTransport.php-118- } app/Services/Mail/SmtpTransport.php-119- return [$code, $response]; app/Services/Mail/SmtpTransport.php-120- } app/Services/Mail/SmtpTransport.php-121- } app/Services/Mail/SmtpTransport.php-122- $meta = stream_get_meta_data($socket); app/Services/Mail/SmtpTransport.php:123: $reason = !empty($meta['timed_out']) ? 'SMTP connection timed out.' : 'SMTP connection closed unexpectedly.'; app/Services/Mail/SmtpTransport.php:124: throw new SmtpException($reason, false); app/Services/Mail/SmtpTransport.php-125- } app/Services/Mail/SmtpTransport.php-126-} -- app/Services/Mail/SmtpException.php-1-email((string) $sender['from_email']); app/Services/Mail/SmtpMessageBuilder.php:16: $toEmail = $this->email((string) $message['to_email']); app/Services/Mail/SmtpMessageBuilder.php-17- $messageId = sprintf('<%s@%s>', bin2hex(random_bytes(16)), $this->messageIdDomain($fromEmail)); app/Services/Mail/SmtpMessageBuilder.php-18- $boundary = 'bcos_' . bin2hex(random_bytes(18)); app/Services/Mail/SmtpMessageBuilder.php-19- $toName = trim((string) ($message['to_name'] ?? '')); app/Services/Mail/SmtpMessageBuilder.php-20- $fromName = trim((string) $sender['from_name']); app/Services/Mail/SmtpMessageBuilder.php-21- $headers = [ app/Services/Mail/SmtpMessageBuilder.php-22- 'Date: ' . gmdate('D, d M Y H:i:s +0000'), app/Services/Mail/SmtpMessageBuilder.php-23- 'Message-ID: ' . $messageId, app/Services/Mail/SmtpMessageBuilder.php-24- 'From: ' . $this->mailbox($fromName, $fromEmail), app/Services/Mail/SmtpMessageBuilder.php-25- 'To: ' . $this->mailbox($toName, $toEmail), app/Services/Mail/SmtpMessageBuilder.php-26- 'Subject: ' . $this->encodeHeader((string) $message['subject']), app/Services/Mail/SmtpMessageBuilder.php-27- 'MIME-Version: 1.0', app/Services/Mail/SmtpMessageBuilder.php-28- 'Content-Type: multipart/alternative; boundary="' . $boundary . '"', app/Services/Mail/SmtpMessageBuilder.php:29: 'X-Mailer: BCOS-SMTP/1.0', app/Services/Mail/SmtpMessageBuilder.php-30- 'Auto-Submitted: auto-generated', app/Services/Mail/SmtpMessageBuilder.php-31- ]; app/Services/Mail/SmtpMessageBuilder.php-32- if (!empty($sender['reply_to_email'])) { app/Services/Mail/SmtpMessageBuilder.php:33: $headers[] = 'Reply-To: ' . $this->email((string) $sender['reply_to_email']); app/Services/Mail/SmtpMessageBuilder.php-34- } app/Services/Mail/SmtpMessageBuilder.php-35- $cc = $this->normalizeRecipients($message['cc'] ?? []); app/Services/Mail/SmtpMessageBuilder.php-36- if ($cc) { app/Services/Mail/SmtpMessageBuilder.php-37- $headers[] = 'Cc: ' . implode(', ', array_map(fn(array $r): string => $this->mailbox($r['name'], $r['email']), $cc)); app/Services/Mail/SmtpMessageBuilder.php-38- } app/Services/Mail/SmtpMessageBuilder.php-39- app/Services/Mail/SmtpMessageBuilder.php-40- $text = $this->normalizeBody((string) $message['text']); app/Services/Mail/SmtpMessageBuilder.php-41- $html = $this->normalizeBody((string) $message['html']); app/Services/Mail/SmtpMessageBuilder.php-42- $body = '--' . $boundary . "\r\n" app/Services/Mail/SmtpMessageBuilder.php-43- . "Content-Type: text/plain; charset=UTF-8\r\n" app/Services/Mail/SmtpMessageBuilder.php-44- . "Content-Transfer-Encoding: quoted-printable\r\n\r\n" app/Services/Mail/SmtpMessageBuilder.php-45- . quoted_printable_encode($text) . "\r\n" app/Services/Mail/SmtpMessageBuilder.php-46- . '--' . $boundary . "\r\n" app/Services/Mail/SmtpMessageBuilder.php-47- . "Content-Type: text/html; charset=UTF-8\r\n" app/Services/Mail/SmtpMessageBuilder.php-48- . "Content-Transfer-Encoding: quoted-printable\r\n\r\n" app/Services/Mail/SmtpMessageBuilder.php-49- . quoted_printable_encode($html) . "\r\n" app/Services/Mail/SmtpMessageBuilder.php-50- . '--' . $boundary . "--\r\n"; app/Services/Mail/SmtpMessageBuilder.php-51- app/Services/Mail/SmtpMessageBuilder.php-52- $recipients = [['email' => $toEmail, 'name' => $toName], ...$cc, ...$this->normalizeRecipients($message['bcc'] ?? [])]; app/Services/Mail/SmtpMessageBuilder.php-53- $unique = []; app/Services/Mail/SmtpMessageBuilder.php-54- foreach ($recipients as $recipient) { app/Services/Mail/SmtpMessageBuilder.php-55- $unique[strtolower($recipient['email'])] = $recipient; app/Services/Mail/SmtpMessageBuilder.php-56- } app/Services/Mail/SmtpMessageBuilder.php-57- return [ app/Services/Mail/SmtpMessageBuilder.php-58- 'message_id' => $messageId, app/Services/Mail/SmtpMessageBuilder.php-59- 'raw' => implode("\r\n", $headers) . "\r\n\r\n" . $body, app/Services/Mail/SmtpMessageBuilder.php-60- 'envelope_from' => $fromEmail, app/Services/Mail/SmtpMessageBuilder.php-61- 'recipients' => array_values($unique), app/Services/Mail/SmtpMessageBuilder.php-62- ]; app/Services/Mail/SmtpMessageBuilder.php-63- } app/Services/Mail/SmtpMessageBuilder.php-64- app/Services/Mail/SmtpMessageBuilder.php-65- /** @return list */ app/Services/Mail/SmtpMessageBuilder.php-66- private function normalizeRecipients(mixed $input): array app/Services/Mail/SmtpMessageBuilder.php-67- { app/Services/Mail/SmtpMessageBuilder.php-68- if (!is_array($input)) return []; app/Services/Mail/SmtpMessageBuilder.php-69- $result = []; app/Services/Mail/SmtpMessageBuilder.php-70- foreach ($input as $entry) { app/Services/Mail/SmtpMessageBuilder.php-71- if (is_string($entry)) { app/Services/Mail/SmtpMessageBuilder.php:72: $result[] = ['email' => $this->email($entry), 'name' => '']; app/Services/Mail/SmtpMessageBuilder.php-73- } elseif (is_array($entry) && isset($entry['email'])) { app/Services/Mail/SmtpMessageBuilder.php:74: $result[] = ['email' => $this->email((string) $entry['email']), 'name' => trim((string) ($entry['name'] ?? ''))]; app/Services/Mail/SmtpMessageBuilder.php-75- } app/Services/Mail/SmtpMessageBuilder.php-76- } app/Services/Mail/SmtpMessageBuilder.php-77- return $result; app/Services/Mail/SmtpMessageBuilder.php-78- } app/Services/Mail/SmtpMessageBuilder.php-79- app/Services/Mail/SmtpMessageBuilder.php-80- private function mailbox(string $name, string $email): string app/Services/Mail/SmtpMessageBuilder.php-81- { app/Services/Mail/SmtpMessageBuilder.php-82- return $name === '' ? $email : $this->encodeHeader($name) . ' <' . $email . '>'; app/Services/Mail/SmtpMessageBuilder.php-83- } app/Services/Mail/SmtpMessageBuilder.php-84- app/Services/Mail/SmtpMessageBuilder.php-85- private function encodeHeader(string $value): string app/Services/Mail/SmtpMessageBuilder.php-86- { app/Services/Mail/SmtpMessageBuilder.php-87- return '=?UTF-8?B?' . base64_encode(str_replace(["\r", "\n"], '', $value)) . '?='; app/Services/Mail/SmtpMessageBuilder.php-88- } app/Services/Mail/SmtpMessageBuilder.php-89- app/Services/Mail/SmtpMessageBuilder.php:90: private function email(string $email): string app/Services/Mail/SmtpMessageBuilder.php-91- { app/Services/Mail/SmtpMessageBuilder.php-92- $email = trim($email); app/Services/Mail/SmtpMessageBuilder.php-93- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { app/Services/Mail/SmtpMessageBuilder.php-94- throw new RuntimeException('Invalid email address.'); app/Services/Mail/SmtpMessageBuilder.php-95- } app/Services/Mail/SmtpMessageBuilder.php-96- return $email; app/Services/Mail/SmtpMessageBuilder.php-97- } app/Services/Mail/SmtpMessageBuilder.php-98- app/Services/Mail/SmtpMessageBuilder.php-99- private function messageIdDomain(string $email): string app/Services/Mail/SmtpMessageBuilder.php-100- { app/Services/Mail/SmtpMessageBuilder.php-101- $domain = substr(strrchr($email, '@') ?: '@localhost', 1); app/Services/Mail/SmtpMessageBuilder.php-102- return preg_replace('/[^a-zA-Z0-9.-]/', '', $domain) ?: 'localhost'; app/Services/Mail/SmtpMessageBuilder.php-103- } app/Services/Mail/SmtpMessageBuilder.php-104- app/Services/Mail/SmtpMessageBuilder.php-105- private function normalizeBody(string $body): string app/Services/Mail/SmtpMessageBuilder.php-106- { app/Services/Mail/SmtpMessageBuilder.php-107- return preg_replace("/\r\n|\r|\n/", "\r\n", $body) ?? $body; app/Services/Mail/SmtpMessageBuilder.php-108- } app/Services/Mail/SmtpMessageBuilder.php-109-} === DOCUMENT DATABASE TABLES === database/migrations/001_foundation_schema.sql-415- title VARCHAR(255) NOT NULL, database/migrations/001_foundation_schema.sql-416- status ENUM('DRAFT','IN_REVIEW','AWAITING_APPROVAL','APPROVED','RELEASED','SUPERSEDED','ARCHIVED') NOT NULL DEFAULT 'DRAFT', database/migrations/001_foundation_schema.sql-417- classification ENUM('PUBLIC','INTERNAL','CONFIDENTIAL','RESTRICTED','HIGHLY_CONFIDENTIAL') NOT NULL DEFAULT 'CONFIDENTIAL', database/migrations/001_foundation_schema.sql-418- owner_user_id CHAR(36) NOT NULL, database/migrations/001_foundation_schema.sql-419- current_version_no INT UNSIGNED NOT NULL DEFAULT 1, database/migrations/001_foundation_schema.sql-420- created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), database/migrations/001_foundation_schema.sql-421- updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), database/migrations/001_foundation_schema.sql-422- CONSTRAINT fk_doc_project FOREIGN KEY (project_id) REFERENCES projects(id), database/migrations/001_foundation_schema.sql-423- CONSTRAINT fk_doc_org FOREIGN KEY (organization_id) REFERENCES organizations(id), database/migrations/001_foundation_schema.sql-424- CONSTRAINT fk_doc_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) database/migrations/001_foundation_schema.sql-425-) ENGINE=InnoDB; database/migrations/001_foundation_schema.sql-426- database/migrations/001_foundation_schema.sql:427:CREATE TABLE document_versions ( database/migrations/001_foundation_schema.sql-428- id CHAR(36) PRIMARY KEY, database/migrations/001_foundation_schema.sql-429- document_id CHAR(36) NOT NULL, database/migrations/001_foundation_schema.sql-430- version_no INT UNSIGNED NOT NULL, database/migrations/001_foundation_schema.sql-431- file_id CHAR(36) NOT NULL, database/migrations/001_foundation_schema.sql-432- change_summary VARCHAR(500) NULL, database/migrations/001_foundation_schema.sql-433- created_by CHAR(36) NOT NULL, database/migrations/001_foundation_schema.sql-434- created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), database/migrations/001_foundation_schema.sql-435- UNIQUE KEY uq_doc_version (document_id, version_no), database/migrations/001_foundation_schema.sql-436- CONSTRAINT fk_dv_document FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, database/migrations/001_foundation_schema.sql-437- CONSTRAINT fk_dv_file FOREIGN KEY (file_id) REFERENCES files(id), database/migrations/001_foundation_schema.sql-438- CONSTRAINT fk_dv_creator FOREIGN KEY (created_by) REFERENCES users(id) database/migrations/001_foundation_schema.sql-439-) ENGINE=InnoDB; -- database/migrations/008_documents_contracts_reports.sql-30- retention_category VARCHAR(80) NULL, database/migrations/008_documents_contracts_reports.sql-31- legal_hold TINYINT(1) NOT NULL DEFAULT 0, database/migrations/008_documents_contracts_reports.sql-32- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, database/migrations/008_documents_contracts_reports.sql-33- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, database/migrations/008_documents_contracts_reports.sql-34- archived_at DATETIME NULL, database/migrations/008_documents_contracts_reports.sql-35- INDEX idx_controlled_documents_project(project_id), database/migrations/008_documents_contracts_reports.sql-36- INDEX idx_controlled_documents_client(client_organization_id), database/migrations/008_documents_contracts_reports.sql-37- CONSTRAINT fk_controlled_documents_project FOREIGN KEY(project_id) REFERENCES projects(id), database/migrations/008_documents_contracts_reports.sql-38- CONSTRAINT fk_controlled_documents_client FOREIGN KEY(client_organization_id) REFERENCES organizations(id), database/migrations/008_documents_contracts_reports.sql-39- CONSTRAINT fk_controlled_documents_owner FOREIGN KEY(owner_user_id) REFERENCES users(id) database/migrations/008_documents_contracts_reports.sql-40-) ENGINE=InnoDB; database/migrations/008_documents_contracts_reports.sql-41- database/migrations/008_documents_contracts_reports.sql:42:CREATE TABLE IF NOT EXISTS controlled_document_versions ( database/migrations/008_documents_contracts_reports.sql-43- id CHAR(36) PRIMARY KEY, database/migrations/008_documents_contracts_reports.sql-44- document_id CHAR(36) NOT NULL, database/migrations/008_documents_contracts_reports.sql-45- version_number INT UNSIGNED NOT NULL, database/migrations/008_documents_contracts_reports.sql-46- content_json JSON NOT NULL, database/migrations/008_documents_contracts_reports.sql-47- rendered_html MEDIUMTEXT NULL, database/migrations/008_documents_contracts_reports.sql-48- content_hash CHAR(64) NOT NULL, database/migrations/008_documents_contracts_reports.sql-49- change_summary VARCHAR(500) NULL, database/migrations/008_documents_contracts_reports.sql-50- created_by CHAR(36) NOT NULL, database/migrations/008_documents_contracts_reports.sql-51- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, database/migrations/008_documents_contracts_reports.sql-52- UNIQUE KEY uq_document_version(document_id,version_number), database/migrations/008_documents_contracts_reports.sql:53: CONSTRAINT fk_document_versions_document FOREIGN KEY(document_id) REFERENCES controlled_documents(id), database/migrations/008_documents_contracts_reports.sql:54: CONSTRAINT fk_document_versions_creator FOREIGN KEY(created_by) REFERENCES users(id) database/migrations/008_documents_contracts_reports.sql-55-) ENGINE=InnoDB; database/migrations/008_documents_contracts_reports.sql-56- database/migrations/008_documents_contracts_reports.sql-57-CREATE TABLE IF NOT EXISTS document_distribution_events ( database/migrations/008_documents_contracts_reports.sql-58- id CHAR(36) PRIMARY KEY, database/migrations/008_documents_contracts_reports.sql-59- document_id CHAR(36) NOT NULL, database/migrations/008_documents_contracts_reports.sql-60- version_number INT UNSIGNED NOT NULL, database/migrations/008_documents_contracts_reports.sql-61- channel ENUM('PORTAL','EMAIL','PRINT','DOWNLOAD','INTERNAL') NOT NULL, database/migrations/008_documents_contracts_reports.sql-62- recipient_name VARCHAR(255) NULL, database/migrations/008_documents_contracts_reports.sql-63- recipient_address VARCHAR(255) NULL, database/migrations/008_documents_contracts_reports.sql-64- purpose VARCHAR(255) NULL, database/migrations/008_documents_contracts_reports.sql-65- policy_notice_acknowledged TINYINT(1) NOT NULL DEFAULT 0, database/migrations/008_documents_contracts_reports.sql-66- tracking_token_hash CHAR(64) NULL, -- app/Modules/Documents/DocumentService.php-1-db->connection();$pdo->beginTransaction();try{$year=(int)gmdate('Y');$q=$pdo->prepare('SELECT id,last_value,prefix FROM document_number_sequences WHERE entity_type=? AND jurisdiction_code=? AND year_value=? FOR UPDATE');$q->execute([$type,$jurisdiction,$year]);$row=$q->fetch(PDO::FETCH_ASSOC);$prefix=$this->prefix($type,$jurisdiction);if(!$row){$id=Uuid::v4();$pdo->prepare('INSERT INTO document_number_sequences(id,entity_type,jurisdiction_code,year_value,last_value,prefix) VALUES(?,?,?,?,1,?)')->execute([$id,$type,$jurisdiction,$year,$prefix]);$n=1;}else{$n=((int)$row['last_value'])+1;$pdo->prepare('UPDATE document_number_sequences SET last_value=? WHERE id=?')->execute([$n,$row['id']]);$prefix=(string)$row['prefix'];}$pdo->commit();return sprintf('%s-%d-%06d',$prefix,$year,$n);}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/Documents/DocumentService.php-8- private function prefix(string $type,string $jurisdiction):string{$map=['PROPOSAL'=>'PRO','QUOTATION'=>'QUO','PRO_FORMA_INVOICE'=>'PFI','INVOICE'=>'INV','RECEIPT'=>'REC','CREDIT_NOTE'=>'CRN','ENGAGEMENT_LETTER'=>'ENG','SERVICE_AGREEMENT'=>'SVA','NDA'=>'NDA','NCNDA'=>'NCNDA','CONTRACT'=>'CON','PURCHASE_ORDER'=>'PO','REPORT'=>'RPT','MEDIA_PERMISSION'=>'MED','DOCUMENT'=>'DOC'];return 'BCOS-'.($map[$type]??'DOC').'-'.$jurisdiction;} app/Modules/Documents/DocumentService.php:9: public function create(array $d,string $user):array{$type=strtoupper((string)($d['document_type']??'DOCUMENT'));$jur=strtoupper((string)($d['jurisdiction_code']??'INT'));$ref=$this->nextReference($type,$jur);$id=Uuid::v4();$content=is_array($d['content']??null)?$d['content']:[];$entityId=(string)($d['issuing_legal_entity_id']??'');if($entityId!=='')$content['_issuing_entity_snapshot']=$this->legalEntitySnapshot($entityId);$json=json_encode($content,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);$meta=$d;$meta['document_type']=$type;$meta['issuing_legal_entity']=$content['_issuing_entity_snapshot']??null;$html=$this->renderHtml($meta,$ref,1,$content);$hash=hash('sha256',$html);$pdo=$this->db->connection();$pdo->beginTransaction();try{$pdo->prepare('INSERT INTO controlled_documents(id,document_reference,document_type,title,project_id,client_organization_id,issuing_legal_entity_id,language_mode,classification,status,current_version,watermark_text,owner_user_id,retention_category) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)')->execute([$id,$ref,$type,(string)($d['title']??$type),$d['project_id']??null,$d['client_organization_id']??null,$entityId!==''?$entityId:null,$d['language_mode']??'EN',$d['classification']??'CONFIDENTIAL','DRAFT',1,$d['watermark_text']??'DRAFT',$user,$d['retention_category']??'CLIENT_PROJECT']);$pdo->prepare('INSERT INTO controlled_document_versions(id,document_id,version_number,content_json,rendered_html,content_hash,change_summary,created_by) VALUES(?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,1,$json,$html,$hash,'Initial version',$user]);$pdo->commit();$this->audit->record('document.created',$user,['document_id'=>$id,'reference'=>$ref,'document_type'=>$type]);return ['id'=>$id,'reference'=>$ref,'version'=>1,'hash'=>$hash,'html'=>$html];}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}} app/Modules/Documents/DocumentService.php:10: public function newVersion(string $id,array $content,string $summary,string $user):array{$pdo=$this->db->connection();$pdo->beginTransaction();try{$q=$pdo->prepare('SELECT * FROM controlled_documents WHERE id=? FOR UPDATE');$q->execute([$id]);$doc=$q->fetch(PDO::FETCH_ASSOC);if(!$doc)throw new RuntimeException('Document not found.');$version=((int)$doc['current_version'])+1;$html=$this->renderHtml($doc,(string)$doc['document_reference'],$version,$content);$json=json_encode($content,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);$hash=hash('sha256',$html);$pdo->prepare('INSERT INTO controlled_document_versions(id,document_id,version_number,content_json,rendered_html,content_hash,change_summary,created_by) VALUES(?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$version,$json,$html,$hash,$summary,$user]);$pdo->prepare("UPDATE controlled_documents SET current_version=?,status='DRAFT',updated_at=UTC_TIMESTAMP() WHERE id=?")->execute([$version,$id]);$pdo->commit();$this->audit->record('document.version_created',$user,['document_id'=>$id,'version'=>$version]);return ['id'=>$id,'version'=>$version,'hash'=>$hash,'html'=>$html];}catch(\Throwable $e){$pdo->rollBack();throw $e;}} app/Modules/Documents/DocumentService.php-11- public function approve(string $id,string $user):void{$this->db->connection()->prepare("UPDATE controlled_documents SET status='APPROVED',approved_by=?,approved_at=UTC_TIMESTAMP() WHERE id=?")->execute([$user,$id]);$this->audit->record('document.approved',$user,['document_id'=>$id]);} app/Modules/Documents/DocumentService.php:12: public function show(string $id):array{$q=$this->db->connection()->prepare('SELECT d.*,v.rendered_html,v.content_hash,v.version_number FROM controlled_documents d JOIN controlled_document_versions v ON v.document_id=d.id AND v.version_number=d.current_version WHERE d.id=?');$q->execute([$id]);$row=$q->fetch(PDO::FETCH_ASSOC);if(!$row)throw new RuntimeException('Document not found.');return $row;} app/Modules/Documents/DocumentService.php-13- public function distribute(string $id,array $d,string $user):array{$token=bin2hex(random_bytes(24));$hash=hash('sha256',$token);$doc=$this->show($id);$this->db->connection()->prepare('INSERT INTO document_distribution_events(id,document_id,version_number,channel,recipient_name,recipient_address,purpose,policy_notice_acknowledged,tracking_token_hash,expires_at,distributed_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)')->execute([Uuid::v4(),$id,$doc['version_number'],$d['channel']??'PORTAL',$d['recipient_name']??null,$d['recipient_address']??null,$d['purpose']??null,!empty($d['policy_notice_acknowledged'])?1:0,$hash,$d['expires_at']??null,$user]);$this->audit->record('document.distributed',$user,['document_id'=>$id,'channel'=>$d['channel']??'PORTAL']);return ['access_token'=>$token,'policy_notice'=>'Access is transparently logged under the BCOS Document Distribution and Privacy Policy.'];} app/Modules/Documents/DocumentService.php-14- public function mediaPermission(array $d,string $user):array{$id=Uuid::v4();$ref=$this->nextReference('MEDIA_PERMISSION',(string)($d['jurisdiction_code']??'INT'));$this->db->connection()->prepare('INSERT INTO media_permissions(id,permission_reference,project_id,client_organization_id,media_type,owner_name,owner_contact,permitted_channels,permitted_territories,attribution_requirement,purpose,valid_from,valid_until,status,consent_document_id,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')->execute([$id,$ref,$d['project_id']??null,$d['client_organization_id']??null,$d['media_type']??'IMAGE',$d['owner_name']??'', $d['owner_contact']??null,json_encode($d['permitted_channels']??[]),json_encode($d['permitted_territories']??[]),$d['attribution_requirement']??null,$d['purpose']??'', $d['valid_from']??null,$d['valid_until']??null,$d['status']??'PENDING',$d['consent_document_id']??null,$user]);$this->audit->record('media_permission.created',$user,['media_permission_id'=>$id,'reference'=>$ref]);return ['id'=>$id,'reference'=>$ref];} app/Modules/Documents/DocumentService.php-15- public function executiveReport(string $projectId,string $user):array{$pdo=$this->db->connection();$q=$pdo->prepare('SELECT p.*,o.legal_name client_name FROM projects p LEFT JOIN organizations o ON o.id=p.client_organization_id WHERE p.id=?');$q->execute([$projectId]);$p=$q->fetch(PDO::FETCH_ASSOC);if(!$p)throw new RuntimeException('Project not found.');$fq=$pdo->prepare('SELECT * FROM vw_project_financial_position WHERE project_id=?');$fq->execute([$projectId]);$f=$fq->fetch(PDO::FETCH_ASSOC)?:[];$rq=$pdo->prepare("SELECT COUNT(*) total, SUM(status='CLOSED') closed FROM project_risks WHERE project_id=?");$rq->execute([$projectId]);$risks=$rq->fetch(PDO::FETCH_ASSOC)?:[];$content=['summary'=>'This executive report consolidates verified BCOS project records for management review.','project'=>$p,'financials'=>$f,'risk_summary'=>$risks,'recommendations'=>['Review outstanding milestones and high-priority risks.','Confirm financial commitments before new expenditure.','Capture lessons learned before project closure.']];return $this->create(['document_type'=>'REPORT','jurisdiction_code'=>'INT','title'=>'Executive Project Report — '.$p['name'],'project_id'=>$projectId,'client_organization_id'=>$p['client_organization_id'],'language_mode'=>'EN','classification'=>'CONFIDENTIAL','watermark_text'=>'CONFIDENTIAL','content'=>$content],$user);} app/Modules/Documents/DocumentService.php-16- private function legalEntitySnapshot(string $id):array{$q=$this->db->connection()->prepare('SELECT le.id,le.entity_code,le.legal_name,COALESCE(le.registration_number,le.registration_no) registration_number,le.jurisdiction,le.country_code,le.tax_number,le.base_currency,lp.registration_place,lp.registration_authority,lp.entity_type,lp.registration_date,lp.registered_office_address,lp.official_business_address,lp.official_digital_address,lp.tax_identification_number,lp.vat_registration_status,lp.vat_registration_number,lp.bank_name,lp.beneficiary_name,lp.account_number_ciphertext,lp.iban_ciphertext,lp.swift_bic,lp.correspondent_bank_details_ciphertext,lp.payment_instructions,lp.governing_law_default,lp.dispute_resolution_default,lp.verification_status,lp.effective_from FROM legal_entities le JOIN legal_entity_profiles lp ON lp.legal_entity_id=le.id AND lp.status="ACTIVE" AND lp.verification_status="VERIFIED" WHERE le.id=:id AND le.status="ACTIVE" LIMIT 1');$q->execute(['id'=>$id]);$row=$q->fetch(PDO::FETCH_ASSOC);if(!$row)throw new RuntimeException('Issuing legal entity is unavailable.');foreach(['account_number_ciphertext'=>'account_number','iban_ciphertext'=>'iban','correspondent_bank_details_ciphertext'=>'correspondent_bank_details'] as $cipher=>$plain){$row[$plain]=null;if(!empty($row[$cipher]))$row[$plain]=$this->crypto->decrypt((string)$row[$cipher]);unset($row[$cipher]);}$row['snapshot_at']=gmdate(DATE_ATOM);return $row;} app/Modules/Documents/DocumentService.php-17- private function renderHtml(array $meta,string $ref,int $version,array $content):string{$logo='/assets/branding/baffoe-b-mark-transparent.png';$title=htmlspecialchars((string)($meta['title']??'Controlled Document'),ENT_QUOTES,'UTF-8');$water=htmlspecialchars((string)($meta['watermark_text']??'DRAFT'),ENT_QUOTES,'UTF-8');$lang=(string)($meta['language_mode']??'EN');$entity=is_array($content['_issuing_entity_snapshot']??null)?$content['_issuing_entity_snapshot']:[];$entityName=htmlspecialchars((string)($entity['legal_name']??'Baffoe Consulting Management Company Limited'),ENT_QUOTES,'UTF-8');$registration=htmlspecialchars((string)($entity['registration_number']??''),ENT_QUOTES,'UTF-8');$entityLine=$registration!==''?'Registration: '.$registration:'';$body=$this->renderContent(array_filter($content,fn($k)=>$k!=='_issuing_entity_snapshot',ARRAY_FILTER_USE_KEY),$lang);return ''.$title.'
'.$water.'

'.$title.'

'.$entityName.'
'.$entityLine.'

Reference: '.htmlspecialchars($ref).'   Version: '.$version.'   Classification: '.htmlspecialchars((string)($meta['classification']??'CONFIDENTIAL')).'

'.$body.'';} app/Modules/Documents/DocumentService.php-18- private function renderContent(array $c,string $lang):string{$out='';foreach($c as $k=>$v){$h=ucwords(str_replace('_',' ',(string)$k));$out.='

'.htmlspecialchars($h).'

';if(is_array($v)){$out.='
'.htmlspecialchars(json_encode($v,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)).'
';}else{$out.='

'.nl2br(htmlspecialchars((string)$v)).'

';}$out.='
';}return $out;} app/Modules/Documents/DocumentService.php-19-} === PREVIEW CSS === public/assets/bcos-s25-3.css-43-.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:18px}.toolbar .search-field{position:relative;flex:1;min-width:240px}.search-field svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);width:17px;color:var(--subtle)}.search-field input{width:100%;height:42px;border:1px solid var(--line);border-radius:11px;background:#fff;padding:0 12px 0 39px}.toolbar select{height:42px;border:1px solid var(--line);border-radius:11px;background:#fff;padding:0 34px 0 11px} public/assets/bcos-s25-3.css-44- public/assets/bcos-s25-3.css-45-/* Cards and dashboard */ public/assets/bcos-s25-3.css-46-.hero{position:relative;overflow:hidden;border-radius:var(--radius-lg);background:linear-gradient(130deg,var(--navy-950),var(--navy-700));color:#fff;padding:26px 28px;display:flex;align-items:center;justify-content:space-between;gap:24px;box-shadow:var(--shadow-md)}.hero:after{content:"";position:absolute;width:330px;height:330px;border:1px solid rgba(255,255,255,.08);border-radius:50%;right:-170px;top:-165px}.hero>div,.hero>aside{position:relative;z-index:1}.hero h1{font-size:clamp(1.75rem,3vw,2.7rem);margin:7px 0 8px;letter-spacing:-.035em}.hero p{margin:0;max-width:760px;color:#cbd8e4;line-height:1.55}.hero-actions{display:flex;gap:9px;margin-top:20px;flex-wrap:wrap}.hero .btn.secondary-dark{border-color:rgba(255,255,255,.25);background:rgba(255,255,255,.07);color:#fff}.health-gauge{width:118px;height:118px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--gold-400) calc(var(--value)*1%),rgba(255,255,255,.14) 0);padding:9px;flex:none}.health-gauge>div{width:100%;height:100%;border-radius:50%;background:var(--navy-850);display:grid;place-content:center;text-align:center}.health-gauge strong{font-size:1.65rem}.health-gauge span{font-size:.65rem;color:#c6d4df;margin-top:2px} public/assets/bcos-s25-3.css-47-.metrics-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px;margin:18px 0}.metric-card{min-width:0;border:1px solid var(--line);border-radius:var(--radius);background:#fff;padding:16px;box-shadow:var(--shadow-sm)}.metric-top{display:flex;justify-content:space-between;gap:8px;color:var(--muted);font-size:.73rem}.metric-icon{width:31px;height:31px;border-radius:9px;background:var(--info-bg);color:var(--info);display:grid;place-items:center}.metric-icon svg{width:16px}.metric-value{display:block;font-size:1.55rem;line-height:1.2;margin:11px 0 3px;letter-spacing:-.025em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.metric-sub{display:flex;align-items:center;gap:5px;color:var(--subtle);font-size:.68rem}.metric-sub.good{color:var(--success)}.metric-sub.warn{color:var(--warning)}.metric-sub.bad{color:var(--danger)} public/assets/bcos-s25-3.css-48-.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(310px,.75fr);gap:16px}.stack{display:grid;gap:16px;align-content:start}.panel{background:#fff;border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow-sm);padding:18px;min-width:0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.panel-head h2,.panel h2{font-size:1rem;margin:0;letter-spacing:-.01em}.panel-head p{font-size:.75rem;color:var(--muted);margin:4px 0 0}.panel-link{border:0;background:transparent;color:var(--navy-700);font-weight:750;font-size:.75rem;padding:0}.list-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:13px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-row:last-child{border-bottom:0}.list-row-main{min-width:0}.list-row b{display:block;font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row small{display:block;color:var(--muted);font-size:.69rem;margin-top:4px;line-height:1.4}.list-row-value{text-align:right;font-size:.77rem;font-weight:750}.list-row-actions{display:flex;gap:6px}.status-pill{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:5px 9px;font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.025em;background:#edf2f7;color:#506275}.status-pill:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.status-pill.green,.status-pill.active,.status-pill.healthy,.status-pill.approved,.status-pill.completed,.status-pill.pass{background:var(--success-bg);color:var(--success)}.status-pill.amber,.status-pill.watch,.status-pill.pending,.status-pill.in_progress,.status-pill.warn{background:var(--warning-bg);color:var(--warning)}.status-pill.red,.status-pill.critical,.status-pill.at_risk,.status-pill.overdue,.status-pill.fail,.status-pill.rejected{background:var(--danger-bg);color:var(--danger)}.status-pill.blue{background:var(--info-bg);color:var(--info)} public/assets/bcos-s25-3.css-49-.priority-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:11px;align-items:start;padding:12px 0;border-bottom:1px solid var(--line-soft)}.priority-row:last-child{border-bottom:0}.priority-marker{width:9px;height:9px;border-radius:50%;background:var(--warning);margin-top:5px}.priority-marker.critical{background:var(--danger)}.priority-marker.medium{background:var(--info)}.priority-row b{display:block;font-size:.82rem}.priority-row p{font-size:.72rem;color:var(--muted);line-height:1.45;margin:4px 0 0}.priority-row time{font-size:.68rem;color:var(--subtle)} public/assets/bcos-s25-3.css-50-.bar-chart{display:grid;gap:12px;padding-top:5px}.bar-row{display:grid;grid-template-columns:120px minmax(80px,1fr) 45px;gap:10px;align-items:center}.bar-row label{font-size:.72rem;color:var(--muted)}.bar-track{height:10px;background:#edf1f5;border-radius:999px;overflow:hidden}.bar-track i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--navy-700),var(--navy-600))}.bar-row strong{text-align:right;font-size:.73rem} public/assets/bcos-s25-3.css-51-.line-chart{width:100%;height:auto;min-height:180px;display:block}.chart-grid-line{stroke:#e6ebf0;stroke-width:1}.chart-line{fill:none;stroke:var(--navy-700);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}.chart-area{fill:url(#areaFill)}.chart-dot{fill:#fff;stroke:var(--gold-500);stroke-width:3}.chart-label{font-size:10px;fill:#7b8999} public/assets/bcos-s25-3.css-52-.progress-line{height:9px;border-radius:99px;background:#e9eef3;overflow:hidden}.progress-line i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--gold-500),var(--gold-400))} public/assets/bcos-s25-3.css-53- public/assets/bcos-s25-3.css-54-/* Tables, grids, empty states */ public/assets/bcos-s25-3.css-55-.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.record-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:17px;box-shadow:var(--shadow-sm);display:grid;gap:11px;min-width:0}.record-card:hover{border-color:#c8d3dd;box-shadow:var(--shadow-md);transform:translateY(-1px)}.record-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.record-card h3{font-size:.98rem;margin:0;line-height:1.35}.record-card p{font-size:.75rem;color:var(--muted);margin:0;line-height:1.5}.record-meta{display:flex;gap:14px;flex-wrap:wrap;font-size:.69rem;color:var(--muted)}.record-meta b{color:var(--ink);margin-right:4px}.record-card-actions{display:flex;justify-content:flex-end;gap:7px;padding-top:4px} public/assets/bcos-s25-3.css-56-.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);background:#fff}.data-table{width:100%;border-collapse:collapse;min-width:760px}.data-table th{background:#f7f9fb;color:#536579;font-size:.67rem;text-transform:uppercase;letter-spacing:.06em;text-align:left;padding:11px 13px;border-bottom:1px solid var(--line)}.data-table td{padding:12px 13px;border-bottom:1px solid var(--line-soft);font-size:.78rem;vertical-align:top}.data-table tr:last-child td{border-bottom:0}.data-table tr:hover td{background:#fafbfd}.ref{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.7rem;color:var(--navy-700)} public/assets/bcos-s25-3.css-57-.empty-state{display:grid;place-items:center;text-align:center;padding:42px 20px;border:1px dashed #c9d4df;border-radius:var(--radius);background:rgba(255,255,255,.55)}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--info-bg);color:var(--info);display:grid;place-items:center;margin-bottom:12px}.empty-icon svg{width:23px}.empty-state h3{margin:0 0 6px;font-size:.96rem}.empty-state p{margin:0;color:var(--muted);font-size:.76rem;max-width:460px;line-height:1.5}.empty-state .btn{margin-top:15px} public/assets/bcos-s25-3.css-58-.loading-state{display:grid;gap:12px}.skeleton{height:74px;border-radius:14px;background:linear-gradient(90deg,#edf1f5 25%,#f7f9fb 37%,#edf1f5 63%);background-size:400% 100%;animation:shimmer 1.25s infinite}@keyframes shimmer{0%{background-position:100% 0}100%{background-position:0 0}} public/assets/bcos-s25-3.css-59-.alert-box{border:1px solid #f0ccd2;border-left:4px solid var(--danger);background:var(--danger-bg);border-radius:12px;padding:13px 14px;color:#862537;font-size:.78rem;line-height:1.5}.notice-box{border:1px solid #d1e5f2;border-left:4px solid var(--info);background:var(--info-bg);border-radius:12px;padding:13px 14px;color:#24536e;font-size:.78rem;line-height:1.5}.warning-box{border:1px solid #edd99f;border-left:4px solid var(--warning);background:var(--warning-bg);border-radius:12px;padding:13px 14px;color:#765100;font-size:.78rem;line-height:1.5} public/assets/bcos-s25-3.css-60- public/assets/bcos-s25-3.css-61-/* Finance and documents */ public/assets/bcos-s25-3.css-62-.finance-band{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.fund-block{padding:15px;border:1px solid var(--line);border-radius:14px;background:#fff}.fund-block span{display:block;color:var(--muted);font-size:.71rem}.fund-block strong{display:block;font-size:1.2rem;margin-top:7px}.fund-visual{height:14px;display:flex;overflow:hidden;border-radius:999px;background:#edf1f5;margin:15px 0}.fund-visual i:nth-child(1){background:var(--navy-700)}.fund-visual i:nth-child(2){background:var(--gold-500)}.fund-visual i:nth-child(3){background:#89a6bd}.fund-legend{display:flex;gap:15px;flex-wrap:wrap;font-size:.69rem;color:var(--muted)}.fund-legend b{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px}.fund-legend span:nth-child(1) b{background:var(--navy-700)}.fund-legend span:nth-child(2) b{background:var(--gold-500)}.fund-legend span:nth-child(3) b{background:#89a6bd} public/assets/bcos-s25-3.css:63:.document-layout{display:grid;grid-template-columns:minmax(350px,.72fr) minmax(0,1.28fr);gap:16px}.document-preview{background:#cfd5dc;border-radius:var(--radius);padding:20px;min-height:650px}.paper{background:#fff;width:min(760px,100%);margin:0 auto;min-height:940px;box-shadow:0 10px 28px rgba(0,0,0,.15);padding:34px;overflow:auto}.paper iframe{width:100%;height:900px;border:0}.document-type-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.document-type{border:1px solid var(--line);background:#fff;border-radius:10px;padding:10px 8px;font-size:.72rem;font-weight:700}.document-type.active{border-color:var(--gold-500);background:#fff9e9;color:#765100} public/assets/bcos-s25-3.css-64- public/assets/bcos-s25-3.css-65-/* Client portal */ public/assets/bcos-s25-3.css-66-.client-portal-hero{background:linear-gradient(135deg,var(--navy-950),#154d71);color:#fff;border-radius:var(--radius-lg);padding:25px 27px;display:flex;justify-content:space-between;gap:20px;align-items:center}.client-portal-hero h1{margin:7px 0;font-size:1.85rem}.client-portal-hero p{margin:0;color:#c7d6e2}.client-portal-role{border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);border-radius:999px;padding:8px 12px;font-size:.7rem;font-weight:800}.project-portfolio{display:grid;gap:11px}.portal-project{border:1px solid var(--line);border-radius:14px;padding:15px;background:#fff}.portal-project-head{display:flex;justify-content:space-between;gap:12px}.portal-project h3{margin:4px 0 10px;font-size:.9rem}.portal-project p{margin:8px 0 0;color:var(--muted);font-size:.71rem}.action-centre-item{border-left:3px solid var(--gold-500);padding:9px 0 9px 12px;margin:4px 0}.action-centre-item b{display:block;font-size:.79rem}.action-centre-item span{display:block;font-size:.68rem;color:var(--muted);margin-top:3px} public/assets/bcos-s25-3.css-67- public/assets/bcos-s25-3.css-68-/* Settings and admin */ public/assets/bcos-s25-3.css-69-.settings-layout{display:grid;grid-template-columns:220px minmax(0,1fr);gap:16px}.settings-nav{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:8px;height:max-content;position:sticky;top:88px}.settings-nav button{width:100%;border:0;background:transparent;text-align:left;border-radius:9px;padding:10px 11px;color:var(--muted);font-size:.78rem;font-weight:700}.settings-nav button.active{background:var(--info-bg);color:var(--navy-700)}.settings-section{display:none}.settings-section.active{display:block}.settings-section h2{margin-top:0;font-size:1.12rem}.settings-section>p{color:var(--muted);font-size:.78rem;line-height:1.5}.entity-tabs{display:flex;gap:8px;overflow:auto;margin-bottom:15px}.entity-tab{white-space:nowrap;border:1px solid var(--line);background:#fff;border-radius:10px;padding:9px 12px;font-size:.72rem;font-weight:750}.entity-tab.active{background:var(--navy-800);border-color:var(--navy-800);color:#fff}.sensitive-field{position:relative}.sensitive-badge{position:absolute;right:10px;top:35px;font-size:.62rem;color:var(--warning);background:var(--warning-bg);padding:3px 6px;border-radius:5px}.session-row{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:12px 0;border-bottom:1px solid var(--line-soft)}.session-row:last-child{border-bottom:0}.session-device{width:36px;height:36px;border-radius:10px;background:var(--surface-2);display:grid;place-items:center;color:var(--navy-700)}.session-row b{font-size:.78rem}.session-row small{display:block;color:var(--muted);font-size:.67rem;margin-top:3px} public/assets/bcos-s25-3.css-70- public/assets/bcos-s25-3.css-71-/* Menus, modal, toast */ public/assets/bcos-s25-3.css-72-.popover{position:fixed;z-index:80;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:0 18px 45px rgba(6,20,38,.18);padding:7px;min-width:220px}.popover button,.popover a{width:100%;display:flex;align-items:center;gap:9px;border:0;background:transparent;text-decoration:none;text-align:left;padding:9px 10px;border-radius:8px;color:var(--ink);font-size:.76rem}.popover button:hover,.popover a:hover{background:var(--surface-2)}.popover hr{border:0;border-top:1px solid var(--line-soft);margin:6px}.popover svg{width:16px;color:var(--muted)} public/assets/bcos-s25-3.css-73-.modal-backdrop{position:fixed;inset:0;background:rgba(4,14,25,.62);z-index:100;display:grid;place-items:center;padding:18px;animation:fade .14s ease}.modal{width:min(760px,100%);max-height:calc(100vh - 36px);overflow:auto;background:#fff;border-radius:var(--radius-lg);box-shadow:0 28px 80px rgba(0,0,0,.28)}.modal.wide{width:min(1080px,100%)}.modal-head{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;gap:15px;background:#fff;border-bottom:1px solid var(--line);padding:18px 20px}.modal-head h2{margin:0;font-size:1.12rem}.modal-head p{margin:4px 0 0;color:var(--muted);font-size:.72rem}.modal-close{width:34px;height:34px;border:1px solid var(--line);background:#fff;border-radius:9px}.modal-body{padding:20px}.modal-foot{position:sticky;bottom:0;background:#fff;border-top:1px solid var(--line);padding:14px 20px;display:flex;justify-content:flex-end;gap:9px} public/assets/bcos-s25-3.css-74-.toast-stack{position:fixed;right:18px;bottom:18px;z-index:130;display:grid;gap:8px;width:min(390px,calc(100vw - 36px))}.toast{background:var(--navy-900);color:#fff;border-radius:12px;padding:13px 14px;box-shadow:0 15px 36px rgba(0,0,0,.24);display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;animation:slide .18s ease}.toast.success{background:#0f6749}.toast.error{background:#8f2638}.toast svg{width:18px}.toast b{display:block;font-size:.78rem}.toast span{display:block;font-size:.7rem;color:rgba(255,255,255,.8);margin-top:2px}.toast button{border:0;background:transparent;color:#fff;padding:0} public/assets/bcos-s25-3.css-75-@keyframes fade{from{opacity:0}to{opacity:1}}@keyframes slide{from{transform:translateY(8px);opacity:0}to{transform:none;opacity:1}} public/assets/bcos-s25-3.css-76- public/assets/bcos-s25-3.css-77-/* Responsive */ public/assets/bcos-s25-3.css:78:@media(max-width:1280px){.metrics-grid{grid-template-columns:repeat(3,1fr)}.dashboard-grid{grid-template-columns:minmax(0,1.35fr) minmax(290px,.75fr)}.document-layout{grid-template-columns:1fr}.document-preview{min-height:400px}.paper{min-height:700px}.finance-band{grid-template-columns:repeat(2,1fr)}} public/assets/bcos-s25-3.css-79-@media(max-width:980px){.app-shell{grid-template-columns:1fr}.sidebar{position:fixed;left:0;top:0;width:278px;transform:translateX(-103%);transition:.22s ease;box-shadow:20px 0 40px rgba(0,0,0,.22)}.sidebar.open{transform:none}.sidebar-scrim{display:none;position:fixed;inset:0;background:rgba(4,14,25,.5);z-index:29}.sidebar-scrim.show{display:block}.menu-toggle{display:grid;place-items:center}.topbar{grid-template-columns:auto minmax(190px,1fr) auto;padding:0 15px}.topbar-spacer{display:none}.top-profile-copy{display:none}.dashboard-grid{grid-template-columns:1fr}.settings-layout{grid-template-columns:1fr}.settings-nav{position:static;display:flex;overflow:auto}.settings-nav button{white-space:nowrap;width:auto}.auth-shell{grid-template-columns:1fr}.auth-brand{display:none}} public/assets/bcos-s25-3.css:80:@media(max-width:720px){.page-content{padding:15px}.topbar{height:62px}.global-search{display:none}.topbar{grid-template-columns:auto 1fr auto}.page-head{flex-direction:column}.page-actions{width:100%;justify-content:flex-start}.hero{align-items:flex-start;flex-direction:column;padding:22px}.health-gauge{width:96px;height:96px}.metrics-grid{grid-template-columns:repeat(2,1fr)}.form-grid{grid-template-columns:1fr}.form-grid .span-2{grid-column:auto}.card-grid{grid-template-columns:1fr}.finance-band{grid-template-columns:1fr}.document-type-grid{grid-template-columns:repeat(2,1fr)}.document-preview{padding:8px}.paper{padding:15px}.bar-row{grid-template-columns:92px 1fr 36px}.client-portal-hero{align-items:flex-start;flex-direction:column}.auth-points{grid-template-columns:1fr}.modal-backdrop{padding:0;place-items:end center}.modal{max-height:92vh;border-radius:20px 20px 0 0}.modal.wide{width:100%}.data-table{min-width:650px}} public/assets/bcos-s25-3.css-81-@media(max-width:420px){.metrics-grid{grid-template-columns:1fr 1fr;gap:8px}.metric-card{padding:13px}.metric-value{font-size:1.25rem}.page-content{padding:11px}.hero{border-radius:16px}.top-actions .icon-button:first-child{display:none}.auth-form-wrap{padding:15px}.auth-card{padding:24px 20px}.document-type-grid{grid-template-columns:1fr}.toolbar .search-field{min-width:100%}} public/assets/bcos-s25-3.css-82-@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}} public/assets/bcos-s25-3.css-83- public/assets/bcos-s25-3.css-84-/* Startup and user density */ public/assets/bcos-s25-3.css-85-.boot-screen{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:10px;text-align:center;background:linear-gradient(145deg,var(--navy-950),var(--navy-800));color:#fff;padding:30px} public/assets/bcos-s25-3.css-86-.boot-screen img{width:112px;height:112px;object-fit:contain;margin-bottom:8px} public/assets/bcos-s25-3.css-87-.boot-screen strong{font-size:1.15rem;letter-spacing:.01em} public/assets/bcos-s25-3.css-88-.boot-screen span{color:#c7d3df;font-size:.86rem} public/assets/bcos-s25-3.css-89-html[data-density="compact"] .page-content{padding:18px} public/assets/bcos-s25-3.css-90-html[data-density="compact"] .panel{padding:14px} public/assets/bcos-s25-3.css-91-html[data-density="compact"] .metric-card{padding:13px} public/assets/bcos-s25-3.css-92-html[data-density="compact"] .list-row{padding:8px 0} public/assets/bcos-s25-3.css-93-html[data-density="compact"] .nav-link{padding:8px 10px} public/assets/bcos-s25-3.css-94- public/assets/bcos-s25-3.css-95-/* Account security */ public/assets/bcos-s25-3.css-96-.security-status{display:flex;align-items:center;justify-content:space-between;gap:16px;border:1px solid var(--line);border-radius:14px;padding:14px 15px;margin-top:14px;background:var(--surface-2)} public/assets/bcos-s25-3.css-97-.security-status.enabled{border-color:#bce5d3;background:var(--success-bg)} public/assets/bcos-s25-3.css-98-.security-status.pending{border-color:#f1d89c;background:var(--warning-bg)} public/assets/bcos-s25-3.css-99-.security-status b{display:block;font-size:.87rem}.security-status span{display:block;margin-top:4px;color:var(--muted);font-size:.75rem;line-height:1.45} public/assets/bcos-s25-3.css-100-.mfa-secret{display:block;margin:11px 0;padding:12px;border:1px dashed #aab8c6;border-radius:10px;background:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:1rem;letter-spacing:.08em;word-break:break-all;user-select:all}