Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ Electronic Campus (eCampus) for Igor Sikorsky Kyiv Polytechnic Institute - a ful

## Quick Commands

**Note: No local npm/node installed. Use Docker for all npm commands.**

```bash
# Via Docker (recommended):
docker run --rm -it -v $(pwd):/app -w /app -p 3000:3000 node:18-alpine npm run dev # Dev server
docker run --rm -it -v $(pwd):/app -w /app node:18-alpine npm run build # Production build
docker run --rm -it -v $(pwd):/app -w /app node:18-alpine npm run lint # ESLint
docker run --rm -it -v $(pwd):/app -w /app node:18-alpine npm run tsc # Type check
docker run --rm -it -v $(pwd):/app -w /app node:18-alpine npm install # Install deps
docker run --rm -it -v $(pwd):/app -w /app -p 6006:6006 node:18-alpine npm run storybook # Storybook

# If npm is available locally:
npm run dev # Start dev server with Turbopack
npm run build # Production build
npm run start # Run production server
Expand Down Expand Up @@ -118,6 +129,81 @@ Required in `.env.development` / `.env.production`:

No testing framework currently configured.

## Module: Certificates

### Overview

The certificate module handles student certificate requests and faculty certificate management (approval/rejection, signing, printing).

### Certificate Status Flow

```
Created → Pending → Processed → Signed
↓ ↓ ↓
Error Error Error
```

- **Created**: Initial request submitted by student
- **Pending**: Approved by faculty, waiting for PDF generation
- **Processed**: PDF generated, ready for signing
- **Signed**: Certificate signed and delivered to student
- **Error**: Generation failed (can be regenerated)

### Key Types

```typescript
// Certificate statuses
enum CertificateStatus {
Error = 'Error',
Created = 'Created',
Pending = 'Pending',
Processed = 'Processed',
Signed = 'Signed',
}

