authorize($request); $items = DB::table('certificates as c')->join('users as u', 'u.id', '=', 'c.user_id')->join('course_versions as cv', 'cv.id', '=', 'c.course_version_id')->where('c.organization_id', $this->tenant->id())->orderByDesc('c.issued_at')->limit(100)->get(['c.id', 'c.certificate_number as number', 'c.verification_code as verificationCode', 'c.issued_at as issuedAt', 'c.expires_at as expiresAt', 'c.revoked_at as revokedAt', 'u.name as learnerName', 'cv.title as courseTitle']); $templates = DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('is_active', true)->orderByDesc('is_default')->get()->map(fn ($row) => ['id' => $row->id, 'name' => $row->name, 'canvas' => json_decode($row->canvas, true), 'isDefault' => (bool) $row->is_default]); $eligible = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id')->join('users as u', 'u.id', '=', 'au.user_id')->join('course_versions as cv', 'cv.id', '=', 'a.assignable_id')->where('a.organization_id', $this->tenant->id())->where('a.assignable_type', 'course')->where('au.status', 'completed')->whereNotExists(fn ($query) => $query->selectRaw('1')->from('certificates as c')->whereColumn('c.user_id', 'au.user_id')->whereColumn('c.course_version_id', 'a.assignable_id'))->limit(100)->get(['u.id as userId', 'u.name as learnerName', 'cv.id as courseVersionId', 'cv.title as courseTitle']); return response()->json(['data' => ['items' => $items, 'templates' => $templates, 'eligible' => $eligible]]); } public function storeTemplate(Request $request): JsonResponse { $this->authorize($request); $data = $request->validate(['name' => ['required', 'string', 'max:120'], 'canvas' => ['required', 'array'], 'isDefault' => ['nullable', 'boolean']]); $id = (string) Str::ulid(); DB::transaction(function () use ($request, $data, $id): void { if ($data['isDefault'] ?? false) { DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->update(['is_default' => false]); } DB::table('certificate_templates')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'created_by' => $request->user()->getKey(), 'name' => $data['name'], 'canvas' => json_encode($data['canvas']), 'is_default' => $data['isDefault'] ?? false, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()]); }); return response()->json(['data' => ['id' => $id]], 201); } public function updateTemplate(Request $request, string $template): JsonResponse { $this->authorize($request); $data = $request->validate(['name' => ['required', 'string', 'max:120'], 'canvas' => ['required', 'array'], 'isDefault' => ['nullable', 'boolean']]); $exists = DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->where('is_active', true)->exists(); abort_unless($exists, 404); DB::transaction(function () use ($data, $template): void { if ($data['isDefault'] ?? false) { DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->update(['is_default' => false]); } DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->update(['name' => $data['name'], 'canvas' => json_encode($data['canvas']), 'is_default' => $data['isDefault'] ?? false, 'updated_at' => now()]); }); return response()->json(['data' => ['updated' => true]]); } public function archiveTemplate(Request $request, string $template): JsonResponse { $this->authorize($request); $updated = DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->where('is_active', true)->update(['is_active' => false, 'is_default' => false, 'updated_at' => now()]); abort_unless($updated === 1, 404); return response()->json(['data' => ['archived' => true]]); } public function issue(Request $request): JsonResponse { $this->authorize($request); $data = $request->validate(['userId' => ['required', 'string'], 'courseVersionId' => ['required', 'string'], 'templateId' => ['nullable', 'string'], 'expiresInMonths' => ['nullable', 'integer', 'min:1', 'max:120']]); $eligible = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id')->where('a.organization_id', $this->tenant->id())->where('a.assignable_type', 'course')->where('a.assignable_id', $data['courseVersionId'])->where('au.user_id', $data['userId'])->where('au.status', 'completed')->exists(); abort_unless($eligible, 422, 'Completion rules are not satisfied for this learner and course version.'); $learner = User::query()->where('organization_id', $this->tenant->id())->findOrFail($data['userId']); $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($data['courseVersionId']); $id = $this->issuer->issue($learner, $version, $data['templateId'] ?? null, $data['expiresInMonths'] ?? null); return response()->json(['data' => ['id' => $id]], 201); } public function issueBulk(Request $request): JsonResponse { $this->authorize($request); $data = $request->validate(['items' => ['required', 'array', 'min:1', 'max:100'], 'items.*.userId' => ['required', 'string'], 'items.*.courseVersionId' => ['required', 'string'], 'templateId' => ['nullable', 'string'], 'expiresInMonths' => ['nullable', 'integer', 'min:1', 'max:120']]); $issued = 0; $skipped = 0; foreach ($data['items'] as $item) { $eligible = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id')->where('a.organization_id', $this->tenant->id())->where('a.assignable_type', 'course')->where('a.assignable_id', $item['courseVersionId'])->where('au.user_id', $item['userId'])->where('au.status', 'completed')->exists(); $exists = DB::table('certificates')->where('organization_id', $this->tenant->id())->where('user_id', $item['userId'])->where('course_version_id', $item['courseVersionId'])->exists(); if (! $eligible || $exists) { $skipped++; continue; } $learner = User::query()->where('organization_id', $this->tenant->id())->find($item['userId']); $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->find($item['courseVersionId']); if (! $learner || ! $version) { $skipped++; continue; } $this->issuer->issue($learner, $version, $data['templateId'] ?? null, $data['expiresInMonths'] ?? null); $issued++; } return response()->json(['data' => ['issued' => $issued, 'skipped' => $skipped]], 201); } public function revoke(Request $request, string $certificate): JsonResponse { $this->authorize($request); $data = $request->validate(['reason' => ['required', 'string', 'max:500']]); $updated = DB::table('certificates')->where('organization_id', $this->tenant->id())->where('id', $certificate)->whereNull('revoked_at')->update(['revoked_at' => now(), 'revocation_reason' => $data['reason'], 'updated_at' => now()]); abort_unless($updated === 1, 404); return response()->json(['data' => ['revoked' => true]]); } public function download(Request $request, string $certificate) { $this->authorize($request); $row = DB::table('certificates')->where('organization_id', $this->tenant->id())->where('id', $certificate)->first(); abort_unless($row && $row->path && $row->disk && Storage::disk($row->disk)->exists($row->path), 404); return Storage::disk($row->disk)->download($row->path, ($row->certificate_number ?: 'certificate').'.pdf'); } public function verify(string $code): JsonResponse { $row = DB::table('certificates as c')->join('users as u', 'u.id', '=', 'c.user_id')->join('course_versions as cv', 'cv.id', '=', 'c.course_version_id')->join('organizations as o', 'o.id', '=', 'c.organization_id')->where('c.verification_code', $code)->first(['c.certificate_number as number', 'c.issued_at as issuedAt', 'c.expires_at as expiresAt', 'c.revoked_at as revokedAt', 'c.revocation_reason as revocationReason', 'u.name as learnerName', 'cv.title as courseTitle', 'o.name as organizationName']); abort_unless($row, 404); $status = $row->revokedAt ? 'revoked' : ($row->expiresAt && now()->isAfter($row->expiresAt) ? 'expired' : 'valid'); return response()->json(['data' => ['status' => $status, ...((array) $row)]]); } private function authorize(Request $request): void { abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); } }