feat: end to end api type safety with redis rate limiting (#573)

* feat: support hono rpc

* add wrangler file

* update name of wrangler file

* add wrangler as a dependency

* add compatibility_flags

* trigger ci

* core: add health check to docker file

* expose port 80

* update custom healthcheck

* feat: implement openapi

* install dependencies in root

* debug yml

* update yml

* update port

* update dockerfile with envs

* completely move away from apiClient

* rename backend to api

* refactor: update server descriptions and comment out local spec saving

* update lock file

* use api.classroomio.com

* add support for redis

* fix: add missing line break in WelcomeModal.svelte

* refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
-2
View File
@@ -1,2 +0,0 @@
node_modules
npm-debug.log
-20
View File
@@ -1,20 +0,0 @@
## Supabase - DB
PUBLIC_SUPABASE_ANON_KEY=
PUBLIC_SUPABASE_URL=
## Cloudflare - Video uploads
# Not required - https://cdn.example.com
CLOUDFLARE_BUCKET_DOMAIN=
CLOUDFLARE_ACCESS_KEY=
CLOUDFLARE_SECRET_ACCESS_KEY=
CLOUDFLARE_ACCOUNT_ID=
# Email
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
SMTP_PORT=
# Sentry DNS
SENTRY_DNS=
-4
View File
@@ -1,4 +0,0 @@
node_modules
.env
.DS_Store
dist
-71
View File
@@ -1,71 +0,0 @@
FROM node:slim AS app
# Add build arguments
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_SUPABASE_URL
ARG CLOUDFLARE_BUCKET_DOMAIN
ARG CLOUDFLARE_ACCESS_KEY
ARG CLOUDFLARE_SECRET_ACCESS_KEY
ARG CLOUDFLARE_ACCOUNT_ID
ARG CLOUDFLARE_BUCKET_ID
ARG SMTP_HOST
ARG SMTP_USER
ARG SMTP_PASSWORD
ARG SMTP_PORT
ARG SMTP_SENDER
RUN mkdir /app
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Install chrome stable from sources, then remove it. Why? The
# npm install of `puppeteer` brings its own bundle chromium build,
# and puppeteer releases are only guaranteed to work with that version.
#
# But the bundled chromium implicitly needs a bunch of shared libs on
# the host. It's a little tedious to find and maintain that list; but
# the official apt distro of `google-chrome-stable` should bring the right
# set along. So do that, but immediately uninstall (to free up layer space).
#
# This is a little brittle, since the puppeteer chrome could in theory diverge
# from the official apt chrome's shared lib deps. But it works..
# RUN apt-get update \
# && apt-get install curl gnupg -y \
# && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
# && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
# && apt-get update \
# && apt-get install google-chrome-stable -y --no-install-recommends \
# && apt-get remove google-chrome-stable -y \
# && rm -rf /var/lib/apt/lists/*
# Add emoji fonts.
# Source: https://gist.github.com/win0err/9d8c7f0feabdfe8a4c9787b02c79ac51
# RUN mkdir ~/.fonts/ && \
# wget https://github.com/samuelngs/apple-emoji-linux/releases/download/ios-15.4/AppleColorEmoji.ttf -O ~/.fonts/AppleColorEmoji.ttf
COPY ./package.json /app
RUN pnpm install
COPY . /app
# Set environment variables from build args
ENV NODE_ENV=production \
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
CLOUDFLARE_BUCKET_DOMAIN=$CLOUDFLARE_BUCKET_DOMAIN \
CLOUDFLARE_ACCESS_KEY=$CLOUDFLARE_ACCESS_KEY \
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
SMTP_HOST=$SMTP_HOST \
SMTP_USER=$SMTP_USER \
SMTP_PASSWORD=$SMTP_PASSWORD \
SMTP_PORT=$SMTP_PORT \
PORT=3002
RUN pnpm build
EXPOSE 3002
CMD ["pnpm", "start"]
-10
View File
@@ -1,10 +0,0 @@
# ClassroomIO Backend
## Supported Features
1. Certificate download for students.
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
## Debugging Fly.io
View logs from fly.io in real time
-431
View File
@@ -1,431 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<title>Certificate</title>
<style>
.student-name {
font-family: 'Qwitcher Grypen', cursive;
}
body {
position: relative;
}
</style>
</head>
<body>
<img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute top-0 left-0 w-full h-[100vh] z-10"
/>
<div class="absolute z-20 left-[5%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="flex border justify-between w-[70%] mx-auto text-center items-end my-14">
<div>
<p class="font-bold">${dateIssued}</p>
<h1 class="border-t border-black h-2/4 font-bold">Date Issued</h1>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div>
<!-- plain blueProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/blue-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-blue-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- plain purpleProfessionalBadgePattern -->
<!-- <img
src="http://localhost:3002/purple-professional-badge-background.png"
alt=""
class="absolute top-0 left-0 w-full z-10"
/>
<div class="absolute w-full pl-20 top-10 z-20">
<header class="w-full flex justify-start items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="text-lg font-bold mt-[9%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-fuchsia-800">Usman Elebute Ayomide</h1>
</div>
<div class="text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-5xl mt-3 leading-[3.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!------------------------------------------------------- new secton ---------------------------------------------------- -->
<!-- PurpleProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/purple-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<svg
width="34"
height="27"
viewBox="0 0 24 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.2076 9.08656C21.5733 9.08656 23.4911 7.16873 23.4911 4.80302C23.4911 2.43731 21.5733 0.519531 19.2076 0.519531C16.8419 0.519531 14.9241 2.43731 14.9241 4.80302C14.9241 7.16873 16.8419 9.08656 19.2076 9.08656Z"
fill="#0542CC"
/>
<path
d="M23.5385 10.7793V16.9927H14.1243V14.7333C14.1243 12.568 15.8659 10.7793 18.0783 10.7793H23.5385Z"
fill="#0542CC"
/>
<path
d="M12.5701 0.519531V16.9945H9.03974C6.78031 16.9945 4.70918 16.0531 3.20289 14.5939C1.69661 13.0876 0.802246 11.0165 0.802246 8.75703C0.802246 4.1911 4.47382 0.519531 9.03974 0.519531H12.5701Z"
fill="#0542CC"
/>
</svg>
<h1 class="text-[#282828]">Classroom<span class="text-[#0233BD]">IO</span></h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl text-[#95449A]">Usman Elebute Ayomide</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">
Building fullstack Applications with superbase & Svelte
</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">
Building full-stack apps with Superbase & Svelte merges robust backend services from
Superbase with Svelte's sleek frontend framework. Leveraging PostgreSQL and Svelte's
reactivity, developers create efficient, feature-rich web apps. This stack streamlines
development, offering scalability and performance for modern applications with minimal
overhead.
</footer>
</div> -->
<!-- BlueProfessionalBadge certificate -->
<!-- <img
src="http://localhost:3002/blue-lined-background.png"
alt=""
class="absolute -top-1 left-0 w-full z-10"
/>
<div class="absolute z-20 left-[10%] top-16 w-[90%]">
<header class="w-full flex justify-center items-center pt-10 gap-3 text-3xl font-bold">
<img src="${orgLogoUrl}" alt="" />
<h1>${orgName}</h1>
</header>
<div class="pl-10 text-lg font-bold mt-[10%]">
<h2>This certificate is awarded to</h2>
<h1 class="text-3xl">${studentName}</h1>
</div>
<div class="pl-10 text-lg font-bold mt-10 w-[70%]">
<h1>has succesfully completed training on</h1>
<h2 class="text-4xl leading-[2.5rem]">${courseName}</h2>
</div>
<div class="pl-10 flex justify-between w-[70%] text-center my-[10%]">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b-2 border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b-2 border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<footer class="text-sm pl-10 w-[90%] pb-10">${courseDescription}</footer>
</div> -->
<!-- --------------------- old certificates ------------------------- -->
<!-- <div
class="flex items-center justify-center border border-dashed border-blue-400 h-full w-full"
>
<div class="h-full flex border-l-8 border-blue-800">
<div class="h-full w-full px-5 py-10">
<p class="text-2xl font-normal my-2 mb-10 uppercase text-gray-500">
Certificate<br />
of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-9xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">
has successfully completed introductory courses in
</p>
<div>
<p
class="bg-transparent font-semibold text-blue-800 text-center uppercase my-10 text-3xl"
>
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl font-light mb-4 text-justify">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
<div class="flex items-center gap-1 mt-14">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
</div>
</div>
</div> -->
<!-- <div
class="flex flex-col items-center justify-center border-4 border-pink-700 py-3 w-full h-full"
>
<div class="flex items-center gap-1 my-2 mb-10">
<img
src="https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/user/Coding%20Dojo1684164720355.webp"
alt="logo"
class="w-24 h-24 rounded-md"
/>
<p class="font-semibold capitalize text-5xl">Coding Dojo</p>
</div>
<div class="w-full px-5 py-3">
<p
class="text-xl text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"
>
Certificate of completion
</p>
<div class="border-b border-gray-500 mb-4">
<p class="text-xl text-black font-normal">This is to certify that</p>
<p class="student-name text-center text-8xl">Rotimi Best</p>
</div>
<div class="mb-2">
<p class="text-xl text-black font-normal">has successfully completed a courses in</p>
<div class="my-10">
<p class="bg-transparent font-semibold text-pink-700 text-center uppercase text-3xl">
Figma for Beginners
</p>
</div>
</div>
<p class="text-xl text-center text-gray-500 font-light">
Netlify Functions can stream data to clients as it becomes available, rather than
returning a buffered payload at the end of the computation. This lets developers and
frameworks create faster experiences by using streaming and partial hydration to get
content and interactions in front of people as quickly as possible. For an extended
example that demonstrates a more likely response streaming use case, consider this
function
</p>
<div class="flex justify-between w-[70%] mx-auto text-center my-14">
<div>
<h1 class="h-2/4">DATE ISSUED:</h1>
<p class="border-b border-black font-bold">02,May 2024</p>
</div>
<div>
<h1 class="h-2/4">FACILITATOR:</h1>
<p class="font-bold">ROTIMI BEST</p>
</div>
<div class="w-[20%]">
<h1 class="h-2/4 border-b border-black"></h1>
<p class="font-bold">Signature</p>
</div>
</div>
</div> -->
</body>
</html>
-182
View File
@@ -1,182 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="flex justify-center items-center flex-col border-[50px] mx-10 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
-18
View File
@@ -1,18 +0,0 @@
# fly.toml app configuration file generated for classroomio-server on 2023-07-27T21:01:09+01:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "classroomio-server"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3002
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
-186
View File
@@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-image: url("https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/course/course-pdf-background.jpg");
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.hero {
background-color: rgb(0, 48, 255);
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 3rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
h4 {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
.footer {
font-size: 3rem;
scale: 3;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
<header class="border-l-[50px] mb-[15%] h-[100vh] m-0 relative">
<h1 class="font-bold text-8xl text-black m-0 w-3/4 absolute left-5 top-40">React js for Beginners</h1>
<h4 class="text-md text-2xl font-light text-black m-0 absolute left-10 bottom-24">By TheSpace</h4>
</header>
<div class="root mx-auto mt-5 prose">
<div class="hero w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
32
</h3>
<h1 class="font-bold text-5xl text-white m-0">The Yams</h1>
<h4 class="text-md font-normal text-white m-0">By The Space</h4>
</div>
<main>
<section>
<h1>Lesson Note: Introduction and Counselling</h1>
<h2>Course: The Ultimate Guide to relocating to the UK via Study Path</h2>
<h3>Lesson Overview:</h3>
<p>This lesson serves as the introduction to the course &quot;The Ultimate Guide to relocating to the UK via
Study Path.&quot; In this session, we will provide an overview of the course and discuss the importance of
effective counseling in the relocation process.</p>
<h3>Learning Objectives:</h3>
<p>By the end of this lesson, students should be able to:</p>
<ul>
<li>Understand the goals and structure of the course.</li>
<li>Recognize the significance of counseling in the relocation process.</li>
<li>Acquire knowledge about the necessary steps involved in relocating to the UK through the study path.</li>
</ul>
<h3>Lesson Content:</h3>
<ol>
<li>
<p><strong>Introduction to the Course</strong></p>
<ul>
<li>Briefly introduce the course title and explain its relevance to students&#39; interests.</li>
<li>Highlight the main topics and modules covered throughout the course.</li>
</ul>
</li>
<li>
<p><strong>Importance of Counseling</strong></p>
<ul>
<li>Define the role of counseling in the relocation process.</li>
<li>Discuss how counseling can assist students in making informed decisions regarding their studies and
life in the UK.</li>
<li>Explain the benefits of seeking professional guidance during the relocation journey.</li>
</ul>
</li>
<li>
<p><strong>Steps for Relocating to the UK via Study Path</strong></p>
<ul>
<li>Provide an overview of the necessary steps involved in relocating to the UK for study purposes.</li>
<li>Discuss the importance of thorough planning, research, and documentation in the relocation process.
</li>
<li>Highlight key points such as visa applications, university admissions, accommodation arrangements, and
financial considerations.</li>
</ul>
</li>
<li>
<p><strong>Q&amp;A and Discussion Time</strong></p>
<ul>
<li>Allocate time for students to ask questions, provide comments, or share personal experiences.</li>
<li>Encourage active participation and foster a supportive learning environment.</li>
</ul>
</li>
</ol>
<h3>Conclusion:</h3>
<p>In this lesson, we introduced the course &quot;The Ultimate Guide to relocating to the UK via Study
Path&quot; and emphasized the significance of counseling in the relocation process. We explored the essential
steps involved in relocating to the UK and highlighted the importance of thorough planning and research.
Students should now have a better understanding of the course objectives and overall expectations. In the next
session, we will delve deeper into the specific requirements for studying in the UK and provide detailed
guidance on university admissions.</p>
</section>
<section>
<h1 class="text-xl font-bold my-1">Extra Resources</h1>
<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=https://youtu.be/dyLaKhePzj8 style="color: blue"
class="underline">Open Here</a>
</div>
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
<li key={index + 1}><a href=https://youtu.be/0fYi8SGA20k style="color: blue"
class="underline">https://youtu.be/0fYi8SGA20k</a></li>
</ol>
</div>
</section>
</main>
</div>
</body>
</html>
-62
View File
@@ -1,62 +0,0 @@
{
"name": "backend",
"version": "0.0.1",
"description": "ClassroomIO Backend for long processes",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc && tsc-alias",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint . --ext .ts",
"format": "prettier --write \"src/**/*.ts\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/classroomio/classroomio.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/classroomio/classroomio/issues"
},
"homepage": "https://github.com/classroomio/classroomio#readme",
"dependencies": {
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@hono/node-server": "^1.8.2",
"@sentry/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1",
"@supabase/supabase-js": "^2.32.0",
"axios": "^1.4.0",
"dotenv": "^16.3.1",
"hono": "^4.0.5",
"hono-rate-limiter": "^0.4.2",
"katex": "^0.16.10",
"marked": "^7.0.3",
"nanoid": "^5.1.5",
"nodemailer": "^6.9.5",
"zeptomail": "^6.0.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/katex": "^0.16.7",
"@types/node": "^20.11.19",
"@types/nodemailer": "^6.4.17",
"@typescript-eslint/eslint-plugin": "7.0.1",
"@typescript-eslint/parser": "7.0.1",
"@vitest/coverage-v8": "^1.2.2",
"eslint": "^9.31.0",
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.1",
"typescript": "^5.5.2",
"vitest": "^1.2.2"
},
"_moduleAliases": {
"$src": "src"
}
}
-20
View File
@@ -1,20 +0,0 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
PUBLIC_SUPABASE_URL: z.string(),
PUBLIC_SUPABASE_ANON_KEY: z.string(),
ZOHO_TOKEN: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_SENDER: z.string().optional()
});
export const env = envSchema.parse(process.env);
-25
View File
@@ -1,25 +0,0 @@
import { env } from '$src/config/env';
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
CLOUDFLARE_SECRET_ACCESS_KEY,
CLOUDFLARE_ACCOUNT_ID
} = env;
export const CLOUDFLARE = {
CONFIGS: {
BUCKET_DOMAIN: CLOUDFLARE_BUCKET_DOMAIN!,
ACCESS_KEY: CLOUDFLARE_ACCESS_KEY!,
SECRET_ACCESS_KEY: CLOUDFLARE_SECRET_ACCESS_KEY!,
ACCOUNT_ID: CLOUDFLARE_ACCOUNT_ID!
},
R2: {
BUCKET: 'videos',
BUCKET_DOMAIN: CLOUDFLARE_ACCOUNT_ID
? `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
: CLOUDFLARE_BUCKET_DOMAIN!,
PRESIGN_EXPIRATION_TIME: 60 * 60, // 1 hour
DOWNLOAD_EXPIRATION_TIME: 60 * 60 * 3 // 3 hours
}
} as const;
-20
View File
@@ -1,20 +0,0 @@
export const ALLOWED_CONTENT_TYPES = [
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
] as const;
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/msword' // .doc
] as const;
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const BUCKET_NAME = {
VIDEOS: 'videos',
DOCUMENTS: 'documents'
} as const;
-66
View File
@@ -1,66 +0,0 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { courseRouter } from '$src/routes/course/course';
import { env } from '$src/config/env';
import { logger } from 'hono/logger';
import { mailRouter } from '$src/routes/mail';
import { prettyJSON } from 'hono/pretty-json';
import { rateLimiter } from 'hono-rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
// Create Hono app
const app = new Hono();
// Middleware
app.use('*', logger());
app.use('*', prettyJSON());
app.use('*', secureHeaders());
app.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true
})
);
app.use(
rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
standardHeaders: 'draft-6', // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header
keyGenerator: (c) => c.req.header('Authorization')?.split(' ')[1] ?? 'unknown' // Method to generate custom identifiers for clients.
})
);
// Routes
app.get('/', (c) => c.json({ message: 'Welcome to ClassroomIO' }));
app.route('/course', courseRouter);
app.route('/mail', mailRouter);
// Error handling
app.onError((err, c) => {
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
// Start server
const port = env.PORT ? parseInt(env.PORT) : 3002;
function startServer() {
console.log('Starting server...');
serve({ fetch: app.fetch, port });
showRoutes(app, { colorize: true });
}
startServer();
-44
View File
@@ -1,44 +0,0 @@
import { Context, Next } from 'hono';
import { validateUser } from '$src/utils/auth/validate-user';
export const authMiddleware = async (c: Context, next: Next) => {
try {
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json(
{
success: false,
message: 'No authorization header provided'
},
401
);
}
// Extract the token from "Bearer <token>"
const token = authHeader.split(' ')[1];
if (!token) {
return c.json(
{
success: false,
message: 'Invalid authorization header format'
},
401
);
}
const user = await validateUser(token);
c.set('user', user); // Attach the user to the context
await next();
} catch (error) {
return c.json(
{
success: false,
message: 'Unauthorized'
},
401
);
}
};
-91
View File
@@ -1,91 +0,0 @@
import { Context, Hono } from 'hono';
import { ZCertificateDownload, ZCourseDownloadContent } from '$src/types/course';
import { generateCertificate } from '$src/utils/certificate';
import { generateCoursePdf } from '$src/utils/course';
import { katexRouter } from '$src/routes/course/katex';
import { lessonRouter } from '$src/routes/course/lesson';
import { presignRouter } from '$src/routes/course/presign';
import { z } from 'zod';
export const courseRouter = new Hono();
courseRouter.route('/katex', katexRouter);
courseRouter.route('/lesson', lessonRouter);
courseRouter.route('/presign', presignRouter);
courseRouter.post('/download/certificate', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZCertificateDownload.parse(data);
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${validatedData.courseName}.pdf"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error generating certificate:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
500
);
}
});
courseRouter.post('/download/content', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZCourseDownloadContent.parse(data);
const pdfBuffer = await generateCoursePdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
} catch (error) {
console.error('Error generating course PDF:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
400
);
}
});
-41
View File
@@ -1,41 +0,0 @@
import { Context, Hono } from 'hono';
import katex from 'katex';
import { z } from 'zod';
export const katexRouter = new Hono();
katexRouter.get('/', async (c: Context) => {
try {
const original = c.req.raw.url;
const latexString = original.split('?')[1].replaceAll('&plus;', '+').replaceAll('&space;', '');
const html = katex.renderToString(latexString, {
output: 'mathml',
throwOnError: false
});
return c.html(html);
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error rendering LaTeX:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
500
);
}
});
-35
View File
@@ -1,35 +0,0 @@
import { Context, Hono } from 'hono';
import { ZCertificateDownload, ZLessonDownloadContent } from '$src/types/course/lesson';
import { generateLessonPdf } from '$src/utils/lesson';
export const lessonRouter = new Hono();
lessonRouter.post('/download/pdf', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZLessonDownloadContent.parse(data);
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
} catch (error) {
console.error('Error generating lesson PDF:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
400
);
}
});
-199
View File
@@ -1,199 +0,0 @@
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import {
ZCourseDocumentPresignUrlUpload,
ZCourseDownloadPresignedUrl,
ZCoursePresignUrlUpload
} from '$src/types/course';
import { BUCKET_NAME } from '$src/constants/upload';
import { CLOUDFLARE } from '$src/constants';
import type { GetSignedUrlParameters } from '$src/utils/s3';
import { Hono } from 'hono';
import { authMiddleware } from '$src/middlewares/auth';
import { generateFileKey } from '$src/utils/upload';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3Client } from '$src/utils/s3';
export const presignRouter = new Hono();
presignRouter.post('/upload', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCoursePresignUrlUpload.parse(body);
const { fileName, fileType } = result;
const fileKey = generateFileKey(fileName);
try {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Pre-signed URL generated successfully'
});
} catch (error) {
console.error('Error generating pre-signed URL:', error);
if (error instanceof Error && error.name === 'SignatureDoesNotMatch') {
return c.json(
{
success: false,
type: 'SIGNATURE_ERROR',
message: 'Invalid signature for the request'
},
403
);
}
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error generating pre-signed URL'
},
500
);
}
});
presignRouter.post('/document/upload', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDocumentPresignUrlUpload.parse(body);
const { fileName, fileType } = result;
const fileKey = generateFileKey(fileName);
try {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: fileKey,
ContentType: fileType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.PRESIGN_EXPIRATION_TIME
});
return c.json({
success: true,
url: presignedUrl,
fileKey,
message: 'Document pre-signed URL generated successfully'
});
} catch (error) {
console.error('Error generating document pre-signed URL:', error);
if (error instanceof Error && error.name === 'SignatureDoesNotMatch') {
return c.json(
{
success: false,
type: 'SIGNATURE_ERROR',
message: 'Invalid signature for the request'
},
403
);
}
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error generating document pre-signed URL'
},
500
);
}
});
presignRouter.post('/download', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
const { keys } = result;
try {
const signedUrls: Record<string, string> = {};
await Promise.all(
keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.VIDEOS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
signedUrls[key] = presignedUrl;
})
);
return c.json({
success: true,
urls: signedUrls,
message: 'Video URLs retrieved successfully'
});
} catch (error) {
console.error('Error Retrieving Video:', error);
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error Retrieving Video'
},
500
);
}
});
presignRouter.post('/download/document', authMiddleware, async (c) => {
const body = await c.req.json();
const result = ZCourseDownloadPresignedUrl.parse(body);
const { keys } = result;
try {
const signedUrls: Record<string, string> = {};
await Promise.all(
keys.map(async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME.DOCUMENTS,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(s3Client as GetSignedUrlParameters[0], command, {
expiresIn: CLOUDFLARE.R2.DOWNLOAD_EXPIRATION_TIME
});
signedUrls[key] = presignedUrl;
})
);
return c.json({
success: true,
urls: signedUrls,
message: 'Document URLs retrieved successfully'
});
} catch (error) {
console.error('Error Retrieving Document:', error);
return c.json(
{
success: false,
type: 'INTERNAL_ERROR',
message: 'Error Retrieving Document'
},
500
);
}
});
-70
View File
@@ -1,70 +0,0 @@
import { Context, Hono } from 'hono';
import { sendWithNodemailer, sendWithZoho } from '$src/services/mail';
import { ZSendEmailValidation } from '$src/types/mail';
import { env } from '$src/config/env';
import { z } from 'zod';
export const mailRouter = new Hono();
mailRouter.post('/send', async (c: Context) => {
try {
const data = await c.req.json();
const validatedData = ZSendEmailValidation.parse(data);
const results = await Promise.all(
validatedData.map(async (emailData) => {
try {
const res = env.ZOHO_TOKEN
? await sendWithZoho(emailData)
: await sendWithNodemailer(emailData);
console.log('Email status:', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
details: results
},
500
);
}
return c.json({ success: true, details: results });
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{
success: false,
error: 'Validation error',
details: error.errors
},
400
);
}
console.error('Error sending emails:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
},
500
);
}
});
-97
View File
@@ -1,97 +0,0 @@
import { extractNameAndEmail, withEmailTemplate } from '$src/utils/mail';
import { nodemailerTransporter, zohoClient } from '$src/utils/email';
import { Hono } from 'hono';
import type { TSendEmailValidation } from '$src/types/mail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
export const mailRouter = new Hono();
interface EmailResponse {
success: boolean;
error?: string;
details?: unknown;
}
let transporter: Transporter | null;
nodemailerTransporter().then((t: Transporter | null) => {
transporter = t;
});
export async function sendWithNodemailer(
emailData: TSendEmailValidation[0]
): Promise<EmailResponse> {
const { from, to, subject, content, replyTo } = emailData;
if (!transporter) {
return {
success: false,
error: 'Email transporter not initialized'
};
}
try {
const result = await transporter.sendMail({
from: from || env.SMTP_SENDER,
to,
subject,
replyTo,
html: withEmailTemplate(content)
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise<EmailResponse> {
const { from, to, subject, content } = emailData;
const fromData = extractNameAndEmail(from || '');
if (!fromData) {
return {
success: false,
error: 'Invalid from data'
};
}
try {
const result = await zohoClient.sendMail({
from: {
address: fromData.email,
name: fromData.name
},
to: [
{
email_address: {
address: to
}
}
],
subject,
htmlbody: content
});
return {
success: true,
details: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
}
-54
View File
@@ -1,54 +0,0 @@
import {
ALLOWED_CONTENT_TYPES,
ALLOWED_DOCUMENT_TYPES,
MAX_FILE_SIZE
} from '$src/constants/upload';
import { z } from 'zod';
export * from './lesson';
export const ZCourseDownloadContent = z.object({
courseTitle: z.string(),
orgName: z.string(),
orgTheme: z.string(),
lessons: z.array(
z.object({
lessonTitle: z.string(),
lessonNumber: z.string(),
lessonNote: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional()
})
)
});
export type TCourseDownloadContent = z.infer<typeof ZCourseDownloadContent>;
export const ZCoursePresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_CONTENT_TYPES, {
errorMap: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`
})
})
});
export type TCoursePresignUrlUpload = z.infer<typeof ZCoursePresignUrlUpload>;
export const ZCourseDocumentPresignUrlUpload = z.object({
fileName: z.string().min(1, 'File name is required'),
fileType: z.enum(ALLOWED_DOCUMENT_TYPES, {
errorMap: () => ({
message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}`
})
})
});
export type TCourseDocumentPresignUrlUpload = z.infer<typeof ZCourseDocumentPresignUrlUpload>;
export const ZCourseDownloadPresignedUrl = z.object({
keys: z.array(z.string()).min(1, 'File names array is required')
});
export type TCourseDownloadPresignedUrl = z.infer<typeof ZCourseDownloadPresignedUrl>;
-25
View File
@@ -1,25 +0,0 @@
import { z } from 'zod';
export const ZLessonDownloadContent = z.object({
title: z.string(),
number: z.string(),
orgName: z.string(),
note: z.string(),
slideUrl: z.string().optional(),
video: z.array(z.string()).optional(),
courseTitle: z.string()
});
export type TLessonDownloadContent = z.infer<typeof ZLessonDownloadContent>;
export const ZCertificateDownload = z.object({
theme: z.string().min(1),
studentName: z.string().min(1),
courseName: z.string().min(1),
courseDescription: z.string().min(1),
orgName: z.string().min(1),
orgLogoUrl: z.string().url().optional(),
facilitator: z.string().optional()
});
export type TCertificateDownload = z.infer<typeof ZCertificateDownload>;
-13
View File
@@ -1,13 +0,0 @@
import { z } from 'zod';
export const ZSendEmailValidation = z.array(
z.object({
from: z.string().optional(),
to: z.string().email(),
subject: z.string().min(1),
content: z.string().min(1),
replyTo: z.string().email().optional()
})
);
export type TSendEmailValidation = z.infer<typeof ZSendEmailValidation>;
@@ -1,21 +0,0 @@
import { User } from '@supabase/supabase-js';
import { getSupabase } from '../supabase.js';
const supabase = getSupabase();
export async function validateUser(accessToken: string): Promise<User> {
let user: User | null = null;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
throw new Error('Unauthenticated user');
}
return user;
}
-124
View File
@@ -1,124 +0,0 @@
import type { TCertificateDownload } from '$src/types/course/lesson';
import { getCloudflarePdfBuffer } from '$src/utils/cloudflare';
export interface CertificateData {
theme: string;
studentName: string;
courseName: string;
courseDescription: string;
orgLogoUrl: string;
orgName: string;
facilitator?: string;
}
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
error?: string;
}
export const generateCertificate = async (data: TCertificateDownload) => {
const { html, styles } = generateCertificateHtml(data);
return getCloudflarePdfBuffer(html, styles);
};
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const html = `
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
const styles = `
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
`;
const pdf = {
html: html,
styles: styles
};
return pdf;
}
-27
View File
@@ -1,27 +0,0 @@
import { CLOUDFLARE } from '$src/constants';
export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
console.log('Generating PDF with Cloudflare API...');
try {
const pdfResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/pdf`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.ACCESS_KEY}`
},
body: JSON.stringify({
html: html,
addStyleTag: [{ content: `${styles}` }]
})
}
);
console.log('PDF response status:', pdfResponse.status);
return pdfResponse;
} catch (error) {
console.error('Error generating PDF:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
}
};
-170
View File
@@ -1,170 +0,0 @@
import type { TCourseDownloadContent } from '$src/types/course';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full break-after-page">
${body}
</body>
</html>
`;
}
type TLessonBody = TCourseDownloadContent['lessons'][0];
function getLessonBody(
{ lessonTitle, lessonNumber, lessonNote, slideUrl, video }: TLessonBody,
orgName: string,
orgTheme: string
): string {
const noteHtml = marked.parse(lessonNote);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div
class="bg-[${orgTheme ? orgTheme : '#0030FF'}] w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-[${orgTheme ? orgTheme : '#0030FF'}] bg-white text-md font-bold tracking-tighter">
${lessonNumber}
</h3>
<h1 class="text-white m-0">${lessonTitle}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</div>
<main class="break-after-page">
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
`;
}
function getCourseBody({
courseTitle,
orgName,
orgTheme,
lessons
}: TCourseDownloadContent): string {
return `
<header class="flex justify-center items-center flex-col border-[${orgTheme ? orgTheme : '#0030FF'}] border-[40px] mx-3 h-[100vh] m-0">
<h1 class="font-bold text-8xl text-black m-0 mx-96">${courseTitle}</h1>
<h4 class="text-md text-2xl font-light text-black mt-5 ">${orgName}</h4>
</header>
<div class="root mx-auto mt-5 prose break-after-page">
${lessons.map((lesson) => getLessonBody(lesson, orgName, orgTheme)).join('')}
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string,
orgTheme: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateCoursePdf(params: TCourseDownloadContent): Promise<Buffer> {
const body = getCourseBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle, params.orgTheme);
}
-35
View File
@@ -1,35 +0,0 @@
import { SendMailClient } from 'zeptomail';
import type { Transporter } from 'nodemailer';
import { env } from '$src/config/env';
import nodemailer from 'nodemailer';
export const zohoClient = new SendMailClient({
url: 'api.zeptomail.eu/',
token: env.ZOHO_TOKEN!
});
export const nodemailerTransporter = async (): Promise<Transporter | null> => {
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
console.error('SMTP configuration missing');
return null;
}
try {
const transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '465', 10),
secure: true,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
});
await transporter.verify();
return transporter;
} catch (error) {
console.error('Transporter error:', error);
return null;
}
};
-3
View File
@@ -1,3 +0,0 @@
export default function genUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
-161
View File
@@ -1,161 +0,0 @@
import type { TLessonDownloadContent } from '$src/types/course/lesson';
import { marked } from 'marked';
function getHtmlTemplate(body: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;200;300;400&family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
<title>Lesson Note</title>
<style>
html {
-webkit-print-color-adjust: exact;
}
header {
background-color: rgb(0, 48, 255);
}
body {
font-family: 'Roboto', sans-serif;
}
.prose h1 {
margin: 0;
}
.prose h2 {
margin: 0.5rem 0 1.5rem 0;
}
.prose h3 {
margin: 0.5rem 0;
}
.roboto-mono {
font-family: 'Roboto Mono', monospace;
}
span {
font-size: 0.8rem;
}
section {
margin-top: 5%;
}
.root {
width: 95%;
}
</style>
<style type="text/tailwindcss">
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose {
max-width: unset;
}
</style>
</head>
<body class="w-full">
${body}
</body>
</html>
`;
}
function getLessonBody({
title,
number,
orgName,
note,
slideUrl,
video,
courseTitle
}: TLessonDownloadContent): string {
const noteHtml = marked.parse(note);
const showExtraResources = slideUrl || (video && video.length > 0);
return `
<div class="root mx-auto mt-5 prose">
<header
class="w-full h-40 flex flex-col items-center justify-center gap-2 rounded-lg m-0">
<h3
class="my-0 rounded-full w-10 text-center mx-auto py-1 text-blue-700 bg-white text-md font-bold tracking-tighter">
${number}
</h3>
<h1 class="text-white m-0">${title}</h1>
<h4 class="roboto-mono text-md font-normal text-white m-0">By ${orgName}</h4>
</header>
<main>
<section>
${noteHtml}
</section>
<section>
${
showExtraResources
? `
<section>
<h1 class='text-xl font-bold my-1'>Extra Resources</h1>
${
slideUrl
? `<div class="flex mt-2 mb-1">
<p class="m-0 mr-2">Slide Link:</p> <a href=${slideUrl} style="color: blue" class="underline">Open Here</a>
</div>`
: ''
}
${
video && video.length > 0
? `
<p class="m-0">Video Link:</p>
<div class="m-0">
<ol class="m-0">
${video
.map(
(videoData, index) => `
<li key=${
index + 1
}><a href=${videoData} style="color: blue" class="underline">${videoData}</a></li>
`
)
.join('')}
</ol>
</div>
`
: ''
}
</section>
`
: ''
}
</section>
</main>
</div>
`;
}
async function generateSinglePdfFromHtml(
htmlContent: string,
courseTitle: string
): Promise<Buffer> {
throw 'Download disabled';
}
export async function generateLessonPdf(params: TLessonDownloadContent): Promise<Buffer> {
const body = getLessonBody(params);
const html = getHtmlTemplate(body);
console.log(html);
return await generateSinglePdfFromHtml(html, params.courseTitle);
}
-213
View File
@@ -1,213 +0,0 @@
interface FromData {
name: string;
email: string;
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
export function extractNameAndEmail(str: string): FromData | undefined {
// Use regular expressions to match the name and email
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
const match = str.match(regex);
if (match) {
// Extract the name and email from the match groups
const name = match[1];
const email = match[2] + '@' + match[3];
return { name, email };
} else {
// Return undefined if the format doesn't match
return { name: str, email: str };
}
}
export const withEmailTemplate = (content: string): string =>
`<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<base target="_blank" />
<style>
body {
background-color: #F6F9FF;
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
sans-serif;
font-size: 15px;
font-weight: 400;
line-height: 26px;
margin: 0;
}
pre {
background: #f4f4f4;
padding: 2px;
}
hr {
border-top: 1px dashed #94a3b8;
margin-top: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
}
table td {
padding: 5px 10px 0px;
vertical-align: middle;
}
.socialicons {
background-color: #fff;
border-radius: 5px;
padding: 5px;
width: initial;
margin: 0 auto;
max-width: 200px;
}
.brandcolor {
color: #00c4b8;
}
.tooltip {
background-color: #f1f5f9;
padding: 1rem;
border-radius: 1rem;
color: #475569;
margin-top: 15px;
margin-bottom: 15px;
}
.tooltip a {
color: #1e293b;
}
.button {
margin-top: 12px;
background: #0233BD;
border-radius: 8px;
text-decoration: none !important;
color: #fff !important;
font-weight: 500;
padding: 10px 30px;
display: inline-block;
font-size: 0.9em;
}
.button:hover {
background: #0233BD;
}
.footer {
text-align: center;
font-size: 12px;
color: #cbd5e1;
}
.footer a {
color: #cbd5e1;
}
.gutter {
padding: 5px 30px;
text-align: center;
background-color: #fff;
border-bottom: 1px solid rgb(221, 208, 208);
max-width: 525px;
margin: 0 auto;
}
img {
max-width: 100%;
height: auto;
}
.gutter img {
max-width: 280px;
}
h1,
h2,
h3,
h4 {
font-weight: 600;
}
@media screen and (max-width: 600px) {
.wrap {
max-width: auto;
}
.gutter {
padding: 10px;
}
}
</style>
</head>
<body style="
background-color: #F6F9FF;
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
sans-serif;
font-size: 15px;
line-height: 26px;
padding: 20px 0;
margin: 0;
color: #1e293b;
">
<div class="gutter">
<a href="https://classroomio.com" target="_blank">
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
</div>
<div class="wrap" style="
background-color: #fff;
padding: 40px 30px;
max-width: 525px;
margin: 0 auto;
">
${content}
</div>
<div class="footer" style="
text-align: center;
font-size: 12px;
color: #fff;
padding: 30px;
background-color: #0542CC;
max-width: 525px;
margin: 0 auto;">
<p>Find us on</p>
<table class="socialicons">
<tr>
<td>
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
</td>
<td>
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
</td>
<td>
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
</td>
</tr>
</table>
<p style="padding-top: 8px; line-height: initial">
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
target="_blank">Terms</a>
|
<a style="text-decoration: none"
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
Policy</a>
</p>
</div>
</body>
</html>
`;
-56
View File
@@ -1,56 +0,0 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
GetObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput,
S3Client
} from '@aws-sdk/client-s3';
import { CLOUDFLARE } from '$src/constants';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
export const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${CLOUDFLARE.CONFIGS.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: CLOUDFLARE.CONFIGS.ACCESS_KEY,
secretAccessKey: CLOUDFLARE.CONFIGS.SECRET_ACCESS_KEY
}
});
export async function uploadToS3(
params: PutObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function getFromS3(
params: GetObjectCommandInput
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const data = await s3Client.send(new GetObjectCommand(params));
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function deleteFromS3(
params: DeleteObjectCommandInput
): Promise<{ success: boolean; error?: string }> {
try {
await s3Client.send(new DeleteObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
-36
View File
@@ -1,36 +0,0 @@
import { SupabaseClient, createClient } from '@supabase/supabase-js';
import { env } from '$src/config/env';
const { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } = env;
let supabase: SupabaseClient | null = null;
interface SupabaseConfig {
supabaseConfig: {
url: string;
anonKey: string;
};
}
export function getSupabase(config?: SupabaseConfig): SupabaseClient {
if (supabase) return supabase;
const finalConfig = config ?? {
supabaseConfig: {
url: PUBLIC_SUPABASE_URL,
anonKey: PUBLIC_SUPABASE_ANON_KEY
}
};
try {
supabase = createClient(finalConfig.supabaseConfig.url, finalConfig.supabaseConfig.anonKey, {
auth: { persistSession: false }
});
} catch (error) {
console.error('Error starting supabase', error);
throw error;
}
return supabase;
}
-24
View File
@@ -1,24 +0,0 @@
import { nanoid } from 'nanoid';
import path from 'path';
/**
* Safely extracts the file extension from a filename.
* Returns an empty string if no extension is found.
*/
export function getExtension(filename: string): string {
if (!filename || typeof filename !== 'string') return '';
const ext = path.extname(filename).split('.');
return ext.length > 1 ? ext[ext.length - 1] : '';
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
*/
export function generateFileKey(fileName: string): string {
const ext = getExtension(fileName);
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
return `${nanoid()}.${ext}`;
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Node",
"outDir": "./dist",
"paths": {
"$src": ["./src"],
"$src/*": ["./src/*"]
},
"resolveJsonModule": false,
"skipLibCheck": true,
"sourceMap": true,
"target": "ESNext",
"typeRoots": ["./node_modules/@types", "./src/types"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/']
}
}
});