Skip to content
This repository has been archived by the owner on Dec 10, 2024. It is now read-only.

Commit

Permalink
feat(cert): preliminary certificate generation, validation, distribut…
Browse files Browse the repository at this point in the history
…ion (wip)
  • Loading branch information
jhdcruz committed Nov 22, 2024
1 parent 4c982da commit f5f1647
Show file tree
Hide file tree
Showing 20 changed files with 1,299 additions and 10 deletions.
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const nextConfig: NextConfig = {
config.resolve.alias = {
...config.resolve.alias,
'onnxruntime-node$': false,
sharp$: false,
pdfkit$: false,
};

return config;
},
serverExternalPackages: [
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@noble/hashes": "1.5.0",
"@opentelemetry/api": "1.9.0",
"@react-email/components": "0.0.28",
"@shopify/react-web-worker": "5.1.6",
"@supabase/ssr": "0.5.2",
"@supabase/supabase-js": "2.46.1",
"@tabler/icons-react": "3.22.0",
Expand All @@ -62,10 +63,15 @@
"@tiptap/starter-kit": "2.10.0",
"@trigger.dev/build": "3.2.1",
"@trigger.dev/sdk": "3.2.1",
"@zip.js/zip.js": "2.7.53",
"blob-util": "2.0.2",
"dayjs": "1.11.13",
"framer-motion": "11.11.17",
"little-date": "1.0.0",
"next": "15.0.4-canary.22",
"pdf-lib": "1.17.1",
"pdfkit": "0.15.1",
"qrcode": "1.5.4",
"react": "19.0.0-rc-fb9a90fa48-20240614",
"react-dom": "19.0.0-rc-fb9a90fa48-20240614",
"react-transition-progress": "0.0.4",
Expand All @@ -78,11 +84,14 @@
"@eslint/eslintrc": "3.2.0",
"@eslint/js": "9.14.0",
"@types/node": "22.9.1",
"@types/pdfkit": "0.13.5",
"@types/qrcode": "1.5.5",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/sanitize-html": "^2.13.0",
"autoprefixer": "10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-0dec889-20241115",
"brfs": "2.0.2",
"eslint": "9.14.0",
"eslint-config-next": "15.0.3",
"eslint-config-prettier": "9.1.0",
Expand All @@ -98,6 +107,7 @@
"supabase": "1.223.10",
"tailwindcss": "3.4.15",
"tailwindcss-animate": "1.0.7",
"transform-loader": "0.2.4",
"trigger.dev": "3.2.1",
"typescript": "5.6.3"
},
Expand All @@ -117,6 +127,7 @@
},
"trustedDependencies": [
"@depot/cli",
"es5-ext",
"esbuild",
"msw",
"onnxruntime-node",
Expand Down
71 changes: 71 additions & 0 deletions src/app/api/triggers/emails/certs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Resend } from 'resend';
import { NextRequest } from 'next/server';
import { runs } from '@trigger.dev/sdk/v3';
import CertEmail from '@/emails/CertEmail';

/**
* Email certificates to evaluation participants
*
* @param req PayloadCerts
*/
export async function POST(req: NextRequest) {
const { runId, activity, recipients } = await req.json();

// Validate required fields
if (!runId || !activity || !recipients?.length) {
return Response.json({ error: 'Missing required fields' }, { status: 400 });
}

const run = await runs.retrieve(runId);
if (!run?.isExecuting) {
return Response.json(
{ error: 'Invalid run ID or run not executing' },
{ status: 400 },
);
}

// send email to assigned faculties
const resend = new Resend(process.env.RESEND_API);
const errors = [];

for (const recipient of recipients) {
const { error } = await resend.emails.send({
from: 'Community Extension Services Office <noreply@mail.deuz.tech>',
to: recipient.recipient_email,
subject: 'Thank you for participating in the activity: ' + activity.title,
react: CertEmail({ activity }),
attachments: [
{
filename: `${recipient.recipient_name.replace(/[^a-z0-9]/gi, '_')}.pdf`,
path: recipient.url as string,
},
],
headers: {
'X-Entity-Ref-ID': runId,
},
});

if (error) {
errors.push({
recipient: recipient.recipient_email,
error: error.message,
});
}
}

if (errors.length > 0) {
return Response.json(
{ errors },
{
status: 400,
},
);
}

return Response.json(
{ message: 'Emails sent successfully' },
{
status: 200,
},
);
}
37 changes: 37 additions & 0 deletions src/app/certs/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import { metadata as defaultMetadata } from '@/app/layout';
import { Box, Text, Title } from '@mantine/core';
import { cookies } from 'next/headers';
import { createServerClient } from '@/libs/supabase/server';

