diff --git a/packages/common/index.ts b/packages/common/index.ts index 7c25eb705..8c6c02881 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -298,16 +298,25 @@ interface Loc { pageNumber: number } +export interface ChatbotChunkMetadata { + name: string // name when cited (e.g. "L2 Slides") + type: string // "inserted_question", "inserted_async_question", etc. + source?: string // source url + loc?: Loc + id?: string + courseId?: number + // below only added in chunks from April 2025 onwards + firstInsertedAt?: Date + lastUpdatedAt?: Date + shouldProbablyKeepWhenCloning?: boolean + asyncQuestionId?: number // inserted async questions only +} + // source document return type (from chatbot db) +// Actually this is kinda messy. It's supposed to just be a chunk, not a full document, but it's also used for the full documents on the frontend. TODO: maybe refactor that a little export interface SourceDocument { id?: string - metadata?: { - loc?: Loc - name: string - type?: string - source?: string - courseId?: string - } + metadata?: ChatbotChunkMetadata type?: string // TODO: is it content or pageContent? since this file uses both. EDIT: It seems to be both/either. Gross. content?: string @@ -358,14 +367,7 @@ export interface ChatbotAskSuggestedParams { export interface AddDocumentChunkParams { documentText: string - metadata: { - name: string - type: string - source?: string - loc?: Loc - id?: string - courseId?: number - } + metadata: ChatbotChunkMetadata } export interface UpdateChatbotQuestionParams { @@ -399,6 +401,10 @@ export interface ChatbotAskResponseChatbotDB { verified: boolean courseId: string isPreviousQuestion: boolean + imageDescriptions?: { + imageId: string // should be a number but I don't trust it + description: string + }[] } export interface AddChatbotQuestionParams { @@ -786,6 +792,17 @@ export type AsyncQuestion = { votes?: AsyncQuestionVotes[] comments: AsyncQuestionComment[] votesSum: number + images: AsyncQuestionImage[] + citations: SourceDocument[] +} + +export type AsyncQuestionImage = { + imageId: number + originalFileName: string + newFileName: string + imageSizeBytes: number + previewImageSizeBytes: number + aiSummary: string } /** @@ -828,10 +845,6 @@ export class AsyncQuestionParams { @IsString() answerText?: string - @IsOptional() - @IsString() - aiAnswerText?: string - @Type(() => Date) closedAt?: Date @@ -848,6 +861,14 @@ export class AsyncQuestionParams { @IsOptional() @IsInt() votesSum?: number + + @IsOptional() + @IsBoolean() + saveToChatbot?: boolean + + @IsOptional() + @IsBoolean() + refreshAIAnswer?: boolean } export class AsyncQuestionVotes { @IsOptional() @@ -1536,9 +1557,26 @@ export class ResolveGroupParams { queueId!: number } -export class CreateAsyncQuestions extends AsyncQuestionParams {} +export class CreateAsyncQuestions extends AsyncQuestionParams { + @IsOptional() + @IsArray() + images?: any // This will be handled by FormData, so we don't need to specify the type here +} -export class UpdateAsyncQuestions extends AsyncQuestionParams {} +export class UpdateAsyncQuestions extends AsyncQuestionParams { + @IsOptional() + @IsArray() + newImages?: any // This will be handled by FormData, so we don't need to specify the type here + + @IsOptional() + @IsArray() + deletedImageIds?: number[] + + // used with staff to delete citations when posting a response + @IsOptional() + @IsBoolean() + deleteCitations?: boolean +} export type TAUpdateStatusResponse = QueuePartial export type QueueNotePayloadType = { @@ -2731,6 +2769,8 @@ export enum LMSIntegrationPlatform { export const ERROR_MESSAGES = { common: { pageOutOfBounds: "Can't retrieve out of bounds page.", + noDiskSpace: + 'There is not enough disk space left to store an image (<1GB). Please immediately contact your course staff and let them know. They will contact the HelpMe team as soon as possible.', }, questionService: { getDBClient: 'Error getting DB client', @@ -2946,8 +2986,6 @@ export const ERROR_MESSAGES = { noProfilePicture: "User doesn't have a profile picture", noCoursesToDelete: "User doesn't have any courses to delete", emailInUse: 'Email is already in use', - noDiskSpace: - 'There is no disk space left to store an image. Please immediately contact your course staff and let them know. They will contact the Khoury Office Hours team as soon as possible.', }, alertController: { duplicateAlert: 'This alert has already been sent', @@ -2970,11 +3008,6 @@ export const ERROR_MESSAGES = { publish: 'Publisher client is unable to publish', clientIdNotFound: 'Client ID not found during subscribing to client', }, - resourcesService: { - noDiskSpace: - 'There is no disk space left to store a iCal file. Please immediately contact your course staff and let them know. They will contact the Khoury Office Hours team as soon as possible.', - saveCalError: 'There was an error saving an iCal to disk', - }, questionType: { questionTypeNotFound: 'Question type not found', }, diff --git a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx index 185c167c5..b32b4744c 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx @@ -152,7 +152,7 @@ const EditCourseForm: React.FC = ({ diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx index 61a4952ff..1450916e0 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx @@ -10,20 +10,22 @@ import EditDocumentChunkModal from './components/EditChatbotDocumentChunkModal' import { AddDocumentChunkParams, SourceDocument } from '@koh/common' import { API } from '@/app/api' import ChunkHelpTooltip from './components/ChunkHelpTooltip' +import { formatDateAndTimeForExcel } from '@/app/utils/timeFormatUtils' interface FormValues { content: string + name: string source: string pageNumber: string } -interface ChatbotDocumentsProps { +interface ChatbotDocumentChunksProps { params: { cid: string } } -export default function ChatbotDocuments({ +export default function ChatbotDocumentChunks({ params, -}: ChatbotDocumentsProps): ReactElement { +}: ChatbotDocumentChunksProps): ReactElement { const courseId = Number(params.cid) const [documents, setDocuments] = useState([]) const [filteredDocuments, setFilteredDocuments] = useState( @@ -36,17 +38,23 @@ 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 now = new Date() const body: AddDocumentChunkParams = { documentText: values.content, metadata: { - name: 'Manually Inserted Information', + name: values.name ?? 'Manually Inserted Info', type: 'inserted_document', source: values.source ?? undefined, loc: values.pageNumber ? { pageNumber: parseInt(values.pageNumber) } : undefined, + shouldProbablyKeepWhenCloning: true, + courseId: courseId, + firstInsertedAt: now, + lastUpdatedAt: now, }, } await API.chatbot.staffOnly @@ -62,6 +70,7 @@ export default function ChatbotDocuments({ } const fetchDocuments = useCallback(async () => { + setDataLoading(true) await API.chatbot.staffOnly .getAllDocumentChunks(courseId) .then((response) => { @@ -76,7 +85,10 @@ export default function ChatbotDocuments({ const errorMessage = getErrorMessage(e) message.error('Failed to load documents: ' + errorMessage) }) - }, [courseId, setDocuments, setFilteredDocuments]) + .finally(() => { + setDataLoading(false) + }) + }, [courseId, setDocuments, setFilteredDocuments, setDataLoading]) useEffect(() => { if (courseId) { @@ -84,7 +96,7 @@ export default function ChatbotDocuments({ } }, [courseId, fetchDocuments]) - const columns = [ + const columns: any[] = [ { title: 'Name', dataIndex: ['metadata', 'name'], @@ -105,7 +117,12 @@ export default function ChatbotDocuments({ prefetch={false} rel="noopener noreferrer" > - {text} + ), @@ -131,6 +148,24 @@ export default function ChatbotDocuments({ key: 'pageNumber', width: 40, }, + { + title: 'Created At', + dataIndex: ['metadata', 'firstInsertedAt'], + key: 'firstInsertedAt', + defaultSortOrder: 'descend', + width: 90, + sorter: (a: SourceDocument, b: SourceDocument) => { + const A = a.metadata?.firstInsertedAt + ? new Date(a.metadata.firstInsertedAt).getTime() + : 0 + const B = b.metadata?.firstInsertedAt + ? new Date(b.metadata.firstInsertedAt).getTime() + : 0 + return A - B + }, + render: (firstInsertedAt: Date) => + formatDateAndTimeForExcel(firstInsertedAt), + }, { title: 'Actions', key: 'actions', @@ -190,7 +225,8 @@ export default function ChatbotDocuments({ const searchTerm = e.target.value.toLowerCase() const filtered = documents.filter((doc) => { const isNameMatch = doc.pageContent - ? doc.pageContent.toLowerCase().includes(searchTerm) + ? doc.pageContent.toLowerCase().includes(searchTerm) || + doc.metadata?.name?.toLowerCase().includes(searchTerm) : false return isNameMatch }) @@ -258,11 +294,18 @@ export default function ChatbotDocuments({ > + + + {/* */} - +
- +
{editingRecord && ( = ({ const handleOkInsert = async () => { const values = await form.validateFields() setSaveLoading(true) + const now = new Date() 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, + firstInsertedAt: now, + lastUpdatedAt: now, + shouldProbablyKeepWhenCloning: true, }, } await API.chatbot.staffOnly diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx index 5e102b5f2..2aca90f24 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx @@ -151,6 +151,7 @@ export default function ChatbotSettings({ .getAllAggregateDocuments(courseId) .then((response) => { const formattedDocuments = response.map((doc) => ({ + ...doc, key: doc.id, docId: doc.id, docName: doc.pageContent, diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx index 0c4e54b4f..610d58881 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx @@ -1,5 +1,5 @@ import { useEffect, useReducer, useState } from 'react' -import { Button, Col, message, Row, Tag, Tooltip } from 'antd' +import { Button, Col, message, Row, Tag, Tooltip, Image } from 'antd' import { AsyncQuestion, asyncQuestionStatus, Role } from '@koh/common' import { CheckCircleOutlined, @@ -24,6 +24,7 @@ import { AsyncQuestionCardUIReducer, initialUIState, } from './AsyncQuestionCardUIReducer' +import SourceLinkCitations from '../../components/chatbot/SourceLinkCitations' const statusDisplayMap = { // if the question has no answer text, it will say "awaiting answer" @@ -359,6 +360,39 @@ const AsyncQuestionCard: React.FC = ({ )} > {{question.questionText ?? ''}} + {question.images && question.images.length > 0 && ( +
+ {question.images.map((image) => ( +
{ + e.stopPropagation() // stop clicks from expanding card + }} + > + +
+ ))} +
+ )} +
+ {question.questionTypes?.map((questionType, index) => ( + + ))} +
+ {question.answerText && ( <>
@@ -383,6 +417,12 @@ const AsyncQuestionCard: React.FC = ({ {thinkText ? cleanAnswer : question.answerText} + {question.citations && question.citations.length > 0 && ( + + )} )} @@ -401,15 +441,6 @@ const AsyncQuestionCard: React.FC = ({ showStudents={showStudents} /> -
- {question.questionTypes?.map((questionType, index) => ( - - ))} -
{question.status === asyncQuestionStatus.AIAnswered && userId === question.creatorId && ( diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index c14f4f84b..f57da2aeb 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -8,6 +8,8 @@ import { Tooltip, Button, Popconfirm, + Upload, + Image, } from 'antd' import { useUserInfo } from '@/app/contexts/userContext' import { useQuestionTypes } from '@/app/hooks/useQuestionTypes' @@ -15,15 +17,40 @@ import { QuestionTagSelector } from '../../../components/QuestionTagElement' import { API } from '@/app/api' import { getErrorMessage } from '@/app/utils/generalUtils' import { AsyncQuestion, asyncQuestionStatus } from '@koh/common' -import { DeleteOutlined } from '@ant-design/icons' +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' import { deleteAsyncQuestion } from '../../utils/commonAsyncFunctions' import { useCourseFeatures } from '@/app/hooks/useCourseFeatures' +import type { GetProp, UploadFile, UploadProps } from 'antd' + +// stuff from antd example code for upload and form +type FileType = Parameters>[0] +const getBase64 = (file: FileType): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result as string) + reader.onerror = (error) => reject(error) + }) +const UploadButton: React.FC = () => ( + +) +/* I think this is just to make sure the file list is an array */ +const normFile = (e: any) => { + if (Array.isArray(e)) { + return e + } + return e?.fileList +} interface FormValues { QuestionAbstract: string questionText: string questionTypesInput: number[] refreshAIAnswer: boolean + images: UploadFile[] } interface CreateAsyncQuestionModalProps { @@ -41,34 +68,57 @@ const CreateAsyncQuestionModal: React.FC = ({ onCreateOrUpdateQuestion, question, }) => { - const { userInfo } = useUserInfo() + const { userInfo, setUserInfo } = useUserInfo() const [questionTypes] = useQuestionTypes(courseId, null) const [form] = Form.useForm() const [isLoading, setIsLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false) const courseFeatures = useCourseFeatures(courseId) + const [previewOpen, setPreviewOpen] = useState(false) + const [previewImage, setPreviewImage] = useState('') - const getAiAnswer = async (question: string) => { - if (!courseFeatures?.asyncCentreAIAnswers) { - return '' + const handlePreviewImage = async (file: UploadFile) => { + if (!file.url && !file.preview) { + file.preview = await getBase64(file.originFileObj as FileType) } - try { - if (userInfo.chat_token.used < userInfo.chat_token.max_uses) { - const data = { - question: question, - history: [], - onlySaveInChatbotDB: true, + + setPreviewImage(file.url || (file.preview as string)) + setPreviewOpen(true) + } + + // Handle pasting images from clipboard + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData.items + const MAX_IMAGES = 8 + const currentImages = form.getFieldValue('images') || [] + + if (currentImages.length >= MAX_IMAGES) { + message.warning(`Maximum ${MAX_IMAGES} images allowed`) + return + } + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile() + if (file) { + // Create a unique name for the pasted image + const fileName = `pasted-image-${Date.now()}.png` + + const uploadFile = { + uid: `paste-${Date.now()}`, + name: fileName, + status: 'done', + originFileObj: file, + } as UploadFile + + // Add the pasted image to the form + const newFileList = [...currentImages, uploadFile] + form.setFieldsValue({ images: newFileList }) + + message.success('Image pasted successfully') + break // Only process one image per paste event } - const response = await API.chatbot.studentsOrStaff.askQuestion( - courseId, - data, - ) - return response.chatbotRepoVersion.answer - } else { - return 'All AI uses have been used up for today. Please try again tomorrow.' } - } catch (e) { - return '' } } @@ -83,86 +133,104 @@ const CreateAsyncQuestionModal: React.FC = ({ // If editing a question, update the question. Else create a new one if (question) { + // create FormData for the request + const formData = new FormData() + formData.append('questionText', values.questionText || '') + formData.append('questionAbstract', values.QuestionAbstract) + formData.append('questionTypes', JSON.stringify(newQuestionTypeInput)) if (values.refreshAIAnswer) { - await getAiAnswer( - ` - Question Abstract: ${values.QuestionAbstract} - Question Text: ${values.questionText} - Question Types: ${newQuestionTypeInput.map((questionType) => questionType.name).join(', ')} - `, - ).then(async (aiAnswer) => { - await API.asyncQuestions - .studentUpdate(question.id, { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - questionAbstract: values.QuestionAbstract, - aiAnswerText: aiAnswer, - answerText: aiAnswer, - }) - .then(() => { - message.success('Question Updated') - setIsLoading(false) - onCreateOrUpdateQuestion() - }) - .catch((e) => { - const errorMessage = getErrorMessage(e) - message.error('Error updating question:' + errorMessage) - setIsLoading(false) - }) + formData.append('refreshAIAnswer', 'true') + } + + // to find out what images are deleted, compare question.images.imageId with values.images.uid + const deletedImageIds = question.images + .filter( + (image) => + !values.images.some((file) => Number(file.uid) === image.imageId), + ) + .map((image) => image.imageId) + formData.append('deletedImageIds', JSON.stringify(deletedImageIds)) + console.log(deletedImageIds) + + // to find out what images are new, get all values.images where uid is NaN + const newImages = values.images.filter((file) => isNaN(Number(file.uid))) + console.log(newImages) + + // Append each new image file + if (newImages) { + newImages.forEach((file: any) => { + // Only append if it's a real file (antd's Upload component adds some metadata we don't want) + if (file.originFileObj) { + formData.append('newImages', file.originFileObj) + } }) - } else { - await API.asyncQuestions - .studentUpdate(question.id, { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - questionAbstract: values.QuestionAbstract, - }) - .then(() => { - message.success('Question Updated') - onCreateOrUpdateQuestion() - }) - .catch((e) => { - const errorMessage = getErrorMessage(e) - message.error('Error updating question:' + errorMessage) - }) - .finally(() => { - setIsLoading(false) - }) } + + await API.asyncQuestions + .studentUpdate(question.id, formData) + .then(() => { + message.success('Question Updated') + onCreateOrUpdateQuestion() + }) + .catch((e) => { + const errorMessage = getErrorMessage(e) + message.error('Error updating question:' + errorMessage) + }) + .finally(() => { + if (values.refreshAIAnswer) { + setUserInfo({ + ...userInfo, + chat_token: { + ...userInfo.chat_token, + used: userInfo.chat_token.used + 1, + }, + }) + } + setIsLoading(false) + }) } else { - // since the ai chatbot may not be running, we don't have a catch statement if it fails and instead we just give it a question text of '' - await getAiAnswer( - ` - Question Abstract: ${values.QuestionAbstract} - Question Text: ${values.questionText} - Question Types: ${newQuestionTypeInput.map((questionType) => questionType.name).join(', ')} - `, - ).then(async (aiAnswer) => { - await API.asyncQuestions - .create( - { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - aiAnswerText: aiAnswer, - answerText: aiAnswer, - questionAbstract: values.QuestionAbstract, - status: courseFeatures?.asyncCentreAIAnswers - ? asyncQuestionStatus.AIAnswered - : asyncQuestionStatus.AIAnsweredNeedsAttention, + // Create FormData for the request + const formData = new FormData() + formData.append('questionText', values.questionText || '') + formData.append('questionAbstract', values.QuestionAbstract) + formData.append('questionTypes', JSON.stringify(newQuestionTypeInput)) + formData.append( + 'status', + courseFeatures?.asyncCentreAIAnswers + ? asyncQuestionStatus.AIAnswered + : asyncQuestionStatus.AIAnsweredNeedsAttention, + ) + + // Append each image file + if (values.images) { + values.images.forEach((file: any) => { + // Only append if it's a real file (antd's Upload component adds some metadata we don't want) + if (file.originFileObj) { + formData.append('images', file.originFileObj) + } + }) + } + + await API.asyncQuestions + .create(formData, courseId) + .then(() => { + message.success('Question Posted') + onCreateOrUpdateQuestion() + }) + .catch((e) => { + const errorMessage = getErrorMessage(e) + message.error('Error creating question:' + errorMessage) + }) + .finally(() => { + setUserInfo({ + ...userInfo, + chat_token: { + ...userInfo.chat_token, + used: userInfo.chat_token.used + 1, }, - courseId, - ) - .then(() => { - message.success('Question Posted') - setIsLoading(false) - onCreateOrUpdateQuestion() }) - .catch((e) => { - const errorMessage = getErrorMessage(e) - message.error('Error creating question:' + errorMessage) - setIsLoading(false) - }) - }) + setIsLoading(false) + }) } } @@ -231,6 +299,11 @@ const CreateAsyncQuestionModal: React.FC = ({ (questionType) => questionType.id, ) : [], + images: question?.images.map((image) => ({ + uid: image.imageId, + name: image.originalFileName, + url: `/api/v1/asyncQuestions/${courseId}/image/${image.imageId}`, + })), }} clearOnDestroy onFinish={(values) => onFinish(values)} @@ -263,14 +336,46 @@ const CreateAsyncQuestionModal: React.FC = ({ + + + {form.getFieldValue('images')?.length >= 8 ? null : } + + + {previewImage && ( + setPreviewOpen(visible), + afterOpenChange: (visible) => !visible && setPreviewImage(''), + }} + src={previewImage} + alt={`Preview of ${previewImage}`} + /> + )} {questionTypes && questionTypes.length > 0 && ( = ({ const [form] = Form.useForm() const [isLoading, setIsLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false) + const [saveToChatbot, setSaveToChatbot] = useState(true) + const [deleteCitations, setDeleteCitations] = useState(false) // put into state rather than FormValues since we need to change(re-render) how the source links appear when toggled const onFinish = async (values: FormValues) => { setIsLoading(true) @@ -59,6 +66,8 @@ const PostResponseModal: React.FC = ({ visible: values.visible, status: newStatus, verified: values.verified, + saveToChatbot: saveToChatbot, + deleteCitations: deleteCitations, }) .then(() => { message.success('Response Successfully Posted/Edited') @@ -77,8 +86,19 @@ const PostResponseModal: React.FC = ({ = ({ onCancel={onCancel} // display delete button for mobile in footer footer={(_, { OkBtn, CancelBtn }) => ( -
- trigger.parentNode as HTMLElement} - okButtonProps={{ loading: deleteLoading }} - onConfirm={async () => { - setDeleteLoading(true) - await deleteAsyncQuestion(question.id, true, onPostResponse) - setDeleteLoading(false) - }} - > - - -
+
+
+ trigger.parentNode as HTMLElement} + okButtonProps={{ loading: deleteLoading }} + onConfirm={async () => { + setDeleteLoading(true) + await deleteAsyncQuestion(question.id, true, onPostResponse) + setDeleteLoading(false) + }} + > + + +
+
+ setSaveToChatbot(e.target.checked)} + // checkboxes will automatically put its children into a span with some padding, so this targets it to get rid of the padding + className="[&>span]:!pr-0" + > + + + Save to Chatbot + + + +
)} @@ -123,7 +161,7 @@ const PostResponseModal: React.FC = ({ visible: question.visible, verified: question.verified, }} - clearOnDestroy + // clearOnDestroy onFinish={(values) => onFinish(values)} > {dom} @@ -138,25 +176,62 @@ const PostResponseModal: React.FC = ({ + + + ), + }} + onClear={() => { + form.setFieldsValue({ + answerText: question.answerText, + }) + }} /> + {question.citations && question.citations.length > 0 && ( + +
+ + + setDeleteCitations(e.target.checked)} + /> + +
+
+ )} - Set question visible to all students - - - -
- } + tooltip="Questions can normally only be seen by staff and the student who asked it. This will make it visible to all students (the student themselves will appear anonymous to other students)" + label="Set question visible to all students" valuePropName="checked" + layout="horizontal" > - - Mark as verified by faculty + + ) diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx index cad09385d..ff48d7017 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx @@ -352,7 +352,13 @@ export default function AsyncCentrePage({ } /> -
+ {/* Learnt a thing: So flex items have a default of min-width: auto, which prevents them + from shrinking below their content's natural width. So, if there's some child element that wants + more width, it will cause all question cards to grow past the parent's max width (causing an overflow). + Doing overflow-hidden will cause outlines to be cut off. So, the real solution here is to add + min-w-0, which overrides min-width: auto and thus allows this flex item to shrink smaller than + its content's natural width. */} +
{/* Filters on DESKTOP ONLY */}

diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx index 76e7ab6ec..ae95fafd2 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx @@ -37,6 +37,8 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { PreDeterminedQuestion, Role, Message } from '@koh/common' import { Bot } from 'lucide-react' +import SourceLinkCitationButton from './SourceLinkCitationButton' +import SourceLinkCitations from './SourceLinkCitations' const { TextArea } = Input @@ -243,13 +245,6 @@ const Chatbot: React.FC = ({ setInput('') } - const extractLMSLink = (content?: string) => { - if (!content) return undefined - const idx = content.indexOf('Page Link:') - if (idx < 0) return undefined - return content.substring(idx + 'Page Link:'.length).trim() - } - if (!cid || !courseFeatures?.chatBotEnabled) { return <> } else { @@ -448,68 +443,10 @@ const Chatbot: React.FC = ({ )}

-
- {item.sourceDocuments && - chatbotQuestionType === 'System' ? ( -
-

User Guide

- -
- ) : ( - item.sourceDocuments && - item.sourceDocuments.map( - (sourceDocument, idx) => ( - -
-

- {sourceDocument.docName} -

- {sourceDocument.type == - 'inserted_lms_document' && - extractLMSLink( - sourceDocument.content, - ) && ( - - )} - {sourceDocument.pageNumbers && - sourceDocument.pageNumbers.map( - (part) => ( - - ), - )} -
-
- ), - ) - )} -
+ {item.type === 'apiMessage' && index === messages.length - 1 && index !== 0 && ( @@ -626,36 +563,3 @@ const Chatbot: React.FC = ({ } export default Chatbot - -const SourceLinkButton: React.FC<{ - docName: string - sourceLink?: string - part?: number -}> = ({ docName, sourceLink, part }) => { - if (!sourceLink) { - return null - } - const pageNumber = part && !isNaN(part) ? Number(part) : undefined - - return ( - -

- {part ? `p. ${part}` : 'Source'} -

-
- ) -} diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx new file mode 100644 index 000000000..c6244aaa6 --- /dev/null +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx @@ -0,0 +1,44 @@ +import { Tooltip } from 'antd' + +const SourceLinkCitationButton: React.FC<{ + docName: string + sourceLink?: string + part?: number + documentText?: string +}> = ({ docName, sourceLink, part, documentText }) => { + if (!sourceLink) { + return null + } + const pageNumber = part && !isNaN(part) ? Number(part) : undefined + + return ( + + +

+ {part ? `p. ${part}` : 'Source'} +

+
+
+ ) +} + +export default SourceLinkCitationButton diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx new file mode 100644 index 000000000..b6b15e304 --- /dev/null +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx @@ -0,0 +1,87 @@ +import { SourceDocument } from '@koh/common' +import { ChatbotQuestionType } from '@/app/typings/chatbot' +import SourceLinkCitationButton from './SourceLinkCitationButton' +import { Tooltip } from 'antd' + +interface SourceLinkCitationsProps { + sourceDocuments: SourceDocument[] | undefined + chatbotQuestionType: ChatbotQuestionType + appearDeleted?: boolean +} + +const extractLMSLink = (content?: string) => { + if (!content) return undefined + const idx = content.indexOf('Page Link:') + if (idx < 0) return undefined + return content.substring(idx + 'Page Link:'.length).trim() +} + +const SourceLinkCitations: React.FC = ({ + sourceDocuments, + chatbotQuestionType, + appearDeleted = false, +}) => { + if (!sourceDocuments) return null + return ( +
+ {chatbotQuestionType === 'System' ? ( +
+

User Guide

+ +
+ ) : ( + sourceDocuments.map((sourceDocument, idx) => ( + +
+ {appearDeleted ? ( + +

+ {sourceDocument.docName} +

+
+ ) : ( +

{sourceDocument.docName}

+ )} + {sourceDocument.type == 'inserted_lms_document' && + extractLMSLink(sourceDocument.content) && ( + + )} + {sourceDocument.pageNumbers && + sourceDocument.pageNumbers.map((part) => ( + + ))} +
+
+ )) + )} +
+ ) +} + +export default SourceLinkCitations diff --git a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx index a33de203e..3543a2985 100644 --- a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx +++ b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx @@ -188,7 +188,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..f3d905885 --- /dev/null +++ b/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx @@ -0,0 +1,71 @@ +import { API } from '@/app/api' +import { userApi } from '@/app/api/userApi' +import { useUserInfo } from '@/app/contexts/userContext' +import { getErrorMessage } from '@/app/utils/generalUtils' +import { + DeleteOutlined, + QuestionCircleOutlined, + SettingOutlined, +} from '@ant-design/icons' +import { Button, Card, message, Tooltip } from 'antd' +import { useState } from 'react' + +const AdvancedSettings: React.FC = () => { + const { userInfo, setUserInfo } = useUserInfo() + const [isLoading, setIsLoading] = useState(false) + + return ( + userInfo && ( + + Advanced Settings + + } + bordered + classNames={{ body: 'py-2' }} + > + + Clear Profile Cache + + + + + ) + ) +} + +export default AdvancedSettings diff --git a/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx b/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx index 474053474..b36b2321d 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 @@ -53,12 +52,6 @@ const CoursePreference: React.FC = () => { }) } - const InstructorCell = ({ courseId }: { courseId: number }) => { - const course = useCourse(courseId) - - return <>{course.course?.coordinator_email} - } - const columns: TableColumnsType = [ { title: 'Course name', @@ -129,4 +122,14 @@ const CoursePreference: React.FC = () => { ) } +const InstructorCell = ({ courseId }: { courseId: number }) => { + const course = useCourse(courseId) + + return ( +
+ {course.course?.coordinator_email} +
+ ) +} + export default CoursePreference diff --git a/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx b/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx index ada667ed0..bbb2cc5f5 100644 --- a/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx @@ -9,23 +9,24 @@ import EditProfile from './EditProfile' import NotificationsSettings from './NotificationsSettings' import CoursePreference from './CoursePreference' import EmailNotifications from './EmailNotifications' -import { useSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation' +import AdvancedSettings from './AdvancedSettings' const ProfileSettings: React.FC = () => { const params = useSearchParams() - const [currentSettings, setCurrentSettings] = useState( - () => { - switch(params.get("page")) { - case "notifications": - return SettingsOptions.NOTIFICATIONS - case "preferences": - return SettingsOptions.PREFERENCES - default: - return SettingsOptions.PROFILE - } + const [currentSettings, setCurrentSettings] = useState(() => { + switch (params.get('page')) { + case 'notifications': + return SettingsOptions.NOTIFICATIONS + case 'preferences': + return SettingsOptions.PREFERENCES + case 'advanced': + return SettingsOptions.ADVANCED + default: + return SettingsOptions.PROFILE } - ) + }) return ( @@ -34,7 +35,10 @@ const ProfileSettings: React.FC = () => { className="mx-auto mt-2 h-fit w-full max-w-max text-center md:mx-0 md:mt-0" > - +
{ {currentSettings === SettingsOptions.PREFERENCES && ( )} + {currentSettings === SettingsOptions.ADVANCED && } diff --git a/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx b/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx index e48d7ed1b..0c62942e6 100644 --- a/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx @@ -2,18 +2,27 @@ import { Collapse, Menu } from 'antd' import EditProfile from './EditProfile' -import { BellOutlined, BookOutlined, UserOutlined } from '@ant-design/icons' +import { + BellOutlined, + BookOutlined, + SettingOutlined, + UserOutlined, +} from '@ant-design/icons' import { SettingsOptions } from '@/app/typings/enum' import NotificationsSettings from './NotificationsSettings' import CoursePreference from './CoursePreference' import { useMediaQuery } from '@/app/hooks/useMediaQuery' import EmailNotifications from './EmailNotifications' +import AdvancedSettings from './AdvancedSettings' interface SettingsMenuProps { - currentSettings: SettingsOptions; + currentSettings: SettingsOptions setCurrentSettings: (settings: SettingsOptions) => void } -const SettingsMenu: React.FC = ({ currentSettings, setCurrentSettings }) => { +const SettingsMenu: React.FC = ({ + currentSettings, + setCurrentSettings, +}) => { const isMobile = useMediaQuery('(max-width: 768px)') return isMobile ? ( @@ -42,6 +51,11 @@ const SettingsMenu: React.FC = ({ currentSettings, setCurrent label: 'Course Preferences', children: , }, + { + key: SettingsOptions.ADVANCED, + label: 'Advanced Settings', + children: , + }, ]} /> ) : ( @@ -65,6 +79,11 @@ const SettingsMenu: React.FC = ({ currentSettings, setCurrent label: 'Course Preferences', icon: , }, + { + key: SettingsOptions.ADVANCED, + label: 'Advanced Settings', + icon: , + }, ]} /> ) diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 649e9c058..2dc949ba3 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -160,6 +160,8 @@ 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 = { @@ -514,14 +516,14 @@ class APIClient { asyncQuestions = { get: async (cid: number): Promise => this.req('GET', `/api/v1/asyncQuestions/${cid}`, undefined), - create: async (body: CreateAsyncQuestions, cid: number) => + create: async (body: CreateAsyncQuestions | FormData, cid: number) => this.req( 'POST', `/api/v1/asyncQuestions/${cid}`, AsyncQuestionParams, body, ), - studentUpdate: async (qid: number, body: UpdateAsyncQuestions) => + studentUpdate: async (qid: number, body: UpdateAsyncQuestions | FormData) => this.req( 'PATCH', `/api/v1/asyncQuestions/student/${qid}`, diff --git a/packages/frontend/app/typings/enum.ts b/packages/frontend/app/typings/enum.ts index 4fe4996d2..9c01289e4 100644 --- a/packages/frontend/app/typings/enum.ts +++ b/packages/frontend/app/typings/enum.ts @@ -3,4 +3,5 @@ export enum SettingsOptions { NOTIFICATIONS = 'NOTIFICATIONS', TEAMS_SETTINGS = 'TEAMS_SETTINGS', PREFERENCES = 'PREFERENCES', + ADVANCED = 'ADVANCED', } diff --git a/packages/server/ormconfig.ts b/packages/server/ormconfig.ts index 9c04e1512..9efff8a1a 100644 --- a/packages/server/ormconfig.ts +++ b/packages/server/ormconfig.ts @@ -16,7 +16,7 @@ import { QuestionTypeModel } from './src/questionType/question-type.entity'; import { AsyncQuestionModel } from './src/asyncQuestion/asyncQuestion.entity'; import { ChatbotQuestionModel } from './src/chatbot/question.entity'; import { InteractionModel } from './src/chatbot/interaction.entity'; -import { QuestionDocumentModel } from './src/chatbot/questionDocument.entity'; +import { ChatbotQuestionSourceDocumentCitationModel } from './src/chatbot/questionDocument.entity'; import { CalendarModel } from './src/calendar/calendar.entity'; import { CalendarStaffModel } from './src/calendar/calendar-staff.entity'; import { OrganizationUserModel } from './src/organization/organization-user.entity'; @@ -40,6 +40,7 @@ import { LMSAnnouncementModel } from './src/lmsIntegration/lmsAnnouncement.entit import { UnreadAsyncQuestionModel } from './src/asyncQuestion/unread-async-question.entity'; import { AsyncQuestionCommentModel } from './src/asyncQuestion/asyncQuestionComment.entity'; import { ChatbotDocPdfModel } from './src/chatbot/chatbot-doc-pdf.entity'; +import { AsyncQuestionImageModel } from './src/asyncQuestion/asyncQuestionImage.entity'; import { isProd } from '@koh/common'; import * as fs from 'fs'; @@ -95,7 +96,7 @@ const typeorm = { CalendarModel, CalendarStaffModel, LastRegistrationModel, - QuestionDocumentModel, + ChatbotQuestionSourceDocumentCitationModel, OrganizationUserModel, OrganizationModel, OrganizationCourseModel, @@ -115,6 +116,7 @@ const typeorm = { UnreadAsyncQuestionModel, LMSAnnouncementModel, ChatbotDocPdfModel, + AsyncQuestionImageModel, ], keepConnectionAlive: true, logging: diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index 0c6b5bd8f..5bb93f249 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -25,6 +25,11 @@ import { Post, Res, UseGuards, + UseInterceptors, + UploadedFiles, + Query, + BadRequestException, + InternalServerErrorException, } from '@nestjs/common'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Roles } from '../decorators/roles.decorator'; @@ -40,11 +45,16 @@ import { CourseRolesGuard } from 'guards/course-roles.guard'; import { AsyncQuestionRolesGuard } from 'guards/async-question-roles.guard'; import { pick } from 'lodash'; import { UserModel } from 'profile/user.entity'; -import { Not } from 'typeorm'; +import { getManager, In, Not } from 'typeorm'; import { ApplicationConfigService } from '../config/application_config.service'; -import { AsyncQuestionService } from './asyncQuestion.service'; +import { ChatbotApiService } from 'chatbot/chatbot-api.service'; +import { AsyncQuestionService, tempFile } from './asyncQuestion.service'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; - +import { FilesInterceptor } from '@nestjs/platform-express'; +import { QuestionTypeModel } from 'questionType/question-type.entity'; +import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; +import { RedisProfileService } from 'redisProfile/redis-profile.service'; +import { ChatbotQuestionSourceDocumentCitationModel } from 'chatbot/questionDocument.entity'; @Controller('asyncQuestions') @UseGuards(JwtAuthGuard, EmailVerifiedGuard) export class asyncQuestionController { @@ -52,6 +62,8 @@ export class asyncQuestionController { private readonly redisQueueService: RedisQueueService, private readonly appConfig: ApplicationConfigService, private readonly asyncQuestionService: AsyncQuestionService, + private readonly chatbotApiService: ChatbotApiService, + private readonly redisProfileService: RedisProfileService, ) {} @Post('vote/:qid/:vote') @@ -110,6 +122,8 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', + 'citations', ], }); @@ -132,149 +146,400 @@ export class asyncQuestionController { @Post(':cid') @UseGuards(CourseRolesGuard) @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) // we let staff post questions too since they might want to use the system for demonstration purposes + @UseInterceptors( + FilesInterceptor('images', 8, { + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit per file + }, + fileFilter: (req, file, cb) => { + // Check mimetype (it can be spoofed fyi so we also need to check the file extension) + 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 createQuestion( - @Body() body: CreateAsyncQuestions, + @Body() body: CreateAsyncQuestions | FormData | any, // delete FormData | any for better type checking temporarily @Param('cid', ParseIntPipe) cid: number, - @UserId() userId: number, + @User(['chat_token']) user: UserModel, @Res() res: Response, - ): Promise { - try { - const question = await AsyncQuestionModel.create({ - courseId: cid, - creatorId: userId, - questionAbstract: body.questionAbstract, - questionText: body.questionText || null, - answerText: body.answerText || null, - aiAnswerText: body.aiAnswerText, - questionTypes: body.questionTypes, - status: body.status || asyncQuestionStatus.AIAnswered, - visible: false, - verified: false, - createdAt: new Date(), - }).save(); - - const newQuestion = await AsyncQuestionModel.findOne({ - where: { + @UploadedFiles() images?: Express.Multer.File[], + ): Promise { + this.asyncQuestionService.validateBodyCreateAsyncQuestions(body); // note that this *will* mutate body + + // Convert & resize images to buffers if they exist + let processedImageBuffers: tempFile[] = []; + if (images && images.length > 0) { + processedImageBuffers = + await this.asyncQuestionService.convertAndResizeImages(images); + } + + let questionId: number | null = null; + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + /* order: + 1. create the question to get async question id + 2. save the images to the database to get their ids + 3. query the chatbot to get the ai answer and the image summaries + 4. update the question with the ai answer and the images with their summaries + */ + const question = await this.asyncQuestionService.createAsyncQuestion( + // this also saves the images to the db + { courseId: cid, - id: question.id, + creatorId: user.id, + questionAbstract: body.questionAbstract, + questionText: body.questionText || null, + answerText: '', // both start as an empty string + aiAnswerText: '', + questionTypes: body.questionTypes as QuestionTypeModel[], + status: body.status || asyncQuestionStatus.AIAnswered, + visible: false, + verified: false, + createdAt: new Date(), }, - relations: [ - 'creator', - 'taHelped', - 'votes', - 'comments', - 'comments.creator', - 'comments.creator.courses', - ], - }); - - await this.redisQueueService.addAsyncQuestion(`c:${cid}:aq`, newQuestion); - await this.asyncQuestionService.createUnreadNotificationsForQuestion( - newQuestion, + processedImageBuffers, + transactionalEntityManager, ); + // now that we have the images, their ids, and the rest of the question, query the chatbot to get both the ai answer and the image summaries (and then store everything in the db) + let aiAnswerText: string | null = null; + if (user.chat_token.used >= user.chat_token.max_uses) { + aiAnswerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + } else { + const chatbotResponse = await this.chatbotApiService.askQuestion( + this.asyncQuestionService.formatQuestionTextForChatbot( + question, + false, + ), + [], + user.chat_token.token, + cid, + processedImageBuffers, + true, + ); + aiAnswerText = chatbotResponse.answer; + const imageDescriptions = chatbotResponse.imageDescriptions; + + // update the question with the ai answer text + question.aiAnswerText = aiAnswerText; + question.answerText = aiAnswerText; // answer text initially becomes the ai answer text (staff can later edit it) + await transactionalEntityManager.save(question); + + await this.asyncQuestionService.saveImageDescriptions( + imageDescriptions, + transactionalEntityManager, + ); + + await this.asyncQuestionService.saveCitations( + chatbotResponse.sourceDocuments, + question, + transactionalEntityManager, + ); + } + questionId = question.id; + }); - res.status(HttpStatus.CREATED).send(newQuestion); - return; - } catch (err) { - console.error(err); - res - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .send({ message: ERROR_MESSAGES.questionController.saveQError }); - return; + if (!questionId) { + throw new InternalServerErrorException('Failed to create question'); } - } - @Patch('student/:questionId') - @UseGuards(AsyncQuestionRolesGuard) - @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) // since were letting staff post questions, they might end up calling this endpoint to update their own questions - async updateStudentQuestion( - @Param('questionId', ParseIntPipe) questionId: number, - @Body() body: UpdateAsyncQuestions, - @UserId() userId: number, - ): Promise { - const question = await AsyncQuestionModel.findOne({ - where: { id: questionId }, + const newQuestion = await AsyncQuestionModel.findOne({ + where: { + courseId: cid, + id: questionId, + }, relations: [ 'creator', + 'taHelped', 'votes', 'comments', 'comments.creator', 'comments.creator.courses', + 'images', + 'citations', ], }); - // deep copy question since it changes - const oldQuestion: AsyncQuestionModel = JSON.parse( - JSON.stringify(question), + + await this.redisQueueService.addAsyncQuestion(`c:${cid}:aq`, newQuestion); + await this.asyncQuestionService.createUnreadNotificationsForQuestion( + newQuestion, ); - if (!question) { - throw new NotFoundException('Question Not Found'); - } + res.status(HttpStatus.CREATED).send(newQuestion); + return; + } - if (question.creatorId !== userId) { - throw new ForbiddenException('You can only update your own questions'); - } - // if you created the question (i.e. a student), you can't update the status to illegal ones - if ( - body.status === asyncQuestionStatus.TADeleted || - body.status === asyncQuestionStatus.HumanAnswered - ) { - throw new ForbiddenException( - `You cannot update your own question's status to ${body.status}`, - ); - } - if ( - body.status === asyncQuestionStatus.AIAnsweredNeedsAttention && - question.status != asyncQuestionStatus.AIAnsweredNeedsAttention - ) { - await this.asyncQuestionService.sendNeedsAttentionEmail(question); - } + @Patch('student/:questionId') + @UseGuards(AsyncQuestionRolesGuard) + @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) // since were letting staff post questions, they might end up calling this endpoint to update their own questions + @UseInterceptors( + FilesInterceptor('newImages', 8, { + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit per file + }, + fileFilter: (req, file, cb) => { + // Check mimetype (it can be spoofed fyi so we also need to check the file extension) + 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 updateQuestionStudent( + @Param('questionId', ParseIntPipe) questionId: number, + @Body() body: UpdateAsyncQuestions | FormData | any, // delete FormData | any for better type checking temporarily + @User(['chat_token']) user: UserModel, + @UploadedFiles() newImages?: Express.Multer.File[], + ): Promise { + this.asyncQuestionService.validateBodyUpdateAsyncQuestions(body); // note that this *will* mutate body - // Update allowed fields - Object.keys(body).forEach((key) => { - if (body[key] !== undefined && body[key] !== null) { - question[key] = body[key]; + let question: AsyncQuestionModel | null = null; + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + question = await transactionalEntityManager.findOne(AsyncQuestionModel, { + where: { id: questionId }, + relations: [ + 'creator', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + ], + }); + if (!question) { + throw new NotFoundException('Question Not Found'); + } + if (question.creatorId !== user.id) { + throw new ForbiddenException('You can only update your own questions'); } - }); - - const updatedQuestion = await question.save(); - - // Mark as new unread for all staff if the question needs attention - if ( - body.status === asyncQuestionStatus.AIAnsweredNeedsAttention && - oldQuestion.status !== asyncQuestionStatus.AIAnsweredNeedsAttention - ) { - await this.asyncQuestionService.markUnreadForRoles( - updatedQuestion, - [Role.TA, Role.PROFESSOR], - userId, - ); - } - // if the question is visible and they rewrote their question and got a new answer text, mark it as unread for everyone - if ( - updatedQuestion.visible && - body.aiAnswerText !== oldQuestion.aiAnswerText && - body.questionText !== oldQuestion.questionText - ) { - await this.asyncQuestionService.markUnreadForAll(updatedQuestion, userId); - } - if (body.status === asyncQuestionStatus.StudentDeleted) { - await this.redisQueueService.deleteAsyncQuestion( - `c:${question.courseId}:aq`, - updatedQuestion, - ); - // delete all unread notifications for this question - await UnreadAsyncQuestionModel.delete({ asyncQuestionId: questionId }); - } else { - await this.redisQueueService.updateAsyncQuestion( - `c:${question.courseId}:aq`, - updatedQuestion, + // deep copy question since it changes + const oldQuestion: AsyncQuestionModel = JSON.parse( + JSON.stringify(question), ); - } - delete question.taHelped; - delete question.votes; + // this doesn't include if question types have changed but meh + const isChangingQuestion = + question.questionText !== oldQuestion.questionText || + question.questionAbstract !== oldQuestion.questionAbstract || + (newImages && newImages.length > 0) || + (body.deletedImageIds && body.deletedImageIds.length > 0); + + // if they're changing the status to needs attention, send the email but don't change anything else + if ( + body.status === asyncQuestionStatus.AIAnsweredNeedsAttention && + question.status != asyncQuestionStatus.AIAnsweredNeedsAttention + ) { + await this.asyncQuestionService.sendNeedsAttentionEmail(question); + // Mark as new unread for all staff if the question needs attention + await this.asyncQuestionService.markUnreadForRoles( + question, + [Role.TA, Role.PROFESSOR], + user.id, + transactionalEntityManager, + ); + } else { + // Update allowed fields + Object.keys(body).forEach((key) => { + if ( + body[key] !== undefined && + body[key] !== null && + question[key] !== undefined + ) { + question[key] = body[key]; + } + }); + // delete any images that are in the deletedImageIds array + if (body.deletedImageIds && body.deletedImageIds.length > 0) { + await this.asyncQuestionService.deleteImages( + questionId, + body.deletedImageIds, + transactionalEntityManager, + ); + } + + // Convert & resize images to buffers if they exist + let processedImageBuffers: tempFile[] = []; + if (newImages && newImages.length > 0) { + const currentImageCount = + await this.asyncQuestionService.getCurrentImageCount( + questionId, + transactionalEntityManager, + ); + if (currentImageCount + newImages.length > 8) { + throw new BadRequestException( + 'You can have at most 8 images uploaded per question', + ); + } + processedImageBuffers = + await this.asyncQuestionService.convertAndResizeImages(newImages); + // save the images to the db (while also setting the imageIds of processedImageBuffers) + await this.asyncQuestionService.saveImagesToDb( + question, + processedImageBuffers, + transactionalEntityManager, + ); + } + + if (body.refreshAIAnswer) { + if (user.chat_token.used >= user.chat_token.max_uses) { + question.aiAnswerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + question.answerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + } else { + // before we ask the chatbot, we need to gather any previously uploaded images from the database and append them onto processedImageBuffers + const alreadyProcessedImageIds = processedImageBuffers.map( + (image) => image.imageId, + ); + const images = await transactionalEntityManager.find( + AsyncQuestionImageModel, + { + where: { + asyncQuestionId: questionId, + imageId: Not(In(alreadyProcessedImageIds)), // don't retrieve from the db the image buffers we just uploaded to it (to save memory) + }, + select: ['imageBuffer', 'newFileName', 'imageId'], // not including the old aiSummaries since getting a new description for them may give a better ai answer + }, + ); + processedImageBuffers.push( + ...images.map((image) => ({ + processedBuffer: image.imageBuffer, + previewBuffer: image.imageBuffer, // unused. Didn't want to include it in the query so that less memory is used + originalFileName: image.originalFileName, + newFileName: image.newFileName, + imageId: image.imageId, + })), + ); + + const chatbotResponse = await this.chatbotApiService.askQuestion( + this.asyncQuestionService.formatQuestionTextForChatbot( + question, + false, + ), + [], + user.chat_token.token, + question.courseId, + processedImageBuffers, + true, + ); + question.aiAnswerText = chatbotResponse.answer; + question.answerText = chatbotResponse.answer; + await this.asyncQuestionService.saveImageDescriptions( + chatbotResponse.imageDescriptions, + transactionalEntityManager, + ); + // first delete old citations, then add the new ones + await this.asyncQuestionService.deleteExistingCitations( + questionId, + transactionalEntityManager, + ); + await this.asyncQuestionService.saveCitations( + chatbotResponse.sourceDocuments, + question, + transactionalEntityManager, + ); + } + // delete the old redis cache since chat_token 'used' got updated (only do if max_uses - used < 10) + if (user.chat_token.max_uses - user.chat_token.used < 10) { + await this.redisProfileService.deleteProfile(`u:${user.id}`); + } + } + // save the changes + await transactionalEntityManager.save(question); + + // re-fetch the updated question + const updatedQuestion = await transactionalEntityManager.findOne( + AsyncQuestionModel, + { + where: { id: questionId }, + relations: [ + 'creator', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + 'images', + 'citations', + ], + }, + ); + // if the question is visible and they rewrote their question and got a new answer text, mark it as unread for everyone + if ( + updatedQuestion.visible && + body.refreshAIAnswer && + isChangingQuestion + ) { + await this.asyncQuestionService.markUnreadForAll( + updatedQuestion, + user.id, + transactionalEntityManager, + ); + } + + if (body.status === asyncQuestionStatus.StudentDeleted) { + await this.redisQueueService.deleteAsyncQuestion( + `c:${question.courseId}:aq`, + updatedQuestion, + ); + // delete all unread notifications for this question + await transactionalEntityManager.delete(UnreadAsyncQuestionModel, { + asyncQuestionId: questionId, + }); + } else { + await this.redisQueueService.updateAsyncQuestion( + `c:${question.courseId}:aq`, + updatedQuestion, + ); + } + delete question.taHelped; + delete question.votes; + } + }); return question; } @@ -282,109 +547,140 @@ export class asyncQuestionController { @Patch('faculty/:questionId') @UseGuards(AsyncQuestionRolesGuard) @Roles(Role.TA, Role.PROFESSOR) - async updateTAQuestion( + async updateQuestionStaff( @Param('questionId', ParseIntPipe) questionId: number, @Body() body: UpdateAsyncQuestions, - @UserId() userId: number, + @User(['chat_token']) user: UserModel, ): Promise { - const question = await AsyncQuestionModel.findOne({ - where: { id: questionId }, - relations: [ - 'creator', - 'taHelped', - 'votes', - 'comments', - 'comments.creator', - 'comments.creator.courses', - ], - }); - // deep copy question since it changes - const oldQuestion: AsyncQuestionModel = JSON.parse( - JSON.stringify(question), - ); - - if (!question) { - throw new NotFoundException('Question Not Found'); - } + let question: AsyncQuestionModel | null = null; + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + question = await transactionalEntityManager.findOne(AsyncQuestionModel, { + where: { id: questionId }, + relations: [ + 'creator', + 'taHelped', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + 'images', + 'citations', + ], + }); + // deep copy question since it changes + const oldQuestion: AsyncQuestionModel = JSON.parse( + JSON.stringify(question), + ); - const courseId = question.courseId; + if (!question) { + throw new NotFoundException('Question Not Found'); + } - // Verify if user is TA/PROF of the course - const requester = await UserCourseModel.findOne({ - where: { - userId: userId, - courseId: courseId, - }, - }); + const courseId = question.courseId; - if (!requester || requester.role === Role.STUDENT) { - throw new ForbiddenException( - 'You must be a TA/PROF to update this question', + // Verify if user is TA/PROF of the course + const requester = await transactionalEntityManager.findOne( + UserCourseModel, + { + where: { + userId: user.id, + courseId: courseId, + }, + }, ); - } - Object.keys(body).forEach((key) => { - if (body[key] !== undefined && body[key] !== null) { - question[key] = body[key]; + if (!requester || requester.role === Role.STUDENT) { + throw new ForbiddenException( + 'You must be a TA/PROF to update this question', + ); } - }); - if (body.status === asyncQuestionStatus.HumanAnswered) { - question.closedAt = new Date(); - question.taHelpedId = userId; - await this.asyncQuestionService.sendQuestionAnsweredEmail(question); - } else if ( - body.status !== asyncQuestionStatus.TADeleted && - body.status !== asyncQuestionStatus.StudentDeleted - ) { - // don't send status change email if its deleted - // (I don't like the vibes of notifying a student that their question was deleted by staff) - // Though technically speaking this isn't even really used yet since there isn't a status that the TA would really turn it to that isn't HumanAnswered or TADeleted - await this.asyncQuestionService.sendGenericStatusChangeEmail( - question, - body.status, - ); - } + Object.keys(body).forEach((key) => { + if (body[key] !== undefined && body[key] !== null) { + question[key] = body[key]; + } + }); - const updatedQuestion = await question.save(); + if (body.status === asyncQuestionStatus.HumanAnswered) { + question.closedAt = new Date(); + question.taHelpedId = user.id; + await this.asyncQuestionService.sendQuestionAnsweredEmail(question); + } else if ( + body.status !== asyncQuestionStatus.TADeleted && + body.status !== asyncQuestionStatus.StudentDeleted + ) { + // don't send status change email if its deleted + // (I don't like the vibes of notifying a student that their question was deleted by staff) + // Though technically speaking this isn't even really used yet since there isn't a status that the TA would really turn it to that isn't HumanAnswered or TADeleted + await this.asyncQuestionService.sendGenericStatusChangeEmail( + question, + body.status, + ); + } - // Mark as new unread for all students if the question is marked as visible - if (body.visible && !oldQuestion.visible) { - await this.asyncQuestionService.markUnreadForRoles( - updatedQuestion, - [Role.STUDENT], - userId, - ); - } - // When the question creator gets their question human verified, notify them - if ( - oldQuestion.status !== asyncQuestionStatus.HumanAnswered && - !oldQuestion.verified && - (body.status === asyncQuestionStatus.HumanAnswered || - body.verified === true) - ) { - await this.asyncQuestionService.markUnreadForCreator(updatedQuestion); - } + const updatedQuestion = await transactionalEntityManager.save(question); - if ( - body.status === asyncQuestionStatus.TADeleted || - body.status === asyncQuestionStatus.StudentDeleted - ) { - await this.redisQueueService.deleteAsyncQuestion( - `c:${courseId}:aq`, - updatedQuestion, - ); - // delete all unread notifications for this question - await UnreadAsyncQuestionModel.delete({ asyncQuestionId: questionId }); - } else { - await this.redisQueueService.updateAsyncQuestion( - `c:${courseId}:aq`, - updatedQuestion, - ); - } + if (body.deleteCitations) { + await this.asyncQuestionService.deleteExistingCitations( + questionId, + transactionalEntityManager, + ); + question.citations = []; + } + // if saveToChatbot is true, add the question to the chatbot + if (body.saveToChatbot) { + await this.asyncQuestionService.upsertQAToChatbot( + updatedQuestion, + courseId, + user.chat_token.token, + ); + } + + // Mark as new unread for all students if the question is marked as visible + if (body.visible && !oldQuestion.visible) { + await this.asyncQuestionService.markUnreadForRoles( + updatedQuestion, + [Role.STUDENT], + user.id, + transactionalEntityManager, + ); + } + // When the question creator gets their question human verified, notify them + if ( + oldQuestion.status !== asyncQuestionStatus.HumanAnswered && + !oldQuestion.verified && + (body.status === asyncQuestionStatus.HumanAnswered || + body.verified === true) + ) { + await this.asyncQuestionService.markUnreadForCreator( + updatedQuestion, + transactionalEntityManager, + ); + } + + if ( + body.status === asyncQuestionStatus.TADeleted || + body.status === asyncQuestionStatus.StudentDeleted + ) { + await this.redisQueueService.deleteAsyncQuestion( + `c:${courseId}:aq`, + updatedQuestion, + ); + // delete all unread notifications for this question + await transactionalEntityManager.delete(UnreadAsyncQuestionModel, { + asyncQuestionId: questionId, + }); + } else { + await this.redisQueueService.updateAsyncQuestion( + `c:${courseId}:aq`, + updatedQuestion, + ); + } - delete question.taHelped; - delete question.votes; + delete question.taHelped; + delete question.votes; + }); return question; } @@ -426,6 +722,8 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', + 'citations', ], }); @@ -544,6 +842,8 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', + 'citations', ], }); @@ -621,6 +921,8 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', + 'citations', ], }); @@ -656,11 +958,17 @@ export class asyncQuestionController { let all: AsyncQuestionModel[] = []; if (!asyncQuestionKeys || Object.keys(asyncQuestionKeys).length === 0) { - console.log('Fetching from Database'); + console.log('Fetching async questions from Database'); all = await AsyncQuestionModel.find({ where: { courseId, - status: Not(asyncQuestionStatus.StudentDeleted), + // don't include studentDeleted or TADeleted questions + status: Not( + In([ + asyncQuestionStatus.StudentDeleted, + asyncQuestionStatus.TADeleted, + ]), + ), }, relations: [ 'creator', @@ -669,6 +977,8 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', + 'citations', ], order: { createdAt: 'DESC', @@ -679,7 +989,7 @@ export class asyncQuestionController { if (all) await this.redisQueueService.setAsyncQuestions(`c:${courseId}:aq`, all); } else { - console.log('Fetching from Redis'); + console.log('Fetching async questions from Redis'); all = Object.values(asyncQuestionKeys).map( (question) => question as AsyncQuestionModel, ); @@ -726,6 +1036,8 @@ export class asyncQuestionController { 'questionTypes', 'votesSum', 'isTaskQuestion', + 'images', + 'citations', ]); if (!question.comments) { @@ -843,4 +1155,41 @@ export class asyncQuestionController { ); return; } + @Get(':courseId/image/:imageId') + @UseGuards(JwtAuthGuard, CourseRolesGuard) + @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) + async getImage( + @Param('courseId', ParseIntPipe) courseId: number, // used for guard + @Param('imageId', ParseIntPipe) imageId: number, + @Query('preview') preview: boolean, + @Res() res: Response, + ) { + const image = await this.asyncQuestionService.getImageById( + imageId, + preview, + ); + + if (!image) { + throw new NotFoundException('Image not found'); + } + + // Encode the filename for Content-Disposition + const encodedFilename = encodeURIComponent(image.newFileName) + .replace(/['()]/g, (match) => { + return match === "'" ? '%27' : match === '(' ? '%28' : '%29'; + }) // Replace special chars with percent encoding + .replace(/\*/g, '%2A'); + + // Create filename* parameter with UTF-8 encoding per RFC 5987 + const filenameAsterisk = `UTF-8''${encodedFilename}`; + + res.set({ + 'Content-Type': 'image/webp', + // 'Cache-Control': 'public, max-age=1296000', // Cache for 4 months + 'Cache-Control': 'public, max-age=1', // Cache for 4 months + 'Content-Disposition': `inline; filename="${image.newFileName}"; filename*=${filenameAsterisk}`, + }); + + res.send(image.buffer); + } } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index 665f99fd6..404f196d3 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -19,6 +19,8 @@ import { AsyncQuestionVotesModel } from './asyncQuestionVotes.entity'; import { QuestionTypeModel } from '../questionType/question-type.entity'; import { AsyncQuestionCommentModel } from './asyncQuestionComment.entity'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; +import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; +import { ChatbotQuestionSourceDocumentCitationModel } from 'chatbot/questionDocument.entity'; @Entity('async_question_model') export class AsyncQuestionModel extends BaseEntity { @@ -100,6 +102,21 @@ export class AsyncQuestionModel extends BaseEntity { @AfterLoad() sumVotes() { - this.votesSum = this.votes.reduce((acc, vote) => acc + vote.vote, 0); + this.votesSum = this.votes + ? this.votes.reduce((acc, vote) => acc + vote.vote, 0) + : 0; } + + @OneToMany(() => AsyncQuestionImageModel, (image) => image.asyncQuestion) + images: AsyncQuestionImageModel[]; + + @OneToMany( + () => ChatbotQuestionSourceDocumentCitationModel, + (citation) => citation.asyncQuestion, + ) + citations: ChatbotQuestionSourceDocumentCitationModel[]; + + // this is the chatbot question id of the aiAnswer + @Column({ nullable: true }) + chatbotQuestionId: string; } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.module.ts b/packages/server/src/asyncQuestion/asyncQuestion.module.ts index c65434c6b..7c9c3ccfa 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.module.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.module.ts @@ -5,19 +5,23 @@ import { AsyncQuestionService } from './asyncQuestion.service'; import { MailModule, MailTestingModule } from 'mail/mail.module'; import { RedisQueueService } from '../redisQueue/redis-queue.service'; import { ApplicationConfigService } from '../config/application_config.service'; - +import { ChatbotApiService } from '../chatbot/chatbot-api.service'; +import { RedisProfileService } from 'redisProfile/redis-profile.service'; @Module({ controllers: [asyncQuestionController], providers: [ AsyncQuestionService, RedisQueueService, ApplicationConfigService, + ChatbotApiService, + RedisProfileService, ], imports: [ NotificationModule, MailModule, RedisQueueService, ApplicationConfigService, + ChatbotApiService, ], exports: [AsyncQuestionService], }) @@ -25,8 +29,19 @@ export class asyncQuestionModule {} @Module({ controllers: [asyncQuestionController], - providers: [AsyncQuestionService, ApplicationConfigService], - imports: [NotificationModule, MailTestingModule, ApplicationConfigService], + providers: [ + AsyncQuestionService, + ApplicationConfigService, + ChatbotApiService, + RedisProfileService, + ], + imports: [ + NotificationModule, + MailTestingModule, + ApplicationConfigService, + ChatbotApiService, + RedisProfileService, + ], exports: [AsyncQuestionService], }) export class asyncQuestionTestingModule {} diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index e0af3026b..58c42725f 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -1,5 +1,19 @@ -import { MailServiceType, Role } from '@koh/common'; -import { Injectable } from '@nestjs/common'; +import { + AddDocumentChunkParams, + asyncQuestionStatus, + CreateAsyncQuestions, + ERROR_MESSAGES, + MailServiceType, + Role, + SourceDocument, + UpdateAsyncQuestions, +} from '@koh/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; import { MailService } from 'mail/mail.service'; import { UserSubscriptionModel } from 'mail/user-subscriptions.entity'; import { UserCourseModel } from 'profile/user-course.entity'; @@ -8,10 +22,20 @@ import { UserModel } from 'profile/user.entity'; import { AsyncQuestionCommentModel } from './asyncQuestionComment.entity'; import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; +import { ChatbotApiService } from 'chatbot/chatbot-api.service'; +import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; +import { EntityManager, In } from 'typeorm'; +import * as checkDiskSpace from 'check-disk-space'; +import * as path from 'path'; +import * as sharp from 'sharp'; +import { ChatbotQuestionSourceDocumentCitationModel } from 'chatbot/questionDocument.entity'; @Injectable() export class AsyncQuestionService { - constructor(private mailService: MailService) {} + constructor( + private mailService: MailService, + private readonly chatbotApiService: ChatbotApiService, + ) {} async sendNewCommentOnMyQuestionEmail( commenter: UserModel, @@ -150,9 +174,10 @@ export class AsyncQuestionService { content: `
A new question has been posted on the Anytime Question Hub and has been marked as needing attention:
${question.questionAbstract ? `Question Abstract: ${question.questionAbstract}` : ''}
${question.questionTypes?.length > 0 ? `Question Types: ${question.questionTypes.map((qt) => qt.name).join(', ')}` : ''} -
${question.questionText ? `Question Text: ${question.questionText}` : ''} + ${question.questionText ? `
Question Text: ${question.questionText}` : ''} + ${question.images?.length > 0 ? `
Question also has ${question.images.length} image${question.images.length === 1 ? '' : 's'}.` : ''}
-
Do NOT reply to this email. View and Answer It Here
`, +
Do NOT reply to this email. View and Answer It Here
`, }), ), ); @@ -287,55 +312,339 @@ export class AsyncQuestionService { question: AsyncQuestionModel, roles: Role[], userToNotNotifyId: number, + transactionalEntityManager?: EntityManager, ) { - await UnreadAsyncQuestionModel.createQueryBuilder() - .update(UnreadAsyncQuestionModel) - .set({ readLatest: false }) - .where('asyncQuestionId = :asyncQuestionId', { - asyncQuestionId: question.id, - }) - .andWhere('userId != :userId', { userId: userToNotNotifyId }) // don't notify me (person who called endpoint) - // Use a subquery to filter by roles - .andWhere( - `"userId" IN ( + if (transactionalEntityManager) { + await transactionalEntityManager + .createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere('userId != :userId', { userId: userToNotNotifyId }) // don't notify me (person who called endpoint) + // Use a subquery to filter by roles + .andWhere( + `"userId" IN ( SELECT "user_course_model"."userId" FROM "user_course_model" WHERE "user_course_model"."role" IN (:...roles) )`, - { roles }, // notify all specified roles - ) - .execute(); + { roles }, // notify all specified roles + ) + .execute(); + } else { + await UnreadAsyncQuestionModel.createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere('userId != :userId', { userId: userToNotNotifyId }) // don't notify me (person who called endpoint) + // Use a subquery to filter by roles + .andWhere( + `"userId" IN ( + SELECT "user_course_model"."userId" + FROM "user_course_model" + WHERE "user_course_model"."role" IN (:...roles) + )`, + { roles }, // notify all specified roles + ) + .execute(); + } } async markUnreadForAll( question: AsyncQuestionModel, userToNotNotifyId: number, + transactionalEntityManager?: EntityManager, ) { - await UnreadAsyncQuestionModel.createQueryBuilder() - .update(UnreadAsyncQuestionModel) - .set({ readLatest: false }) - .where('asyncQuestionId = :asyncQuestionId', { - asyncQuestionId: question.id, - }) - .andWhere( - `userId != :userId`, - { userId: userToNotNotifyId }, // don't notify me (person who called endpoint) - ) - .execute(); + if (transactionalEntityManager) { + await transactionalEntityManager + .createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId != :userId`, + { userId: userToNotNotifyId }, // don't notify me (person who called endpoint) + ) + .execute(); + } else { + await UnreadAsyncQuestionModel.createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId != :userId`, + { userId: userToNotNotifyId }, // don't notify me (person who called endpoint) + ) + .execute(); + } } - async markUnreadForCreator(question: AsyncQuestionModel) { - await UnreadAsyncQuestionModel.createQueryBuilder() - .update(UnreadAsyncQuestionModel) - .set({ readLatest: false }) - .where('asyncQuestionId = :asyncQuestionId', { + async markUnreadForCreator( + question: AsyncQuestionModel, + transactionalEntityManager?: EntityManager, + ) { + if (transactionalEntityManager) { + await transactionalEntityManager + .createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId = :userId`, + { userId: question.creatorId }, // notify ONLY question creator + ) + .execute(); + } else { + await UnreadAsyncQuestionModel.createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId = :userId`, + { userId: question.creatorId }, // notify ONLY question creator + ) + .execute(); + } + } + + async upsertQAToChatbot( + question: AsyncQuestionModel, + courseId: number, + userToken: string, + ) { + const now = new Date(); + // Since the name can take up quite a bit of space, no more than 60 characters (show ... if longer) + const chunkName = `${(question.questionAbstract ?? question.questionText).slice(0, 60)}${(question.questionAbstract ?? question.questionText).length > 60 ? '...' : ''}`; + const chunkParams: AddDocumentChunkParams = { + documentText: `${this.formatQuestionTextForChatbot(question, true)}\n\nAnswer: ${question.answerText}`, + metadata: { + name: chunkName, + type: 'inserted_async_question', asyncQuestionId: question.id, - }) - .andWhere( - `userId = :userId`, - { userId: question.creatorId }, // notify ONLY question creator - ) - .execute(); + source: `/course/${courseId}/async_centre`, + courseId: courseId, + firstInsertedAt: now, // note that the chatbot will ignore this field if its an update + lastUpdatedAt: now, + shouldProbablyKeepWhenCloning: true, + }, + }; + await this.chatbotApiService.addDocumentChunk( + chunkParams, + courseId, + userToken, + ); + } + + /* Just for formatting the details of the question for sending to the chatbot or for a chunk. + Does stuff like if there's only an abstract, the abstract will just be called "Question" instead of having "Question Abstract" and "Question Text" + */ + formatQuestionTextForChatbot( + question: AsyncQuestionModel, + includeImageDescriptions = false, // unused atm + ) { + return `${question.questionText ? `Question Abstract: ${question.questionAbstract}` : `Question: ${question.questionAbstract}`} + ${question.questionText ? `Question Text: ${question.questionText}` : ''} + ${question.questionTypes && question.questionTypes.length > 0 ? `Question Types: ${question.questionTypes.map((questionType) => questionType.name).join(', ')}` : ''} + ${includeImageDescriptions ? `Question Image Descriptions: ${question.images.map((image, idx) => `Image ${idx + 1}: ${image.aiSummary}`).join('\n')}` : ''} +`; + } + + async createAsyncQuestion( + questionData: Partial, + imageBuffers: tempFile[], + transactionalEntityManager: EntityManager, + ): Promise { + // Create the question first + const question = await transactionalEntityManager.save( + AsyncQuestionModel.create(questionData), + ); + + // Process and save images + await this.saveImagesToDb( + question, + imageBuffers, + transactionalEntityManager, + ); + + return question; + } + + async saveImagesToDb( + question: AsyncQuestionModel, + imageBuffers: tempFile[], + transactionalEntityManager: EntityManager, + ) { + // Check disk space before proceeding + const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); + if (spaceLeft.free < 1_000_000_000) { + throw new ServiceUnavailableException(ERROR_MESSAGES.common.noDiskSpace); + } + const startTime = Date.now(); + + // Process and save images + if (imageBuffers.length > 0) { + const imagePromises = imageBuffers.map(async (buffer) => { + const imageModel = new AsyncQuestionImageModel(); + imageModel.asyncQuestion = question; + imageModel.imageBuffer = buffer.processedBuffer; + imageModel.previewImageBuffer = buffer.previewBuffer; + imageModel.imageSizeBytes = buffer.processedBuffer.length; + imageModel.previewImageSizeBytes = buffer.previewBuffer.length; + + imageModel.originalFileName = buffer.originalFileName; + imageModel.newFileName = buffer.newFileName; + + const image = await transactionalEntityManager.save(imageModel); + buffer.imageId = image.imageId; // add the image id to the buffer so we can use it later (for passing to chatbot) + + return buffer; + }); + + await Promise.all(imagePromises); + } + + const endTime = Date.now(); + const processingTime = endTime - startTime; + + if (processingTime > 10000) { + // more than 10 seconds + console.error(`saveImagesToDb took too long: ${processingTime}ms`); + } + } + + async deleteImages( + questionId: number, + imageIds: number[], + transactionalEntityManager: EntityManager, + ) { + await transactionalEntityManager.delete(AsyncQuestionImageModel, { + imageId: In(imageIds), + asyncQuestionId: questionId, + }); + } + + async saveImageDescriptions( + imageDescriptions: { imageId: string; description: string }[], + transactionalEntityManager: EntityManager, + ) { + // map over imageDescriptions and update the images with the descriptions + for (const imageDescription of imageDescriptions) { + if ( + Number.isInteger(imageDescription.imageId) && + Number(imageDescription.imageId) > 0 + ) { + await transactionalEntityManager.update( + AsyncQuestionImageModel, + { + // using .update() instead of .save() so we don't have to load the image into memory again + imageId: Number(imageDescription.imageId), + }, + { + aiSummary: imageDescription.description, + }, + ); + } + } + } + + async convertAndResizeImages( + images: Express.Multer.File[], + ): Promise { + const startTime = Date.now(); + const results = await Promise.all( + images.map(async (image) => { + // create both full and preview versions (the preview version is much smaller), they all get converted to webp + const [processedBuffer, previewBuffer] = await Promise.all([ + sharp(image.buffer) + .resize(1920, 1080, { + fit: 'inside', // resize to fit within 1920x1080, but keep aspect ratio + withoutEnlargement: true, // don't enlarge the image + }) + .webp({ quality: 80 }) // convert to webp with quality 80 + .toBuffer(), + sharp(image.buffer) // preview images get sent first so they need to be low quality so they get sent fast. + .resize(400, 300, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 30 }) + .toBuffer(), + ]); + + // Remove the original extension and replace with .webp + const sanitizedFilename = + image.originalname + .replace(/[/\\?%*:|"<>]/g, '_') // Replace unsafe characters with underscores + .replace(/\.[^/.]+$/, '') // Remove the original extension + .trim() + '.webp'; // Add .webp extension + + return { + processedBuffer, + previewBuffer, + originalFileName: image.originalname, + newFileName: sanitizedFilename, + }; + }), + ); + + const endTime = Date.now(); + const processingTime = endTime - startTime; + if (processingTime > 10000) { + // more than 10 seconds + console.error( + `convertAndResizeImages took too long: ${processingTime}ms`, + ); + } + + return results; + } + + async getImageById( + imageId: number, + preview: boolean, + ): Promise<{ + buffer: Buffer; + contentType: string; + newFileName: string; + } | null> { + let image; + if (preview) { + image = await AsyncQuestionImageModel.findOne({ + where: { imageId }, + select: ['previewImageBuffer', 'newFileName'], + }); + } else { + image = await AsyncQuestionImageModel.findOne({ + where: { imageId }, + select: ['imageBuffer', 'newFileName'], + }); + } + + if (!image) return null; + + return { + buffer: preview ? image.previewImageBuffer : image.imageBuffer, + contentType: 'image/webp', + newFileName: image.newFileName, + }; + } + + async getCurrentImageCount( + questionId: number, + transactionalEntityManager: EntityManager, + ) { + return await transactionalEntityManager.count(AsyncQuestionImageModel, { + where: { asyncQuestionId: questionId }, + }); } /** @@ -347,4 +656,118 @@ export class AsyncQuestionService { const hash = userId + questionId; return hash % 70; } + + validateBodyCreateAsyncQuestions( + body: CreateAsyncQuestions | FormData | any, + ) { + // the body *should* follow CreateAsyncQuestions, but since we're using formdata, we have to manually parse the fields and validate them + if (body.questionTypes) { + try { + body.questionTypes = JSON.parse(body.questionTypes); + } catch (error) { + throw new BadRequestException( + 'Question Types field must be a valid JSON array', + ); + } + } + if (!body.questionAbstract) { + throw new BadRequestException('Question Abstract is required'); + } + body.questionText = body.questionText ? body.questionText.toString() : ''; + body.questionAbstract = body.questionAbstract + ? body.questionAbstract.toString() + : ''; + body.status = body.status + ? body.status.toString() + : asyncQuestionStatus.AIAnswered; + } + + validateBodyUpdateAsyncQuestions( + body: UpdateAsyncQuestions | FormData | any, + ) { + // the body *should* follow UpdateAsyncQuestions, but since we're using formdata, we have to manually parse the fields and validate them + if (body.questionTypes) { + try { + body.questionTypes = JSON.parse(body.questionTypes); + } catch (error) { + throw new BadRequestException( + 'Question Types field must be a valid JSON array', + ); + } + } + + body.questionText = body.questionText ? body.questionText.toString() : ''; + body.questionAbstract = body.questionAbstract + ? body.questionAbstract.toString() + : ''; + body.status = body.status + ? body.status.toString() + : asyncQuestionStatus.AIAnswered; + + if (body.deletedImageIds) { + try { + body.deletedImageIds = JSON.parse(body.deletedImageIds); + } catch (error) { + throw new BadRequestException( + 'Delete Image Ids field must be a valid JSON array', + ); + } + } + + // if you created the question (i.e. a student), you can't update the status to illegal ones + if ( + body.status === asyncQuestionStatus.TADeleted || + body.status === asyncQuestionStatus.HumanAnswered + ) { + throw new ForbiddenException( + `You cannot update your own question's status to ${body.status}`, + ); + } + + if (body.refreshAIAnswer) { + body.refreshAIAnswer = body.refreshAIAnswer.toString() === 'true'; + } + } + + async deleteExistingCitations( + questionId: number, + transactionalEntityManager: EntityManager, + ) { + await transactionalEntityManager.delete( + ChatbotQuestionSourceDocumentCitationModel, + { asyncQuestionId: questionId }, + ); + } + + async saveCitations( + sourceDocuments: SourceDocument[], + question: AsyncQuestionModel, + transactionalEntityManager: EntityManager, + ) { + const citations = sourceDocuments.map((sourceDocument) => { + const citation = new ChatbotQuestionSourceDocumentCitationModel(); + citation.asyncQuestion = question; + citation.pageContent = sourceDocument.pageContent; + citation.content = sourceDocument.content; + citation.sourceDocumentChunkId = sourceDocument.id; + citation.metadata = sourceDocument.metadata; + citation.docName = sourceDocument.docName; + citation.sourceLink = sourceDocument.sourceLink; + citation.pageNumbers = sourceDocument.pageNumbers; + citation.pageNumber = sourceDocument.pageNumber; + return citation; + }); + await transactionalEntityManager.save( + ChatbotQuestionSourceDocumentCitationModel, + citations, + ); + } +} + +export interface tempFile { + processedBuffer: Buffer; + previewBuffer: Buffer; + originalFileName: string; + newFileName: string; + imageId?: number; } diff --git a/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts b/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts new file mode 100644 index 000000000..6e3989d42 --- /dev/null +++ b/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts @@ -0,0 +1,45 @@ +import { + BaseEntity, + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { AsyncQuestionModel } from './asyncQuestion.entity'; + +@Entity('async_question_image_model') +export class AsyncQuestionImageModel extends BaseEntity { + @PrimaryGeneratedColumn() + imageId: number; + + @Column() + asyncQuestionId: number; + + @ManyToOne((type) => AsyncQuestionModel, (question) => question.images, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'asyncQuestionId' }) + asyncQuestion: AsyncQuestionModel; + + @Column() + originalFileName: string; + + @Column() + newFileName: string; + + @Column({ type: 'bytea', select: false }) // don't include these in select statements unless you specifically ask for them + imageBuffer: Buffer; + + @Column({ type: 'bytea', select: false }) + previewImageBuffer: Buffer; + + @Column({ default: 0 }) + imageSizeBytes: number; + + @Column({ default: 0 }) + previewImageSizeBytes: number; + + @Column({ default: '' }) + aiSummary: string; // used for the alt text of the image +} diff --git a/packages/server/src/chatbot/chatbot-api.service.ts b/packages/server/src/chatbot/chatbot-api.service.ts index ad8b3e37f..2c7e12da6 100644 --- a/packages/server/src/chatbot/chatbot-api.service.ts +++ b/packages/server/src/chatbot/chatbot-api.service.ts @@ -5,9 +5,11 @@ import { AddDocumentChunkParams, ChatbotQuestionResponseChatbotDB, UpdateChatbotQuestionParams, + ChatbotAskResponseChatbotDB, } from '@koh/common'; import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { tempFile } from 'asyncQuestion/asyncQuestion.service'; @Injectable() /* This is a list of all endpoints from the chatbot repo. @@ -91,11 +93,59 @@ export class ChatbotApiService { history: any, userToken: string, courseId: number, - ) { - return this.request('POST', 'ask', courseId, userToken, { - question, - history, - }); + images?: tempFile[], // only really used for async questions right now, feel free to refactor this to be more generalizable if needed in the future + skipSimilaritySearch?: boolean, + ): Promise { + try { + const formData = new FormData(); + formData.append('question', question); + formData.append('history', JSON.stringify(history)); + + // Add images if they exist + if (images && images.length > 0) { + images.forEach((imageBuffer, index) => { + formData.append( + 'images', + new Blob( + [imageBuffer.processedBuffer], // give chatbot the higher-quality, non-preview images + { type: 'image/webp' }, + ), + `${imageBuffer.imageId ?? `image${index + 1}`}.webp`, + ); + }); + } + + const url = new URL( + `${this.chatbotApiUrl}/${courseId}/ask${skipSimilaritySearch ? '?skipSimilaritySearch=true' : ''}`, + ); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'HMS-API-KEY': this.chatbotApiKey, + HMS_API_TOKEN: userToken, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new HttpException( + error.error || 'Error from chatbot service', + response.status, + ); + } + + return await response.json(); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException( + 'Failed to connect to chatbot service', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } async getAllQuestions(courseId: number, userToken: string) { @@ -173,6 +223,7 @@ export class ChatbotApiService { courseId: number, userToken: string, ) { + // note: will perform an upsert if the body.metadata has an asyncQuestionId, rather than just an insert return this.request('POST', 'documentChunk', courseId, userToken, body); } diff --git a/packages/server/src/chatbot/chatbot.controller.ts b/packages/server/src/chatbot/chatbot.controller.ts index e31c1305e..387d09f54 100644 --- a/packages/server/src/chatbot/chatbot.controller.ts +++ b/packages/server/src/chatbot/chatbot.controller.ts @@ -16,6 +16,7 @@ import { InternalServerErrorException, Res, Req, + ServiceUnavailableException, } from '@nestjs/common'; import { ChatbotService } from './chatbot.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -35,6 +36,7 @@ import { AddDocumentChunkParams, ChatbotQuestionResponseChatbotDB, UpdateChatbotQuestionParams, + ERROR_MESSAGES, } from '@koh/common'; import { CourseRolesGuard } from 'guards/course-roles.guard'; import { Roles } from 'decorators/roles.decorator'; @@ -49,6 +51,8 @@ import { CourseModel } from 'course/course.entity'; import { generateHTMLForMarkdownToPDF } from './markdown-to-pdf-styles'; import { ChatbotDocPdfModel } from './chatbot-doc-pdf.entity'; import { Response, Request } from 'express'; +import checkDiskSpace from 'check-disk-space'; +import path from 'path'; @Controller('chatbot') @UseGuards(JwtAuthGuard, EmailVerifiedGuard) @@ -552,6 +556,13 @@ export class ChatbotController { if (!file) { throw new BadRequestException('No file uploaded'); } + + // Check disk space before proceeding + const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); + if (spaceLeft.free < 1_000_000_000) { + throw new ServiceUnavailableException(ERROR_MESSAGES.common.noDiskSpace); + } + const fileExtension = file.originalname.split('.').pop()?.toLowerCase(); // if the file is a text file (including markdown and csv), don't allow sizes over 2 MB (since 4MB of text is actually a lot) diff --git a/packages/server/src/chatbot/questionDocument.entity.ts b/packages/server/src/chatbot/questionDocument.entity.ts index 8a0c5921d..0a406bb6c 100644 --- a/packages/server/src/chatbot/questionDocument.entity.ts +++ b/packages/server/src/chatbot/questionDocument.entity.ts @@ -6,22 +6,52 @@ import { ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { ChatbotQuestionModel } from './question.entity'; +import { AsyncQuestionModel } from '../asyncQuestion/asyncQuestion.entity'; +import { ChatbotChunkMetadata } from '@koh/common'; -@Entity('question_document_model') -export class QuestionDocumentModel extends BaseEntity { +/* This is just the citations (i.e. when a question is asked, the relevant source document chunks are gathered and stored here) */ +@Entity('chatbot_question_source_document_citation_model') +export class ChatbotQuestionSourceDocumentCitationModel extends BaseEntity { @PrimaryGeneratedColumn() - public id: number; + citationId: number; @Column() - questionId: number; + sourceDocumentChunkId: string; @Column() - name: string; + pageContent: string; + + @Column({ nullable: true }) + content: string; + + @Column() + docName: string; + + @Column({ type: 'jsonb' }) + metadata: ChatbotChunkMetadata; + + @Column({ nullable: true }) + sourceLink: string; + + @Column('integer', { array: true, nullable: true }) + pageNumbers: number[]; + + @Column({ nullable: true }) + pageNumber: number; + // not relating it to chatbot questions themselves just yet TODO: do this + // (partially because we get the source documents from the chatbot repo api and not from helpme db) + // @ManyToOne(() => ChatbotQuestionModel) + // @JoinColumn({ name: 'questionId' }) + // question: ChatbotQuestionModel; @Column() - type: string; + asyncQuestionId: number; - @Column('text', { array: true }) - parts: string[]; + // only really using for async questions atm + @ManyToOne( + () => AsyncQuestionModel, + (asyncQuestion) => asyncQuestion.citations, + ) + @JoinColumn({ name: 'asyncQuestionId' }) + asyncQuestion: AsyncQuestionModel; } diff --git a/packages/server/src/profile/profile.controller.ts b/packages/server/src/profile/profile.controller.ts index 1cc8ae375..e6dcb7424 100644 --- a/packages/server/src/profile/profile.controller.ts +++ b/packages/server/src/profile/profile.controller.ts @@ -24,7 +24,7 @@ import * as fs from 'fs'; import { memoryStorage } from 'multer'; import * as path from 'path'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { User } from '../decorators/user.decorator'; +import { User, UserId } from '../decorators/user.decorator'; import { UserModel } from './user.entity'; import { ProfileService } from './profile.service'; import { EmailVerifiedGuard } from 'guards/email-verified.guard'; @@ -85,6 +85,12 @@ export class ProfileController { } } + @Delete('/clear_cache') + @UseGuards(JwtAuthGuard, EmailVerifiedGuard) + async clearCache(@UserId() userId: number): Promise { + await this.redisProfileService.deleteProfile(`u:${userId}`); + } + @Patch() @UseGuards(JwtAuthGuard, EmailVerifiedGuard) async patch( @@ -107,6 +113,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( diff --git a/packages/server/src/profile/profile.service.ts b/packages/server/src/profile/profile.service.ts index cc969bad4..b2daeaf80 100644 --- a/packages/server/src/profile/profile.service.ts +++ b/packages/server/src/profile/profile.service.ts @@ -19,10 +19,10 @@ import { UserModel } from './user.entity'; import { RedisProfileService } from '../redisProfile/redis-profile.service'; import { pick } from 'lodash'; import { OrganizationService } from '../organization/organization.service'; -import checkDiskSpace from 'check-disk-space'; +import * as checkDiskSpace from 'check-disk-space'; import * as path from 'path'; import * as fs from 'fs'; -import sharp from 'sharp'; +import * as sharp from 'sharp'; @Injectable() export class ProfileService { @@ -126,8 +126,9 @@ export class ProfileService { // Check disk space before proceeding const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); if (spaceLeft.free < 1_000_000_000) { + // 1GB throw new ServiceUnavailableException( - ERROR_MESSAGES.profileController.noDiskSpace, + ERROR_MESSAGES.common.noDiskSpace, ); } 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)