feat: course news feed (#136)

* style: Improved font size of the answers in the FAQ section of the landing page

* woking on the anouncement page

* working on the anouncement feature

* fixed some bugs in the emoji and comment

* minor cleanup

* Delete .github/workflows/test-pull-request.yml

This commit was stashed but it seems i accidentally applied on this commit

* updated the navicon for the anouncement

* added role level security

* still working on the emoji click

* everything works now

* cleaned up the code

* cleaned up the code

* cleaned up the code

* cleaned up the code

* refined the formatTime function using dayjs

* refined the formatTime function using dayjs

* typed,the code,created a migration file

* increase editor width/height

* added the confirmation modal and also the edit functionality for a improve UX

* some cleanup

* handle edge case,only tutor or admin can edit or delete

* improved the fetchNews function and also added role level access for deleting the comment

* chore: refactor code

* clean up

* feat: add pinned feed

* feat: remove overview on sidebar

* fix formatting of todo

* feat: prevent async block when sending email

* feat: notify teacher and student of new post/comment

* refactoring

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
Co-authored-by: Rotimi Best <rotimiibitoyeemma@gmail.com>
This commit is contained in:
Em_Dev
2024-02-28 03:00:31 +01:00
committed by GitHub
co-authored by Rotimi Best Rotimi Best
parent 474e83c3d9
commit 5cdb74a6b5
39 changed files with 1141 additions and 275 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ PUBLIC_SUPABASE_URL=http://localhost:54321
PUBLIC_SUPABASE_ANON_KEY=
# Email setup - Optional if you want to get/send emails
SMTP_PASSWORD_HELP=
SMTP_USER_HELP=
SMTP_PASSWORD_NOTIFY=
SMTP_USER_NOTIFY=
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
@@ -46,7 +46,7 @@
return people.find((person) => person.profile_id === profileId);
};
const sendEmail = () => {
const notifyEducator = () => {
const student = getStudent($group.students, $profile.id);
const teacherFullname = $group.tutors[0]?.fullname;
const teacherEmail = $group.tutors[0]?.email;
@@ -118,7 +118,7 @@
$course.id,
getGroupMemberId($group.people, $profile.id)
);
sendEmail();
notifyEducator();
}
}
@@ -14,7 +14,7 @@
import Confetti from '$lib/components/Confetti/index.svelte';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { getTextFromHTML } from '$lib/utils/functions/course';
import { getTextFromHTML } from '$lib/utils/functions/toHtml';
import { writable } from 'svelte/store';
import { Circle3 } from 'svelte-loading-spinners';
@@ -1,22 +1,10 @@
<script lang="ts">
import { lesson } from '$lib/components/Course/components/Lesson/store/lessons';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
function isNoteEmpty(note: string) {
if (!note || typeof note !== 'string') return true;
if (!document) return false;
const dummyDiv = document.createElement('div');
dummyDiv.innerHTML = note;
const rawText = dummyDiv.textContent?.trim();
return rawText === '';
}
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
</script>
{#if !isNoteEmpty($lesson.materials?.note)}
{#if !isHtmlValueEmpty($lesson.materials?.note)}
<HtmlRender className="m-auto">
<svelte:fragment slot="content">
<div>
@@ -1,5 +1,4 @@
<script lang="ts">
import { browser } from '$app/environment';
import isEmpty from 'lodash/isEmpty';
import { useCompletion } from 'ai/svelte';
import MODES from '$lib/utils/constants/mode.js';
@@ -36,8 +35,8 @@
import ComponentVideo from './components/ComponentVideo.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import type { LessonPage } from '$lib/utils/types';
import { getTextFromHTML } from '$lib/utils/functions/course';
import { snackbar } from '$lib/components/Snackbar/store';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
export let mode = MODES.view;
export let prevMode = '';
@@ -80,18 +79,10 @@
return handleUpdateLessonMaterials(_lesson, lessonId);
}
function isNoteEmpty(note: string) {
if (!note || typeof note !== 'string') return true;
if (!document) return false;
return browser ? getTextFromHTML(note) === '' : false;
}
function isMaterialsEmpty(materials: LessonPage['materials']) {
const { slide_url, videos, note } = materials;
return isNoteEmpty(note) && !slide_url && isEmpty(videos);
return isHtmlValueEmpty(note) && !slide_url && isEmpty(videos);
}
function handleSave(prevMode: string) {
@@ -106,7 +97,7 @@
tabs = tabs.map((tab) => {
let badgeValue = 0;
if (tab.value === 1 && !isNoteEmpty(note)) {
if (tab.value === 1 && !isHtmlValueEmpty(note)) {
badgeValue = 1;
} else if (tab.value === 2 && !!slide_url) {
badgeValue = 1;
@@ -8,12 +8,15 @@
import Badge from 'carbon-icons-svelte/lib/Badge.svelte';
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte';
export let name = '';
</script>
{#if name === 'Overview'}
<TableOfContents size={20} class="carbon-icon mr-2" />
{:else if name === 'News Feed'}
<Bullhorn size={20} class="carbon-icon mr-2" />
{:else if name === 'Lessons'}
<IbmWatsonKnowledgeCatalog size={20} class="carbon-icon mr-2" />
{:else if name === 'Attendance'}
@@ -146,7 +146,7 @@
$: {
navItems = [
{
label: 'Overview',
label: 'News Feed',
to: getNavItemRoute($course.id),
hideSortIcon: true
},
@@ -207,8 +207,6 @@
}
];
}
$: console.log({ isStudent: $globalStore.isStudent });
</script>
<aside
@@ -0,0 +1,40 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
export let openDeleteModal = false;
export let deleteFeed = () => {};
async function handleDelete() {
deleteFeed();
openDeleteModal = false;
}
</script>
<Modal
onClose={() => (openDeleteModal = false)}
bind:open={openDeleteModal}
width="w-96"
modalHeading="Delete Feed"
>
<div>
<h1 class="dark:text-white text-lg">Are you sure you want to delete this feed?</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="No"
onClick={() => (openDeleteModal = false)}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label="Yes"
onClick={handleDelete}
/>
</div>
</div>
</Modal>
@@ -0,0 +1,127 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { createNewFeed } from '$lib/utils/services/newsfeed';
import { snackbar } from '$lib/components/Snackbar/store';
import type { Feed, Author } from '$lib/utils/types/feed';
import {
NOTIFICATION_NAME,
triggerSendEmail
} from '$lib/utils/services/notification/notification';
export let author: Author | any = {};
export let courseId = '';
export let onSave = (feed: Feed) => {};
export let onEdit = (id: string, content: string) => {};
export let edit = false;
export let editFeed: Feed;
let newPost = '';
let createdFeed;
const onPost = async () => {
if (!newPost) return;
// Edit state
if (edit) {
onEdit(editFeed.id, newPost);
snackbar.success('Feed edited successfully');
edit = false;
newPost = '';
$isNewFeedModal.open = false;
return;
}
// Create state
try {
const {
response: { data }
} = await createNewFeed({
content: newPost,
author_id: author.id,
course_id: courseId,
reaction: {
smile: [],
thumbsup: [],
thumbsdown: [],
clap: []
}
});
if (!data) return;
createdFeed = data[0];
} catch (error) {
return snackbar.error('An error occurred while creating feed');
}
if (!createdFeed) return;
onSave({
id: createdFeed.id,
content: newPost,
author: {
profile: {
id: author.id,
username: author.username,
fullname: author.fullname,
avatar_url: author.avatar
}
},
created_at: createdFeed.created_at,
comment: [],
reaction: createdFeed.reaction,
isPinned: false
});
snackbar.success('New feed added successfully');
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: createdFeed.author_id,
feedId: createdFeed.id
});
resetEditor();
};
const resetEditor = () => {
newPost = '';
edit = false;
$isNewFeedModal.open = false;
};
$: newPost = edit === true ? editFeed?.content : '';
</script>
<Modal
onClose={resetEditor}
bind:open={$isNewFeedModal.open}
width="w-4/5"
maxWidth="max-w-lg"
modalHeading={edit === true ? 'Edit Post' : 'Make a Post'}
>
<section class="flex flex-col rounded-xl pb-3 h-full w-2/">
<TextEditor
value={newPost}
onChange={(text) => {
newPost = text;
}}
placeholder="Share an update with your students"
maxHeight={400}
/>
<div class="flex items-center justify-end py-2">
<div class="flex gap-2">
<PrimaryButton
label="Cancel"
variant={VARIANTS.OUTLINED}
onClick={() => ($isNewFeedModal.open = false)}
/>
<PrimaryButton label="Post" onClick={onPost} />
</div>
</div>
</section>
</Modal>
@@ -0,0 +1,220 @@
<script lang="ts">
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
import Send from 'carbon-icons-svelte/lib/Send.svelte';
import Chip from '$lib/components/Chip/index.svelte';
import UserMultiple from 'carbon-icons-svelte/lib/UserMultiple.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
import type { Author, Feed } from '$lib/utils/types/feed';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import pluralize from 'pluralize';
import { onMount } from 'svelte';
export let feed: Feed;
export let editFeed: Feed | any = {};
export let author: Author | any = {};
export let edit = false;
export let deleteFeed = (arg: string) => {};
export let deleteComment = (arg: string) => {};
export let addNewComment = (arg1: string, arg2: string, arg3: string) => {};
export let addNewReaction = (arg1: string, arg2: string, arg3: string) => {};
export let onPin = (feedId: Feed['id'], isPinned: Feed['isPinned']) => {};
export let isActive = false;
let comment = '';
let areCommentsExpanded = false;
let isDeleteFeedModal = false;
let reactions = {
smile: '😀',
thumbsup: '👍',
thumbsdown: '👎',
clap: '👏'
};
const openEditFeed = () => {
$isNewFeedModal.open = true;
edit = true;
if (edit === true) {
editFeed = feed;
}
};
const expandComment = () => {
areCommentsExpanded = !areCommentsExpanded;
};
const handleAddNewReaction = (reactionType: string) => {
addNewReaction(reactionType, feed.id, author.id);
};
const handleAddNewComment = (event) => {
if (event.key === 'Enter' || event.type === 'click') {
event.preventDefault();
addNewComment(comment, feed.id, author.id);
comment = '';
}
};
const handleDeleteFeed = () => {
deleteFeed(feed.id);
};
const handleDeleteComment = (id: string) => {
deleteComment(id);
};
onMount(() => {
if (isActive) {
const el = document.getElementById(feed.id);
el?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
}
});
</script>
<div
id={feed.id}
class="flex flex-col gap-5 {isActive
? 'border-2 border-primary-700'
: 'border border-gray-200'} rounded-md mb-7 max-w-3xl"
>
<section>
<div class="p-3 pb-0">
<div class="flex justify-between mb-2">
<span class="flex items-center gap-3">
<div class="w-9 h-9">
<img
src={feed.author?.profile.avatar_url}
alt="users banner"
class="w-full h-full rounded-full object-cover"
/>
</div>
<span>
<p class="text-base font-semibold capitalize">{feed.author?.profile.fullname}</p>
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.created_at)}</p>
</span>
</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu flipped>
<OverflowMenuItem
text={feed.isPinned ? 'Unpin' : 'Pin'}
on:click={() => onPin(feed.id, feed.isPinned)}
/>
<OverflowMenuItem text="Edit" on:click={openEditFeed} />
<OverflowMenuItem danger text="Delete" on:click={() => (isDeleteFeedModal = true)} />
</OverflowMenu>
</RoleBasedSecurity>
</div>
{#if !isHtmlValueEmpty(feed.content)}
<HtmlRender className="text-sm font-medium w-[80%] mb-4">
<svelte:fragment slot="content">
<div>
{@html feed.content}
</div>
</svelte:fragment>
</HtmlRender>
{/if}
</div>
<div class="flex gap-4 px-3">
<div class="flex gap-4 px-3">
{#each Object.keys(feed.reaction) as reactionType}
{#if reactions[reactionType]}
<button
on:click={() => handleAddNewReaction(reactionType)}
class={`flex transition ${
feed.reaction[reactionType].length >= 1 &&
'bg-primary-200 dark:bg-gray-900 dark:text-black px-1 border border-primary-600 rounded-full'
}`}
>
<div class="text-[15px]">{reactions[reactionType]}</div>
{#if feed.reaction[reactionType].length >= 1}
<Chip value={feed.reaction[reactionType].length} className="bg-transparent" />
{/if}
</button>
{/if}
{/each}
</div>
</div>
</section>
<section class="border-t border-gray-200 p-3">
{#if feed.comment.length > 0}
<button
on:click={expandComment}
class="flex flex-row items-center gap-1 -mx-2 px-2 rounded-md"
>
<UserMultiple size={16} />
<p class="text-sm py-2">
{pluralize('comment', feed.comment.length, true)}
</p>
</button>
{/if}
<div>
{#each feed.comment as comment, index}
{#if comment.content && (areCommentsExpanded || index === feed.comment.length - 1)}
<div class="group flex justify-between items-center py-2">
<span class="flex items-center gap-3">
<div class="w-9 h-9">
<img
src={comment.author?.profile?.avatar_url}
alt="users banner"
class="w-full h-full rounded-full object-cover"
/>
</div>
<span>
<div class="flex items-center gap-2">
<p class="text-sm font-medium capitalize">{comment.author?.profile?.fullname}</p>
<p class="text-xs font-medium text-gray-600">{calDateDiff(comment.created_at)}</p>
</div>
<p>{comment.content}</p>
</span>
</span>
{#if comment.author?.profile?.id === $profile.id || $isOrgAdmin}
<OverflowMenu flipped class="hidden group-hover:flex">
<OverflowMenuItem
danger
text="Delete"
on:click={() => handleDeleteComment(comment.id)}
/>
</OverflowMenu>
{/if}
</div>
{/if}
{/each}
</div>
<div class="flex items-center justify-between gap-2">
<div class="w-7 h-7">
<img
src={author.avatar}
alt="users banner"
class="w-full h-full rounded-full object-cover"
/>
</div>
<div class="flex-1">
<input
type="text"
bind:value={comment}
on:keydown={handleAddNewComment}
placeholder="Add class comment"
class="w-full bg-transparent border border-gray-200 rounded-3xl"
/>
</div>
<button on:click={handleAddNewComment}>
<Send size={24} />
</button>
</div>
</section>
<DeleteFeedConfirmation bind:openDeleteModal={isDeleteFeedModal} deleteFeed={handleDeleteFeed} />
</div>
@@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export const isNewFeedModal = writable({
open: false
});
@@ -1,56 +0,0 @@
<script>
import RoleBasedSecurity from '../RoleBasedSecurity/index.svelte';
import PageNav from '../PageNav/index.svelte';
import MODES from '$lib/utils/constants/mode';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PageBody from '../PageBody/index.svelte';
import { course } from './store';
import { updateCourse } from '$lib/utils/services/courses';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
let mode = MODES.view;
let isDirty = false;
async function handleModeChange() {
if (mode === MODES.edit) {
mode = MODES.view;
if (isDirty) {
const { overview, id } = $course;
await updateCourse(id, undefined, { overview });
}
} else {
mode = MODES.edit;
}
}
</script>
<PageNav title="Overview" disableSticky={true}>
<svelte:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="flex items-center">
<PrimaryButton
className="mr-2"
label={mode === MODES.edit ? 'Save' : 'Edit'}
onClick={handleModeChange}
/>
</div>
</RoleBasedSecurity>
</svelte:fragment>
</PageNav>
<PageBody width="max-w-3xl px-3">
{#if mode === MODES.edit}
<TextEditor
value={$course.overview}
onChange={(html) => {
isDirty = true;
$course.overview = html;
}}
placeholder="Write your overview here"
height={700}
/>
{:else}
<HtmlRender className="p-2" content={$course.overview || ''} />
{/if}
</PageBody>
@@ -19,10 +19,8 @@
let prevCourseId = '';
async function onCourseIdChange(courseId = '') {
console.log('courseid changed', courseId);
if (!courseId || prevCourseId === courseId || !browser || $course.id === courseId) return;
console.log('making request', courseId);
isFetching = true;
course.set(defaultCourse);
@@ -6,7 +6,7 @@
import { courses, createCourseModal } from '$lib/components/Courses/store';
import { validateForm } from '$lib/components/Courses/functions';
import { ROLE } from '$lib/utils/constants/roles';
import { addGroupMember } from '$lib/utils/services/courses';
import { addGroupMember, addDefaultNewsFeed } from '$lib/utils/services/courses';
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
@@ -81,12 +81,27 @@
});
// 3. Add group members
await addGroupMember({
const { data } = await addGroupMember({
profile_id: $profile.id,
email: $profile.email,
group_id,
role_id: ROLE.TUTOR
});
// 4. Add default news feed.
if (Array.isArray(data) && data.length) {
const { id: authorId } = data[0];
console.log('Add news feed into course');
await addDefaultNewsFeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
course_id: newCourse[0]?.id,
is_pinned: true,
author_id: authorId
});
}
if (newCourse[0] != null && newCourse[0].id) {
goto(`/courses/${newCourse[0]?.id}`);
}
@@ -1,105 +0,0 @@
<script lang="ts">
import { Container, Head, Hr, Html, Img, Preview, Section, Text } from 'svelte-email';
export let firstName = 'Seun';
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';
const main = {
background: `radial-gradient(
circle at 10% 20%,
rgb(239, 246, 249) 0%,
rgb(206, 239, 253) 90%
)`
};
const container = {
margin: '100px auto',
padding: '40px',
backgroundColor: '#fff',
border: '1px solid #cccccc',
borderRadius: '10px'
};
const logo = {
margin: '0 auto'
};
const paragraph = {
fontFamily,
fontSize: '16px',
lineHeight: '26px'
};
const footerParagraph = {
...paragraph,
margin: '0px'
};
const footer = {
fontFamily,
color: '#8898aa',
fontSize: '12px'
};
</script>
<Html lang="en">
<Head />
<Preview preview="Welcome to ClassroomIO" />
<Section style={main}>
<Container style={container}>
<Img
src="https://app.classroomio.com/logo-512.png"
alt="Svelte logo"
style={logo}
width="50"
height="50"
/>
<Text
style={{
textAlign: 'center',
color: '#1d4ee2',
fontWeight: 'bold',
fontSize: '20px'
}}>ClassroomIO</Text
>
<Text style={paragraph}>Dear {firstName},</Text>
<Text style={paragraph}>Welcome to the vibrant community of ClassroomIO!</Text>
<Text style={paragraph}>
We're thrilled to have you join us in shaping the future of education. Your decision to
embark on this journey with us is deeply appreciated, and we're genuinely excited about the
possibilities that lie ahead.
</Text>
<Text style={paragraph}>
At ClassroomIO, we understand the dedication and passion that educators like you bring to
the table. That's why we've built a platform that's not only designed to make your teaching
experience seamless but also to empower you to create transformative learning moments for
your students.
</Text>
<Text style={paragraph}>
Your choice to use ClassroomIO is a testament to your commitment to innovation in education.
It's educators like you who drive progress and inspire change, and we're here to support you
every step of the way.
</Text>
<Text style={paragraph}>
Our team is working diligently behind the scenes, shipping new features and updates daily.
This means you can expect a platform that's constantly evolving, adapting to your needs, and
staying at the forefront of educational technology.
</Text>
<Text style={paragraph}>
We encourage you to explore ClassroomIO's range of features, from interactive quizzes and
robust course management tools to collaborative forums and AI-integrated lesson planning.
Your feedback is invaluable to us as we work together to refine and enhance these features
to best suit your teaching style and objectives.
</Text>
<Text style={paragraph}>
Once again, welcome to the ClassroomIO family. We're here to help, to listen, and to
celebrate your successes. Get ready to unlock a world of possibilities in education!
</Text>
<Text style={footerParagraph}>Best regards</Text>
<Text style={footerParagraph}>Rotimi Best</Text>
<Text style={footerParagraph}>ClassroomIO</Text>
</Container>
</Section>
</Html>
@@ -17,13 +17,6 @@ export const getStudentInviteLink = (_course: Course, orgSiteName: string, origi
return `${origin}/invite/s/${hash}`;
};
export const getTextFromHTML = (html: string): string => {
const dummyDiv = document.createElement('div');
dummyDiv.innerHTML = html;
return dummyDiv.textContent?.trim() || '';
};
const tagsToReplace: { [k: string]: string } = {
'&': '&amp;',
'<': '&lt;',
@@ -0,0 +1,16 @@
import { browser } from '$app/environment';
export const isHtmlValueEmpty = (html: string) => {
if (!html || typeof html !== 'string') return true;
if (!document || !browser) return false;
return getTextFromHTML(html) === '';
};
export const getTextFromHTML = (html: string): string => {
const dummyDiv = document.createElement('div');
dummyDiv.innerHTML = html;
return dummyDiv.textContent?.trim() || '';
};
@@ -228,7 +228,6 @@ export async function cloneCourse(courseId: string, newTitle: string): Promise<C
-> Note the following information will be copied
3. Things to copy
Overview
Lessons
Exercises - questions, options, everything basically.
@@ -142,6 +142,10 @@ export function addGroupMember(member: any) {
return supabase.from('groupmember').insert(member).select();
}
export function addDefaultNewsFeed(feed) {
return supabase.from('course_newsfeed').insert(feed);
}
export function updatedGroupMember(update: any, match: any) {
return supabase.from('groupmember').update(update).match(match);
}
@@ -0,0 +1,181 @@
import type { Course } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
import type { Reaction, FeedApi } from '$lib/utils/types/feed';
export async function fetchNewsFeeds(courseId?: Course['id']) {
const response = await supabase
.from('course_newsfeed')
.select(
`
id,
created_at,
content,
course_id,
author:groupmember(
profile(
id,
fullname,
avatar_url
)
),
reaction,
is_pinned,
comment:course_newsfeed_comment(
id,
created_at,
author:groupmember( profile(id, fullname, avatar_url) ),
content,
course_newsfeed_id)
`
)
.match({ course_id: courseId })
.order('created_at', { ascending: false })
.returns<FeedApi[]>();
const { data, error } = response;
return { data, error };
}
export async function createNewFeed(post: {
content: string;
author_id: string;
course_id: string;
reaction: Reaction;
}) {
const response = await supabase
.from('course_newsfeed')
.insert({
content: post.content,
author_id: post.author_id,
course_id: post.course_id,
reaction: post.reaction
})
.select();
return { response };
}
export async function handleEditFeed(feedId: string, content: string) {
const response = await supabase
.from('course_newsfeed')
.update({ content: content })
.match({ id: feedId })
.select();
return response;
}
export async function createComment(comment: {
content: string;
author_id: string;
course_newsfeed_id: string;
}) {
const response = await supabase
.from('course_newsfeed_comment')
.insert({
content: comment.content,
author_id: comment.author_id,
course_newsfeed_id: comment.course_newsfeed_id
})
.select();
return { response };
}
export async function toggleFeedIsPinned(feedId: string, isPinned: boolean) {
const response = await supabase
.from('course_newsfeed')
.update({
is_pinned: isPinned
})
.match({ id: feedId });
return { response };
}
export async function deleteNewsFeedComment(commentId: string) {
const response = await supabase.from('course_newsfeed_comment').delete().match({ id: commentId });
return response;
}
export async function deleteNewsFeed(feedId: string) {
await supabase.from('course_newsfeed_comment').delete().match({ course_newsfeed_id: feedId });
const response = await supabase.from('course_newsfeed').delete().match({ id: feedId });
return response;
}
export async function getFeedForNotification(feedId: string, authorId: string) {
const { data, error } = await supabase
.from('course_newsfeed')
.select(
`
content,
author:groupmember(profile(fullname, email)),
course(
id,
title,
group(
organization(siteName, name),
members:groupmember(id, profile(email, fullname))
)
)
`
)
.eq('id', feedId)
.limit(1)
.returns<
{
content: string;
author: {
profile: {
email: string;
fullname: string;
};
};
course: {
id: string;
title: string;
group: {
organization: {
name: string;
siteName: string;
};
members: {
id: string;
profile: {
email: string;
fullname: string;
};
}[];
};
};
}[]
>();
if (error) {
console.error('Failed to get feed', error);
return null;
}
console.log({
data
});
const [feed] = data || [];
if (!feed) return;
return {
id: feedId,
courseId: feed.course.id,
courseTitle: feed.course.title,
teacherName: feed.author?.profile?.fullname,
teacherEmail: feed.author?.profile?.email,
content: feed.content,
org: feed.course.group?.organization,
courseMembers: feed.course?.group?.members
?.filter((member) => member.id !== authorId)
?.map((member) => {
return member.profile;
})
};
}
@@ -9,7 +9,8 @@ export const NOTIFICATION_NAME = {
STUDENT_COURSE_WELCOME: 'STUDENT COURSE WELCOME',
TEACHER_STUDENT_JOINED: 'TEACHER STUDENT JOINED',
SUBMISSION_UPDATE: 'SUBMISSION UPDATE',
EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE'
EXERCISE_SUBMISSION_UPDATE: 'EXERCISE SUBMISSION UPDATE',
NEWSFEED: 'NEWSFEED'
};
const NAME_TO_PATH = {
@@ -22,7 +23,8 @@ const NAME_TO_PATH = {
[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'
[NOTIFICATION_NAME.EXERCISE_SUBMISSION_UPDATE]: '/api/email/course/exercise_submission_update',
[NOTIFICATION_NAME.NEWSFEED]: '/api/email/course/newsfeed'
};
const getAccessToken = async () => {
@@ -30,7 +32,7 @@ const getAccessToken = async () => {
return data.session?.access_token || '';
};
export const triggerSendEmail = async (name: string, body: { [k: string]: any }) => {
export const triggerSendEmail = async (name: string, body: { [k: string]: unknown }) => {
try {
const accessToken = await getAccessToken();
const path = NAME_TO_PATH[name];
@@ -51,7 +53,6 @@ export const triggerSendEmail = async (name: string, body: { [k: string]: any })
console.log(response);
throw new Error(await response.text());
}
const result = await response.json();
} catch (error) {
console.error(name, error);
}
@@ -4,21 +4,10 @@ import {
SMTP_HOST,
SMTP_USER,
SMTP_PASSWORD,
SMTP_USER_HELP,
SMTP_PASSWORD_HELP
SMTP_USER_NOTIFY,
SMTP_PASSWORD_NOTIFY
} 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> => {
@@ -28,8 +17,8 @@ const getTransporter = async (isPersonal?: boolean): Promise<boolean> => {
port: 465,
secure: true,
auth: {
user: isPersonal ? SMTP_USER : SMTP_USER_HELP,
pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_HELP
user: isPersonal ? SMTP_USER : SMTP_USER_NOTIFY,
pass: isPersonal ? SMTP_PASSWORD : SMTP_PASSWORD_NOTIFY
}
});
@@ -60,14 +49,22 @@ export const sendEmail = async ({
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
});
try {
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);
console.log('Message sent: %s', info.messageId);
return info;
} catch (error) {
console.error({ error });
return error;
}
};
@@ -0,0 +1,46 @@
export interface Author {
id: string;
username: string;
fullname: string;
avatar_url: string;
}
export interface Comment {
id: string;
created_at: string;
content: string;
author: {
profile: Author;
};
}
export interface Reaction {
smile: string[];
thumbsup: string[];
thumbsdown: string[];
clap: string[];
}
export interface FeedApi {
id: string;
content: string;
author: {
profile: Author;
};
created_at: string;
comment: Comment[];
reaction: Reaction[];
is_pinned: false;
}
export interface Feed {
id: string;
content: string;
author: {
profile: Author;
};
created_at: string;
comment: Comment[];
reaction: Reaction[];
isPinned: boolean;
}
@@ -24,12 +24,12 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to,
subject: `[Submitted]: ${exerciseTitle}`,
content
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -0,0 +1,85 @@
import { json } from '@sveltejs/kit';
import { getSupabase } from '$lib/utils/functions/supabase';
import { sendEmail } from '$lib/utils/services/notification/send';
import { getFeedForNotification } from '$lib/utils/services/newsfeed/index';
const supabase = getSupabase();
const sendEmailNotification = async (feedId: string, authorId: string, comment?: string) => {
const feed = await getFeedForNotification(feedId, authorId);
if (!feed) return;
const postLink = `https://${feed.org.siteName}.classroomio.com/courses/${feed.courseId}?feedId=${feed.id}`;
if (comment) {
// send only to teacher
sendEmail({
from: `"${feed.org.name} - ClassroomIO" <notify@classroomio.com>`,
to: feed.teacherEmail,
subject: `[${feed.courseTitle}] - News feed comment`,
content: `
<p>A student left you a comment on your newsfeed post</p>
<div style="font-style: italic; margin-top: 10px;">${comment}</div>
<div>
<a class="button" href="${postLink}">View comment</a>
</div>
`
}).then((info) => console.log('Email sent:', info));
// dont continue
return;
}
// else send to everyone except the author of the post
Promise.all(
feed.courseMembers.map((member) => {
return sendEmail({
from: `"${feed.org.name} - ClassroomIO" <notify@classroomio.com>`,
to: member.email,
replyTo: feed.teacherEmail,
subject: `[${feed.courseTitle}] - New post in course`,
content: `
<p>${feed.teacherName} made a post in a course you are taking: ${feed.courseTitle}.</p>
<div style="font-style: italic; margin-top: 10px;">${feed.content}</div>
<div>
<a class="button" href="${postLink}">View post</a>
</div>
`
});
})
).then((emails) => console.log(`Sent emails to ${emails.length} students`));
};
export async function POST({ request }) {
const { authorId, feedId, comment } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/newsfeed');
if (!authorId || !feedId || !accessToken) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
sendEmailNotification(feedId, authorId, comment);
// TODO: Support sending to other platforms like telegram and discord
// sendDiscordNotification(); sendTelegramNotification();
return json({
success: true,
message: 'Email sent'
});
}
@@ -32,8 +32,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} - ClassroomIO" <help@classroomio.com>`,
sendEmail({
from: `"${orgName} - ClassroomIO" <notify@classroomio.com>`,
to,
replyTo: teacherEmail,
subject: `[${courseName}] One more step left`,
@@ -44,7 +44,7 @@ export async function POST({ request }) {
<p>Talk to you soon and see you in class.</p>
<p>${orgName}</p>
`
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -26,8 +26,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to,
subject: `${orgName} - Welcome to Class 🎉`,
content: `
@@ -38,7 +38,7 @@ export async function POST({ request }) {
<p>Cheers,</p>
<p>${orgName}</p>
`
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -24,12 +24,12 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to,
subject: 'Submission Update',
content
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -31,8 +31,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `ClassroomIO" <help@classroomio.com>`,
sendEmail({
from: `ClassroomIO" <notify@classroomio.com>`,
to,
subject: `[${courseName}] Request to Join Course!`,
content: `
@@ -44,7 +44,7 @@ export async function POST({ request }) {
Email: ${studentEmail}
</p>
`
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -25,8 +25,8 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
from: `"ClassroomIO" <help@classroomio.com>`,
sendEmail({
from: `"ClassroomIO" <notify@classroomio.com>`,
to,
subject: `[${courseName}] You've got a new student 🎉!`,
content: `
@@ -36,7 +36,7 @@ export async function POST({ request }) {
<p>If you run into any issues, please don’t fail to reach out to us, we’d love to make your teaching
experience as easy as possible.</p>
`
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -28,8 +28,8 @@ export async function POST({ request }) {
const origin = request.headers.get('origin');
const inviteLink = `${origin}/org/${orgSiteName}/courses`;
await sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <help@classroomio.com>`,
sendEmail({
from: `"${orgName} (via ClassroomIO.com)" <notify@classroomio.com>`,
to,
subject: `You have been invited to a course in ${orgName}!`,
content: `
@@ -40,7 +40,7 @@ export async function POST({ request }) {
<a class="button" href="${inviteLink}">Open Dashboard</a>
</div>
`
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -12,7 +12,7 @@ export async function POST({ request }) {
const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/invite', body);
if (!org || !Object.keys(org).length || !email) {
if (!org || !Object.keys(org).length || !email || !accessToken) {
return json(
{ success: false, message: 'Org data and Teacher name are required' },
{ status: 400 }
@@ -42,7 +42,7 @@ export async function POST({ request }) {
const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
console.log('inviteData', inviteData);
await sendEmail({
sendEmail({
to: email,
subject: `Join ${name} on ClassroomIO 😃`,
content: `
@@ -53,7 +53,7 @@ export async function POST({ request }) {
</div>
`,
isPersonalEmail: true
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -9,7 +9,7 @@ export async function POST({ request }) {
const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/welcome', to, name);
if (!to || !name) {
if (!to || !name || !accessToken) {
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
}
@@ -25,7 +25,7 @@ export async function POST({ request }) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
await sendEmail({
sendEmail({
to,
subject: 'Thank you so so so much for choosing ClassroomIO!',
content: `
@@ -48,7 +48,7 @@ export async function POST({ request }) {
</div>
`,
isPersonalEmail: true
});
}).then((info) => console.log('Email sent:', info));
return json({
success: true,
@@ -1,10 +1,279 @@
<script>
import Overview from '$lib/components/Course/index.svelte';
<script lang="ts">
import { page } from '$app/stores';
import PinFilled from 'carbon-icons-svelte/lib/PinFilled.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import { group } from '$lib/components/Course/store';
import { profile } from '$lib/utils/store/user';
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
import NewsFeedCard from '$lib/components/Course/components/NewsFeed/NewsFeedCard.svelte';
import NewFeedModal from '$lib/components/Course/components/NewsFeed/NewFeedModal.svelte';
import {
createComment,
deleteNewsFeedComment,
deleteNewsFeed,
fetchNewsFeeds,
handleEditFeed,
toggleFeedIsPinned
} from '$lib/utils/services/newsfeed';
import Box from '$lib/components/Box/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import type { Feed } from '$lib/utils/types/feed';
import {
NOTIFICATION_NAME,
triggerSendEmail
} from '$lib/utils/services/notification/notification';
export let data;
let createdComment;
let edit = false;
let editFeed: Feed;
let author = {
id: '',
username: '',
fullname: '',
avatar: ''
};
let newsFeed: Feed[] = [];
let query = new URLSearchParams($page.url.search);
let feedId = query.get('feedId') || '';
const onEdit = (id: string, content: string) => {
handleEditFeed(id, content);
newsFeed = newsFeed.map((feed) => {
if (feed.id === id) {
return { ...feed, content: content };
}
return feed;
});
};
const deleteComment = (id: string) => {
deleteNewsFeedComment(id);
newsFeed = newsFeed.flatMap((feed) => ({
...feed,
comment: feed.comment.filter((comment) => comment.id !== id)
}));
return snackbar.success('Comment Deleted');
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const reactedFeed = newsFeed.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
let reactedAuthorIds: string[] = reactedFeed.reaction[reactionType];
if (reactedAuthorIds.includes(authorId)) {
reactedAuthorIds = reactedAuthorIds.filter(
(reactionAuthorId) => reactionAuthorId !== authorId
);
} else {
reactedAuthorIds = [...reactedAuthorIds, authorId];
}
reactedFeed.reaction = {
...reactedFeed.reaction,
[reactionType]: reactedAuthorIds
};
const response = await supabase
.from('course_newsfeed')
.update({
reaction: reactedFeed.reaction
})
.eq('id', feedId);
if (response.error) {
return snackbar.error('An error occurred while reacting to news feed');
}
newsFeed = newsFeed.map((feed) => {
if (feed.id === feedId) {
feed.reaction = reactedFeed.reaction;
}
return feed;
});
};
const addNewComment = async (comment: string, feedId: string, authorId: string) => {
const { response } = await createComment({
content: comment,
author_id: authorId,
course_newsfeed_id: feedId
});
if (response.error) {
return snackbar.error('An error occurred while creating comment');
}
if (!response.data) return;
createdComment = response?.data[0];
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
authorId: author.id,
feedId: feedId,
comment
});
newsFeed = newsFeed.map((feed) => {
if (feed.id === feedId) {
const newComment = {
id: createdComment.id,
author: {
profile: {
id: author.id,
username: author.username,
fullname: author.fullname,
avatar_url: author.avatar
}
},
created_at: createdComment.created_at,
content: comment
};
snackbar.success('Comment added');
return {
...feed,
comment: [...feed.comment, { ...newComment }]
};
}
return feed;
});
};
const onPin = async (feedId, isPinned) => {
const newIsPinned = !isPinned;
const { response } = await toggleFeedIsPinned(feedId, newIsPinned);
if (response.error) {
return snackbar.error('Failed to toggle pinned feed');
}
newsFeed = newsFeed.map((feed) => {
if (feed.id === feedId) {
snackbar.success(`${newIsPinned ? 'Pinned' : 'Unpinned'} Successfully`);
feed.isPinned = newIsPinned;
return feed;
}
return feed;
});
};
const deleteFeed = (id: string) => {
deleteNewsFeed(id);
const deletedFeed = newsFeed.filter((feed) => feed.id !== id);
snackbar.success('Feed deleted successfully');
newsFeed = deletedFeed;
};
const initNewsFeed = async (courseId: string) => {
if (!courseId) return;
const { data, error } = await fetchNewsFeeds(courseId);
if (error) {
snackbar.error('Failed to fetch news feeds');
return;
}
if (!data) return;
newsFeed = data.map((feedItem) => ({
...feedItem,
isPinned: feedItem.is_pinned
}));
};
function setAuthor(groups, profileId) {
const currentGroupMember = groups.people.find((person) => person.profile_id === profileId);
author = {
id: currentGroupMember?.id || '',
username: $profile.username || '',
fullname: $profile.fullname || '',
avatar: $profile.avatar_url || ''
};
}
$: initNewsFeed(data.courseId);
$: setAuthor($group, $profile.id);
$: newsFeed = newsFeed.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));
</script>
<CourseContainer bind:courseId={data.courseId}>
<Overview />
<PageNav title="News Feed" disableSticky={true}>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<PrimaryButton className="mr-2" label="Add" onClick={() => ($isNewFeedModal.open = true)} />
</RoleBasedSecurity>
</slot:fragment>
</PageNav>
<PageBody width="max-w-4xl px-3">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<NewFeedModal
courseId={data.courseId}
{author}
bind:edit
bind:editFeed
onSave={(newFeed) => {
newsFeed = [newFeed, ...newsFeed];
}}
{onEdit}
/>
</RoleBasedSecurity>
{#if !newsFeed.length}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">No post yet</h2>
<p class="text-sm text-center text-slate-500">
Make a post to your class. Start by clicking on the Add button.
</p>
</div>
</Box>
{:else}
{#each newsFeed as feed}
{#if feed.isPinned}
<div class="flex items-center gap-2 mb-3">
<PinFilled size={16} />
<p class="text-sm">Pinned</p>
</div>
{/if}
<NewsFeedCard
{feed}
{deleteFeed}
{addNewComment}
{deleteComment}
{addNewReaction}
{onPin}
{author}
bind:edit
bind:editFeed
isActive={feedId === feed.id}
/>
{/each}
{/if}
</PageBody>
</CourseContainer>
@@ -97,7 +97,7 @@
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
}
const sendEmail = (submissionData) => {
const notifyStudent = (submissionData) => {
maxMark = getMaxPoints(submissionData?.questions);
totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
@@ -155,7 +155,7 @@
status_id: itemToWithNewStatus.statusId
};
sendEmail(submissionIdData[itemToWithNewStatus.id]);
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
updateSubmission({
id: itemToWithNewStatus.id,
@@ -219,7 +219,7 @@
status_id: itemToWithNewStatus.statusId
};
sendEmail(submissionIdData[itemToWithNewStatus.id]);
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
// Update backend
updateSubmission({
id: itemToWithNewStatus.id,
+3 -3
View File
@@ -4944,7 +4944,7 @@ packages:
/@vue/compiler-core@3.3.8:
resolution: {integrity: sha512-hN/NNBUECw8SusQvDSqqcVv6gWq8L6iAktUR0UF3vGu2OhzRqcOiAno0FmBJWwxhYEXRlQJT5XnoKsVq1WZx4g==}
dependencies:
'@babel/parser': 7.23.4
'@babel/parser': 7.23.9
'@vue/shared': 3.3.8
estree-walker: 2.0.2
source-map-js: 1.0.2
@@ -4960,7 +4960,7 @@ packages:
/@vue/compiler-sfc@3.3.8:
resolution: {integrity: sha512-WMzbUrlTjfYF8joyT84HfwwXo+8WPALuPxhy+BZ6R4Aafls+jDBnSz8PDz60uFhuqFbl3HxRfxvDzrUf3THwpA==}
dependencies:
'@babel/parser': 7.23.4
'@babel/parser': 7.23.9
'@vue/compiler-core': 3.3.8
'@vue/compiler-dom': 3.3.8
'@vue/compiler-ssr': 3.3.8
@@ -4982,7 +4982,7 @@ packages:
/@vue/reactivity-transform@3.3.8:
resolution: {integrity: sha512-49CvBzmZNtcHua0XJ7GdGifM8GOXoUMOX4dD40Y5DxI3R8OUhMlvf2nvgUAcPxaXiV5MQQ1Nwy09ADpnLQUqRw==}
dependencies:
'@babel/parser': 7.23.4
'@babel/parser': 7.23.9
'@vue/compiler-core': 3.3.8
'@vue/shared': 3.3.8
estree-walker: 2.0.2
@@ -0,0 +1,48 @@
create table "public"."course_newsfeed" (
"created_at" timestamp with time zone not null default now(),
"author_id" uuid,
"content" text,
"id" uuid not null default gen_random_uuid(),
"course_id" uuid,
"reaction" jsonb default '{"clap": [], "smile": [], "thumbsup": [], "thumbsdown": []}'::jsonb,
"is_pinned" boolean not null default false
);
create table "public"."course_newsfeed_comment" (
"created_at" timestamp with time zone not null default now(),
"author_id" uuid,
"content" text,
"id" bigint generated by default as identity not null,
"course_newsfeed_id" uuid
);
CREATE UNIQUE INDEX course_newsfeed_comment_id_key ON public.course_newsfeed_comment USING btree (id);
CREATE UNIQUE INDEX course_newsfeed_comment_pkey ON public.course_newsfeed_comment USING btree (id);
CREATE UNIQUE INDEX course_newsfeed_pkey ON public.course_newsfeed USING btree (id);
alter table "public"."course_newsfeed" add constraint "course_newsfeed_pkey" PRIMARY KEY using index "course_newsfeed_pkey";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_pkey" PRIMARY KEY using index
"course_newsfeed_comment_pkey";
alter table "public"."course_newsfeed" add constraint "course_newsfeed_author_id_fkey" FOREIGN KEY (author_id) REFERENCES groupmember(id) not valid;
alter table "public"."course_newsfeed" validate constraint "course_newsfeed_author_id_fkey";
alter table "public"."course_newsfeed" add constraint "course_newsfeed_course_id_fkey" FOREIGN KEY (course_id) REFERENCES course(id) not valid;
alter table "public"."course_newsfeed" validate constraint "course_newsfeed_course_id_fkey";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_id_key" UNIQUE using index "course_newsfeed_comment_id_key";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_author_id_fkey" FOREIGN KEY (author_id) REFERENCES groupmember(id) not valid;
alter table "public"."course_newsfeed_comment" validate constraint "course_newsfeed_comment_author_id_fkey";
alter table "public"."course_newsfeed_comment" add constraint "course_newsfeed_comment_course_newsfeed_id_fkey" FOREIGN KEY (course_newsfeed_id) REFERENCES course_newsfeed(id) not valid;
alter table "public"."course_newsfeed_comment" validate constraint "course_newsfeed_comment_course_newsfeed_id_fkey";
+7 -4
View File
@@ -12,15 +12,18 @@ Features that would implemented internally with possible collaboration with the
### Backlog
- [] Fix video upload functionality and make it open source
- [] move email sending to defer.run
- [] rrefactor email sending
- [] don't send email address from FE
- [] don't await sending via nodemailer
- [] implement multi language
- [] Implement supabase RLS
- [] Integrate lemonsqueezy
- [] implement supabase RLS
- [] integrate lemonsqueezy
### In progress
- [] Course announcements
- [] Create basic API for ClassroomIO
- [] Link shorterner API
- [] Fix video upload functionality and make it open source
### Done