Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature certificate #61

Merged
merged 25 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
781ed57
feat: initial certificate page
fikrialwan May 28, 2024
bd481df
feat: show date from event data
fikrialwan May 30, 2024
3d26f1e
fix: db provider
fikrialwan May 31, 2024
a882578
feat: initial certificate page
fikrialwan May 28, 2024
f6115d9
feat: show date from event data
fikrialwan May 30, 2024
1ae3c11
Merge branch 'feature-certificate' of github.com:bandungdevcom/bandun…
fikrialwan May 31, 2024
613f580
feat: add certificate table
fikrialwan Jun 1, 2024
d0da6f3
feat: validation user has certificate
fikrialwan Jun 9, 2024
b3bae7d
feat: add check user helper
fikrialwan Jun 9, 2024
1aabcd4
feat: add button download certificate
fikrialwan Jun 9, 2024
8189b4f
feat: only show button when event is finished
fikrialwan Jun 9, 2024
a7da9f0
feat: complete certificate.json for event 2024-02-18
fikrialwan Jul 4, 2024
abd530d
feat: remove certificate json
fikrialwan Aug 10, 2024
6b155f9
refactor(certificate): move from page to component content
fikrialwan Oct 27, 2024
f85bd5e
feat(certificate): add verification certificate url
fikrialwan Oct 27, 2024
87cbe07
feat(certificate): update id to slug
fikrialwan Oct 27, 2024
86d614d
fix(certificate): message error
fikrialwan Oct 27, 2024
20aea7a
fix(certificate): better message error user not registered yet
fikrialwan Oct 27, 2024
d7d4e19
docs: certificate descrition on readme
fikrialwan Oct 27, 2024
fd48c76
fix(certificate): image not show
fikrialwan Oct 28, 2024
0d3399c
fix(certificate): get image server using fs
fikrialwan Oct 28, 2024
48a4028
fix(certificate): move convert image to server
fikrialwan Oct 28, 2024
4dd8d08
fix(certificate): using image url
fikrialwan Oct 28, 2024
4d243ef
fix(certificate): naming variable
fikrialwan Oct 29, 2024
1c65476
fix(certificate): remove unused classname
fikrialwan Oct 29, 2024
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
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
agus-wesly marked this conversation as resolved.
Show resolved Hide resolved
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