Merge pull request #307 from learnhouse/feat/fix-bugs

Fix Various Bugs
This commit is contained in:
Badr B. 2024-08-29 20:23:44 +02:00 committed by GitHub
commit 1bc078bd0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3053 additions and 818 deletions

View file

@ -0,0 +1,47 @@
"""Org IDs Cascades
Revision ID: 83b6d9d6f57a
Revises: cb2029aadc2d
Create Date: 2024-08-29 19:38:10.022100
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = '83b6d9d6f57a'
down_revision: Union[str, None] = 'cb2029aadc2d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('chapter_org_id_fkey', 'chapter', type_='foreignkey')
op.drop_constraint('chapteractivity_org_id_fkey', 'chapteractivity', type_='foreignkey')
op.create_foreign_key(None, 'chapteractivity', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('collectioncourse_org_id_fkey', 'collectioncourse', type_='foreignkey')
op.create_foreign_key(None, 'collectioncourse', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('coursechapter_org_id_fkey', 'coursechapter', type_='foreignkey')
op.create_foreign_key(None, 'coursechapter', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('courseupdate_org_id_fkey', 'courseupdate', type_='foreignkey')
op.create_foreign_key(None, 'courseupdate', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'courseupdate', type_='foreignkey')
op.create_foreign_key('courseupdate_org_id_fkey', 'courseupdate', 'organization', ['org_id'], ['id'])
op.drop_constraint(None, 'coursechapter', type_='foreignkey')
op.create_foreign_key('coursechapter_org_id_fkey', 'coursechapter', 'organization', ['org_id'], ['id'])
op.drop_constraint(None, 'collectioncourse', type_='foreignkey')
op.create_foreign_key('collectioncourse_org_id_fkey', 'collectioncourse', 'organization', ['org_id'], ['id'])
op.drop_constraint(None, 'chapteractivity', type_='foreignkey')
op.create_foreign_key('chapteractivity_org_id_fkey', 'chapteractivity', 'organization', ['org_id'], ['id'])
op.create_foreign_key('chapter_org_id_fkey', 'chapter', 'organization', ['org_id'], ['id'])
# ### end Alembic commands ###

View file

@ -0,0 +1,153 @@
"""Cloud Changes
Revision ID: cb2029aadc2d
Revises: d8bc71595932
Create Date: 2024-08-29 19:24:34.859544
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = 'cb2029aadc2d'
down_revision: Union[str, None] = 'd8bc71595932'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('activity', 'course_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
op.drop_constraint('activity_org_id_fkey', 'activity', type_='foreignkey')
op.create_foreign_key(None, 'activity', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('block_org_id_fkey', 'block', type_='foreignkey')
op.create_foreign_key(None, 'block', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.alter_column('collection', 'org_id',
existing_type=sa.INTEGER(),
type_=sa.BigInteger(),
existing_nullable=True)
op.drop_constraint('collection_org_id_fkey', 'collection', type_='foreignkey')
op.create_foreign_key(None, 'collection', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.alter_column('collectioncourse', 'collection_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
op.alter_column('collectioncourse', 'course_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
op.drop_constraint('course_org_id_fkey', 'course', type_='foreignkey')
op.create_foreign_key(None, 'course', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.alter_column('coursechapter', 'course_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
op.alter_column('coursechapter', 'chapter_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
op.drop_constraint('resourceauthor_user_id_fkey', 'resourceauthor', type_='foreignkey')
op.create_foreign_key(None, 'resourceauthor', 'user', ['user_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('role_org_id_fkey', 'role', type_='foreignkey')
op.create_foreign_key(None, 'role', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.drop_constraint('trailrun_user_id_fkey', 'trailrun', type_='foreignkey')
op.drop_constraint('trailrun_course_id_fkey', 'trailrun', type_='foreignkey')
op.drop_constraint('trailrun_trail_id_fkey', 'trailrun', type_='foreignkey')
op.drop_constraint('trailrun_org_id_fkey', 'trailrun', type_='foreignkey')
op.create_foreign_key(None, 'trailrun', 'user', ['user_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailrun', 'course', ['course_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailrun', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailrun', 'trail', ['trail_id'], ['id'], ondelete='CASCADE')
op.alter_column('trailstep', 'trailrun_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
op.drop_constraint('trailstep_activity_id_fkey', 'trailstep', type_='foreignkey')
op.drop_constraint('trailstep_org_id_fkey', 'trailstep', type_='foreignkey')
op.drop_constraint('trailstep_course_id_fkey', 'trailstep', type_='foreignkey')
op.drop_constraint('trailstep_user_id_fkey', 'trailstep', type_='foreignkey')
op.drop_constraint('trailstep_trail_id_fkey', 'trailstep', type_='foreignkey')
op.create_foreign_key(None, 'trailstep', 'organization', ['org_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailstep', 'user', ['user_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailstep', 'trail', ['trail_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailstep', 'course', ['course_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'trailstep', 'activity', ['activity_id'], ['id'], ondelete='CASCADE')
op.alter_column('userorganization', 'org_id',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('userorganization', 'org_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
op.drop_constraint(None, 'trailstep', type_='foreignkey')
op.drop_constraint(None, 'trailstep', type_='foreignkey')
op.drop_constraint(None, 'trailstep', type_='foreignkey')
op.drop_constraint(None, 'trailstep', type_='foreignkey')
op.drop_constraint(None, 'trailstep', type_='foreignkey')
op.create_foreign_key('trailstep_trail_id_fkey', 'trailstep', 'trail', ['trail_id'], ['id'])
op.create_foreign_key('trailstep_user_id_fkey', 'trailstep', 'user', ['user_id'], ['id'])
op.create_foreign_key('trailstep_course_id_fkey', 'trailstep', 'course', ['course_id'], ['id'])
op.create_foreign_key('trailstep_org_id_fkey', 'trailstep', 'organization', ['org_id'], ['id'])
op.create_foreign_key('trailstep_activity_id_fkey', 'trailstep', 'activity', ['activity_id'], ['id'])
op.alter_column('trailstep', 'trailrun_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
op.drop_constraint(None, 'trailrun', type_='foreignkey')
op.drop_constraint(None, 'trailrun', type_='foreignkey')
op.drop_constraint(None, 'trailrun', type_='foreignkey')
op.drop_constraint(None, 'trailrun', type_='foreignkey')
op.create_foreign_key('trailrun_org_id_fkey', 'trailrun', 'organization', ['org_id'], ['id'])
op.create_foreign_key('trailrun_trail_id_fkey', 'trailrun', 'trail', ['trail_id'], ['id'])
op.create_foreign_key('trailrun_course_id_fkey', 'trailrun', 'course', ['course_id'], ['id'])
op.create_foreign_key('trailrun_user_id_fkey', 'trailrun', 'user', ['user_id'], ['id'])
op.drop_constraint(None, 'role', type_='foreignkey')
op.create_foreign_key('role_org_id_fkey', 'role', 'organization', ['org_id'], ['id'])
op.drop_constraint(None, 'resourceauthor', type_='foreignkey')
op.create_foreign_key('resourceauthor_user_id_fkey', 'resourceauthor', 'user', ['user_id'], ['id'])
op.alter_column('coursechapter', 'chapter_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
op.alter_column('coursechapter', 'course_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
op.drop_constraint(None, 'course', type_='foreignkey')
op.create_foreign_key('course_org_id_fkey', 'course', 'organization', ['org_id'], ['id'])
op.alter_column('collectioncourse', 'course_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
op.alter_column('collectioncourse', 'collection_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
op.drop_constraint(None, 'collection', type_='foreignkey')
op.create_foreign_key('collection_org_id_fkey', 'collection', 'organization', ['org_id'], ['id'])
op.alter_column('collection', 'org_id',
existing_type=sa.BigInteger(),
type_=sa.INTEGER(),
existing_nullable=True)
op.drop_constraint(None, 'block', type_='foreignkey')
op.create_foreign_key('block_org_id_fkey', 'block', 'organization', ['org_id'], ['id'])
op.drop_constraint(None, 'activity', type_='foreignkey')
op.create_foreign_key('activity_org_id_fkey', 'activity', 'organization', ['org_id'], ['id'])
op.alter_column('activity', 'course_id',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
# ### end Alembic commands ###

View file

@ -11,6 +11,8 @@ class CollectionCourse(SQLModel, table=True):
course_id: int = Field( course_id: int = Field(
sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))
) )
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -1,5 +1,5 @@
from typing import Optional from typing import Optional
from sqlalchemy import BigInteger, Column, ForeignKey from sqlalchemy import BigInteger, Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
class ChapterActivity(SQLModel, table=True): class ChapterActivity(SQLModel, table=True):
@ -8,6 +8,8 @@ class ChapterActivity(SQLModel, table=True):
chapter_id: int = Field(sa_column=Column(BigInteger, ForeignKey("chapter.id", ondelete="CASCADE"))) chapter_id: int = Field(sa_column=Column(BigInteger, ForeignKey("chapter.id", ondelete="CASCADE")))
activity_id: int = Field(sa_column=Column(BigInteger, ForeignKey("activity.id", ondelete="CASCADE"))) activity_id: int = Field(sa_column=Column(BigInteger, ForeignKey("activity.id", ondelete="CASCADE")))
course_id : int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE"))) course_id : int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE")))
org_id : int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -9,7 +9,9 @@ class ChapterBase(SQLModel):
name: str name: str
description: Optional[str] = "" description: Optional[str] = ""
thumbnail_image: Optional[str] = "" thumbnail_image: Optional[str] = ""
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column("org_id", ForeignKey("organization.id", ondelete="CASCADE"))
)
course_id: int = Field( course_id: int = Field(
sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE")) sa_column=Column("course_id", ForeignKey("course.id", ondelete="CASCADE"))
) )

View file

@ -12,6 +12,8 @@ class CourseChapter(SQLModel, table=True):
chapter_id: int = Field( chapter_id: int = Field(
sa_column=Column(Integer, ForeignKey("chapter.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("chapter.id", ondelete="CASCADE"))
) )
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -12,7 +12,9 @@ class CourseUpdate(SQLModel, table=True):
sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE")) sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))
) )
linked_activity_uuids: Optional[str] = Field(default=None) linked_activity_uuids: Optional[str] = Field(default=None)
org_id: int = Field(default=None, foreign_key="organization.id") org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: str creation_date: str
update_date: str update_date: str

View file

@ -34,6 +34,7 @@ from src.services.orgs.orgs import (
get_organization, get_organization,
get_organization_by_slug, get_organization_by_slug,
get_orgs_by_user, get_orgs_by_user,
get_orgs_by_user_admin,
update_org, update_org,
update_org_logo, update_org_logo,
update_org_signup_mechanism, update_org_signup_mechanism,
@ -329,6 +330,22 @@ async def api_user_orgs(
) )
@router.get("/user_admin/page/{page}/limit/{limit}")
async def api_user_orgs_admin(
request: Request,
page: int,
limit: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> List[OrganizationRead]:
"""
Get orgs by page and limit by current user
"""
return await get_orgs_by_user_admin(
request, db_session, str(current_user.id), page, limit
)
@router.put("/{org_id}") @router.put("/{org_id}")
async def api_update_org( async def api_update_org(
request: Request, request: Request,

View file

@ -330,7 +330,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess
# Org Config # Org Config
org_config = OrganizationConfigBase( org_config = OrganizationConfigBase(
config_version="1.0", config_version="1.1",
general=OrgGeneralConfig( general=OrgGeneralConfig(
enabled=True, enabled=True,
color="normal", color="normal",

View file

@ -377,7 +377,7 @@ def send_invite_email(
<body> <body>
<p>Hello {email}</p> <p>Hello {email}</p>
<p>You have been invited to {org.name} by @{user.username}. Your invite code is {invite['invite_code']}.</p> <p>You have been invited to {org.name} by @{user.username}. Your invite code is {invite['invite_code']}.</p>
<p>Click <a href="{org.slug}.learnhouse.io/signup?inviteCode={invite['invite_code']}">here</a> to sign up.</p> <p>Click <a href="{org.slug}.learnhouse.io/signup?orgslug={org.slug}&inviteCode={invite['invite_code']}">here</a> to sign up.</p>
<p>Thank you</p> <p>Thank you</p>
</body> </body>
</html> </html>

View file

@ -158,7 +158,7 @@ async def create_org(
db_session.refresh(user_org) db_session.refresh(user_org)
org_config = org_config = OrganizationConfigBase( org_config = org_config = OrganizationConfigBase(
config_version="1.0", config_version="1.",
general=OrgGeneralConfig( general=OrgGeneralConfig(
enabled=True, enabled=True,
color="normal", color="normal",
@ -179,10 +179,7 @@ async def create_org(
collaboration=CollaborationOrgConfig(enabled=True, limit=0), collaboration=CollaborationOrgConfig(enabled=True, limit=0),
api=APIOrgConfig(enabled=True, limit=0), api=APIOrgConfig(enabled=True, limit=0),
), ),
cloud=OrgCloudConfig( cloud=OrgCloudConfig(plan="free", custom_domain=False),
plan='free',
custom_domain=False
)
) )
org_config = json.loads(org_config.json()) org_config = json.loads(org_config.json())
@ -463,7 +460,7 @@ async def delete_org(
return {"detail": "Organization deleted"} return {"detail": "Organization deleted"}
async def get_orgs_by_user( async def get_orgs_by_user_admin(
request: Request, request: Request,
db_session: Session, db_session: Session,
user_id: str, user_id: str,
@ -507,6 +504,47 @@ async def get_orgs_by_user(
return orgsWithConfig return orgsWithConfig
async def get_orgs_by_user(
request: Request,
db_session: Session,
user_id: str,
page: int = 1,
limit: int = 10,
) -> list[OrganizationRead]:
statement = (
select(Organization)
.join(UserOrganization)
.where(UserOrganization.user_id == user_id)
.offset((page - 1) * limit)
.limit(limit)
)
# Get organizations where the user is an admin
result = db_session.exec(statement)
orgs = result.all()
orgsWithConfig = []
for org in orgs:
# Get org config
statement = select(OrganizationConfig).where(
OrganizationConfig.org_id == org.id
)
result = db_session.exec(statement)
org_config = result.first()
config = OrganizationConfig.model_validate(org_config) if org_config else {}
org = OrganizationRead(**org.model_dump(), config=config)
orgsWithConfig.append(org)
return orgsWithConfig
# Config related # Config related
async def update_org_signup_mechanism( async def update_org_signup_mechanism(
request: Request, request: Request,

2
apps/web/.gitignore vendored
View file

@ -45,3 +45,5 @@ next.config.original.js
.sentryclirc .sentryclirc
certificates certificates
# Sentry Config File
.env.sentry-build-plugin

View file

@ -170,7 +170,7 @@ const LoginClient = (props: LoginClientProps) => {
</FormField> </FormField>
<div> <div>
<Link <Link
href={getUriWithOrg(props.org.slug, '/forgot')} href={{ pathname: getUriWithoutOrg('/forgot'), query: props.org.slug ? { orgslug: props.org.slug } : null }}
passHref passHref
className="text-xs text-gray-500 hover:underline" className="text-xs text-gray-500 hover:underline"
> >

View file

@ -0,0 +1,23 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

View file

@ -1,5 +1,5 @@
import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext'; import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext';
import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext'; import { useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext';
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import AssignmentBoxUI from '@components/Objects/Activities/Assignment/AssignmentBoxUI' import AssignmentBoxUI from '@components/Objects/Activities/Assignment/AssignmentBoxUI'

View file

@ -16,7 +16,7 @@ type QuizSchema = {
text: string; text: string;
fileID: string; fileID: string;
type: 'text' | 'image' | 'audio' | 'video'; type: 'text' | 'image' | 'audio' | 'video';
correct: boolean; assigned_right_answer: boolean;
}[]; }[];
}; };
@ -25,6 +25,7 @@ type QuizSubmitSchema = {
submissions: { submissions: {
questionUUID: string; questionUUID: string;
optionUUID: string; optionUUID: string;
answer: boolean
}[]; }[];
}; };
@ -34,6 +35,12 @@ type TaskQuizObjectProps = {
assignmentTaskUUID?: string; assignmentTaskUUID?: string;
}; };
type Submission = {
questionUUID: string;
optionUUID: string;
answer: boolean;
};
function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectProps) { function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectProps) {
const session = useLHSession() as any; const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token; const access_token = session?.data?.tokens?.access_token;
@ -44,7 +51,7 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
/* TEACHER VIEW CODE */ /* TEACHER VIEW CODE */
const [questions, setQuestions] = useState<QuizSchema[]>([ const [questions, setQuestions] = useState<QuizSchema[]>([
{ questionText: '', questionUUID: 'question_' + uuidv4(), options: [{ text: '', fileID: '', type: 'text', correct: false, optionUUID: 'option_' + uuidv4() }] }, { questionText: '', questionUUID: 'question_' + uuidv4(), options: [{ text: '', fileID: '', type: 'text', assigned_right_answer: false, optionUUID: 'option_' + uuidv4() }] },
]); ]);
const handleQuestionChange = (index: number, value: string) => { const handleQuestionChange = (index: number, value: string) => {
@ -61,7 +68,7 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
const addOption = (qIndex: number) => { const addOption = (qIndex: number) => {
const updatedQuestions = [...questions]; const updatedQuestions = [...questions];
updatedQuestions[qIndex].options.push({ text: '', fileID: '', type: 'text', correct: false, optionUUID: 'option_' + uuidv4() }); updatedQuestions[qIndex].options.push({ text: '', fileID: '', type: 'text', assigned_right_answer: false, optionUUID: 'option_' + uuidv4() });
setQuestions(updatedQuestions); setQuestions(updatedQuestions);
}; };
@ -72,7 +79,7 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
}; };
const addQuestion = () => { const addQuestion = () => {
setQuestions([...questions, { questionText: '', questionUUID: 'question_' + uuidv4(), options: [{ text: '', fileID: '', type: 'text', correct: false, optionUUID: 'option_' + uuidv4() }] }]); setQuestions([...questions, { questionText: '', questionUUID: 'question_' + uuidv4(), options: [{ text: '', fileID: '', type: 'text', assigned_right_answer: false, optionUUID: 'option_' + uuidv4() }] }]);
}; };
const removeQuestion = (qIndex: number) => { const removeQuestion = (qIndex: number) => {
@ -81,12 +88,12 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
setQuestions(updatedQuestions); setQuestions(updatedQuestions);
}; };
const toggleCorrectOption = (qIndex: number, oIndex: number) => { const toggleOption = (qIndex: number, oIndex: number) => {
const updatedQuestions = [...questions]; const updatedQuestions = [...questions];
// Find the option to toggle // Find the option to toggle
const optionToToggle = updatedQuestions[qIndex].options[oIndex]; const optionToToggle = updatedQuestions[qIndex].options[oIndex];
// Toggle the 'correct' property of the option // Toggle the 'correct' property of the option
optionToToggle.correct = !optionToToggle.correct; optionToToggle.assigned_right_answer = !optionToToggle.assigned_right_answer;
setQuestions(updatedQuestions); setQuestions(updatedQuestions);
}; };
@ -123,20 +130,24 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
async function chooseOption(qIndex: number, oIndex: number) { async function chooseOption(qIndex: number, oIndex: number) {
const updatedSubmissions = [...userSubmissions.submissions]; const updatedSubmissions = [...userSubmissions.submissions];
const questionUUID = questions[qIndex].questionUUID; const question = questions[qIndex];
const optionUUID = questions[qIndex].options[oIndex].optionUUID; const option = question?.options[oIndex];
// Check if this question already has a submission with the selected option if (!question || !option) return;
const existingSubmissionIndex = updatedSubmissions.findIndex(
const questionUUID = question.questionUUID;
const optionUUID = option.optionUUID;
if (!questionUUID || !optionUUID) return;
const submissionIndex = updatedSubmissions.findIndex(
(submission) => submission.questionUUID === questionUUID && submission.optionUUID === optionUUID (submission) => submission.questionUUID === questionUUID && submission.optionUUID === optionUUID
); );
if (existingSubmissionIndex === -1 && optionUUID && questionUUID) { if (submissionIndex === -1) {
// If the selected option is not already chosen, add it to the submissions updatedSubmissions.push({ questionUUID, optionUUID, answer: true });
updatedSubmissions.push({ questionUUID, optionUUID });
} else { } else {
// If the selected option is already chosen, remove it from the submissions updatedSubmissions[submissionIndex].answer = !updatedSubmissions[submissionIndex].answer;
updatedSubmissions.splice(existingSubmissionIndex, 1);
} }
setUserSubmissions({ setUserSubmissions({
@ -176,12 +187,34 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
const submitFC = async () => { const submitFC = async () => {
// Ensure all questions and options have submissions
const updatedSubmissions: Submission[] = questions.flatMap(question => {
return question.options.map(option => {
const existingSubmission = userSubmissions.submissions.find(
submission => submission.questionUUID === question.questionUUID && submission.optionUUID === option.optionUUID
);
return existingSubmission || {
questionUUID: question.questionUUID || '',
optionUUID: option.optionUUID || '',
answer: false // Mark unsubmitted options as false
};
});
});
// Update userSubmissions with the complete set of submissions
const updatedUserSubmissions: QuizSubmitSchema = {
...userSubmissions,
submissions: updatedSubmissions
};
// Save the quiz to the server // Save the quiz to the server
const values = { const values = {
task_submission: userSubmissions, task_submission: updatedUserSubmissions,
grade: 0, grade: 0,
task_submission_grade_feedback: '', task_submission_grade_feedback: '',
}; };
if (assignmentTaskUUID) { if (assignmentTaskUUID) {
const res = await handleAssignmentTaskSubmission(values, assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token); const res = await handleAssignmentTaskSubmission(values, assignmentTaskUUID, assignment.assignment_object.assignment_uuid, access_token);
if (res) { if (res) {
@ -190,6 +223,7 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
}); });
toast.success('Task saved successfully'); toast.success('Task saved successfully');
setShowSavingDisclaimer(false); setShowSavingDisclaimer(false);
setUserSubmissions(updatedUserSubmissions);
} else { } else {
toast.error('Error saving task, please retry later.'); toast.error('Error saving task, please retry later.');
} }
@ -214,30 +248,22 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
async function gradeFC() { async function gradeFC() {
if (assignmentTaskUUID) { if (assignmentTaskUUID) {
// Ensure maxPoints is defined const maxPoints = assignmentTaskOutsideProvider?.max_grade_value || 100;
const maxPoints = assignmentTaskOutsideProvider?.max_grade_value || 100; // Default to 100 if not defined const totalOptions = questions.reduce((total, question) => total + question.options.length, 0);
let correctAnswers = 0;
// Ensure userSubmissions.questions are set questions.forEach((question) => {
const totalQuestions = questions.length; question.options.forEach((option) => {
let correctQuestions = 0; const submission = userSubmissions.submissions.find(
let incorrectQuestions = 0; (sub) => sub.questionUUID === question.questionUUID && sub.optionUUID === option.optionUUID
);
userSubmissions.submissions.forEach((submission) => { if (submission?.answer === option.assigned_right_answer) {
const question = questions.find((q) => q.questionUUID === submission.questionUUID); correctAnswers++;
const option = question?.options.find((o) => o.optionUUID === submission.optionUUID);
if (option?.correct) {
correctQuestions++;
} else {
incorrectQuestions++;
} }
}); });
});
// Calculate grade with penalties for incorrect answers const finalGrade = Math.round((correctAnswers / totalOptions) * maxPoints);
const pointsPerQuestion = maxPoints / totalQuestions;
const rawGrade = (correctQuestions - incorrectQuestions) * pointsPerQuestion;
// Ensure the grade is within the valid range
const finalGrade = Math.max(0, Math.min(rawGrade, maxPoints));
// Save the grade to the server // Save the grade to the server
const values = { const values = {
@ -337,15 +363,15 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
{view === 'teacher' && ( {view === 'teacher' && (
<> <>
<div <div
className={`w-fit flex-none flex text-xs px-2 py-0.5 space-x-1 items-center h-fit rounded-lg ${option.correct ? 'bg-lime-200 text-lime-600' : 'bg-rose-200/60 text-rose-500' className={`w-fit flex-none flex text-xs px-2 py-0.5 space-x-1 items-center h-fit rounded-lg ${option.assigned_right_answer ? 'bg-lime-200 text-lime-600' : 'bg-rose-200/60 text-rose-500'
} hover:bg-lime-300 text-sm transition-all ease-linear cursor-pointer`} } hover:bg-lime-300 text-sm transition-all ease-linear cursor-pointer`}
onClick={() => toggleCorrectOption(qIndex, oIndex)} onClick={() => toggleOption(qIndex, oIndex)}
> >
{option.correct ? <Check size={12} className="mx-auto" /> : <X size={12} className="mx-auto" />} {option.assigned_right_answer ? <Check size={12} className="mx-auto" /> : <X size={12} className="mx-auto" />}
{option.correct ? ( {option.assigned_right_answer ? (
<p className="mx-auto font-bold text-xs">Correct</p> <p className="mx-auto font-bold text-xs">True</p>
) : ( ) : (
<p className="mx-auto font-bold text-xs">Incorrect</p> <p className="mx-auto font-bold text-xs">False</p>
)} )}
</div> </div>
<div <div
@ -359,30 +385,38 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
{view === 'grading' && ( {view === 'grading' && (
<> <>
<div <div
className={`w-fit flex-none flex text-xs px-2 py-0.5 space-x-1 items-center h-fit rounded-lg ${option.correct ? 'bg-lime-200 text-lime-600' : 'bg-rose-200/60 text-rose-500' className={`w-fit flex-none flex text-xs px-2 py-0.5 space-x-1 items-center h-fit rounded-lg ${option.assigned_right_answer ? 'bg-lime-200 text-lime-600' : 'bg-rose-200/60 text-rose-500'
} hover:bg-lime-300 text-sm transition-all ease-linear cursor-pointer`} } hover:bg-lime-300 text-sm transition-all ease-linear cursor-pointer`}
> >
{option.correct ? <Check size={12} className="mx-auto" /> : <X size={12} className="mx-auto" />} {option.assigned_right_answer ? <Check size={12} className="mx-auto" /> : <X size={12} className="mx-auto" />}
{option.correct ? ( {option.assigned_right_answer ? (
<p className="mx-auto font-bold text-xs">Marked as Correct</p> <p className="mx-auto font-bold text-xs">Marked as True</p>
) : ( ) : (
<p className="mx-auto font-bold text-xs">Marked as Incorrect</p> <p className="mx-auto font-bold text-xs">Marked as False</p>
)} )}
</div> </div>
</> </>
)} )}
{view === 'student' && ( {view === 'student' && (
<div className={`w-[20px] flex-none flex items-center h-[20px] rounded-lg ${userSubmissions.submissions.find( <div
className={`w-[20px] flex-none flex items-center h-[20px] rounded-lg ${
userSubmissions.submissions.find(
(submission) => (submission) =>
submission.questionUUID === question.questionUUID && submission.optionUUID === option.optionUUID submission.questionUUID === question.questionUUID &&
submission.optionUUID === option.optionUUID &&
submission.answer
) )
? "bg-green-200/60 text-green-500 hover:bg-green-300" // Selected state colors ? "bg-green-200/60 text-green-500 hover:bg-green-300"
: "bg-slate-200/60 text-slate-500 hover:bg-slate-300" // Default state colors : "bg-slate-200/60 text-slate-500 hover:bg-slate-300"
} text-sm transition-all ease-linear cursor-pointer`}> } text-sm transition-all ease-linear cursor-pointer`}
onClick={() => chooseOption(qIndex, oIndex)}
>
{userSubmissions.submissions.find( {userSubmissions.submissions.find(
(submission) => (submission) =>
submission.questionUUID === question.questionUUID && submission.optionUUID === option.optionUUID submission.questionUUID === question.questionUUID &&
submission.optionUUID === option.optionUUID &&
submission.answer
) ? ( ) ? (
<Check size={12} className="mx-auto" /> <Check size={12} className="mx-auto" />
) : ( ) : (
@ -391,22 +425,30 @@ function TaskQuizObject({ view, assignmentTaskUUID, user_id }: TaskQuizObjectPro
</div> </div>
)} )}
{view === 'grading' && ( {view === 'grading' && (
<div className={`w-[20px] flex-none flex items-center h-[20px] rounded-lg ${userSubmissions.submissions.find( <>
<div className={`w-[20px] flex-none flex items-center h-[20px] rounded-lg ${
userSubmissions.submissions.find(
(submission) => (submission) =>
submission.questionUUID === question.questionUUID && submission.optionUUID === option.optionUUID submission.questionUUID === question.questionUUID &&
submission.optionUUID === option.optionUUID &&
submission.answer
) )
? "bg-green-200/60 text-green-500 hover:bg-green-300" // Selected state colors ? "bg-green-200/60 text-green-500"
: "bg-slate-200/60 text-slate-500 hover:bg-slate-300" // Default state colors : "bg-slate-200/60 text-slate-500"
} text-sm transition-all ease-linear cursor-pointer`}> } text-sm`}>
{userSubmissions.submissions.find( {userSubmissions.submissions.find(
(submission) => (submission) =>
submission.questionUUID === question.questionUUID && submission.optionUUID === option.optionUUID submission.questionUUID === question.questionUUID &&
submission.optionUUID === option.optionUUID &&
submission.answer
) ? ( ) ? (
<Check size={12} className="mx-auto" /> <Check size={12} className="mx-auto" />
) : ( ) : (
<X size={12} className="mx-auto" /> <X size={12} className="mx-auto" />
)} )}
</div> </div>
</>
)} )}
</div> </div>

View file

@ -4,7 +4,7 @@ import Modal from '@components/StyledElements/Modal/Modal';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import { getUserAvatarMediaDirectory } from '@services/media/media'; import { getUserAvatarMediaDirectory } from '@services/media/media';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import { Loader, SendHorizonal, UserCheck, X } from 'lucide-react'; import { SendHorizonal, UserCheck, X } from 'lucide-react';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import EvaluateAssignment from './Modals/EvaluateAssignment'; import EvaluateAssignment from './Modals/EvaluateAssignment';

View file

@ -1,5 +1,5 @@
import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext'; import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext';
import { Apple, ArrowRightFromLine, BookOpenCheck, Check, Download, Info, Medal, MoveRight, X } from 'lucide-react'; import { BookOpenCheck, Check, Download, Info, MoveRight, X } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react' import React from 'react'
import TaskQuizObject from '../../_components/TaskEditor/Subs/TaskTypes/TaskQuizObject'; import TaskQuizObject from '../../_components/TaskEditor/Subs/TaskTypes/TaskQuizObject';

View file

@ -6,7 +6,7 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config';
import { getAssignmentsFromACourse } from '@services/courses/assignments'; import { getAssignmentsFromACourse } from '@services/courses/assignments';
import { getCourseThumbnailMediaDirectory } from '@services/media/media'; import { getCourseThumbnailMediaDirectory } from '@services/media/media';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import { Book, EllipsisVertical, GalleryVertical, GalleryVerticalEnd, Info, Layers2, PenBox, UserRoundPen } from 'lucide-react'; import { EllipsisVertical, GalleryVerticalEnd, Info, Layers2, UserRoundPen } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react' import React from 'react'
import useSWR from 'swr'; import useSWR from 'swr';

View file

@ -1,5 +1,4 @@
import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext'; import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext';
import { useAssignmentsTask } from '@components/Contexts/Assignments/AssignmentsTaskContext';
import { useCourse } from '@components/Contexts/CourseContext'; import { useCourse } from '@components/Contexts/CourseContext';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import { getTaskRefFileDir } from '@services/media/media'; import { getTaskRefFileDir } from '@services/media/media';

View file

@ -32,23 +32,19 @@ function QuizBlockComponent(props: any) {
const isEditable = editorState.isEditable const isEditable = editorState.isEditable
const handleAnswerClick = (question_id: string, answer_id: string) => { const handleAnswerClick = (question_id: string, answer_id: string) => {
// if the quiz is submitted, do nothing if (submitted) return;
if (submitted) {
return const existingAnswerIndex = userAnswers.findIndex(
(answer: any) => answer.question_id === question_id && answer.answer_id === answer_id
);
if (existingAnswerIndex !== -1) {
// Remove the answer if it's already selected
setUserAnswers(userAnswers.filter((_, index) => index !== existingAnswerIndex));
} else {
// Add the answer
setUserAnswers([...userAnswers, { question_id, answer_id }]);
} }
const userAnswer = {
question_id: question_id,
answer_id: answer_id,
}
const newAnswers = [...userAnswers, userAnswer]
// only accept one answer per question
const filteredAnswers = newAnswers.filter(
(answer: any) => answer.question_id !== question_id
)
setUserAnswers([...filteredAnswers, userAnswer])
} }
const refreshUserSubmission = () => { const refreshUserSubmission = () => {
@ -57,38 +53,31 @@ function QuizBlockComponent(props: any) {
} }
const handleUserSubmission = () => { const handleUserSubmission = () => {
if (userAnswers.length === 0) { setSubmitted(true);
setSubmissionMessage('Please answer at least one question!')
return
}
setSubmitted(true) const correctAnswers = questions.every((question: Question) => {
const correctAnswers = question.answers.filter((answer: Answer) => answer.correct);
// check if all submitted answers are correct const userAnswersForQuestion = userAnswers.filter(
const correctAnswers = questions.map((question: Question) => {
const correctAnswer: any = question.answers.find(
(answer: Answer) => answer.correct
)
const userAnswer = userAnswers.find(
(userAnswer: any) => userAnswer.question_id === question.question_id (userAnswer: any) => userAnswer.question_id === question.question_id
) );
if (correctAnswer.answer_id === userAnswer.answer_id) {
return true
} else {
return false
}
})
// check if all answers are correct // If no correct answers are set and user didn't select any, it's correct
const allCorrect = correctAnswers.every( if (correctAnswers.length === 0 && userAnswersForQuestion.length === 0) {
(answer: boolean) => answer === true return true;
)
if (allCorrect) {
setSubmissionMessage('All answers are correct!')
} else {
setSubmissionMessage('Some answers are incorrect!')
} }
// Check if user selected all correct answers and no incorrect ones
return (
correctAnswers.length === userAnswersForQuestion.length &&
correctAnswers.every((correctAnswer: Answer) =>
userAnswersForQuestion.some(
(userAnswer: any) => userAnswer.answer_id === correctAnswer.answer_id
)
)
);
});
setSubmissionMessage(correctAnswers ? 'All answers are correct!' : 'Some answers are incorrect!');
} }
const getAnswerID = (answerIndex: number, questionId: string) => { const getAnswerID = (answerIndex: number, questionId: string) => {
@ -204,19 +193,14 @@ function QuizBlockComponent(props: any) {
const markAnswerCorrect = (question_id: string, answer_id: string) => { const markAnswerCorrect = (question_id: string, answer_id: string) => {
const newQuestions = questions.map((question: Question) => { const newQuestions = questions.map((question: Question) => {
if (question.question_id === question_id) { if (question.question_id === question_id) {
question.answers.map((answer: Answer) => { question.answers = question.answers.map((answer: Answer) => ({
if (answer.answer_id === answer_id) { ...answer,
answer.correct = true correct: answer.answer_id === answer_id ? !answer.correct : answer.correct,
} else { }));
answer.correct = false
} }
return question;
return answer });
}) saveQuestions(newQuestions);
}
return question
})
saveQuestions(newQuestions)
} }
return ( return (
@ -308,29 +292,21 @@ function QuizBlockComponent(props: any) {
key={answer.answer_id} key={answer.answer_id}
className={twMerge( className={twMerge(
'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear', 'outline outline-3 pr-2 shadow w-full flex items-center space-x-2 h-[30px] bg-opacity-50 hover:bg-opacity-100 hover:shadow-md rounded-s rounded-lg bg-white text-sm hover:scale-105 active:scale-110 duration-150 cursor-pointer ease-linear',
answer.correct && isEditable answer.correct && isEditable ? 'outline-lime-300' : 'outline-white',
? 'outline-lime-300' userAnswers.some(
: 'outline-white',
userAnswers.find(
(userAnswer: any) => (userAnswer: any) =>
userAnswer.question_id === question.question_id && userAnswer.question_id === question.question_id &&
userAnswer.answer_id === answer.answer_id && userAnswer.answer_id === answer.answer_id &&
!isEditable !isEditable
) ) ? 'outline-slate-300' : '',
? 'outline-slate-300' submitted && answer.correct ? 'outline-lime-300 text-lime' : '',
: '',
submitted && answer.correct
? 'outline-lime-300 text-lime'
: '',
submitted && submitted &&
!answer.correct && !answer.correct &&
userAnswers.find( userAnswers.some(
(userAnswer: any) => (userAnswer: any) =>
userAnswer.question_id === question.question_id && userAnswer.question_id === question.question_id &&
userAnswer.answer_id === answer.answer_id userAnswer.answer_id === answer.answer_id
) ) ? 'outline-red-400' : ''
? 'outline-red-400'
: ''
)} )}
onClick={() => onClick={() =>
handleAnswerClick(question.question_id, answer.answer_id) handleAnswerClick(question.question_id, answer.answer_id)
@ -347,7 +323,7 @@ function QuizBlockComponent(props: any) {
: '', : '',
submitted && submitted &&
!answer.correct && !answer.correct &&
userAnswers.find( userAnswers.some(
(userAnswer: any) => (userAnswer: any) =>
userAnswer.question_id === question.question_id && userAnswer.question_id === question.question_id &&
userAnswer.answer_id === answer.answer_id userAnswer.answer_id === answer.answer_id

View file

@ -0,0 +1,9 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}

View file

@ -37,7 +37,7 @@ export default async function middleware(req: NextRequest) {
// Out of orgslug paths & rewrite // Out of orgslug paths & rewrite
const standard_paths = ['/home'] const standard_paths = ['/home']
const auth_paths = ['/login', '/signup', '/reset'] const auth_paths = ['/login', '/signup', '/reset', '/forgot']
if (standard_paths.includes(pathname)) { if (standard_paths.includes(pathname)) {
// Redirect to the same pathname with the original search params // Redirect to the same pathname with the original search params
return NextResponse.rewrite(new URL(`${pathname}${search}`, req.url)) return NextResponse.rewrite(new URL(`${pathname}${search}`, req.url))

View file

@ -17,3 +17,46 @@ const nextConfig = {
} }
module.exports = nextConfig module.exports = nextConfig
// Injected content via Sentry wizard below
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig(
module.exports,
{
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: "learnhouse",
project: "learnhouse-web",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
}
);

View file

@ -19,19 +19,20 @@
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@sentry/nextjs": "^8.27.0",
"@stitches/react": "^1.2.8", "@stitches/react": "^1.2.8",
"@tiptap/core": "^2.5.8", "@tiptap/core": "^2.6.6",
"@tiptap/extension-code-block-lowlight": "^2.5.8", "@tiptap/extension-code-block-lowlight": "^2.6.6",
"@tiptap/extension-collaboration": "^2.5.8", "@tiptap/extension-collaboration": "^2.6.6",
"@tiptap/extension-collaboration-cursor": "^2.5.8", "@tiptap/extension-collaboration-cursor": "^2.6.6",
"@tiptap/extension-youtube": "^2.5.8", "@tiptap/extension-youtube": "^2.6.6",
"@tiptap/html": "^2.5.8", "@tiptap/html": "^2.6.6",
"@tiptap/pm": "^2.5.8", "@tiptap/pm": "^2.6.6",
"@tiptap/react": "^2.5.8", "@tiptap/react": "^2.6.6",
"@tiptap/starter-kit": "^2.5.8", "@tiptap/starter-kit": "^2.6.6",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"dayjs": "^1.11.12", "dayjs": "^1.11.13",
"formik": "^2.4.6", "formik": "^2.4.6",
"framer-motion": "^10.18.0", "framer-motion": "^10.18.0",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
@ -39,7 +40,7 @@
"katex": "^0.16.11", "katex": "^0.16.11",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"lucide-react": "^0.424.0", "lucide-react": "^0.424.0",
"next": "14.2.5", "next": "14.2.7",
"next-auth": "^4.24.7", "next-auth": "^4.24.7",
"nextjs-toploader": "^1.6.12", "nextjs-toploader": "^1.6.12",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
@ -53,14 +54,14 @@
"react-katex": "^3.0.1", "react-katex": "^3.0.1",
"react-spinners": "^0.13.8", "react-spinners": "^0.13.8",
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"sharp": "^0.33.4", "sharp": "^0.33.5",
"styled-components": "^6.1.12", "styled-components": "^6.1.12",
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.5.2",
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.11", "y-prosemirror": "^1.2.12",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.18" "yjs": "^13.6.18"
}, },
@ -70,15 +71,15 @@
"@types/react-beautiful-dnd": "^13.1.8", "@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "18.2.23", "@types/react-dom": "18.2.23",
"@types/react-katex": "^3.0.4", "@types/react-katex": "^3.0.4",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.11",
"@types/styled-components": "^5.1.34", "@types/styled-components": "^5.1.34",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-next": "^14.2.5", "eslint-config-next": "^14.2.7",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"postcss": "^8.4.40", "postcss": "^8.4.41",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.10",
"typescript": "5.4.4" "typescript": "5.4.4"
} }
} }

2994
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
enabled: process.env.NODE_ENV === 'development',
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
Sentry.replayIntegration({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
})

View file

@ -0,0 +1,18 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
enabled: process.env.NODE_ENV === 'development',
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
})

View file

@ -0,0 +1,20 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV === 'development',
// Uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: process.env.NODE_ENV === 'development',
})

View file

@ -9,8 +9,8 @@
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.0", "eslint": "^8.57.0",
"prettier": "^3.3.0", "prettier": "^3.3.3",
"turbo": "^1.13.3" "turbo": "^1.13.4"
}, },
"packageManager": "pnpm@9.0.6" "packageManager": "pnpm@9.0.6"
} }

68
pnpm-lock.yaml generated
View file

@ -12,11 +12,11 @@ importers:
specifier: ^8.57.0 specifier: ^8.57.0
version: 8.57.0 version: 8.57.0
prettier: prettier:
specifier: ^3.3.0 specifier: ^3.3.3
version: 3.3.0 version: 3.3.3
turbo: turbo:
specifier: ^1.13.3 specifier: ^1.13.4
version: 1.13.3 version: 1.13.4
packages: packages:
@ -323,8 +323,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier@3.3.0: prettier@3.3.3:
resolution: {integrity: sha512-J9odKxERhCQ10OC2yb93583f6UnYutOeiV5i0zEDS7UGTdUt0u+y8erxl3lBKvwo/JHyyoEdXjwp4dke9oyZ/g==} resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@ -374,38 +374,38 @@ packages:
text-table@0.2.0: text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
turbo-darwin-64@1.13.3: turbo-darwin-64@1.13.4:
resolution: {integrity: sha512-glup8Qx1qEFB5jerAnXbS8WrL92OKyMmg5Hnd4PleLljAeYmx+cmmnsmLT7tpaVZIN58EAAwu8wHC6kIIqhbWA==} resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
turbo-darwin-arm64@1.13.3: turbo-darwin-arm64@1.13.4:
resolution: {integrity: sha512-/np2xD+f/+9qY8BVtuOQXRq5f9LehCFxamiQnwdqWm5iZmdjygC5T3uVSYuagVFsZKMvX3ycySwh8dylGTl6lg==} resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
turbo-linux-64@1.13.3: turbo-linux-64@1.13.4:
resolution: {integrity: sha512-G+HGrau54iAnbXLfl+N/PynqpDwi/uDzb6iM9hXEDG+yJnSJxaHMShhOkXYJPk9offm9prH33Khx2scXrYVW1g==} resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
turbo-linux-arm64@1.13.3: turbo-linux-arm64@1.13.4:
resolution: {integrity: sha512-qWwEl5VR02NqRyl68/3pwp3c/olZuSp+vwlwrunuoNTm6JXGLG5pTeme4zoHNnk0qn4cCX7DFrOboArlYxv0wQ==} resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
turbo-windows-64@1.13.3: turbo-windows-64@1.13.4:
resolution: {integrity: sha512-Nudr4bRChfJzBPzEmpVV85VwUYRCGKecwkBFpbp2a4NtrJ3+UP1VZES653ckqCu2FRyRuS0n03v9euMbAvzH+Q==} resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
turbo-windows-arm64@1.13.3: turbo-windows-arm64@1.13.4:
resolution: {integrity: sha512-ouJCgsVLd3icjRLmRvHQDDZnmGzT64GBupM1Y+TjtYn2LVaEBoV6hicFy8x5DUpnqdLy+YpCzRMkWlwhmkX7sQ==} resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
turbo@1.13.3: turbo@1.13.4:
resolution: {integrity: sha512-n17HJv4F4CpsYTvKzUJhLbyewbXjq1oLCi90i5tW1TiWDz16ML1eDG7wi5dHaKxzh5efIM56SITnuVbMq5dk4g==} resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==}
hasBin: true hasBin: true
type-check@0.4.0: type-check@0.4.0:
@ -757,7 +757,7 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.3.0: {} prettier@3.3.3: {}
punycode@2.3.1: {} punycode@2.3.1: {}
@ -793,32 +793,32 @@ snapshots:
text-table@0.2.0: {} text-table@0.2.0: {}
turbo-darwin-64@1.13.3: turbo-darwin-64@1.13.4:
optional: true optional: true
turbo-darwin-arm64@1.13.3: turbo-darwin-arm64@1.13.4:
optional: true optional: true
turbo-linux-64@1.13.3: turbo-linux-64@1.13.4:
optional: true optional: true
turbo-linux-arm64@1.13.3: turbo-linux-arm64@1.13.4:
optional: true optional: true
turbo-windows-64@1.13.3: turbo-windows-64@1.13.4:
optional: true optional: true
turbo-windows-arm64@1.13.3: turbo-windows-arm64@1.13.4:
optional: true optional: true
turbo@1.13.3: turbo@1.13.4:
optionalDependencies: optionalDependencies:
turbo-darwin-64: 1.13.3 turbo-darwin-64: 1.13.4
turbo-darwin-arm64: 1.13.3 turbo-darwin-arm64: 1.13.4
turbo-linux-64: 1.13.3 turbo-linux-64: 1.13.4
turbo-linux-arm64: 1.13.3 turbo-linux-arm64: 1.13.4
turbo-windows-64: 1.13.3 turbo-windows-64: 1.13.4
turbo-windows-arm64: 1.13.3 turbo-windows-arm64: 1.13.4
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies: