diff --git a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx index a3d8da5f9..23cdd8f26 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx @@ -175,7 +175,7 @@ const EditCourseForm: React.FC = ({ diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_knowledge_base/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_knowledge_base/page.tsx index 11c3582dc..39f92313d 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_knowledge_base/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_knowledge_base/page.tsx @@ -24,14 +24,15 @@ interface FormValues { content: string source: string pageNumber: string + name: string } -interface ChatbotDocumentsProps { +interface ChatbotKnowledgeBaseProps { params: Promise<{ cid: string }> } -export default function ChatbotDocuments( - props: ChatbotDocumentsProps, +export default function ChatbotKnowledgeBase( + props: ChatbotKnowledgeBaseProps, ): ReactElement { const params = use(props.params) const courseId = Number(params.cid) @@ -46,14 +47,15 @@ export default function ChatbotDocuments( const [editRecordModalOpen, setEditRecordModalOpen] = useState(false) const [form] = Form.useForm() const [addDocChunkPopupVisible, setAddDocChunkPopupVisible] = useState(false) + const [dataLoading, setDataLoading] = useState(false) const addDocument = async (values: FormValues) => { const body: AddDocumentChunkParams = { documentText: values.content, metadata: { - name: 'Manually Inserted Information', + name: values.name || 'Manually Inserted Information', type: 'inserted_document', - source: values.source ?? undefined, + source: values.source || undefined, loc: values.pageNumber ? { pageNumber: parseInt(values.pageNumber) } : undefined, @@ -91,6 +93,7 @@ export default function ChatbotDocuments( const fetchDocuments = useCallback(async () => { if (courseId) { + setDataLoading(true) await API.chatbot.staffOnly .getAllDocumentChunks(courseId) .then((response) => { @@ -106,6 +109,7 @@ export default function ChatbotDocuments( message.error('Failed to load documents: ' + errorMessage) }) } + setDataLoading(false) }, [courseId, setDocuments, setFilteredDocuments, search]) useEffect(() => { @@ -270,11 +274,18 @@ export default function ChatbotDocuments( > + + + {/* */} - + = ({ const newChunk: AddDocumentChunkParams = { documentText: values.question + '\nAnswer:' + values.answer, metadata: { - name: 'inserted Q&A', + name: 'Previously Asked Question', type: 'inserted_question', id: editingRecord.vectorStoreId, courseId: cid, diff --git a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx index cc0c8a599..726dbfd76 100644 --- a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx +++ b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx @@ -227,7 +227,7 @@ export default function AddCoursePage(): ReactElement { diff --git a/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx b/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx new file mode 100644 index 000000000..ff7b8db83 --- /dev/null +++ b/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx @@ -0,0 +1,14 @@ +import UserAccessTokens from './UserAccessTokens' +import ClearProfileCache from './ClearProfileCache' + +const AdvancedSettings: React.FC = () => { + return ( +
+

Advanced Settings

+ + +
+ ) +} + +export default AdvancedSettings diff --git a/packages/frontend/app/(dashboard)/profile/components/ClearProfileCache.tsx b/packages/frontend/app/(dashboard)/profile/components/ClearProfileCache.tsx new file mode 100644 index 000000000..9f309908c --- /dev/null +++ b/packages/frontend/app/(dashboard)/profile/components/ClearProfileCache.tsx @@ -0,0 +1,62 @@ +import { API } from '@/app/api' +import { useUserInfo } from '@/app/contexts/userContext' +import { getErrorMessage } from '@/app/utils/generalUtils' +import { DeleteOutlined, QuestionCircleOutlined } from '@ant-design/icons' +import { Button, Card, message, Tooltip } from 'antd' +import { useState } from 'react' + +const ClearProfileCache: React.FC = () => { + const { userInfo, setUserInfo } = useUserInfo() + const [isLoading, setIsLoading] = useState(false) + + return ( + <> + {userInfo && ( + Profile Cache} classNames={{ body: 'py-2' }}> + + Clear Backend Profile Cache + + + + + )} + + ) +} + +export default ClearProfileCache diff --git a/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx b/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx index 474053474..57f7bf2c0 100644 --- a/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx @@ -6,7 +6,6 @@ import { getErrorMessage } from '@/app/utils/generalUtils' import { ExclamationCircleOutlined } from '@ant-design/icons' import { UserCourse } from '@koh/common' import { Button, message, Modal, Table, TableColumnsType } from 'antd' -import useSWR from 'swr' const { confirm } = Modal @@ -56,7 +55,11 @@ const CoursePreference: React.FC = () => { const InstructorCell = ({ courseId }: { courseId: number }) => { const course = useCourse(courseId) - return <>{course.course?.coordinator_email} + return ( +
+ {course.course?.coordinator_email} +
+ ) } const columns: TableColumnsType = [ diff --git a/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx b/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx index e70e1e479..31919c8dd 100644 --- a/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx @@ -11,7 +11,7 @@ import CoursePreference from './CoursePreference' import EmailNotifications from './EmailNotifications' import UserChatbotHistory from './UserChatbotHistory' import { useSearchParams } from 'next/navigation' -import UserAccessTokens from '@/app/(dashboard)/profile/components/UserAccessTokens' +import AdvancedSettings from './AdvancedSettings' const ProfileSettings: React.FC = () => { const params = useSearchParams() @@ -24,8 +24,8 @@ const ProfileSettings: React.FC = () => { return SettingsOptions.PREFERENCES case 'chatbot_history': return SettingsOptions.CHATBOT_HISTORY - case 'access_tokens': - return SettingsOptions.ACCESS_TOKENS + case 'advanced': + return SettingsOptions.ADVANCED default: return SettingsOptions.PROFILE } @@ -60,12 +60,10 @@ const ProfileSettings: React.FC = () => { {currentSettings === SettingsOptions.PREFERENCES && ( )} - {currentSettings === SettingsOptions.ACCESS_TOKENS && ( - - )} {currentSettings === SettingsOptions.CHATBOT_HISTORY && ( )} + {currentSettings === SettingsOptions.ADVANCED && } diff --git a/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx b/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx index d3eec86a8..ee468bd1f 100644 --- a/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx @@ -16,6 +16,7 @@ import CoursePreference from './CoursePreference' import { useMediaQuery } from '@/app/hooks/useMediaQuery' import EmailNotifications from './EmailNotifications' import UserChatbotHistory from './UserChatbotHistory' +import AdvancedSettings from './AdvancedSettings' interface SettingsMenuProps { currentSettings: SettingsOptions @@ -59,6 +60,11 @@ const SettingsMenu: React.FC = ({ label: 'Chatbot History', children: , }, + { + key: SettingsOptions.ADVANCED, + label: 'Advanced', + children: , + }, ]} /> ) : ( @@ -88,16 +94,9 @@ const SettingsMenu: React.FC = ({ icon: , }, { - key: 'Advanced', + key: SettingsOptions.ADVANCED, label: 'Advanced', icon: , - children: [ - { - key: SettingsOptions.ACCESS_TOKENS, - label: 'Access Tokens', - icon: , - }, - ], }, ]} /> diff --git a/packages/frontend/app/(dashboard)/profile/components/UserAccessTokens.tsx b/packages/frontend/app/(dashboard)/profile/components/UserAccessTokens.tsx index c026aaa16..df0ed0040 100644 --- a/packages/frontend/app/(dashboard)/profile/components/UserAccessTokens.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/UserAccessTokens.tsx @@ -49,7 +49,7 @@ const UserAccessTokens: React.FC = () => {

These are access tokens generated for learning management systems your diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 894fd8459..284f81cbb 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -270,6 +270,8 @@ export class APIClient { this.req('DELETE', `/api/v1/profile/delete_profile_picture`), readChangelog: async (): Promise => this.req('PATCH', `/api/v1/profile/read_changelog`, undefined), + clearCache: async (): Promise => + this.req('DELETE', `/api/v1/profile/clear_cache`), } chatbot = { @@ -720,19 +722,19 @@ export class APIClient { includeQueueQuestions: boolean = true, includeAnytimeQuestions: boolean = true, includeChatbotInteractions: boolean = true, - groupBy: 'day' | 'week' = 'week' + groupBy: 'day' | 'week' = 'week', ): Promise => { const queryParams = new URLSearchParams({ includeQueueQuestions: includeQueueQuestions.toString(), includeAnytimeQuestions: includeAnytimeQuestions.toString(), includeChatbotInteractions: includeChatbotInteractions.toString(), - groupBy + groupBy, }) return this.req( 'GET', `/api/v1/courses/${courseId}/export-tool-usage?${queryParams.toString()}`, - undefined + undefined, ) }, } diff --git a/packages/frontend/app/typings/enum.ts b/packages/frontend/app/typings/enum.ts index 0396fcae5..9e6ec9427 100644 --- a/packages/frontend/app/typings/enum.ts +++ b/packages/frontend/app/typings/enum.ts @@ -4,5 +4,5 @@ export enum SettingsOptions { TEAMS_SETTINGS = 'TEAMS_SETTINGS', PREFERENCES = 'PREFERENCES', CHATBOT_HISTORY = 'CHATBOT_HISTORY', - ACCESS_TOKENS = 'ACCESS_TOKENS', + ADVANCED = 'ADVANCED', } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index 119a15143..72bb160c0 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -47,7 +47,7 @@ export class AsyncQuestionService { content: `
${commenterIsStaff ? commenter.name : 'Someone'} has commented on your "${question.questionAbstract ?? (question.questionText ? question.questionText.slice(0, 50) : '')}" Anytime Question:
Comment Text: ${comment.commentText}
-
Note: Do NOT reply to this email. View and Reply Here
`, +
Do NOT reply to this email. View and Answer It Here
`, }) .catch((err) => { console.error( diff --git a/packages/server/src/profile/profile.controller.ts b/packages/server/src/profile/profile.controller.ts index 55e0acf9e..d372e5c6d 100644 --- a/packages/server/src/profile/profile.controller.ts +++ b/packages/server/src/profile/profile.controller.ts @@ -121,6 +121,36 @@ export class ProfileController { @UseInterceptors( FileInterceptor('file', { storage: memoryStorage(), + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit per file + }, + fileFilter: (req, file, cb) => { + // Check mimetype + if (!file.mimetype.startsWith('image/')) { + cb(new Error('Only image files are allowed'), false); + return; + } + // Check file extension + const allowedExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.svg', + '.tiff', + '.gif', + ]; + const fileExt = file.originalname + .toLowerCase() + .substring(file.originalname.lastIndexOf('.')); + if (!allowedExtensions.includes(fileExt)) { + cb(new Error('Only image files are allowed'), false); + return; + } + cb(null, true); + }, }), ) async uploadImage( @@ -210,4 +240,12 @@ export class ProfileController { .send({ message: 'Error reading changelogs' }); } } + + // Only 5 calls allowed in 5 minutes + @Throttle({ default: { limit: 5, ttl: minutes(5) } }) + @Delete('/clear_cache') + @UseGuards(JwtAuthGuard, EmailVerifiedGuard) + async clearCache(@UserId() userId: number): Promise { + await this.redisProfileService.deleteProfile(`u:${userId}`); + } } diff --git a/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts b/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts index 1f0df9581..61b1cd932 100644 --- a/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts +++ b/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts @@ -12,7 +12,7 @@ import { StudentTaskProgress } from '@koh/common'; @Entity('student_task_progress_model') export class StudentTaskProgressModel extends BaseEntity { - @Column({ type: 'json', nullable: true }) + @Column({ type: 'json', nullable: true }) // todo: maybe migrate this to jsonb for better querying? taskProgress: StudentTaskProgress; // this is the main item that this entity stores @PrimaryColumn() // two primary columns are needed to make the composite primary key (each studentTaskProgress is uniquely defined by each [cid, uid] combo)