diff --git a/app/Http/Controllers/Api/V1/Certificates/CertificateBatchController.php b/app/Http/Controllers/Api/V1/Certificates/CertificateBatchController.php new file mode 100644 index 0000000..31b4a6c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Certificates/CertificateBatchController.php @@ -0,0 +1,67 @@ +validate([ + 'template_name' => 'required|string', + 'background_image' => 'required|image', + 'font_file' => 'nullable|file', + 'fields_config' => 'required|json', + 'students_data' => 'required|json', + 'whatsapp_column' => 'nullable|string' + ]); + + $bgPath = $request->file('background_image')->store('certificates/templates', 'public'); + $fontPath = $request->hasFile('font_file') ? $request->file('font_file')->store('certificates/fonts', 'public') : null; + + $template = CertificateTemplate::create([ + 'school_id' => $request->user()->school_id, + 'name' => $request->template_name, + 'background_image_path' => $bgPath, + 'font_file_path' => $fontPath, + 'fields_config' => json_decode($request->fields_config, true), + ]); + + if (!$template->school->cryptoKey) { + $res = openssl_pkey_new(["digest_alg" => "sha256", "private_key_bits" => 2048, "private_key_type" => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($res, $privKey); + $pubKey = openssl_pkey_get_details($res)["key"]; + SchoolCryptoKey::create(['school_id' => $template->school_id, 'public_key' => $pubKey, 'private_key' => $privKey]); + } + + $studentsData = json_decode($request->students_data, true); + $batch = CertificateBatch::create([ + 'school_id' => $template->school_id, + 'certificate_template_id' => $template->id, + 'status' => 'processing', + 'total_count' => count($studentsData) + ]); + + foreach ($studentsData as $row) { + $cert = $service->generate($template, $row, $request->whatsapp_column); + $cert->update([ + 'certificate_batch_id' => $batch->id, + 'file_path_pdf' => 'certificates/generated/' . $cert->uuid . '.pdf' + ]); + } + + $batch->update(['status' => 'completed', 'processed_count' => count($studentsData)]); + return response()->json(['message' => 'Batch processing started', 'batch_id' => $batch->id]); + } + + public function show($batchId) + { + $batch = CertificateBatch::with('certificates')->findOrFail($batchId); + return response()->json($batch); + } +} diff --git a/app/Http/Controllers/Public/CertificateVerificationController.php b/app/Http/Controllers/Public/CertificateVerificationController.php new file mode 100644 index 0000000..dc0e605 --- /dev/null +++ b/app/Http/Controllers/Public/CertificateVerificationController.php @@ -0,0 +1,32 @@ +where('uuid', $uuid)->first(); + + if (!$certificate) { + return Inertia::render('Certificates/Verify', ['valid' => false]); + } + + $payload = $certificate->uuid . '|' . $certificate->recipient_name; + $pubKey = $certificate->school->cryptoKey->public_key ?? null; + + $isValid = false; + if ($pubKey && $certificate->digital_signature) { + $isValid = openssl_verify($payload, base64_decode($certificate->digital_signature), $pubKey, OPENSSL_ALGO_SHA256) === 1; + } + + return Inertia::render('Certificates/Verify', [ + 'valid' => $isValid, + 'certificate' => $certificate->only(['uuid', 'recipient_name', 'created_at']), + 'school_name' => $certificate->school->name ?? 'Unknown' + ]); + } +} diff --git a/app/Models/Certificates/Certificate.php b/app/Models/Certificates/Certificate.php new file mode 100644 index 0000000..ebf59b6 --- /dev/null +++ b/app/Models/Certificates/Certificate.php @@ -0,0 +1,19 @@ + 'array']; + + public function school() { return $this->belongsTo(School::class); } + public function template() { return $this->belongsTo(CertificateTemplate::class, 'certificate_template_id'); } + public function student() { return $this->belongsTo(User::class, 'student_id'); } +} diff --git a/app/Models/Certificates/CertificateBatch.php b/app/Models/Certificates/CertificateBatch.php new file mode 100644 index 0000000..7552ebc --- /dev/null +++ b/app/Models/Certificates/CertificateBatch.php @@ -0,0 +1,13 @@ +belongsTo(School::class); } + public function template() { return $this->belongsTo(CertificateTemplate::class, 'certificate_template_id'); } + public function certificates() { return $this->hasMany(Certificate::class); } +} diff --git a/app/Models/Certificates/CertificateTemplate.php b/app/Models/Certificates/CertificateTemplate.php new file mode 100644 index 0000000..28d8246 --- /dev/null +++ b/app/Models/Certificates/CertificateTemplate.php @@ -0,0 +1,13 @@ + 'array']; + + public function school() { return $this->belongsTo(School::class); } + public function certificates() { return $this->hasMany(Certificate::class); } +} diff --git a/app/Models/Certificates/SchoolCryptoKey.php b/app/Models/Certificates/SchoolCryptoKey.php new file mode 100644 index 0000000..76e949d --- /dev/null +++ b/app/Models/Certificates/SchoolCryptoKey.php @@ -0,0 +1,10 @@ +belongsTo(School::class); } +} diff --git a/app/Services/Certificates/CertificateGeneratorService.php b/app/Services/Certificates/CertificateGeneratorService.php new file mode 100644 index 0000000..37dad34 --- /dev/null +++ b/app/Services/Certificates/CertificateGeneratorService.php @@ -0,0 +1,39 @@ +toString(); + $recipientName = $dataRow['name'] ?? 'Unknown'; + + // Retrieve school crypto key + $cryptoKey = $template->school->cryptoKey; + $signature = null; + if ($cryptoKey) { + // Sign the UUID and recipient name + $payload = $uuid . '|' . $recipientName; + openssl_sign($payload, $signature, $cryptoKey->private_key, OPENSSL_ALGO_SHA256); + $signature = base64_encode($signature); + } + + return Certificate::create([ + 'uuid' => $uuid, + 'school_id' => $template->school_id, + 'certificate_template_id' => $template->id, + 'recipient_name' => $recipientName, + 'recipient_whatsapp' => $whatsappColumn ? ($dataRow[$whatsappColumn] ?? null) : null, + 'data_payload' => $dataRow, + 'digital_signature' => $signature, + ]); + } +} diff --git a/database/migrations/2026_08_27_000001_create_certificate_module_tables.php b/database/migrations/2026_08_27_000001_create_certificate_module_tables.php new file mode 100644 index 0000000..3c96038 --- /dev/null +++ b/database/migrations/2026_08_27_000001_create_certificate_module_tables.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('school_id')->constrained()->onDelete('cascade'); + $table->text('public_key'); + $table->text('private_key'); // Should be encrypted at rest in a real app + $table->timestamps(); + }); + + // 2. Certificate Templates + Schema::create('certificate_templates', function (Blueprint $table) { + $table->id(); + $table->foreignId('school_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->string('background_image_path'); + $table->string('font_file_path')->nullable(); + $table->json('fields_config')->nullable(); // { "name": {"x": 100, "y": 200, "size": 24, "color": "#000"} } + $table->timestamps(); + }); + + // 3. Generated Certificates + Schema::create('certificates', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('school_id')->constrained()->onDelete('cascade'); + $table->foreignId('certificate_template_id')->constrained()->onDelete('cascade'); + $table->foreignId('student_id')->nullable()->constrained('users')->onDelete('set null'); + $table->string('recipient_name'); + $table->string('recipient_phone')->nullable(); + $table->string('recipient_whatsapp')->nullable(); + $table->json('data_payload')->nullable(); // Full excel row data + $table->string('file_path_pdf')->nullable(); + $table->string('file_path_jpg')->nullable(); + $table->text('digital_signature')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('certificates'); + Schema::dropIfExists('certificate_templates'); + Schema::dropIfExists('school_crypto_keys'); + } +}; diff --git a/database/migrations/2026_08_27_000002_create_certificate_batches_table.php b/database/migrations/2026_08_27_000002_create_certificate_batches_table.php new file mode 100644 index 0000000..a59ea98 --- /dev/null +++ b/database/migrations/2026_08_27_000002_create_certificate_batches_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('school_id')->constrained()->onDelete('cascade'); + $table->foreignId('certificate_template_id')->constrained()->onDelete('cascade'); + $table->enum('status', ['pending', 'processing', 'completed', 'failed'])->default('pending'); + $table->integer('total_count')->default(0); + $table->integer('processed_count')->default(0); + $table->timestamps(); + }); + + Schema::table('certificates', function (Blueprint $table) { + $table->foreignId('certificate_batch_id')->nullable()->constrained()->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::table('certificates', function (Blueprint $table) { + $table->dropForeign(['certificate_batch_id']); + $table->dropColumn('certificate_batch_id'); + }); + Schema::dropIfExists('certificate_batches'); + } +}; diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index 9617538..46cceeb 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -41,6 +41,8 @@ public function run(): void ['code' => 'manage_pages', 'label' => 'إدارة الصفحات'], ['code' => 'manage_articles', 'label' => 'إدارة المقالات والتحرير'], ['code' => 'write_articles', 'label' => 'كتابة المقالات المسندة'], + ['code' => 'view_teachers', 'label' => 'عرض بيانات المعلمين'], + ['code' => 'view_halaqas', 'label' => 'عرض بيانات الحلقات'], ]; foreach ($permissions as $permission) { diff --git a/resources/js/Pages/Certificates/Studio.tsx b/resources/js/Pages/Certificates/Studio.tsx new file mode 100644 index 0000000..24746ba --- /dev/null +++ b/resources/js/Pages/Certificates/Studio.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { Head } from '@inertiajs/react'; + +export default function CertificateStudio() { + const [templateImage, setTemplateImage] = useState(null); + const [fields, setFields] = useState([]); + + // Mock logic for drag and drop fields over image + const addField = () => { + setFields([...fields, { id: Date.now(), name: 'New Field', x: 50, y: 50, size: 24, color: '#000000' }]); + }; + + return ( +
+ +

