chore: move backend to hono + typescript (#558)

* feat: setup secure presigned uploads and downloads

* clean up

* update lock

* Get presigned url finally working

* 557: Migrate backend to hono + ts

* chore: update Dockerfile and package.json for backend configuration

* add dev command

* remove unused packages

* Enhance backend API and frontend components for course management

- Updated CORS configuration in the backend to allow specific methods and headers.
- Refactored lesson download endpoint to use a new route for PDF downloads.
- Removed unused certificate download functionality from the lesson router.
- Updated frontend components to reflect changes in API endpoints and improve video upload functionality.
- Introduced new video upload component and adjusted video handling in lesson materials.
- Cleaned up unnecessary imports and optimized component structure for better performance.

* PR cleanups

* Enhance email configuration by adding SMTP_SENDER environment variable

- Updated docker-compose.yaml to include SMTP_SENDER.
- Modified Dockerfile to accept SMTP_SENDER as an argument.
- Updated environment schema in env.ts to define SMTP_SENDER as optional.
- Adjusted mail service to use SMTP_SENDER for the email 'from' field.
- Updated documentation to reflect the new SMTP_SENDER variable.

* Remove debug log statement from sendEmail function

---------

Co-authored-by: Iniubong Obonguko <[email protected]>
This commit is contained in:
Rotimi Best
2025-06-09 01:38:57 +01:00
committed by GitHub
co-authored by Iniubong Obonguko
parent 9eb7b01587
commit 82be557a42
80 changed files with 3291 additions and 2302 deletions
@@ -13,6 +13,7 @@
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import type { ProfileCourseProgress } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { apiClient } from '$lib/utils/services/api';
let isLoading = false;
let isCourseComplete = false;
@@ -23,24 +24,22 @@
isLoading = true;
try {
const response = await fetch(env.PUBLIC_SERVER_URL + '/downloadCertificate', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
const response = await apiClient.post(
'/course/download/certificate',
{
theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`,
courseName: `${$course.title}`,
courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`,
orgName: `${$currentOrg.name}`
})
});
const data = await response.blob();
console.log(data);
const file = new Blob([data], { type: 'application/pdf' });
},
{
responseType: 'blob'
}
);
const file = new Blob([response.data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
@@ -85,12 +84,12 @@
</script>
<Box>
<div class="flex flex-col items-center justify-center w-max h-full gap-5">
<div class="flex h-full w-max flex-col items-center justify-center gap-5">
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-xl font-normal text-center">
<p class="text-center text-xl font-normal">
{$t(title)}
</p>
<p class="text-sm font-normal text-center max-w-md">
<p class="max-w-md text-center text-sm font-normal">
{$t(subtitle)}
</p>
<PrimaryButton
@@ -1,4 +1,5 @@
<script>
<script lang="ts">
import type { LessonVideoType } from '$lib/utils/types';
import { isLessonDirty, lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import TextField from '$lib/components/Form/TextField.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
@@ -26,7 +27,7 @@
$lesson.materials.videos = [
...existingLinks,
...validLinks.map((link = '') => ({
type: 'generic',
type: 'generic' as LessonVideoType,
link,
metadata: {}
}))
@@ -1,6 +1,6 @@
<script>
import YoutubeVideo from './YoutubeVideo.svelte';
import LocalVideo from './LocalVideo.svelte';
import UploadVideo from './UploadVideo.svelte';
import EmbedLink from './EmbedLink.svelte';
import * as CONSTANTS from './constants';
import { t } from '$lib/utils/functions/translations';
@@ -9,25 +9,25 @@
export let lessonId = '';
let tabs = CONSTANTS.videoTabs;
const tabs = CONSTANTS.videoTabs;
let currentTab = tabs[0].value;
const onChange = (tab) => () => (currentTab = tab);
</script>
<section class="w-full h-full flex flex-col md:flex-row items-start gap-3">
<div class="flex flex-row md:flex-col gap-2 items-center">
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3 w-full">
<section class="flex h-full w-full flex-col items-start gap-3 md:flex-row">
<div class="flex flex-row items-center gap-2 md:flex-col">
<p class="mb-3 w-full text-start text-sm font-normal text-[#4F4B4B] dark:text-[#b0a9a9]">
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_by')}
</p>
{#each tabs as item (item.value)}
<button
on:click={onChange(item.value)}
class={`w-full px-4 py-3 my-1 border ${
class={`my-1 w-full border px-4 py-3 ${
currentTab === item.value
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200 dark:border-neutral-600'
} rounded-md cursor-pointer flex flex-row items-center justify-start gap-2 whitespace-nowrap`}
} flex cursor-pointer flex-row items-center justify-start gap-2 whitespace-nowrap rounded-md`}
>
{#if $isFreePlan && item.value === 3}
<FlashFilled size={20} class="text-blue-700" />
@@ -42,13 +42,13 @@
</button>
{/each}
</div>
<main class="w-full h-full">
<main class="h-full w-full">
{#if currentTab === 1}
<YoutubeVideo />
{:else if currentTab === 2}
<EmbedLink />
{:else}
<LocalVideo {lessonId} />
<UploadVideo {lessonId} />
{/if}
</main>
</section>
@@ -1,69 +1,96 @@
<script lang="ts">
import axios from 'axios';
import { env } from '$env/dynamic/public';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import axios from 'axios';
import {
lesson,
deleteLessonVideo,
uploadCourseVideoStore
} from '$lib/components/Course/components/Lesson/store/lessons';
import { onMount, onDestroy } from 'svelte';
import { type ComponentProps } from 'svelte';
import { ProgressBar } from 'carbon-components-svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { isFreePlan } from '$lib/utils/store/org';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
let value = 0;
let max = 100;
let status = 'active';
let fileSize;
let isDisabled = false;
import { apiClient } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations';
export let lessonId = '';
let value = 0;
let max = 100;
let status: ComponentProps<ProgressBar>['status'] = 'active';
let fileSize;
let isDisabled = false;
let formRes;
let isLoaded = false;
let fileInput;
let submit;
let uploadedFileUrl = '';
let isLoading = false;
let prevProgress = 0;
let uploadChannel;
let fileName = '';
function isVideoAdded(link) {
return $lesson.materials?.videos?.find((v) => v.link === link);
}
async function onUpload(e) {
isLoading = true;
async function onUpload() {
if (!fileInput) return;
const formData = new FormData();
$uploadCourseVideoStore.isUploading = true;
const videoFile = fileInput.files[0];
formData.append('videoFile', videoFile);
console.log({ size: videoFile?.size });
fileSize = videoFile?.size / (1024 * 1024);
try {
const response = await axios({
method: 'POST',
url: env.PUBLIC_SERVER_URL + '/uploadVideo?lessonId=' + lessonId,
data: formData,
const uploadPresignResponse = await apiClient.post('/course/presign/upload', {
fileName: videoFile.name,
fileType: videoFile.type
});
const presignedUrl = uploadPresignResponse.data.url;
const uploadResponse = await axios.put(presignedUrl, videoFile, {
headers: {
'Content-Type': videoFile.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
headers: {
'Content-Type': 'multipart/form-data; boundary=MyBoundary'
},
onUploadProgress: (progressEvent) => {
value = Math.round((progressEvent.loaded * 100) / progressEvent?.total / 2);
value = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1) / 2);
}
});
formRes = response.data;
console.log('Upload res', formRes);
isLoading = false;
const downloadPresignedResponse = await apiClient.post('/course/presign/download', {
keys: [uploadPresignResponse.data.fileKey]
});
if (!downloadPresignedResponse.data.success) {
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: downloadPresignedResponse.data.message
};
return;
}
const fileKey = uploadPresignResponse.data.fileKey;
const presignedUrls = downloadPresignedResponse.data.urls;
formRes = {
url: presignedUrls[fileKey],
fileKey: uploadPresignResponse.data.fileKey,
status: uploadResponse.status
};
console.log('formRes', formRes);
$lesson.materials.videos = [
...$lesson.materials.videos,
{
type: 'upload',
link: formRes.url,
key: formRes?.fileKey
}
];
console.log('Upload res', uploadResponse, 'formRes', formRes);
$uploadCourseVideoStore.isUploading = false;
isLoaded = false;
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
@@ -76,50 +103,20 @@
isLoaded = true;
}
async function isDoneUploading(response) {
uploadedFileUrl = response?.success && response?.url;
if (uploadedFileUrl && !isVideoAdded(uploadedFileUrl)) {
$lesson.materials.videos = [
...$lesson.materials.videos,
{
type: 'muse',
link: uploadedFileUrl,
metadata: response?.metadata || {}
}
];
}
}
function tryAgain() {
formRes = null;
isLoaded = false;
isLoading = false;
$uploadCourseVideoStore.isUploading = false;
value = 0;
}
onMount(async () => {
uploadChannel = supabase
.channel('upload-progress')
.on('broadcast', { event: lessonId }, (payload) => {
console.log('The progress of the upload', payload);
value = value + payload?.payload - prevProgress;
prevProgress = payload.payload;
})
.subscribe();
});
onDestroy(() => {
supabase.removeChannel(uploadChannel);
});
$: helperText = value + '% of ' + Math.round(fileSize) + 'MB';
$: if (value === max) {
helperText = 'Done';
status = 'finished';
}
$: isDoneUploading(formRes);
$: isDisabled = isLoading || !env.PUBLIC_SERVER_URL || $isFreePlan;
$: isDisabled = $uploadCourseVideoStore.isUploading || !env.PUBLIC_SERVER_URL || $isFreePlan;
</script>
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
@@ -129,16 +126,16 @@
{#if !isLoaded}
<button
type="button"
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)}
class="h-full w-full {isDisabled && 'hover:cursor-not-allowed opacity-50'}"
on:click={() => (fileInput && !$uploadCourseVideoStore.isUploading ? fileInput.click() : null)}
class="h-full w-full {isDisabled && 'opacity-50 hover:cursor-not-allowed'}"
disabled={isDisabled}
>
<form
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
on:submit|preventDefault={onUpload}
>
{#if isLoading}
<div class="flex flex-col gap-5 max-w-[500px] w-[60%] justify-center">
{#if $uploadCourseVideoStore.isUploading}
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
<p class="mt-5 text-center">
{$t('course.navItem.lessons.materials.tabs.video.add_video.uploading')}
</p>
@@ -186,7 +183,7 @@
onClick={tryAgain}
/>
</div>
{:else if !formRes?.success}
{:else if formRes?.status !== 200}
<div class="flex h-full w-full flex-col items-center justify-center rounded-xl">
<img src="/video-upload-error.svg" alt="upload error" />
<span class="pb-2 pt-3">
@@ -210,13 +207,11 @@
<span class="flex items-center gap-2">
<div class="overflow-hidden rounded-sm">
<video class="w-[200px]">
<source src={uploadedFileUrl} type="video/mp4" />
<!-- <source src="/path/to/video.webm" type="video/webm" /> -->
<!-- Captions are optional -->
<source src={formRes.url} type="video/mp4" />
<track kind="captions" />
</video>
</div>
<p>{fileInput?.files?.[0]?.name || uploadedFileUrl}</p>
<p>{fileInput?.files?.[0]?.name || fileName}</p>
</span>
<button on:click={deleteLessonVideo}>
<img src="/delete-video.svg" alt="deletevideo" class="dark:invert" />
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { CopyButton, Tag } from 'carbon-components-svelte';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
@@ -6,6 +6,7 @@
import { lesson, isLessonDirty } from '$lib/components/Course/components/Lesson/store/lessons';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import type { LessonVideoType } from '$lib/utils/types';
let youtubeLinks = '';
let error = '';
@@ -26,7 +27,7 @@
$lesson.materials.videos = [
...existingLinks,
...validLinks.map((link = '') => ({
type: 'youtube',
type: 'youtube' as LessonVideoType,
link,
metadata: {}
}))
@@ -48,7 +49,7 @@
}
</script>
<div class="w-full flex items-{error ? 'center' : 'end'} justify-between gap-5">
<div class="flex w-full items-{error ? 'center' : 'end'} justify-between gap-5">
<TextField
label={$t('course.navItem.lessons.materials.tabs.video.add_video.youtube_link')}
bind:value={youtubeLinks}
@@ -1,29 +1,41 @@
<script lang="ts">
import { onMount } from 'svelte';
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
import { lesson } from '../../store/lessons';
let errors = {};
let player = null;
let errors: Record<string, string> = {};
let videoElements: (HTMLVideoElement | null)[] = [];
function initPlyr(_player: any, _video: string | undefined) {
if (!_player) return;
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map((p) => {
// @ts-ignore
return new Plyr(p);
});
// @ts-ignore
window.players = players;
function initPlyr() {
if (typeof (window as any).Plyr === 'undefined') {
console.warn('Plyr is not defined. Make sure it is properly imported.');
return;
}
const players = Array.from(document.querySelectorAll('.plyr-video-trigger')).map(
(p) => new (window as any).Plyr(p as HTMLElement)
);
if (typeof window !== 'undefined') {
(window as any).players = players;
}
}
$: initPlyr(player, $lesson.materials.videos);
$: {
videoElements = videoElements.slice(0, $lesson.materials.videos.length);
videoElements = [
...videoElements,
...Array($lesson.materials.videos.length - videoElements.length).fill(null)
];
}
onMount(() => {
initPlyr();
});
</script>
{#if $lesson.materials.videos.length}
<div class="w-full">
{#each $lesson.materials.videos as video}
<div class="w-full overflow-hidden mb-5">
{#each $lesson.materials.videos as video, index}
<div class="mb-5 w-full overflow-hidden">
{#key video.link}
<div class="mb-5">
{#if video.type === 'youtube'}
@@ -58,7 +70,12 @@
/>
</div>
{:else}
<video bind:this={player} class="plyr-video-trigger" playsinline controls>
<video
bind:this={videoElements[index]}
class="plyr-video-trigger h-full w-full"
playsinline
controls
>
<source src={video.link} type="video/mp4" />
<track kind="captions" />
</video>
@@ -59,9 +59,7 @@
let timeoutId: NodeJS.Timeout;
let tabs = CONSTANTS.tabs;
let currentTab = tabs[0].value;
let errors: {
video: string;
};
let errors: Record<string, string> = {};
let editorWindowRef: Window;
let aiButtonRef: HTMLDivElement;
let openPopover = false;
@@ -268,7 +266,10 @@
}
const onClose = () => {
if ($uploadCourseVideoStore.isUploading) return;
$uploadCourseVideoStore.isModalOpen = false;
autoSave($lesson.materials, $lessonByTranslation[lessonId], $isLoading, lessonId);
};
function getComponentOrder(tabs = CONSTANTS.tabs) {
@@ -1,19 +1,21 @@
import { writable } from 'svelte/store';
import type { Writable, Updater } from 'svelte/store';
import type { Course, Lesson, LessonComment, LessonPage, LessonSection } from '$lib/utils/types';
import type { Updater, Writable } from 'svelte/store';
import {
createLesson,
updateLesson,
deleteLesson,
createLessonSection,
updateLessonSection,
deleteLessonSection
deleteLesson,
deleteLessonSection,
updateLesson,
updateLessonSection
} from '$lib/utils/services/courses';
import type { Lesson, Course, LessonPage, LessonComment, LessonSection } from '$lib/utils/types';
import { LOCALE } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import { writable } from 'svelte/store';
export const uploadCourseVideoStore = writable({
isUploading: false,
isModalOpen: false
});
@@ -16,7 +16,6 @@
</script>
<div
role="button"
tabindex="0"
class="overflow-y-auto {isPageNavHidden
? 'h-[calc(100vh-65px)] lg:h-[calc(100vh-127px)]'