export const metadata: Metadata = {
title: 'Certifications - ' + defaultMetadata.title,
};

export default async function PublicCertsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const cookieStore = cookies();
const supabase = await createServerClient(cookieStore);

const { id } = await params;

// get certs details
const certsQuery = await supabase
.from('certs')
.select()
.eq('hash', id)
.limit(1)
.single();

return (
<Box>
<Title order={3}>Valid Certificate</Title>
<br />
<Text fw="bold">{certsQuery.data?.recipient_name}</Text>
<Text size="sm">{certsQuery.data?.recipient_email}</Text>
</Box>
);
}
41 changes: 41 additions & 0 deletions src/app/certs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import type { Metadata } from 'next';
import Image from 'next/image';
import { metadata as defaultMetadata } from '@/app/layout';
import { Container, Box, Group } from '@mantine/core';
import cesoLogo from '@/components/_assets/img/ceso-manila.webp';

export const metadata: Metadata = {
title: defaultMetadata.title,
description: defaultMetadata.description,
};

export default async function Layout({ children }: { children: ReactNode }) {
return (
<Container pb="lg" size="md">
<Group justify="center" my="xl">
<Image
alt="Community Extensions Services Office of Technological Institute of the Philippines - Manila"
className="rounded-md shadow-md"
height={102}
placeholder="blur"
priority={false}
src={cesoLogo}
width={256}
/>
</Group>

<Box
bg="light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
)"
className="rounded-xl shadow-lg"
my="lg"
p="xl"
>
{children}
</Box>
</Container>
);
}
102 changes: 102 additions & 0 deletions src/app/portal/certs/_components/ActivityInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use client';

import { memo, useState, useEffect } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import {
Autocomplete,
type AutocompleteProps,
Avatar,
Group,
Text,
Loader,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { getActivities } from '@/libs/supabase/api/activity';
import type { Tables } from '@/libs/supabase/_database';
import { formatDateRange } from 'little-date';

export const ActivityInput = memo((props: AutocompleteProps) => {
const [query, setQuery] = useState('');
const [activityQuery] = useDebouncedValue(query, 200);
const [data, setData] = useState<Tables<'activities_details_view'>[]>([]);
const [loading, setLoading] = useState(false);

// custom autocomplete item ui
const renderAutocompleteOption: AutocompleteProps['renderOption'] = ({
option,
}) => {
const activity = data.find((activity) => activity.id === option.value);
return (
<Group gap="sm">
<Avatar
className="object-contain"
color="initials"
name={activity?.title as string}
radius="md"
size={90}
src={activity?.image_url}
/>
<div>
<Text fw="bold" size="sm">
{data.find((activity) => activity.id === option.value)?.title}
</Text>
<Text c="dimmed" size="xs">
{activity?.date_starting &&
formatDateRange(
new Date(activity.date_starting),
new Date(activity?.date_ending!),
{
includeTime: true,
},
)}
</Text>
</div>
</Group>
);
};

useEffect(() => {
const fetchSeries = async () => {
setLoading(true);
const response = await getActivities({ search: activityQuery });

if (response.data) {
setData(response.data);
} else {
notifications.show({
title: 'Unable to fetch activity',
message: response.message,
color: 'red',
withBorder: true,
withCloseButton: true,
autoClose: 5000,
});
}

setLoading(false);
};

// practivities query on initial render
if (activityQuery) {
// noinspection JSIgnoredPromiseFromCall
void fetchSeries();
}
}, [activityQuery]);

return (
<Autocomplete
data={data.map((activity) => ({
value: activity.id!,
label: activity.title,
}))}
label="Activity Title"
limit={5}
onChangeCapture={(e) => setQuery(e.currentTarget.value)}
placeholder="Brigada Eskwela"
renderOption={renderAutocompleteOption}
rightSection={loading ? <Loader size="1rem" /> : null}
{...props}
/>
);
});
ActivityInput.displayName = 'ActivityInput';
Loading

0 comments on commit f5f1647

Please sign in to comment.