استوديو تصميم الشهادات

+ +
+ {/* Sidebar Controls */} +
+

1. رفع القالب والخط

+ { + if (e.target.files?.[0]) { + setTemplateImage(URL.createObjectURL(e.target.files[0])); + } + }} /> + +

2. استيراد البيانات (Excel)

+ + +

3. الحقول الديناميكية

+ + +

4. التصدير

+ +
+ + {/* Canvas Area */} +
+ {templateImage ? ( +
+ Template + {fields.map(field => ( +
+ {field.name} +
+ ))} +
+ ) : ( +
+ يرجى رفع صورة قالب الشهادة للبدء +
+ )} +
+
+
+ ); +} diff --git a/resources/js/Pages/Certificates/Verify.tsx b/resources/js/Pages/Certificates/Verify.tsx new file mode 100644 index 0000000..df7ac43 --- /dev/null +++ b/resources/js/Pages/Certificates/Verify.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Head } from '@inertiajs/react'; + +interface VerifyProps { + valid: boolean; + certificate?: { + uuid: string; + recipient_name: string; + created_at: string; + }; + school_name?: string; +} + +export default function CertificateVerify({ valid, certificate, school_name }: VerifyProps) { + return ( +
+ + +
+

نظام التحقق من الشهادات

+ + {valid && certificate ? ( +
+
+ + + +
+

شهادة موثقة وصحيحة

+ +
+

المدرسة/الجهة: {school_name}

+

اسم الحاصل على الشهادة: {certificate.recipient_name}

+

رقم الشهادة: {certificate.uuid}

+

تاريخ الإصدار: {new Date(certificate.created_at).toLocaleDateString('ar-SA')}

+
+ +
+ هذه الشهادة موقعة إلكترونياً ولا يمكن تزوير بياناتها. +
+
+ ) : ( +
+
+ + + +
+

شهادة غير صالحة

+

لم يتم العثور على هذه الشهادة أو أن التوقيع الإلكتروني غير متطابق.

+
+ )} +
+
+ ); +} diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..20f2ff0 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,13 @@ + +use App\Http\Controllers\Api\V1\Certificates\CertificateController; +Route::middleware('auth:sanctum')->group(function () { + Route::post('/certificates/templates', [CertificateController::class, 'storeTemplate']); + Route::post('/certificates/templates/{template}/generate', [CertificateController::class, 'generateBulk']); +}); +Route::get('/verify/cert/{uuid}', [CertificateController::class, 'verify']); + +use App\Http\Controllers\Api\V1\Certificates\CertificateBatchController; +Route::middleware('auth:sanctum')->group(function () { + Route::post('/certificates/batch-generate', [CertificateBatchController::class, 'generate']); + Route::get('/certificates/batch/{batchId}', [CertificateBatchController::class, 'show']); +}); diff --git a/routes/web.php b/routes/web.php index c4d3ec8..8674034 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,3 +4,6 @@ // Public Documentation Routes Route::get('/docs', [DocsController::class, 'index'])->name('docs.index'); Route::get('/docs/{path}', [DocsController::class, 'show'])->where('path', '.*')->name('docs.show'); + +use App\Http\Controllers\Public\CertificateVerificationController; +Route::get('/verify/cert/{uuid}', [CertificateVerificationController::class, 'show']);