CLASSROOMIO-T-80: Replace sapper with sveltekit

This commit is contained in:
Rotimi Best
2023-08-02 19:35:27 +01:00
parent 8ffdf319b3
commit 7dfbb05451
320 changed files with 5207 additions and 18330 deletions
+25
View File
@@ -0,0 +1,25 @@
export const blockedSubdomain = [
'app',
'apps',
'play',
'support',
'help',
'teachers',
'church',
'tech',
'build',
'community',
'conference',
'campaign',
'blog',
'forum',
'mobile',
'dev',
'stage',
'prod',
'group',
'groups',
'academy',
'schools',
'training',
];
+19
View File
@@ -0,0 +1,19 @@
export const SIGNUP_FIELDS = {
email: '',
password: '',
confirmPassword: '',
};
export const RESET_FIELDS = {
password: '',
confirmPassword: '',
};
export const LOGIN_FIELDS = {
email: '',
password: '',
};
export const FORGOT_PASSWORD_FIELDS = {
email: '',
};
+4
View File
@@ -0,0 +1,4 @@
export default {
edit: 'edit',
view: 'view',
};
+110
View File
@@ -0,0 +1,110 @@
import cloneDeep from 'lodash/cloneDeep';
export const themeImages = {
standard: {
card: 'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/standard-quiz-card.svg',
editor:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/standard-quiz-editor.svg',
play: 'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/standard-quiz-play.svg',
},
mainland: {
card: 'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/mainstream-quiz-card.svg',
editor:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/mainstream-quiz-editor.svg',
play: 'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/mainstream-quiz-play.svg',
},
};
export const defOption = {
id: 1,
label: '',
options: [],
};
export const optionImage = {
circle:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/circle.svg',
spade:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/spade.svg',
square:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/square.svg',
pentagon:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/pentagon.svg',
hexagon:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/hexagon.svg',
triangle:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/triangle.svg',
};
export const allOptions = [
{
id: 'circle',
label: '',
isCorrect: false,
},
{
id: 'triangle',
label: '',
isCorrect: false,
},
{
id: 'spade',
label: '',
isCorrect: false,
},
{
id: 'square',
label: '',
isCorrect: false,
},
{
id: 'pentagon',
label: '',
isCorrect: false,
},
{
id: 'hexagon',
label: '',
isCorrect: false,
},
];
export const booleanOptions = [
{
id: 'triangle',
label: 'True',
image:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/triangle.svg',
},
{
id: 'square',
label: 'False',
image:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/quizes/square.svg',
},
];
export const allThemes = [
{
id: 'standard',
label: 'Standard',
},
{
id: 'mainland',
label: 'Mainland Bridge',
},
];
export const defQuestion = {
id: new Date().getTime(),
title: '',
type: 'multichoice',
options: [],
timelimit: '10s',
};
export const STEPS = {
CONNECT_TO_PLAY: 'CONNECT_TO_PLAY',
WAIT_FOR_PLAYERS: 'WAIT_FOR_PLAYERS',
SHOW_NEXT_QUESTION: 'SHOW_NEXT_QUESTION',
SCOREBOARD: 'SCOREBOARD',
PODIUM: 'PODIUM',
};
+30
View File
@@ -0,0 +1,30 @@
export const ROLE = {
ADMIN: 1,
TUTOR: 2,
STUDENT: 3,
};
export const ROLE_LABEL = {
[ROLE.ADMIN]: 'Admin',
[ROLE.TUTOR]: 'Tutor',
[ROLE.STUDENT]: 'Student',
};
export const ROLES = [
{
label: 'All',
value: 'all',
},
{
label: ROLE_LABEL[ROLE.ADMIN],
value: ROLE.ADMIN,
},
{
label: ROLE_LABEL[ROLE.TUTOR],
value: ROLE.TUTOR,
},
{
label: ROLE_LABEL[ROLE.STUDENT],
value: ROLE.STUDENT,
},
];
+32
View File
@@ -0,0 +1,32 @@
export const ROUTE = {
HOME: '/',
DASHBOARD: '/dashboard',
ONBOARDING: '/onboarding',
COURSES: '/courses',
COURSE: '/course',
LOGIN: '/login',
SIGN_UP: '/signup',
PROFILE: '/profile',
PEOPLE: '/people',
DISCUSSIONS: '/discussions',
ASK: '/ask',
FORGOT: '/forgot',
RESET: '/reset',
};
export const PUBLIC_ROUTES = [
`^${ROUTE.HOME}$`,
ROUTE.LOGIN,
ROUTE.SIGN_UP,
ROUTE.FORGOT,
ROUTE.RESET,
`^${ROUTE.COURSE}/.*`,
];
export const ROUTES_TO_HIDE_NAV = [
ROUTE.LOGIN,
ROUTE.SIGN_UP,
ROUTE.FORGOT,
ROUTE.RESET,
ROUTE.ONBOARDING,
];
+8
View File
@@ -0,0 +1,8 @@
export async function handleAuthChange(event, session) {
await fetch('/api/auth', {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify({ event, session }),
});
}
+29
View File
@@ -0,0 +1,29 @@
export function toggleBodyByTheme(isDark) {
if (!document) return;
if (isDark) {
document.body.className = document.body.className.concat(' ', 'dark');
} else {
document.body.className = document.body.className.replace(' dark', '');
}
}
export function isCoursesPage(path) {
return /courses\/[a-z 0-9 -]/.test(path);
}
export function isCoursePage(path) {
return /course\/[a-z 0-9 -]/.test(path);
}
export function isOrgPage(path) {
return /org\/[a-z 0-9 -]/.test(path);
}
export function isQuizPage(path) {
return /org\/[a-z 0-9 -]+\/quiz\/[a-z 0-9 -]/.test(path);
}
export function isLMSPage(path) {
return /lms[\/a-z 0-9 -]*/.test(path);
}
+13
View File
@@ -0,0 +1,13 @@
export default (date) => {
const d = new Date(date);
const ye = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(d);
const mo = new Intl.DateTimeFormat('en', { month: '2-digit' }).format(d);
const da = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(d);
// return `${ye}-${mo}-${da} ${d.toLocaleTimeString('en-US')}`;
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'full',
timeStyle: 'medium',
}).format(d);
};
@@ -0,0 +1,51 @@
export function formatYoutubeVideo(url: string, errors: { video: string }) {
console.log(`url`, url);
const prefix = 'https://www.youtube.com/embed';
// https://www.youtube.com/embed/qajK1J1neAM
if (url.includes('embed')) {
return url;
}
// https://youtu.be/qajK1J1neAM
if (url.includes('.be/')) {
const splittedUrlWithBe = url.split('.be/');
return `${prefix}/${splittedUrlWithBe[1]}`;
}
// https://www.youtube.com/watch?v=qajK1J1neAM
const splitedUrl = url.split('watch');
if (splitedUrl.length !== 2) {
errors.video = 'Not a valid youtube link';
return;
}
const query = new URLSearchParams(splitedUrl[1]);
return `${prefix}/${query.get('v')}`;
}
export function getEmbedId(url: string) {
// https://www.youtube.com/embed/qajK1J1neAM
if (url.includes('embed')) {
return url.split('embed/')[1];
}
// https://youtu.be/qajK1J1neAM
if (url.includes('.be/')) {
const splittedUrlWithBe = url.split('.be/');
return splittedUrlWithBe[1];
}
// https://www.youtube.com/watch?v=qajK1J1neAM
const splitedUrl = url.split('watch');
if (splitedUrl.length !== 2) {
return '';
}
const query = new URLSearchParams(splitedUrl[1]);
return query.get('v');
}
+9
View File
@@ -0,0 +1,9 @@
function generateSlug(title) {
return title
.match(/[^\W\s\d]*/g, '')
.join('')
.toLowerCase()
.concat(`-${new Date().getTime()}`);
}
export default generateSlug;
@@ -0,0 +1,7 @@
export default (currency: string | undefined) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
});
};
@@ -0,0 +1,5 @@
export default (created_at, due_date) => {
return !!due_date
? !!(new Date(created_at).getTime() <= new Date(due_date).getTime())
: true;
};
+13
View File
@@ -0,0 +1,13 @@
export function isUUID(uuid: string) {
let s = '' + uuid;
const m: RegExpMatchArray | null = s.match(
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
);
if (m === null) {
return false;
}
return true;
}
+5
View File
@@ -0,0 +1,5 @@
export function genQuizPin() {
var minm = 100000;
var maxm = 999999;
return Math.floor(Math.random() * (maxm - minm + 1)) + minm;
}
@@ -0,0 +1,5 @@
export function removeDuplicate(arr) {
let s = new Set(arr);
let it = s.values();
return Array.from(it);
}
@@ -0,0 +1,59 @@
import dayjs from 'dayjs';
import isYesterday from 'dayjs/plugin/isYesterday';
import isToday from 'dayjs/plugin/isToday';
import isTomorrow from 'dayjs/plugin/isTomorrow';
import type {
UserLessonDataType,
LessonsByMonthIndexType,
} from '$lib/utils/types';
dayjs.extend(isYesterday);
dayjs.extend(isToday);
dayjs.extend(isTomorrow);
export const formatUserUpcomingData = (
data: UserLessonDataType[]
): LessonsByMonthIndexType => {
const result: LessonsByMonthIndexType = {};
for (const item of data) {
const { lesson_at } = item;
const lessonAtDate = new Date(lesson_at);
const monthIndex = lessonAtDate.getMonth();
const dateIndex = lessonAtDate.getDate();
if (result[monthIndex]) {
const prevDateData = result[monthIndex][dateIndex];
if (Array.isArray(prevDateData)) {
result[monthIndex] = {
...result[monthIndex],
[dateIndex]: [...prevDateData, item],
};
} else {
result[monthIndex] = { ...result[monthIndex], [dateIndex]: [item] };
}
} else {
result[monthIndex] = { [dateIndex]: [item] };
}
}
return result;
};
export const formatDate = (_selectedDate: string | undefined): string => {
if (!_selectedDate) return '';
const selectedDate = dayjs(_selectedDate);
if (selectedDate.isToday()) {
return 'Today';
} else if (selectedDate.isYesterday()) {
return 'Yesterday';
} else if (selectedDate.isTomorrow()) {
return 'Tomorrow';
}
return selectedDate.format('ddd, MMM D');
};
@@ -0,0 +1,8 @@
import { PUBLIC_ROUTES } from '$lib/utils/constants/routes';
export default (route) => {
return PUBLIC_ROUTES.some((publicRoute) => {
const regex = new RegExp(publicRoute, 'g');
return regex.test(route);
});
};
@@ -0,0 +1,5 @@
function showAppsSideBar(path) {
return path.includes('/lessons/') && !path.includes('landingpage');
}
export default showAppsSideBar;
+28
View File
@@ -0,0 +1,28 @@
import {
PUBLIC_SUPABASE_ANON_KEY,
PUBLIC_SUPABASE_URL,
} from '$env/static/public';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import type { ConfigType } from '../types';
export let supabase: SupabaseClient;
export const getSupabase = (config?: ConfigType) => {
if (supabase) return supabase;
config = {
supabaseConfig: {
bucketPath:
'https://koxqonvbkeakwvmdegcf.supabase.in/storage/v1/object/sign/',
url: PUBLIC_SUPABASE_URL || '',
anonKey: PUBLIC_SUPABASE_ANON_KEY || '',
},
};
supabase = createClient(
config.supabaseConfig.url,
config.supabaseConfig.anonKey
);
return supabase;
};
View File
+5
View File
@@ -0,0 +1,5 @@
export function validateEmail(email) {
const re =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
+191
View File
@@ -0,0 +1,191 @@
import get from 'lodash/get';
import isNumber from 'lodash/isNumber';
import z from 'zod';
import { validateEmail } from './validateEmail';
const createQuizValidationSchema = z.object({
title: z.string().min(6, {
message: 'Must be 6 or more characters long',
invalid_type_error: 'Must not be empty',
}),
});
const askCommunityValidationSchema = z.object({
title: z.string().min(6, {
message: 'Must be 6 or more characters long',
invalid_type_error: 'Must not be empty',
}),
body: z.string().min(20, {
message: 'Must be 10 or more characters long',
invalid_type_error: 'Must not be empty',
}),
});
const commentInCommunityValidationSchema = z.object({
comment: z.string().min(6, {
message: 'Must be 6 or more characters long',
invalid_type_error: 'Must not be empty',
}),
});
const forgotValidationSchema = z.object({
email: z.string().email({
message: 'Invalid email address',
invalid_type_error: 'Must not be empty',
}),
});
const authValidationSchema = z.object({
email: z.string().email({
message: 'Invalid email address',
invalid_type_error: 'Must not be empty',
}),
password: z.string().min(6, {
message: 'Must be 6 or more characters long',
invalid_type_error: 'Must not be empty',
}),
});
const resetValidationSchema = z.object({
password: z.string().min(6, {
message: 'Must be 6 or more characters long',
invalid_type_error: 'Must not be empty',
}),
});
const onboardingValidationSchema = {
stepOne: z.object({
fullname: z.string().min(1, { message: 'Enter your fullname' }),
orgName: z.string().min(1, { message: 'Enter your organisation name' }),
siteName: z.string().min(1, { message: 'Enter your site name' }),
}),
stepTwo: z.object({
goal: z
.string({
required_error: 'Select an option',
invalid_type_error: 'Select an option',
})
.min(1),
source: z
.string({
required_error: 'Select an option',
invalid_type_error: 'Select an option',
})
.min(1),
}),
};
export const getConfirmPasswordError = ({ password, confirmPassword }) => {
return password > 6 && confirmPassword > 6 && password !== confirmPassword
? 'Does not match password'
: null;
};
export const processErrors = (error, mapToId) => {
const errors = {};
if (Array.isArray(error?.issues)) {
for (const issue of error.issues) {
const { message, path } = issue;
if (!path.length) {
continue;
}
if (mapToId) {
let value = '';
path.forEach((p, i) => {
const isLast = !path[i + 1];
const formatP = isNumber(p) ? `[${p}]` : p;
value += !value ? formatP : `.${formatP}`;
});
const key = value.replace('label', 'id');
errors[key] = value;
} else {
errors[path[0]] = message;
}
}
}
return errors;
};
export const authValidation = (fields = {}) => {
const { error } = authValidationSchema.safeParse(fields);
return processErrors(error);
};
export const resetValidation = (fields = {}) => {
const { error } = resetValidationSchema.safeParse(fields);
return processErrors(error);
};
export const forgotValidation = (fields = {}) => {
const { error } = forgotValidationSchema.safeParse(fields);
return processErrors(error);
};
export const onboardingValidation = (fields = {}, step) => {
const schema =
step === 1
? onboardingValidationSchema.stepOne
: onboardingValidationSchema.stepTwo;
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const createQuizValidation = (fields = {}) => {
const { error } = createQuizValidationSchema.safeParse(fields);
return processErrors(error);
};
export const askCommunityValidation = (fields = {}) => {
const { error } = askCommunityValidationSchema.safeParse(fields);
return processErrors(error);
};
export const commentInCommunityValidation = (fields = {}) => {
const { error } = commentInCommunityValidationSchema.safeParse(fields);
return processErrors(error);
};
export const getDisableSubmit = ({ password, confirmPassword }) => {
return !!(password && confirmPassword && password !== confirmPassword);
};
export const validateEmailInString = (emailsStr) => {
let error = '';
let hasError = false;
if (!emailsStr) {
return {
hasError: true,
error: 'Enter an email',
emails: [],
};
}
const emails = emailsStr.split(',').map((_email) => {
const email = _email.trim();
if (!validateEmail(email)) {
hasError = true;
error += !error.length ? `Not valid email: ${email}` : `, ${email}`;
}
return email;
});
return {
hasError,
error,
emails,
};
};
@@ -0,0 +1,5 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function takeAttendance(update) {
return supabase.from('group_attendance').upsert(update);
}
+263
View File
@@ -0,0 +1,263 @@
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_complete: lesson.is_complete,
lesson_at: new Date(),
note: lesson.note,
order: lesson.order || index + 1,
public: lesson.public,
slide_url: lesson.slide_url,
title: lesson.title,
video_url: lesson.video_url,
}));
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
*/
+427
View File
@@ -0,0 +1,427 @@
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,
} from '$lib/utils/types';
export async function fetchCourse(
courseId?: Course['id'],
slug?: Course['slug']
) {
let match: { slug?: string; id?: string } = {};
if (slug) {
match.slug = slug;
} else {
match.id = courseId;
}
const { data, error } = await supabase
.from('course')
.select(
`
id,
title,
description,
overview,
logo,
group(*,
members:groupmember(*,
profile(*)
)
),
slug,
cost,
currency,
metadata,
is_certificate_downloadable,
certificate_theme,
lessons:lesson(
id, title,public, lesson_at, call_url, is_complete, order, created_at,
note, video_url, slide_url, call_url, totalExercises:exercise(count),
profile:teacher_id(id, avatar_url, fullname)
),
attendance:group_attendance(*)
`
)
.match(match)
.single();
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 setProfileIdOfGroupMember(
email: string,
profileId: string
) {
const { data, error } = await supabase
.from('groupmember')
.update({
email: null,
profile_id: profileId,
})
.match({ email: email });
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 && data.Key) {
const { publicURL } = supabase.storage
.from('avatars')
.getPublicUrl(filename);
if (!publicURL) return;
logo = publicURL;
}
return logo;
}
export async function updateCourse(
courseId: Course['id'],
avatar: string | undefined,
course: Course
) {
if (avatar) {
const filename = `course/${courseId + Date.now()}.webp`;
const { data } = await supabase.storage
.from('avatars')
.upload(filename, avatar, {
cacheControl: '3600',
upsert: false,
});
if (data && data.Key) {
const { publicURL } = supabase.storage
.from('avatars')
.getPublicUrl(filename);
if (!publicURL) return;
course.logo = 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').delete().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, video_url, slide_url, call_url, totalExercises:exercise(count)`
)
.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 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);
}
// TODO: Add questionnaire type
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);
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 };
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);
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 });
}
+25
View File
@@ -0,0 +1,25 @@
import type { UserLessonDataType } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
export async function fetchUserUpcomingData(
profileId: string | null
): Promise<UserLessonDataType[] | []> {
if (!profileId) {
return [];
}
const { data: userUpcomingData } = await supabase.rpc(
'get_user_upcoming_lessons',
{
profile_id_arg: profileId,
}
);
console.log(`userUpcomingData`, userUpcomingData);
if (!Array.isArray(userUpcomingData)) {
return [];
}
return userUpcomingData;
}
+7
View File
@@ -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 };
}
+177
View File
@@ -0,0 +1,177 @@
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
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';
export async function getOrgTeam(orgId) {
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 });
const team = [];
if (data?.length) {
data.forEach((teamMember) => {
team.push({
id: teamMember.id,
email: teamMember?.profile?.email || teamMember?.email,
verified: teamMember?.verified,
profileId: teamMember?.profile?.id,
role: ROLE_LABEL[teamMember?.role_id] || '',
isAdmin: teamMember?.role_id === ROLE.ADMIN,
});
});
orgTeam.set(team);
}
return {
team,
error,
};
}
export async function getOrganizations(userId) {
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
profile_id,
organization!organizationmember_organization_id_fkey (
id,
name,
siteName,
avatar_url
)
`
)
.eq('profile_id', userId);
const orgsArray = [];
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,
avatar_url: orgMember?.organization?.avatar_url,
memberId: orgMember?.id,
});
});
orgs.set(orgsArray);
currentOrg.set(orgsArray[0]);
}
return {
orgs: orgsArray,
currentOrg: orgsArray[0],
error,
};
}
export async function getOrgAudience(orgId) {
// 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,
organizationmember!inner(
role_id,
organization_id
)
`
)
.eq('organizationmember.organization_id', orgId)
.eq('organizationmember.role_id', 3); // is a student, tutor is 2 and admin is 1
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) {
const { data, error } = await supabase
.from('course')
.select(
`
*,
group!inner(
organization!inner(id, name, siteName, avatar_url)
)
`
)
.eq('group.organization.siteName', siteName)
.eq('is_published', true);
if (error) {
return [];
}
const org = get(data, '[0].group.organization', {
id: '',
name: 'ClassroomIO',
shortName: '',
siteName: '',
avatar_url: '/logo-192.png',
});
currentOrg.set(org);
return data;
}
export async function getCurrentOrg(siteName) {
const { data, error } = await supabase
.from('organization')
.select(
`
id,
name,
siteName,
avatar_url
`
)
.eq('siteName', siteName);
if (error || isEmpty(data)) {
console.error('Error getOrganization', error);
return goto('/404');
}
currentOrg.set(data[0]);
}
+26
View File
@@ -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,67 @@
import { supabase } from '$lib/utils/functions/supabase';
export function fetchSubmissionStatus() {
return supabase.from('submissionstatus').select(`*`);
}
export function fetchSubmissions(course_id) {
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 }) {
return supabase
.from('submission')
.select(
`
id,
answers:question_answer(*),
status_id
`
)
.match({
submitted_by: submittedBy,
exercise_id: exerciseId,
course_id: courseId,
});
}
export async function updateSubmission({ id, status_id, total }, otherArgs) {
const toUpdate = {
status_id,
};
if (typeof total === 'number') {
toUpdate.total = total;
}
return supabase.from('submission').update(toUpdate, otherArgs).match({ id });
}
export async function updateQuestionAnswer(update, match) {
return supabase.from('question_answer').update(update).match(match);
}
+7
View File
@@ -0,0 +1,7 @@
import { writable } from 'svelte/store';
export const appStore = writable({
isDark: false,
isStudentDomain: false,
siteNameFromDomain: '',
});
+3
View File
@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const attendance = writable({});
+48
View File
@@ -0,0 +1,48 @@
import cloneDeep from 'lodash/cloneDeep';
import { writable, derived } from 'svelte/store';
import { allOptions, STEPS, booleanOptions } from '../constants/quiz';
export const orgs = writable([]);
export const currentOrg = writable({
id: '',
name: '',
shortName: '',
siteName: '',
avatar_url: '',
memberId: '',
});
export const orgAudience = writable([]);
export const orgTeam = writable([]);
export const currentOrgPath = derived(
currentOrg,
($currentOrg) => `/org/${$currentOrg.siteName}`
);
// Quiz
export const createQuizModal = writable({
open: false,
openEdit: false,
title: '',
id: null,
});
export const deleteModal = writable({
id: null,
open: false,
isQuestion: false,
});
export const quizesStore = writable([]);
export const quizStore = writable({
uuid: '',
title: '',
questions: [],
timelimit: '10s',
theme: 'standard',
pin: '',
});
export const playQuizStore = writable({
step: STEPS.CONNECT_TO_PLAY,
});
+3
View File
@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const isMobile = writable(false);
+22
View File
@@ -0,0 +1,22 @@
import { writable } from 'svelte/store';
export const user = writable({
openAuthModal: false,
fetchingUser: false,
isLoggedIn: false,
currentSession: null,
expiresAt: 0,
});
export const profile = writable({
id: null,
fullname: '',
avatar_url:
'https://pbs.twimg.com/profile_images/1416443682157473795/dGtFbtht_normal.jpg',
username: 'Elon',
user_id: '',
email: '',
role: '',
goal: '',
source: '',
});
+3
View File
@@ -0,0 +1,3 @@
export type ConfigType = {
supabaseConfig: { bucketPath: string; url: string; anonKey: string };
};
+17
View File
@@ -0,0 +1,17 @@
export type UserLessonDataType = {
call_url: string;
course_id: string;
course_title: string;
lesson_at: string;
lesson_id: string;
lesson_title: string;
is_complete: boolean;
};
type LessonByDayType = {
[dayIndex: number]: UserLessonDataType[];
};
export type LessonsByMonthIndexType = {
[monthIndex: number]: LessonByDayType;
};
+231
View File
@@ -0,0 +1,231 @@
export * from './config';
export * from './dashboard';
interface CourseMetadata {
requirements?: string;
description?: string;
goals?: string;
videoUrl?: '';
showDiscount: false;
discount: 0;
reward?: {
show: boolean;
description: string;
};
instructor?: {
name: string;
role: string;
coursesNo: number;
description: string;
imgUrl: string;
};
reviews?: Array<Review>;
}
// Generated from https://supabase-schema.vercel.app/
export interface Organization {
id: string /* primary key */;
name: any; // type unknown;
}
export interface Profile {
id: string /* primary key */;
fullname: string;
username: string;
avatar_url?: string;
created_at?: string;
updated_at?: string;
email?: any; // type unknown;
can_add_course?: boolean;
role?: any; // type unknown;
goal?: any; // type unknown;
source?: any; // type unknown;
}
export interface Question_type {
id: number /* primary key */;
label: any; // type unknown;
created_at?: string;
updated_at?: string;
typename?: any; // type unknown;
}
export interface Submissionstatus {
id: number /* primary key */;
label: any; // type unknown;
updated_at?: string;
}
export interface Group {
id: string /* primary key */;
name: any; // type unknown;
description?: string;
created_at?: string;
updated_at?: string;
organization_id?: string /* foreign key to organization.id */;
organization?: Organization;
}
export interface Role {
type: any; // type unknown;
description?: any; // type unknown;
id: number /* primary key */;
updated_at?: string;
created_at?: string;
}
export interface Waitinglist {
id: number /* primary key */;
email: any; // type unknown;
created_at?: string;
}
export interface Course {
title: any; // type unknown;
description: any; // type unknown;
overview?: any; // type unknown;
id: string /* primary key */;
created_at?: string;
updated_at?: string;
group_id?: string /* foreign key to group.id */;
is_template?: boolean;
organization_id?: string /* foreign key to organization.id */;
logo?: string;
slug?: any; // type unknown;
metadata?: CourseMetadata;
cost?: number;
currency?: string;
group?: Group;
organization?: Organization;
is_certificate_downloadable?: boolean;
certificate_theme?: string;
}
export interface Groupmember {
id: string /* primary key */;
group_id: string /* foreign key to group.id */;
role_id: number /* foreign key to role.id */;
profile_id?: string /* foreign key to profile.id */;
email?: any; // type unknown;
created_at?: string;
assigned_student_id?: any; // type unknown;
group?: Group;
role?: Role;
profile?: Profile;
}
export interface Organizationmember {
id: number /* primary key */;
organization_id: string /* foreign key to organization.id */;
role_id: number /* foreign key to role.id */;
profile_id: string /* foreign key to profile.id */;
organization?: Organization;
role?: Role;
profile?: Profile;
}
export interface Group_attendance {
id: number /* primary key */;
created_at?: string;
updated_at?: string;
course_id?: string /* foreign key to course.id */;
student_id?: string /* foreign key to groupmember.id */;
is_present?: boolean;
lesson_id: string;
course?: Course;
groupmember?: Groupmember;
}
export interface Lesson {
note?: any; // type unknown;
video_url?: any; // type unknown;
slide_url?: any; // type unknown;
course_id: string /* foreign key to course.id */;
id: string /* primary key */;
created_at?: string;
updated_at?: string;
title: any; // type unknown;
public?: boolean;
lesson_at?: string;
teacher_id?: string /* foreign key to profile.id */;
is_complete?: boolean;
call_url?: string;
order?: number;
course?: Course;
profile?: Profile;
}
export interface Exercise {
title: any; // type unknown;
description?: any; // type unknown;
lesson_id?: string /* foreign key to lesson.id */;
created_at?: string;
updated_at?: string;
id: string /* primary key */;
due_by?: string;
lesson?: Lesson;
}
export interface Question {
id: number /* primary key */;
question_type_id: number /* foreign key to question_type.id */;
title: any; // type unknown;
created_at?: string;
updated_at?: string;
exercise_id: string /* foreign key to exercise.id */;
name?: string;
points?: any; // type unknown;
order?: number;
question_type?: Question_type;
exercise?: Exercise;
}
export interface Submission {
id: string /* primary key */;
reviewer_id?: number;
status_id?: number /* foreign key to submissionstatus.id */;
total?: number;
created_at?: string;
updated_at?: string;
exercise_id: string /* foreign key to exercise.id */;
submitted_by?: string /* foreign key to groupmember.id */;
course_id?: string /* foreign key to course.id */;
submissionstatus?: Submissionstatus;
exercise?: Exercise;
groupmember?: Groupmember;
course?: Course;
}
export interface Option {
id: number /* primary key */;
label: any; // type unknown;
is_correct: boolean;
question_id: number /* foreign key to question.id */;
value?: string;
created_at?: string;
updated_at?: string;
question?: Question;
}
export interface Question_answer {
id: number /* primary key */;
answers?: any[];
question_id: number /* foreign key to question.id */;
open_answer?: string;
group_member_id: string /* foreign key to groupmember.id */;
submission_id?: string /* foreign key to submission.id */;
point?: number;
question?: Question;
groupmember?: Groupmember;
submission?: Submission;
}
export interface Review {
id: number;
hide: boolean;
name: string;
avatar_url: string;
rating: number;
created_at: number;
description: string;
}
+17
View File
@@ -0,0 +1,17 @@
export interface CurrentSessionType {
access_token: string;
expires_at: number;
expires_in: number;
refresh_token: string;
token_type: string;
}
export interface UserType {
openAuthModal: boolean;
fetchingUser: boolean | null;
isLoggedIn: boolean;
currentSession: CurrentSessionType | null;
expiresAt: number;
}
export interface ProfileType {}