feat: 🚀 😃 convert repo into a monorepo 🚀 😃 (#51)

* Merge main into turbo-monrepo

* feat: rename apps

* feat: remove supbase videos and serve all from project

* feat: move supabase to root

* feat: update README

* docs: update link
This commit is contained in:
Rotimi Best
2023-11-25 21:56:02 +00:00
committed by GitHub
parent 03ca379094
commit 41e29014ef
806 changed files with 85853 additions and 5746 deletions
@@ -0,0 +1,5 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function takeAttendance(update) {
return supabase.from('group_attendance').upsert(update).select();
}
@@ -0,0 +1,256 @@
import { get } from 'svelte/store';
import type { PostgrestError } from '@supabase/supabase-js';
import { supabase } from '$lib/utils/functions/supabase';
// @ts-ignore
import { addGroupMember } from './index';
// @ts-ignore
import { profile } from '$lib/utils/store/user';
import type { Course, Exercise, Lesson, Profile } from '$lib/utils/types';
// @ts-ignore
import { ROLE } from '$lib/utils/constants/roles';
// @ts-ignore
import { QUESTION_TYPE } from '$lib/components/Question/constants';
type GetCourseResponse = {
data: Course;
error: PostgrestError | null;
};
async function fetchCourseData(courseId: string): Promise<GetCourseResponse> {
const { data, error } = await supabase
.from('course')
.select(
`
*,
lessons:lesson(
*,
exercise:id(
id, title, description, due_by,
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
)
)
`
)
.match({ id: courseId })
.single();
return { data, error };
}
async function cloneGroupAndBasicCourse(
course: Course,
newTitle: string
): Promise<{ newCourse: Course }> {
const { description } = course;
const { data: newGroup } = await supabase
.from('group')
.insert({ name: newTitle, description })
.select();
console.log(`newGroup`, newGroup);
if (!Array.isArray(newGroup)) {
throw new Error('Group wasnt created successfully');
}
const { id: group_id } = newGroup[0];
// 2. Create course with group_id
const { data: newCourse } = await supabase
.from('course')
.insert({
...course,
title: newTitle,
description,
group_id,
lessons: undefined,
id: undefined,
updated_at: undefined,
created_at: undefined
})
.select();
console.log(`newCourse`, newCourse);
// 3. Add group members
const profileStoreValue: Profile = get(profile);
// Get profile of author and add as member
await addGroupMember({
profile_id: profileStoreValue.id,
email: profileStoreValue.email,
group_id,
role_id: ROLE.TUTOR
});
console.log('profileStoreValue', profileStoreValue);
return {
newCourse: newCourse?.[0]
};
}
async function cloneLessons(lessons: Lesson[], courseId: Course['id']) {
const clonedLessons = lessons
.sort((a, b) => new Date(a.created_at || '').getTime() - new Date(b.created_at || '').getTime())
.map((lesson, index) => ({
call_url: lesson.call_url,
course_id: courseId,
is_unlocked: lesson.is_unlocked,
lesson_at: new Date(),
note: lesson.note,
order: lesson.order || index + 1,
public: lesson.public,
slide_url: lesson.slide_url,
title: lesson.title,
videos: lesson.videos
}));
const { data } = await supabase.from('lesson').insert(clonedLessons).select();
const newLessons = data?.map((dataItem, dataIndex) => {
return {
...dataItem,
// @ts-ignore
exercise: lessons[dataIndex].exercise
};
});
console.log('newLessons', newLessons);
return { newLessons };
}
async function cloneExercises(lessons: Lesson[]) {
console.log('lessons', lessons);
// Lessons
for (const lesson of lessons) {
// @ts-ignore
const { id: lessonId, exercise = [] } = lesson;
// Exercises
for (const exerciseItem of exercise) {
const { title, description, questions = [] } = exerciseItem;
// Insert
const { data } = await supabase
.from('exercise')
.insert({
description,
due_by: new Date(),
lesson_id: lessonId,
title
})
.select();
const newExercise = Array.isArray(data) ? data[0] : data;
console.log('newExercise', newExercise);
// Questions
for (const question of questions) {
const { options = [] } = question;
// Insert
const { data } = await supabase
.from('question')
.insert({
name: question.name,
title: question.title,
points: question.points,
order: question.order,
question_type_id: question.question_type_id,
exercise_id: newExercise.id
})
.select();
const newQuestion = Array.isArray(data) ? data[0] : data;
console.log('newQuestion', newQuestion);
// Options: Don't map options for 'Paragraph' questions
if (QUESTION_TYPE.TEXTAREA !== newQuestion.question_type_id) {
// @ts-ignore
const newOptions = options.map((option) => ({
value: option.value,
label: option.label,
is_correct: option.is_correct,
question_id: newQuestion.id
}));
// Insert[Batch]
await supabase.from('option').insert(newOptions).select();
}
}
}
}
return true;
}
export async function cloneCourse(courseId: string, newTitle: string): Promise<Course> {
const { data: course, error } = await fetchCourseData(courseId);
console.log(`course`, course);
if (error) {
console.error('Error when cloning', error);
throw error;
}
/**
* 1. Copy course table
* 2. Copy lessons table
* 3. Copy exercises table
* 4. Copy questions table
* 5. Copy options table
*/
// tables: group, groupmember, course(group_id)
const { newCourse } = await cloneGroupAndBasicCourse(course, newTitle);
console.log('returned -> newCourse', newCourse);
// @ts-ignore
const { newLessons } = await cloneLessons(course.lessons, newCourse.id);
console.log('returned -> newLessons', newLessons);
const cloneExercisesRes = await cloneExercises(newLessons || []);
console.log('returned -> cloneExercisesRes', cloneExercisesRes);
return newCourse;
}
/*
1. Click on 3 dots to clone course
2. Show a modal with a new name "Copy of previous course name"
-> Note the following information will be copied
3. Things to copy
Overview
Lessons
Exercises - questions, options, everything basically.
Technical approach
- Fetch all the data to the client
- Manually call functions to create
- call a psql function with newname, course_id
- copy all in course table
- copy all in group table by course_id
- add only one group member as admin
- get all lessons in course
- copy each one with unique ids
- get all exercises by course id
- copy exercise
- get questions
- copy questions
- get options
- copy options
*/
@@ -0,0 +1,415 @@
import { supabase } from '$lib/utils/functions/supabase';
import { isUUID } from '$lib/utils/functions/isUUID';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import type {
Lesson,
Course,
Group,
Groupmember,
Exercise,
LessonCompletion,
ExerciseTemplate
} from '$lib/utils/types';
import { STATUS } from '$lib/utils/constants/course';
import type { PostgrestSingleResponse } from '@supabase/supabase-js';
export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']) {
let match: { slug?: string; id?: string; status?: string } = {};
if (slug) {
match.slug = slug;
} else {
match.id = courseId;
}
match.status = STATUS[STATUS.ACTIVE];
const response: PostgrestSingleResponse<Course | null> = await supabase
.from('course')
.select(
`
id,
title,
description,
overview,
logo,
is_published,
group(*,
members:groupmember(*,
profile(*)
)
),
slug,
cost,
currency,
metadata,
is_certificate_downloadable,
certificate_theme,
lessons:lesson(
id, title,public, lesson_at, call_url, is_unlocked, order, created_at,
note, videos, slide_url, call_url, totalExercises:exercise(count), totalComments:lesson_comment(count),
profile:teacher_id(id, avatar_url, fullname),
lesson_completion(id, profile_id, is_complete)
),
attendance:group_attendance(*),
polls:apps_poll(status)
`
)
.match(match)
.single();
const { data, error } = response;
console.log(`error`, error);
if (!data || error) {
console.log(`data`, data);
console.log(`fetchCourse => error`, error);
// return this.redirect(301, '/courses');
return { data, error };
}
return {
data,
error
};
}
export async function fetchGroup(groupId: Group['id']) {
const { data, error } = await supabase
.from('group')
.select(`*,members:groupmember(*,profile(*))`)
.match({ id: groupId })
.single();
return {
data,
error
};
}
export async function uploadAvatar(courseId: string, avatar: string) {
const filename = `course/${courseId + Date.now()}.webp`;
let logo;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data } = supabase.storage.from('avatars').getPublicUrl(filename);
if (!data.publicUrl) return;
logo = data.publicUrl;
}
return logo;
}
export async function updateCourse(
courseId: Course['id'],
avatar: string | undefined,
course: Partial<Course>
) {
if (avatar && courseId) {
const filename = `course/${courseId + Date.now()}.webp`;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
if (!response.publicUrl) return;
course.logo = response.publicUrl;
}
}
await supabase.from('course').update(course).match({ id: courseId });
return course.logo;
}
export async function deleteCourse(courseId: Course['id']) {
return await supabase.from('course').update({ status: 'DELETED' }).match({ id: courseId });
}
export function addGroupMember(member: any) {
return supabase.from('groupmember').insert(member).select();
}
export function updatedGroupMember(update: any, match: any) {
return supabase.from('groupmember').update(update).match(match);
}
export function deleteGroupMember(groupMemberId: Groupmember['id']) {
return supabase.from('groupmember').delete().match({ id: groupMemberId });
}
export function fetchLesson(lessonId: Lesson['id']) {
return supabase
.from('lesson')
.select(
`id, note, videos, slide_url, call_url, totalExercises:exercise(count), totalComments:lesson_comment(count), lesson_completion(id, profile_id, is_complete)`
)
.eq('id', lessonId)
.single();
}
export function createLesson(lesson: any) {
return supabase.from('lesson').insert(lesson).select();
}
export function updateLesson(lesson: any, lessonId: Lesson['id']) {
return supabase
.from('lesson')
.update({ ...lesson, id: undefined })
.match({ id: lessonId });
}
export function updateLessonCompletion(completion: LessonCompletion) {
return supabase.from('lesson_completion').upsert(completion);
}
export function deleteLesson(lessonId: Lesson['id']) {
// Need to implement soft delete
return supabase.from('lesson').delete().match({ id: lessonId });
}
export function createExercise(exercise: any) {
return supabase.from('exercise').insert(exercise).select();
}
export function fetchExercisesByMarks(courseId: Course['id']) {
return supabase.rpc('get_exercises').eq('course_id', courseId);
}
function isNew(item: any) {
return isNaN(item);
}
export async function createExerciseFromTemplate(
lessonId: string,
template: ExerciseTemplate
): Promise<any | undefined> {
const { data, error } = await createExercise({
title: template.title,
description: template.description,
lesson_id: lessonId
});
if (error) {
console.error('Something went wrong', error);
return;
}
const { id } = data[0] || {};
if (!id) {
console.error('Something went wrong, no id', error);
return;
}
await upsertExercise(template.questionnaire, id);
return data[0];
}
export async function upsertExercise(questionnaire: any, exerciseId: Exercise['id']) {
const {
questions,
title,
description,
due_by,
is_title_dirty,
is_description_dirty,
is_due_by_dirty
} = questionnaire;
if (is_description_dirty || is_title_dirty || is_due_by_dirty) {
await supabase
.from('exercise')
.update({
title,
description,
due_by
})
.match({ id: exerciseId });
}
const updatedQuestions = [];
for (const question of questions) {
const { title, id, name, question_type, options, deleted_at, order, points, is_dirty } =
question;
// "DELETE" /delete/:questionId - Don't delete if answer already given
if (deleted_at) {
// Delete from server only if this question exists in the database
if (!isNew(id)) {
await supabase.from('option').delete().match({ question_id: id });
await supabase.from('question_answer').delete().match({ question_id: id });
const { error } = await supabase.from('question').delete().match({ id });
if (error) {
console.error('Cannot delete this question', error);
continue;
}
}
// Skip the remaining operation so we filter out this question from the new questions array
continue;
}
// "INSERT" or "UPDATE" /update/:questionId or /insert/:questionId
const newQuestion = {
id: isNew(id) ? undefined : id,
name: isNew(id) ? undefined : name,
title,
points,
order,
question_type_id: question_type.id,
exercise_id: exerciseId
};
let questionSupabaseRes;
if (is_dirty || isNew(id)) {
const res = await supabase.from('question').upsert(newQuestion).select();
if (res.error) {
console.error(`Upsert question`, res.error);
}
questionSupabaseRes = Array.isArray(res.data) ? res.data[0] : null;
} else {
questionSupabaseRes = Object.assign(newQuestion);
}
if (questionSupabaseRes) {
const { question_type_id, id, name, order } = questionSupabaseRes;
// Delete cause this is not a field in the table
delete newQuestion.question_type_id;
// @ts-ignore
newQuestion.question_type = { id: question_type_id, label: question_type.label || '' };
newQuestion.id = id;
newQuestion.name = name;
newQuestion.order = order;
// @ts-ignore
newQuestion.options = [];
// Don't map options for 'Paragraph' questions
if (QUESTION_TYPE.TEXTAREA !== question_type_id) {
for (const option of options) {
const { deleted_at, is_dirty } = option;
// "DELETE" /delete/:optionId
if (deleted_at) {
// if this option exists in the database
if (!isNew(option.id)) {
supabase.from('option').delete().match({ id: option.id });
}
// Skip the remaining operation so we filter out this option from the new option array
continue;
}
// "INSERT" and "UPDATE"
const newOption = {
...option,
is_dirty: undefined,
id: isNew(option.id) ? undefined : option.id,
value: isUUID(option.value) ? option.value : undefined, // this value is of UUID type
question_id: newQuestion.id
};
if (is_dirty || isNew(option.id)) {
const { data } = await supabase.from('option').upsert(newOption).select();
if (Array.isArray(data)) {
// @ts-ignore
newQuestion.options.push(data[0]);
}
} else {
// @ts-ignore
newQuestion.options.push(newOption);
}
}
}
updatedQuestions.push(newQuestion);
}
}
return updatedQuestions;
}
interface LooseObject {
[key: string]: any;
}
export async function submitExercise(
answers: Array<string>,
questions: Array<{ name: string; id: string }>,
exerciseId: Exercise['id'],
courseId: Course['id'],
groupMemberId: Groupmember['id'] | undefined
) {
if (!groupMemberId) {
return;
}
const questionsByName = questions.reduce(
(acc, q) => ({ ...acc, [q.name]: q.id }),
{}
) as LooseObject;
const questionAnswers = [];
const { data: submission } = await supabase
.from('submission')
.insert({
submitted_by: groupMemberId,
exercise_id: exerciseId,
course_id: courseId
})
.select();
for (const questionName in answers) {
const value = answers[questionName];
const questionAnswer = {
group_member_id: groupMemberId,
question_id: questionsByName[questionName],
open_answer: '',
answers: [],
submission_id: Array.isArray(submission) ? submission[0].id : null
};
if (typeof value === 'string') {
questionAnswer.open_answer = value;
} else {
questionAnswer.answers = value;
}
questionAnswers.push(questionAnswer);
}
const res = await supabase.from('question_answer').insert(questionAnswers).select();
console.log(`res`, res);
}
export async function deleteExercise(questions: Array<{ id: string }>, exerciseId: Exercise['id']) {
for (const question of questions) {
const { id } = question;
await supabase.from('option').delete().match({ question_id: id });
await supabase.from('question_answer').delete().match({ question_id: id });
await supabase.from('question').delete().match({ id });
}
await supabase.from('submission').delete().match({ exercise_id: exerciseId });
await supabase.from('exercise').delete().match({ id: exerciseId });
}
@@ -0,0 +1,82 @@
import type { ExerciseTemplate, CustomQuestionType } from '$lib/utils/types';
const QuestionTypes: CustomQuestionType[] = [
{
id: 1,
label: 'RADIO'
},
{
id: 2,
label: 'CHECKBOX'
},
{
id: 3,
label: 'TEXTAREA'
}
];
const quiz: ExerciseTemplate = {
title: 'HTML Elements Quiz',
description: 'Test your knowledge of HTML elements.',
questionnaire: {
questions: [
{
title: 'What does the <p> element stand for?',
name: 'p_element',
points: 2,
order: 1,
question_type: QuestionTypes[0], // RADIO
options: [
{ label: 'Paragraph', is_correct: true },
{ label: 'Page', is_correct: false },
{ label: 'Preformatted', is_correct: false }
]
},
{
title: 'Which HTML element is used for creating an unordered list?',
name: 'ul_element',
points: 2,
order: 2,
question_type: QuestionTypes[0], // RADIO
options: [
{ label: '<ul>', is_correct: true },
{ label: '<ol>', is_correct: false },
{ label: '<li>', is_correct: false }
]
},
{
title: 'What does the <a> element do?',
name: 'a_element',
points: 2,
order: 3,
question_type: QuestionTypes[1], // CHECKBOX
options: [
{ label: 'Defines a hyperlink', is_correct: true },
{ label: 'Creates a table', is_correct: false },
{ label: 'Adds an image', is_correct: false },
{ label: 'Defines a form', is_correct: false }
]
},
{
title: 'Which HTML element is used for creating a line break?',
name: 'br_element',
points: 2,
order: 4,
question_type: QuestionTypes[0], // RADIO
options: [
{ label: '<br>', is_correct: true },
{ label: '<p>', is_correct: false },
{ label: '<hr>', is_correct: false }
]
},
{
title: 'What does the <h1> to <h6> elements represent?',
name: 'h_elements',
points: 2,
order: 5,
question_type: QuestionTypes[2], // TEXTAREA
options: []
}
]
}
};
@@ -0,0 +1,47 @@
import type { UserLessonDataType } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
function getFirstAndLastDayOfMonth(): { firstDay: string; lastDay: string } {
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth();
// Create a date object for the first day of the month
const firstDay = new Date(year, month, 1).toISOString();
// Find the last day of the next month and subtract one day to get the last day of the current month
const nextMonth: any = new Date(year, month + 1, 1);
const lastDay = new Date(nextMonth - 1).toISOString();
return {
firstDay,
lastDay
};
}
export async function fetchUserUpcomingData(
profileId: string | null,
orgId: string
): Promise<UserLessonDataType[] | []> {
if (!profileId) {
return [];
}
const { lastDay, firstDay } = getFirstAndLastDayOfMonth();
const { data: userUpcomingData } = await supabase
.rpc('get_user_upcoming_lessons', {
profile_id_arg: profileId,
org_id_arg: orgId
})
.filter('lesson_at', 'gte', firstDay)
.filter('lesson_at', 'lte', lastDay);
console.log(`userUpcomingData`, userUpcomingData);
if (!Array.isArray(userUpcomingData)) {
return [];
}
return userUpcomingData;
}
@@ -0,0 +1,104 @@
import { supabase } from '$lib/utils/functions/supabase';
import type { PostgrestError } from '@supabase/supabase-js';
export interface LMSExercise {
id: string;
title: string;
updated_at: string;
questions: {
points: number;
}[];
submission: {
status_id: number;
updated_at: string;
total: number;
groupmember: {
id: string;
profile: {
id: string;
}[];
}[];
}[];
lesson: {
id: string;
title: string;
order: number;
course: {
id: string;
title: string;
group: {
organisation: {
id: string;
}[];
}[];
groupmember: {
id: string;
profile: {
id: string;
}[];
}[];
};
};
}
interface FetchLMSExercisesResponse {
exercises: LMSExercise[] | null;
error: PostgrestError | null;
}
export async function fetchLMSExercises(
profileId: string,
orgId: string
): Promise<FetchLMSExercisesResponse> {
const { data, error } = await supabase
.from('exercise')
.select(
`
id,
title,
updated_at,
questions:question(points),
submission (
status_id,
updated_at,
total,
groupmember:submitted_by!inner (
id,
profile:profile_id!inner (
id
)
)
),
lesson!inner (
id,
title,
order,
is_unlocked,
course!inner (
id,
title,
group:group_id!inner (
organisation:organization_id!inner (
id
),
groupmember!inner(
id,
profile:profile_id!inner (
id
)
)
)
)
)
`
)
.eq('lesson.is_unlocked', true)
.eq('lesson.course.group.organisation.id', orgId)
.eq('lesson.course.group.groupmember.profile.id', profileId)
.eq('submission.groupmember.profile.id', profileId);
return {
exercises: data as LMSExercise[] | null,
error
};
}
@@ -0,0 +1,7 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function fetchMarks(courseId) {
const { data } = await supabase.rpc('get_marks').eq('course_id', courseId);
return { data };
}
@@ -0,0 +1,4 @@
1. Welcome new user into classroomio
2. Sends teacher welcome to course
3. Notify teacher someone wants to buy course
4
@@ -0,0 +1,58 @@
import { supabase } from '$lib/utils/functions/supabase';
export const NOTIFICATION_NAME = {
WELCOME_TO_APP: 'WELCOME TO APP',
INVITE_TEACHER: 'INVITE TEACHER',
WELCOME_TEACHER_TO_COURSE: 'WELCOME TEACHER TO COURSE',
SEND_TEACHER_STUDENT_BUY_REQUEST: 'SEND TEACHER STUDENT BUY REQUEST',
STUDENT_PROOVE_COURSE_PAYMENT: 'STUDENT PROOVE COURSE PAYMENT',
STUDENT_COURSE_WELCOME: 'STUDENT COURSE WELCOME',
TEACHER_STUDENT_JOINED: 'TEACHER STUDENT JOINED',
SUBMISSION_UPDATE: 'SUBMISSION UPDATE',
EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE'
};
const NAME_TO_PATH = {
[NOTIFICATION_NAME.WELCOME_TO_APP]: '/api/email/welcome',
[NOTIFICATION_NAME.INVITE_TEACHER]: '/api/email/invite',
[NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE]: '/api/email/course/teacher_welcome',
[NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST]:
'/api/email/course/teacher_student_buycourse',
[NOTIFICATION_NAME.STUDENT_PROOVE_COURSE_PAYMENT]: '/api/email/course/student_prove_payment',
[NOTIFICATION_NAME.STUDENT_COURSE_WELCOME]: '/api/email/course/student_welcome',
[NOTIFICATION_NAME.TEACHER_STUDENT_JOINED]: '/api/email/course/teacher_student_joined',
[NOTIFICATION_NAME.SUBMISSION_UPDATE]: '/api/email/course/submission_update',
[NOTIFICATION_NAME.EXERCISE_SUBMISSION_UPDATE]: '/api/email/course/exercise_submission_update'
};
const getAccessToken = async () => {
const { data } = await supabase.auth.getSession();
return data.session?.access_token || '';
};
export const triggerSendEmail = async (name: string, body: { [k: string]: any }) => {
try {
const accessToken = await getAccessToken();
const path = NAME_TO_PATH[name];
if (!path) {
console.error('Could not trigger send');
return;
}
const response = await fetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(body)
});
if (!response.ok) {
console.log(response);
throw new Error(await response.text());
}
const result = await response.json();
} catch (error) {
console.error(name, error);
}
};
@@ -0,0 +1,73 @@
import nodemailer from 'nodemailer';
import { withEmailTemplate } from './template';
import {
SMTP_HOST,
SMTP_USER,
SMTP_PASSWORD,
SMTP_USER_HELP,
SMTP_PASSWORD_HELP
} from '$env/static/private';
const content = `
<div>
<h1>Welcome</h1>
<br/>
<p>Thank you so much for trying out ClassroomIO</p>
<a href="https://app.classroomio.com/login">Login</a><br/>
<br/>
Your ClassroomIO Team
</div>
`;
let transporter: nodemailer.Transporter;
const getTransporter = async (isPersonal?: boolean): Promise<boolean> => {
return await new Promise((resolve, reject) => {
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: 465,
secure: true,
auth: {
user: isPersonal ? SMTP_USER : SMTP_USER_HELP,
pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_HELP
}
});
transporter.verify(function (error, success) {
if (error) {
console.log('Transporter error', error);
reject(error);
} else {
console.log('Server is ready to take our messages');
resolve(success);
}
});
});
};
export const sendEmail = async ({
from,
to,
subject,
content,
isPersonalEmail,
replyTo
}: {
from?: string;
to: string;
subject: string;
content: string;
isPersonalEmail?: boolean;
replyTo?: string;
}) => {
await getTransporter(isPersonalEmail);
const info = await transporter.sendMail({
from: from || '"Best from ClassroomIO" <best@classroomio.com>', // sender address
to, // list of receivers
subject, // Subject line
replyTo,
html: withEmailTemplate(content) // html body
});
console.log('Message sent: %s', info.messageId);
};
@@ -0,0 +1,219 @@
export const withEmailTemplate = (content: 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: #fff;
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
sans-serif;
font-size: 15px;
font-weight: 300;
line-height: 26px;
margin: 0;
color: #1e293b;
}
pre {
background: #f4f4f4;
padding: 2px;
}
hr {
border-top: 1px dashed #94a3b8;
margin-top: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
}
table td {
padding: 5px;
}
.socialicons {
max-width: 200px;
margin-left: auto;
margin-right: auto;
margin-top: 27px;
}
.wrap {
background-color: #f1f5f9;
padding: 30px;
max-width: 525px;
margin: 0 auto;
border-radius: 12px;
}
.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;
margin-right: 5px;
}
.gutter {
padding: 30px;
text-align: center;
}
img {
max-width: 100%;
height: auto;
}
.gutter img {
max-width: 280px;
}
a {
color: #0233BD;
}
a:hover {
color: #0233BD;
}
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: #fff;
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
sans-serif;
font-size: 15px;
line-height: 26px;
margin: 0;
color: #1e293b;
"
>
<div class="gutter" style="padding: 30px">
<a href="https://classroomio.com" target="_blank">
<img
src="https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/classroomio-logo-full.png"
alt="ClassroomIO Logo"
/></a>
</div>
<div
class="wrap"
style="
background-color: #f1f5f9;
padding: 30px;
max-width: 525px;
margin: 0 auto;
border-radius: 12px;
"
>
${content}
</div>
<div
class="footer"
style="text-align: center; font-size: 12px; color: #cbd5e1"
>
<table class="socialicons">
<tr>
<td>
<a target="_blank" href="https://twitter.com/classroomio"
><img
title="X"
src="https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/classroomio-x.png"
alt="X"
width="32"
/></a>
</td>
<td>
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"
><img
title="Youtube"
src="https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/classroomio-youtube.png"
alt="Youtube"
width="32"
/></a>
</td>
<td>
<a target="_blank" href="https://www.facebook.com/classroomiohq"
><img
title="Facebook"
src="https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/classroomio-facebook.png"
alt="Facebook"
width="32"
/></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>
`;
@@ -0,0 +1,232 @@
import isEmpty from 'lodash/isEmpty';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { supabase } from '$lib/utils/functions/supabase';
import { orgs, currentOrg, orgAudience, orgTeam } from '$lib/utils/store/org';
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
import type { CurrentOrg, OrgTeamMember, OrgAudience } from '$lib/utils/types/org';
export async function getOrgTeam(orgId: string) {
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
email,
verified,
role_id,
profile(
id,
fullname,
email
)
`
)
.eq('organization_id', orgId)
.neq('role_id', ROLE.STUDENT)
.order('id', { ascending: false })
.returns<
{
id: number;
email: string;
verified: boolean;
role_id: number;
profile: {
id: string;
fullname: string;
email: string;
};
}[]
>();
const team: OrgTeamMember[] = [];
if (data?.length) {
data.forEach((teamMember) => {
team.push({
id: teamMember.id,
email: teamMember?.profile?.email || teamMember.email,
verified: teamMember.verified,
profileId: teamMember?.profile?.id,
fullname: teamMember?.profile?.fullname || '',
role: ROLE_LABEL[teamMember?.role_id] || '',
isAdmin: teamMember?.role_id === ROLE.ADMIN
});
});
orgTeam.set(team);
}
return {
team: get(orgTeam),
error
};
}
export async function getOrganizations(userId: string) {
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
profile_id,
role_id,
created_at,
organization!organizationmember_organization_id_fkey (
id,
name,
siteName,
avatar_url,
landingpage,
theme,
created_at
)
`
)
.eq('profile_id', userId)
.order('id', { ascending: false })
.returns<
{
id: string;
profile_id: string;
role_id: string;
created_at: string;
organization: CurrentOrg;
}[]
>();
const orgsArray: CurrentOrg[] = [];
if (Array.isArray(data) && data.length) {
data.forEach((orgMember) => {
orgsArray.push({
id: orgMember?.organization?.id,
name: orgMember?.organization?.name,
shortName: orgMember?.organization?.name?.substring(0, 2)?.toUpperCase() || '',
siteName: orgMember?.organization?.siteName,
theme: orgMember?.organization?.theme,
avatar_url: orgMember?.organization?.avatar_url,
memberId: orgMember?.id,
role_id: orgMember?.role_id,
landingpage: orgMember?.organization?.landingpage
});
});
orgs.set(orgsArray);
if (localStorage) {
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
const lastOrg = orgsArray.find((org) => org.siteName === lastOrgSiteName);
if (lastOrg) {
currentOrg.set(lastOrg);
}
}
const _currentOrg = get(currentOrg);
if (!_currentOrg.siteName) {
currentOrg.set(orgsArray[0]);
}
}
return {
orgs: orgsArray,
currentOrg: get(currentOrg),
error
};
}
export async function getOrgAudience(orgId: string) {
// get all students who are participants in any course belonging to an org
const { data, error } = await supabase
.from('profile')
.select(
`
id,
fullname,
email,
avatar_url,
created_at,
groupmember!inner(
role_id,
group_id:group!inner(
organization_id
)
)
`
)
.eq('groupmember.group.organization_id', orgId)
.eq('groupmember.role_id', ROLE.STUDENT);
console.log('data', data);
const audience = (data || []).map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email,
avatar_url: profile.avatar_url,
date_joined: new Date(profile.created_at).toDateString()
}));
orgAudience.set(audience);
return {
audience: audience,
error
};
}
export async function getCourseBySiteName(siteName: string) {
const { data, error } = await supabase
.from('course')
.select(
`
*,
lessons:lesson(count),
group!inner(
organization!inner(id, name, siteName, avatar_url)
)
`
)
.eq('group.organization.siteName', siteName)
.eq('status', 'ACTIVE')
.eq('is_published', true);
if (error) {
return [];
}
return data;
}
export async function getCurrentOrg(siteName: string, justGet = false) {
const { data, error } = await supabase
.from('organization')
.select(
`
id,
name,
siteName,
avatar_url,
landingpage,
theme
`
)
.eq('siteName', siteName)
.returns<CurrentOrg[]>();
const isDataEmpty = !data?.[0];
if (!justGet && (error || isDataEmpty)) {
console.error('Error getOrganization', error);
return goto('/404');
}
if (!justGet) {
if (isDataEmpty) return;
currentOrg.set(data[0]);
} else if (!isDataEmpty) {
return data[0];
}
}
@@ -0,0 +1,26 @@
import { createQuizModal, deleteModal } from '$lib/utils/store/org';
export function onEdit(quiz) {}
export function onShare(quiz) {}
export function onReport(quiz) {}
export function onRename(quiz) {
createQuizModal.update((m) => ({
...m,
id: quiz.id,
title: quiz.title,
openEdit: true
}));
}
export function onMakeCopy(quiz) {}
export function onDelete(quiz) {
deleteModal.update((m) => ({
...m,
open: true,
id: quiz.id
}));
}
@@ -0,0 +1,22 @@
import { dev } from '$app/environment';
import posthog from 'posthog-js';
export const capturePosthogEvent = (event: string, properties?: Record<string, any>): void => {
if (dev) return;
posthog.capture(event, properties);
};
export const identifyPosthogUser = (id: string, properties?: Record<string, any>): void => {
if (dev) return;
posthog.identify(id, properties);
};
export const initPosthog = (): void => {
if (dev) return;
posthog.init('phc_JfdHOZ6v0cVlGELBYx1Tmoen2nxNOrAzvgvrPA6Ksov', {
api_host: 'https://eu.posthog.com'
});
};
@@ -0,0 +1,26 @@
import { dev } from '$app/environment';
import * as Sentry from '@sentry/browser';
import { CaptureConsole } from '@sentry/integrations';
export const initSentry = (): void => {
if (dev) return;
Sentry.init({
dsn: 'https://c966f7e8cb1d4306be20b26bb4f0cc96@o476906.ingest.sentry.io/5999999',
integrations: [
new CaptureConsole({
levels: ['error']
}),
new Sentry.Replay()
],
environment: 'production',
replaysSessionSampleRate: 0.5,
replaysOnErrorSampleRate: 1.0
});
};
export const setSentryUser = (user: Record<string, any>): void => {
if (dev) return;
Sentry.setUser(user);
};
@@ -0,0 +1,102 @@
import type { PostgrestSingleResponse } from '@supabase/supabase-js';
import { supabase } from '$lib/utils/functions/supabase';
import type { ExerciseSubmissions } from '$lib/utils/types';
export function fetchSubmissionStatus() {
return supabase.from('submissionstatus').select(`*`);
}
export function fetchSubmissions(course_id: string) {
return supabase
.from('submission')
.select(
`
id,
created_at,
answers:question_answer(*),
exercise:exercise_id(
id, title, due_by,
lesson:lesson_id(id, title),
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
),
status_id,
course:course_id(*),
groupmember:submitted_by(
profile(*)
)
`
)
.match({
course_id
});
}
export async function fetchSubmission({
courseId,
exerciseId,
submittedBy
}: {
exerciseId: string;
courseId?: string;
submittedBy?: string;
}) {
const query: {
exercise_id: string;
course_id?: string;
submitted_by?: string;
} = {
exercise_id: exerciseId
};
if (courseId) {
query.course_id = courseId;
}
if (submittedBy) {
query.submitted_by = submittedBy;
}
return supabase
.from('submission')
.select(
`
id,
answers:question_answer(*),
status_id,
submitted_by:groupmember!inner(
profile!inner(
id,
fullname,
avatar_url
)
)
`
)
.match(query)
.returns<ExerciseSubmissions[]>();
}
export async function updateSubmission(
{ id, status_id, total }: { id?: number; status_id?: number; total?: number },
otherArgs?: any
) {
const toUpdate: {
status_id?: number;
total?: number;
} = {
status_id
};
if (typeof total === 'number') {
toUpdate.total = total;
}
return supabase.from('submission').update(toUpdate, otherArgs).match({ id });
}
export async function updateQuestionAnswer(update: any, match: any) {
return supabase.from('question_answer').update(update).match(match);
}