Skip to content

Commit

Permalink
Feature certificate (#61)
Browse files Browse the repository at this point in the history
* feat: initial certificate page
* feat: show date from event data
* fix: db provider
* feat: initial certificate page
* feat: show date from event data
* feat: add certificate table
* feat: validation user has certificate
* feat: add check user helper
* feat: add button download certificate
* feat: only show button when event is finished
* feat: complete certificate.json for event 2024-02-18
* feat: remove certificate json
* refactor(certificate): move from page to component content
* feat(certificate): add verification certificate url
* feat(certificate): update id to slug
* fix(certificate): message error
* fix(certificate): better message error user not registered yet
* docs: certificate descrition on readme
* fix(certificate): image not show
* fix(certificate): get image server using fs
* fix(certificate): move convert image to server
* fix(certificate): using image url
* fix(certificate): naming variable
* fix(certificate): remove unused classname
  • Loading branch information
fikrialwan authored Nov 5, 2024
1 parent 43022fb commit 32ca6eb
Show file tree
Hide file tree
Showing 14 changed files with 789 additions and 14 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ so it doesn't have to be everyone.
]
```

You may also create `certificate.json` in `prisma/credentials` folder with the format
below. This file can include only specific certificates for users who should
have access in development, so it doesn't need to contain all certificates.

```json
[
{
"slug": "Slug",
"slugEvent": "Event Slug",
"email": "Email"
}
// ...
]
```

Then seed the initial data when needed:

```sh
Expand Down
146 changes: 146 additions & 0 deletions app/components/contents/certificate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
Document,
Image,
Page,
StyleSheet,
Text,
View,
} from "@react-pdf/renderer"
import { parsedEnv } from "~/utils/env.server"

const styles = StyleSheet.create({
page: {
flexDirection: "row",
backgroundColor: "white",
position: "relative",
},
container: {
width: "100vw",
height: "100vh",
},
backgroundImage: {
height: "100vh",
width: "70vw",
position: "absolute",
objectFit: "contain",
objectPosition: "top right",
opacity: 0.05,
top: 0,
right: 0,
zIndex: 1,
},
section: {
margin: "20 30",
padding: 20,
flexGrow: 1,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
zIndex: 10,
},
title: {
fontSize: "30px",
},
bandungDevIcon: {
width: "200px",
height: "50px",
objectPosition: "center left",
objectFit: "contain",
marginBottom: "10px",
},
containerContent: {
display: "flex",
flexDirection: "column",
gap: "20px",
},
name: {
fontSize: "36px",
color: "#3C83F6",
},
containerContentDetail: {
display: "flex",
flexDirection: "column",
gap: "5px",
},
containerContentFooter: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
},
signature: {
height: "60px",
width: "60px",
},
signatureTitle: {
marginTop: "5px",
fontSize: "10px",
},
containerVerificationCertificate: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
fontSize: "14px",
gap: "4px",
},
url: {
fontSize: "10px",
},
})

interface CertificateType {
eventName: string
fullName: string
date: string
url: string
}

export function Certificate({
eventName,
fullName,
date,
url,
}: CertificateType) {
const { SIGNATURE_URL, APP_URL } = parsedEnv

return (
<Document>
<Page size="A4" style={styles.page} orientation="landscape">
<View style={styles.container}>
<Image
style={styles.backgroundImage}
source={`${APP_URL}/images/logos/png/bandungdev-icon-white.png`}
/>
<View style={styles.section}>
<View>
<Image
style={styles.bandungDevIcon}
source={`${APP_URL}/images/logos/png/bandungdev-logo-text.png`}
/>
<Text style={styles.title}>CERTIFICATE OF ATTENDANCE</Text>
</View>
<View style={styles.containerContent}>
<Text>This certificate is presented to</Text>
<Text style={styles.name}>{fullName}</Text>
<View style={styles.containerContentDetail}>
<Text>for attending {eventName}</Text>
<Text>{date}</Text>
</View>
</View>
<View style={styles.containerContentFooter}>
<View>
<Image style={styles.signature} source={SIGNATURE_URL} />
<Text>M. Haidar Hanif</Text>
<Text style={styles.signatureTitle}>Lead BandungDev</Text>
</View>
<View style={styles.containerVerificationCertificate}>
<Text>Certificate verification</Text>
<Text style={styles.url}>{url}</Text>
</View>
</View>
</View>
</View>
</Page>
</Document>
)
}
42 changes: 42 additions & 0 deletions app/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,45 @@ export function checkAllowance(

return foundRoles ? true : false
}

/**
* Check User
*
* Complete check by getting user from the database
*
* Quick check without getting user from the database is unnecessary,
* because need to always check the user data availability
*
* Remix way to protect routes, can only be used server-side
* https://remix.run/docs/en/main/pages/faq#md-how-can-i-have-a-parent-route-loader-validate-the-user-and-protect-all-child-routes
*
* Usage:
* await checkUser(request, ["ADMIN", "MANAGER"])
*/
export async function checkUser(
request: Request,
expectedRoleSymbols?: Role["symbol"][],
) {
const userSession = await authenticator.isAuthenticated(request)

if (userSession) {
const user = await modelUser.getForSession({ id: userSession.id })
invariant(user, "User not found")

const userIsAllowed = expectedRoleSymbols
? checkAllowance(expectedRoleSymbols, user)
: true

return {
user,
userId: user.id,
userIsAllowed,
}
}

return {
user: undefined,
userId: undefined,
userIsAllowed: false,
}
}
23 changes: 23 additions & 0 deletions app/models/certificate.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type Certficate } from "@prisma/client"
import { prisma } from "~/libs/db.server"

