From 11decaedd61de0e1939bd62af720b4a2ab762f66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:50:08 +0000 Subject: [PATCH 1/2] Initial plan From c33ff81a0719e2fdeb830c81e9e695d570a00e51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:58:51 +0000 Subject: [PATCH 2/2] Add certificate PDF download functionality (backend + frontend) Co-authored-by: Hemavathi15sg <224925058+Hemavathi15sg@users.noreply.github.com> --- .../Controllers/CertificatesController.cs | 98 +++++++++++++ api/CourseRegistration.API/Program.cs | 1 + .../Services/CertificateService.cs | 116 ++++++++++++++++ .../Services/ICertificateService.cs | 7 + .../CertificateServiceDownloadTests.cs | 129 ++++++++++++++++++ frontend/certificate.css | 33 +++++ frontend/certificate.js | 54 +++++++- 7 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 api/CourseRegistration.API/Controllers/CertificatesController.cs create mode 100644 api/CourseRegistration.Tests/Services/CertificateServiceDownloadTests.cs create mode 100644 frontend/certificate.css diff --git a/api/CourseRegistration.API/Controllers/CertificatesController.cs b/api/CourseRegistration.API/Controllers/CertificatesController.cs new file mode 100644 index 0000000..13880af --- /dev/null +++ b/api/CourseRegistration.API/Controllers/CertificatesController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Mvc; +using CourseRegistration.Application.DTOs; +using CourseRegistration.Application.Services; + +namespace CourseRegistration.API.Controllers; + +/// +/// Controller for certificate operations +/// +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class CertificatesController : ControllerBase +{ + private readonly ICertificateService _certificateService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the CertificatesController + /// + public CertificatesController(ICertificateService certificateService, ILogger logger) + { + _certificateService = certificateService ?? throw new ArgumentNullException(nameof(certificateService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets a certificate by ID + /// + /// Certificate ID + /// Certificate details + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(CertificateDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCertificate(Guid id) + { + _logger.LogInformation("Getting certificate with ID: {CertificateId}", id); + + var certificate = await _certificateService.GetCertificateByIdAsync(id); + if (certificate == null) + { + return NotFound(new { message = "Certificate not found" }); + } + + return Ok(certificate); + } + + /// + /// Searches certificates by student name + /// + /// Student name to search for + /// List of matching certificates + [HttpGet("search")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> SearchCertificates( + [FromQuery] string studentName = "") + { + _logger.LogInformation("Searching certificates for student name: {StudentName}", studentName); + + if (string.IsNullOrWhiteSpace(studentName)) + { + return BadRequest(new { message = "Student name is required" }); + } + + var certificates = await _certificateService.GetCertificatesByStudentNameAsync(studentName); + return Ok(certificates); + } + + /// + /// Downloads a certificate as a PDF file + /// + /// Certificate ID + /// PDF file + [HttpGet("{certificateId:guid}/download")] + [Produces("application/pdf")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task DownloadCertificate(Guid certificateId) + { + _logger.LogInformation("Download requested for certificate ID: {CertificateId}", certificateId); + + if (certificateId == Guid.Empty) + { + return BadRequest(new { message = "Invalid certificate ID" }); + } + + var pdfBytes = await _certificateService.DownloadCertificatePdfAsync(certificateId); + if (pdfBytes == null) + { + return NotFound(new { message = "Certificate not found" }); + } + + var fileName = $"certificate-{certificateId}.pdf"; + return File(pdfBytes, "application/pdf", fileName); + } +} diff --git a/api/CourseRegistration.API/Program.cs b/api/CourseRegistration.API/Program.cs index e7e52e4..cd7e7ed 100644 --- a/api/CourseRegistration.API/Program.cs +++ b/api/CourseRegistration.API/Program.cs @@ -66,6 +66,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register authorization services builder.Services.AddScoped(); diff --git a/api/CourseRegistration.Application/Services/CertificateService.cs b/api/CourseRegistration.Application/Services/CertificateService.cs index 01e20a5..05bb5e8 100644 --- a/api/CourseRegistration.Application/Services/CertificateService.cs +++ b/api/CourseRegistration.Application/Services/CertificateService.cs @@ -148,6 +148,122 @@ public string GenerateCertificateNumber() return $"CERT-{year}-{sequence:D3}"; } + public async Task DownloadCertificatePdfAsync(Guid certificateId) + { + await Task.CompletedTask; // Simulate async operation + + var certificate = _certificates.FirstOrDefault(c => c.CertificateId == certificateId); + if (certificate == null) + { + return null; + } + + var dto = MapToDto(certificate); + return BuildMinimalPdf(dto); + } + + private static byte[] BuildMinimalPdf(CertificateDto cert) + { + // Build the page content stream using standard PDF Type1 font (Helvetica) + var content = new System.Text.StringBuilder(); + content.AppendLine("BT"); + content.AppendLine("/F1 28 Tf"); + content.AppendLine("160 720 Td"); + content.AppendLine($"(Certificate of Completion) Tj"); + content.AppendLine("/F1 18 Tf"); + content.AppendLine("0 -60 Td"); + content.AppendLine($"(This certifies that) Tj"); + content.AppendLine("/F1 22 Tf"); + content.AppendLine("0 -40 Td"); + var displayName = cert.StudentName.Length > 60 ? cert.StudentName[..60] + "..." : cert.StudentName; + content.AppendLine($"({EscapePdfString(displayName)}) Tj"); + content.AppendLine("/F1 16 Tf"); + content.AppendLine("0 -40 Td"); + content.AppendLine("(has successfully completed the course:) Tj"); + content.AppendLine("/F1 20 Tf"); + content.AppendLine("0 -36 Td"); + content.AppendLine($"({EscapePdfString(cert.CourseName)}) Tj"); + content.AppendLine("/F1 14 Tf"); + content.AppendLine("0 -50 Td"); + content.AppendLine($"(Instructor: {EscapePdfString(cert.InstructorName)}) Tj"); + content.AppendLine("0 -24 Td"); + content.AppendLine($"(Issue Date: {cert.IssueDate.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}) Tj"); + content.AppendLine("0 -24 Td"); + content.AppendLine($"(Certificate No: {EscapePdfString(cert.CertificateNumber)}) Tj"); + content.AppendLine("0 -24 Td"); + content.AppendLine($"(Grade: {cert.FinalGrade}) Tj"); + content.AppendLine("ET"); + + var streamBytes = System.Text.Encoding.Latin1.GetBytes(content.ToString()); + var streamLength = streamBytes.Length; + + // Assemble PDF objects + var objects = new List(); + + // Object 1: Catalog + objects.Add("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj"); + // Object 2: Pages + objects.Add("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj"); + // Object 3: Page + objects.Add("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]\n /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj"); + // Object 4: Content stream + objects.Add($"4 0 obj\n<< /Length {streamLength} >>\nstream\n{content}endstream\nendobj"); + // Object 5: Font + objects.Add("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj"); + + // Build xref table + var pdfBuilder = new System.Text.StringBuilder(); + pdfBuilder.Append("%PDF-1.4\n"); + + var offsets = new List(); + foreach (var obj in objects) + { + offsets.Add(pdfBuilder.Length); + pdfBuilder.Append(obj); + pdfBuilder.Append("\n"); + } + + var xrefOffset = pdfBuilder.Length; + pdfBuilder.Append("xref\n"); + pdfBuilder.Append($"0 {objects.Count + 1}\n"); + pdfBuilder.Append("0000000000 65535 f \n"); + foreach (var offset in offsets) + { + pdfBuilder.Append($"{offset:D10} 00000 n \n"); + } + pdfBuilder.Append("trailer\n"); + pdfBuilder.Append($"<< /Size {objects.Count + 1} /Root 1 0 R >>\n"); + pdfBuilder.Append("startxref\n"); + pdfBuilder.Append($"{xrefOffset}\n"); + pdfBuilder.Append("%%EOF\n"); + + return System.Text.Encoding.Latin1.GetBytes(pdfBuilder.ToString()); + } + + private static string EscapePdfString(string input) + { + // Filter to Latin1 (ISO-8859-1) range, then escape special PDF string characters + var latin1Safe = new System.Text.StringBuilder(input.Length); + foreach (var ch in input) + { + if (ch <= 0xFF) + { + latin1Safe.Append(ch); + } + else + { + latin1Safe.Append('?'); + } + } + + return latin1Safe.ToString() + .Replace("\\", "\\\\") + .Replace("(", "\\(") + .Replace(")", "\\)") + .Replace("\r", "") + .Replace("\n", " "); + } + private CertificateDto MapToDto(Certificate certificate) { var student = _students.FirstOrDefault(s => s.StudentId == certificate.StudentId); diff --git a/api/CourseRegistration.Application/Services/ICertificateService.cs b/api/CourseRegistration.Application/Services/ICertificateService.cs index b1c733c..0901143 100644 --- a/api/CourseRegistration.Application/Services/ICertificateService.cs +++ b/api/CourseRegistration.Application/Services/ICertificateService.cs @@ -40,4 +40,11 @@ public interface ICertificateService /// /// Unique certificate number string GenerateCertificateNumber(); + + /// + /// Generate a PDF byte array for the given certificate + /// + /// Certificate ID + /// PDF bytes, or null if the certificate is not found + Task DownloadCertificatePdfAsync(Guid certificateId); } \ No newline at end of file diff --git a/api/CourseRegistration.Tests/Services/CertificateServiceDownloadTests.cs b/api/CourseRegistration.Tests/Services/CertificateServiceDownloadTests.cs new file mode 100644 index 0000000..b8c4127 --- /dev/null +++ b/api/CourseRegistration.Tests/Services/CertificateServiceDownloadTests.cs @@ -0,0 +1,129 @@ +using Xunit; +using CourseRegistration.Application.Services; +using CourseRegistration.Application.DTOs; +using CourseRegistration.Domain.Enums; + +namespace CourseRegistration.Tests.Services; + +/// +/// Unit tests for CertificateService PDF download functionality +/// +public class CertificateServiceDownloadTests +{ + private readonly CertificateService _service; + + public CertificateServiceDownloadTests() + { + _service = new CertificateService(); + } + + [Fact] + public async Task DownloadCertificatePdfAsync_WithValidId_ReturnsPdfBytes() + { + // Arrange + var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto + { + StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + CourseId = Guid.Parse("33333333-3333-3333-3333-333333333333"), + FinalGrade = Grade.A, + Remarks = "Test certificate" + }); + + // Act + var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId); + + // Assert + Assert.NotNull(pdfBytes); + Assert.True(pdfBytes.Length > 0); + } + + [Fact] + public async Task DownloadCertificatePdfAsync_WithValidId_ReturnsPdfHeader() + { + // Arrange + var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto + { + StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + CourseId = Guid.Parse("33333333-3333-3333-3333-333333333333"), + FinalGrade = Grade.B, + Remarks = "PDF header test" + }); + + // Act + var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId); + + // Assert + Assert.NotNull(pdfBytes); + var header = System.Text.Encoding.Latin1.GetString(pdfBytes, 0, Math.Min(8, pdfBytes.Length)); + Assert.StartsWith("%PDF-", header); + } + + [Fact] + public async Task DownloadCertificatePdfAsync_WithInvalidId_ReturnsNull() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act + var pdfBytes = await _service.DownloadCertificatePdfAsync(nonExistentId); + + // Assert + Assert.Null(pdfBytes); + } + + [Fact] + public async Task DownloadCertificatePdfAsync_WithEmptyGuid_ReturnsNull() + { + // Act + var pdfBytes = await _service.DownloadCertificatePdfAsync(Guid.Empty); + + // Assert + Assert.Null(pdfBytes); + } + + [Fact] + public async Task DownloadCertificatePdfAsync_PdfContainsCertificateDetails() + { + // Arrange + // StudentId 11111111... maps to "John Doe" and CourseId 33333333... maps to + // "Introduction to Programming" per CertificateService in-memory seed data. + var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto + { + StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + CourseId = Guid.Parse("33333333-3333-3333-3333-333333333333"), + FinalGrade = Grade.A, + Remarks = "Content verification test" + }); + + // Act + var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId); + + // Assert + Assert.NotNull(pdfBytes); + var pdfContent = System.Text.Encoding.Latin1.GetString(pdfBytes); + Assert.Contains("Certificate of Completion", pdfContent); + Assert.Contains("John Doe", pdfContent); + Assert.Contains("Introduction to Programming", pdfContent); + } + + [Fact] + public async Task DownloadCertificatePdfAsync_PdfEndsWithEof() + { + // Arrange + var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto + { + StudentId = Guid.Parse("22222222-2222-2222-2222-222222222222"), + CourseId = Guid.Parse("44444444-4444-4444-4444-444444444444"), + FinalGrade = Grade.B, + Remarks = "EOF test" + }); + + // Act + var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId); + + // Assert + Assert.NotNull(pdfBytes); + var pdfContent = System.Text.Encoding.Latin1.GetString(pdfBytes); + Assert.Contains("%%EOF", pdfContent); + } +} diff --git a/frontend/certificate.css b/frontend/certificate.css new file mode 100644 index 0000000..3a9f3ff --- /dev/null +++ b/frontend/certificate.css @@ -0,0 +1,33 @@ +.certificate { + padding: 2rem; + max-width: 600px; + margin: 0 auto; +} + +.download-btn { + display: inline-block; + margin-top: 1rem; + padding: 0.6rem 1.4rem; + background-color: #4c51bf; + color: #fff; + border: none; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.download-btn:hover:not(:disabled) { + background-color: #434190; +} + +.download-btn:disabled { + background-color: #a0aec0; + cursor: not-allowed; +} + +.download-error { + margin-top: 0.75rem; + color: #c53030; + font-size: 0.9rem; +} diff --git a/frontend/certificate.js b/frontend/certificate.js index 13b8a42..8137abf 100644 --- a/frontend/certificate.js +++ b/frontend/certificate.js @@ -1,18 +1,68 @@ //Create a comprehensive certificate generation and display system for the Course Registration System that allows users to // search for and view digital certificates for completed courses. -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import axios from 'axios'; import { useParams } from 'react-router-dom'; import './certificate.css'; function Certificate() { const { certificateId } = useParams(); + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); + + const handleDownload = async () => { + if (!certificateId) return; + + setDownloading(true); + setDownloadError(null); + + try { + const encodedId = encodeURIComponent(certificateId); + const response = await axios.get( + `/api/certificates/${encodedId}/download`, + { responseType: 'blob' } + ); + + const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `certificate-${certificateId}.pdf`); + document.body.appendChild(link); + link.click(); + link.parentNode.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + if (error.response) { + if (error.response.status === 404) { + setDownloadError('Certificate not found. Please verify the certificate ID.'); + } else { + setDownloadError('Failed to download certificate. Please try again later.'); + } + } else { + setDownloadError('Network error. Please check your connection and try again.'); + } + } finally { + setDownloading(false); + } + }; return (

Certificate

{certificateId ? ( -

Certificate ID: {certificateId}

+
+

Certificate ID: {certificateId}

+ + {downloadError && ( +

{downloadError}

+ )} +
) : (

No certificate selected.

)}