// Certificate model
interface Certificate {
id: number;
publicKey: string;
status: CertificateStatus;
originalRequired: boolean; // true = paper original needed
approved: boolean | null; // null = pending review
// ... other fields
}
```

### Server Actions (`src/actions/certificates.actions.ts`)

| Action | Description |
|--------|-------------|
| `getCertificateList()` | Get student's own certificates |
| `getCertificateTypes()` | Get available certificate types |
| `createCertificateRequest(body)` | Student requests a certificate |
| `getAllFacultyCertificates(query)` | Faculty: list all certificates with pagination |
| `getCertificate(id)` | Get single certificate details |
| `getCertificateData(id)` | Get student data for certificate |
| `updateCertificate(id, body)` | Approve/reject certificate |
| `regenerateCertificate(id)` | Retry failed PDF generation |
| `signCertificate(id)` | Mark certificate as signed |
| `getCertificatePDF(id)` | Download signed PDF (with stamp) |
| `getUnsignedCertificatePDF(id)` | Download unsigned PDF (no stamp) |
| `getSignatories()` | Get available signatories |
| `verifyCertificate(id)` | Public: verify certificate by public key |
| `createCertificateAsOperator(body)` | Operator: create certificate for student |
| `searchStudentsForOperator(query)` | Operator: search students |

### Routes

- `/module/certificates` - Student certificate requests
- `/module/facultycertificate` - Faculty certificate management
- `/module/facultycertificate/[id]` - Certificate details & actions

### Print Logic

Unsigned PDF (without stamp) available when:
- `originalRequired === false` (electronic certificate)
- `status === Processed || status === Signed`

## Useful Paths

- Components: `src/components/ui/`
Expand Down
102 changes: 100 additions & 2 deletions src/actions/certificates.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { revalidatePath } from 'next/cache';
import { CertificateVerificationResult } from '@/types/models/certificate/certificate-verification-result';
import { parseContentDispositionFilename } from '@/lib/utils';
import { CertificateStatus } from '@/types/models/certificate/status';
import { CertificateSignatory, DeanSignatory } from '@/types/models/certificate/signatory';
import { StudentCertificateData } from '@/types/models/certificate/student-certificate-data';
import { CertificateOperatorCreateRequest, StudentSearchResult } from '@/types/models/certificate/operator-request';
import qs from 'query-string';

export async function getCertificateTypes() {
Expand All @@ -19,6 +22,8 @@ export async function getCertificateTypes() {
export type UpdateCertificateBody = {
approve: boolean;
reason?: string;
notes?: string;
signatoryId?: number;
};

export async function updateCertificate(id: number, body: UpdateCertificateBody) {
Expand All @@ -32,11 +37,24 @@ export async function updateCertificate(id: number, body: UpdateCertificateBody)
}
revalidatePath('/module/facultycertificate', 'layout');
}

export async function regenerateCertificate(id: number) {
const res = await campusFetch(`/certificates/${id}/regenerate`, {
method: 'POST',
});

if (!res.ok) {
throw new Error(res.statusText);
}
revalidatePath('/module/facultycertificate', 'layout');
}

type CertificateRequestBody = {
type: string;
originalRequired?: boolean;
notes?: string;
purpose?: string;
includeOrderInfo?: boolean;
};

export async function createCertificateRequest(body: CertificateRequestBody) {
Expand Down Expand Up @@ -80,11 +98,12 @@ export async function getCertificatePDF(id: number) {

const cd = response.headers.get('Content-Disposition') || '';
const filename = parseContentDispositionFilename(cd) ?? `certificate.pdf`;
const blob = await response.blob();
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');

return {
filename,
blob,
base64,
};
} catch (error) {
console.error('Error downloading PDF:', error);
Expand Down Expand Up @@ -143,3 +162,82 @@ export async function signCertificate(id: number) {

revalidatePath('/module/facultycertificate', 'layout');
}

export async function getSignatories() {
const response = await campusFetch<CertificateSignatory[]>('/certificates/signatories');
if (!response.ok) {
throw new Error(`${response.status} Error`);
}
return response.json();
}

/**
* Get available signatories from Dean DB for a specific student.
* Used by operators to select who will sign the certificate.
*/
export async function getStudentSignatories(studentUserAccountId: number) {
const response = await campusFetch<DeanSignatory[]>(`/certificates/signatories/student/${studentUserAccountId}`);
if (!response.ok) {
throw new Error(`${response.status} Error`);
}
return response.json();
}

export async function getCertificateData(id: number) {
const response = await campusFetch<StudentCertificateData>(`/certificates/${id}/data`);
if (!response.ok) {
throw new Error(`${response.status} Error`);
}
return response.json();
}

export async function getUnsignedCertificatePDF(id: number) {
const response = await campusFetch(`/certificates/${id}/pdf/unsigned`, {
headers: { Accept: 'application/pdf' },
});

if (!response.ok) {
throw new Error(`Failed to download unsigned PDF: ${response.status} ${response.statusText}`);
}

const cd = response.headers.get('Content-Disposition') || '';
const filename = parseContentDispositionFilename(cd) ?? `certificate-unsigned.pdf`;
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');

return { filename, base64 };
}

export async function createCertificateAsOperator(body: CertificateOperatorCreateRequest) {
const res = await campusFetch('/certificates/operator', {
method: 'POST',
body: JSON.stringify(body),
});

if (!res.ok) {
throw new Error(res.statusText);
}

revalidatePath('/module/facultycertificate', 'layout');
return res.json();
}

export interface OperatorStudentsQuery {
page?: number;
size?: number;
filter?: string;
}

export async function searchStudentsForOperator(query: OperatorStudentsQuery = {}) {
const queryParams = qs.stringify(query);
const response = await campusFetch<StudentSearchResult[]>(`/certificates/operator/students?${queryParams}`);

if (!response.ok) {
throw new Error(`${response.status} Error`);
}

const items = await response.json();
const totalCount = parseInt(response.headers.get('x-total-count') || '0', 10);

return { items, totalCount };
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { usePagination } from '@/hooks/use-pagination';
import { Certificate } from '@/types/models/certificate/certificate';
import saveAs from 'file-saver';
import { PAGE_SIZE_SMALL } from '@/lib/constants/page-size';
import { base64ToBlob } from '@/lib/utils';

interface Props {
certificates: Certificate[];
Expand All @@ -28,44 +29,57 @@ export function HistoryTable({ certificates }: Props) {
const { paginatedItems: paginatedCertificates, page } = usePagination(PAGE_SIZE_SMALL, certificates);

const handleDownload = async (id: number) => {
const { filename, blob } = await getCertificatePDF(id);

const { filename, base64 } = await getCertificatePDF(id);
const blob = base64ToBlob(base64, 'application/pdf');
saveAs(blob, filename);
};

return (
<Card className="rounded-b-6 col-span-full flex w-full flex-[2] basis-4/7 flex-col gap-4 bg-white p-4 sm:gap-6 sm:p-6 md:p-9 xl:col-span-5">
<Card className="rounded-b-6 col-span-full flex w-full min-w-0 flex-1 flex-col gap-4 bg-white p-4 sm:gap-6 sm:p-6 md:p-9 xl:col-span-5">
<Heading6>{tTable('title')}</Heading6>
<Table>
<TableHeader>
<TableRow>
<TableHead>{tTable('type')}</TableHead>
<TableHead>{tTable('documentNumber')}</TableHead>
<TableHead>{tTable('date')}</TableHead>
<TableHead>{tTable('status')}</TableHead>
<TableHead>{tTable('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedCertificates.map((certificate) => {
const shouldShowDownloadButton =
certificate.status === CertificateStatus.Processed || certificate.status === CertificateStatus.Signed;
const isElectronic = !certificate.originalRequired;
const canDownload =
isElectronic &&
(certificate.status === CertificateStatus.Processed || certificate.status === CertificateStatus.Signed);
const isReadyAtDeanOffice = certificate.originalRequired && certificate.status === CertificateStatus.Signed;

return (
<TableRow key={certificate.id}>
<TableCell className="w-[140px]">
<Paragraph className="m-0 text-sm font-normal">{tEnums(dash(certificate.type))}</Paragraph>
<Paragraph className="m-0 text-sm font-normal text-neutral-600">{certificate.purpose}</Paragraph>
</TableCell>
<TableCell className="w-[140px]">
{certificate.documentNumber ? (
<Paragraph className="m-0 text-sm font-medium">{certificate.documentNumber}</Paragraph>
) : (
<Paragraph className="m-0 text-sm text-neutral-400">—</Paragraph>
)}
</TableCell>
<TableCell className="w-[100px]">{dayjs(certificate.created).format('DD.MM.YYYY')}</TableCell>
<TableCell className="w-[100px]">
<CertificateStatusBadge certificate={certificate} />
</TableCell>
<TableCell className="w-[100px]">
{shouldShowDownloadButton && (
{canDownload && (
<Button variant="secondary" onClick={() => handleDownload(certificate.id)}>
{tTable('download')}
</Button>
)}
{isReadyAtDeanOffice && (
<Paragraph className="m-0 text-sm font-normal text-green-600">{tTable('readyAtDeanOffice')}</Paragraph>
)}
</TableCell>
</TableRow>
);
Expand Down
Loading
Loading