export const modelCertificate = {
getByGlug({ slug }: Pick<Certficate, "slug">) {
return prisma.certficate.findUnique({
where: { slug },
include: { user: true, event: true },
})
},

getBySlugEventAndEmail({
slugEvent,
email,
}: Pick<Certficate, "slugEvent" | "email">) {
return prisma.certficate.findFirst({
where: {
slugEvent,
email,
},
})
},
}
58 changes: 58 additions & 0 deletions app/routes/events.$eventSlug.certificate[.pdf].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { renderToStream } from "@react-pdf/renderer"
import { type LoaderFunctionArgs } from "@remix-run/node"
import { Certificate } from "~/components/contents/certificate"
import { requireUser } from "~/helpers/auth"
import { prisma } from "~/libs/db.server"
import { modelCertificate } from "~/models/certificate.server"
import { modelEvent } from "~/models/event.server"
import { formatCertificateDate } from "~/utils/datetime"
import { parsedEnv } from "~/utils/env.server"
import { invariant, invariantResponse } from "~/utils/invariant"

export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const { user } = await requireUser(request)

invariant(params.eventSlug, "params.eventSlug unavailable")

const [event, certificate] = await prisma.$transaction([
modelEvent.getBySlug({ slug: params.eventSlug }),
modelCertificate.getBySlugEventAndEmail({
slugEvent: params.eventSlug,
email: user.email,
}),
])

invariantResponse(event, "Event not found", { status: 404 })

invariantResponse(certificate, "Certificate not found", { status: 404 })

const dateTimeFormatted = formatCertificateDate(
event.dateTimeStart,
event.dateTimeEnd,
)

const { APP_URL } = parsedEnv

const stream = await renderToStream(
<Certificate
eventName={event.title}
fullName={user.fullname}
date={dateTimeFormatted}
url={`${APP_URL}/events/certificate/${certificate.slug}.pdf`}
/>,
)

const body: Buffer = await new Promise((resolve, reject) => {
const buffers: Uint8Array[] = []
stream.on("data", data => {
buffers.push(data)
})
stream.on("end", () => {
resolve(Buffer.concat(buffers))
})
stream.on("error", reject)
})

const headers = new Headers({ "Content-Type": "application/pdf" })
return new Response(body, { status: 200, headers })
}
37 changes: 31 additions & 6 deletions app/routes/events.$eventSlug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@ import {
type MetaFunction,
} from "@remix-run/node"
import { Link, useLoaderData, type Params } from "@remix-run/react"
import { BadgeEventStatus } from "~/components/shared/badge-event-status"
import { ViewHTML } from "~/components/shared/view-html"
import dayjs from "dayjs"

import { BadgeEventStatus } from "~/components/shared/badge-event-status"
import {
ErrorHelpInformation,
GeneralErrorBoundary,
} from "~/components/shared/error-boundary"
import { FormChangeStatus } from "~/components/shared/form-change-status"
import { ImageCover } from "~/components/shared/image-cover"
import { Timestamp } from "~/components/shared/timestamp"
import { ViewHTML } from "~/components/shared/view-html"
import { Alert } from "~/components/ui/alert"
import { Button } from "~/components/ui/button"
import { ButtonLink } from "~/components/ui/button-link"
import { Iconify } from "~/components/ui/iconify"
import { Separator } from "~/components/ui/separator"
import { checkUser } from "~/helpers/auth"
import { useRootLoaderData } from "~/hooks/use-root-loader-data"
import { prisma } from "~/libs/db.server"
import { modelCertificate } from "~/models/certificate.server"
import { modelEventStatus } from "~/models/event-status.server"
import { modelEvent } from "~/models/event.server"
import {
Expand Down Expand Up @@ -49,31 +53,39 @@ export const meta: MetaFunction<typeof loader> = ({ params, data }) => {
})
}

export const loader = async ({ params }: LoaderFunctionArgs) => {
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
invariant(params.eventSlug, "params.eventSlug unavailable")

const [event, eventStatuses] = await prisma.$transaction([
const { user } = await checkUser(request)

const [event, eventStatuses, hasCertificate] = await prisma.$transaction([
modelEvent.getBySlug({ slug: params.eventSlug }),
modelEventStatus.getAll(),
modelCertificate.getBySlugEventAndEmail({
slugEvent: params.eventSlug,
email: user ? user.email : "",
}),
])

invariantResponse(event, "Event not found", { status: 404 })
invariantResponse(eventStatuses, "Event statuses unavailable", {
status: 404,
})

return json({ event, eventStatuses })
return json({ event, eventStatuses, hasCertificate })
}

export default function EventSlugRoute() {
const { userSession } = useRootLoaderData()
const { event, eventStatuses } = useLoaderData<typeof loader>()
const { event, eventStatuses, hasCertificate } =
useLoaderData<typeof loader>()

const isOwner = event.organizerId === userSession?.id
const isUpdated = event.createdAt !== event.updatedAt
const isArchived = event.status.symbol === "ARCHIVED"
const isOnline = event.category?.symbol === "ONLINE"
const isHybrid = event.category?.symbol === "HYBRID"
const isFinished = dayjs().isAfter(dayjs(event.dateTimeEnd))

return (
<div className="site-container space-y-8 pt-20 sm:pt-20">
Expand Down Expand Up @@ -233,6 +245,19 @@ export default function EventSlugRoute() {
</div>
</p>
)}
{isFinished && Boolean(hasCertificate) && (
<div className="flex w-full justify-end">
<Button asChild>
<Link
to={`/events/${event.slug}/certificate.pdf`}
target="_blank"
>
<Iconify icon="ph:download-simple-light" />
Download Certificate
</Link>
</Button>
</div>
)}
</div>
</header>

Expand Down
Loading

0 comments on commit 32ca6eb

Please sign in to comment.