diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index e772f60b8..3b880e937 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1073,6 +1073,78 @@ "submitting": "Resetting..." } } + }, + "faqs": { + "title": "Robot Game Rules FAQ", + "actions": { + "create": "Create FAQ" + }, + "filter": { + "season": "Season", + "all-seasons": "All Seasons" + }, + "table": { + "question": "Question", + "answer": "Answer", + "season": "Season", + "created-by": "Created By", + "order": "Order", + "actions": "Actions" + }, + "empty-state": "No FAQs found. Create your first FAQ to get started.", + "errors": { + "no-permission": "You don't have permission to manage FAQs.", + "load-failed": "Failed to load FAQs. Please try again." + }, + "editor": { + "title-create": "Create FAQ", + "title-edit": "Edit FAQ", + "fields": { + "season": "Season", + "question": "Question", + "answer": "Answer", + "display-order": "Display Order", + "display-order-help": "Leave empty to add at the end" + }, + "toolbar": { + "bold": "Bold", + "italic": "Italic", + "color": "Text color", + "bullets": "Bulleted list", + "image": "Insert image", + "media": "Insert media", + "delete-image": "Click to delete image", + "delete-video": "Click to delete video", + "confirm-delete-image": "Are you sure you want to delete this image?", + "confirm-delete-video": "Are you sure you want to delete this video?" + }, + "errors": { + "required-fields": "Please fill in all required fields.", + "save-failed": "Failed to save FAQ. Please try again.", + "invalid-image-type": "Image must be a PNG or JPEG file.", + "image-too-large": "Image size must not exceed 2 MB.", + "image-upload-failed": "Failed to upload image. Please try again.", + "invalid-video-type": "Video must be an MP4 or WebM file.", + "video-too-large": "Video size must not exceed 50 MB.", + "video-upload-failed": "Failed to upload video. Please try again." + }, + "actions": { + "cancel": "Cancel", + "save": "Save", + "saving": "Saving..." + } + }, + "delete": { + "title": "Delete FAQ", + "message": "Are you sure you want to delete this FAQ? This action cannot be undone.", + "question": "Question", + "error": "Failed to delete FAQ. Please try again.", + "actions": { + "cancel": "Cancel", + "delete": "Delete", + "deleting": "Deleting..." + } + } } }, "general": { @@ -1082,7 +1154,8 @@ "manage-events": "Manage Events", "manage-event-details": "Manage Event Details", "manage-teams": "Manage Teams", - "view-insights": "View Insights" + "view-insights": "View Insights", + "manage-faq": "Manage FAQ" } }, "layouts": { @@ -1093,6 +1166,7 @@ "events": "Events", "teams": "Teams", "insights": "Insights", + "faqs": "FAQ", "graphql": "GraphQL" }, "user-menu": { diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 75a2d731c..9bec3084e 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1073,6 +1073,78 @@ "submitting": "מאפס..." } } + }, + "faqs": { + "title": "שאלות נפוצות - חוקי משחק הרובוט", + "actions": { + "create": "יצירת שאלה נפוצה" + }, + "filter": { + "season": "עונה", + "all-seasons": "כל העונות" + }, + "table": { + "question": "שאלה", + "answer": "תשובה", + "season": "עונה", + "created-by": "נוצר על ידי", + "order": "סדר", + "actions": "פעולות" + }, + "empty-state": "לא נמצאו שאלות נפוצות. צור את השאלה הנפוצה הראשונה שלך כדי להתחיל.", + "errors": { + "no-permission": "אין לך הרשאה לנהל שאלות נפוצות.", + "load-failed": "טעינת השאלות הנפוצות נכשלה. אנא נסה שוב." + }, + "editor": { + "title-create": "יצירת שאלה נפוצה", + "title-edit": "עריכת שאלה נפוצה", + "fields": { + "season": "עונה", + "question": "שאלה", + "answer": "תשובה", + "display-order": "סדר תצוגה", + "display-order-help": "השאר ריק כדי להוסיף בסוף" + }, + "toolbar": { + "bold": "מודגש", + "italic": "נטוי", + "color": "צבע טקסט", + "bullets": "רשימת נקודות", + "image": "הוספת תמונה", + "media": "הוספת מדיה", + "delete-image": "לחץ למחיקת התמונה", + "delete-video": "לחץ למחיקת הוידאו", + "confirm-delete-image": "האם אתה בטוח שברצונך למחוק תמונה זו?", + "confirm-delete-video": "האם אתה בטוח שברצונך למחוק וידאו זה?" + }, + "errors": { + "required-fields": "אנא מלא את כל השדות הנדרשים.", + "save-failed": "שמירת השאלה הנפוצה נכשלה. אנא נסה שוב.", + "invalid-image-type": "התמונה חייבת להיות קובץ PNG או JPEG.", + "image-too-large": "גודל התמונה לא יכול לעלות על 2 MB.", + "image-upload-failed": "העלאת התמונה נכשלה. אנא נסה שוב.", + "invalid-video-type": "הוידאו חייב להיות קובץ MP4 או WebM.", + "video-too-large": "גודל הוידאו לא יכול לעלות על 50 MB.", + "video-upload-failed": "העלאת הוידאו נכשלה. אנא נסה שוב." + }, + "actions": { + "cancel": "ביטול", + "save": "שמירה", + "saving": "שומר..." + } + }, + "delete": { + "title": "מחיקת שאלה נפוצה", + "message": "האם אתה בטוח שברצונך למחוק שאלה נפוצה זו? פעולה זו אינה ניתנת לביטול.", + "question": "שאלה", + "error": "מחיקת השאלה הנפוצה נכשלה. אנא נסה שוב.", + "actions": { + "cancel": "ביטול", + "delete": "מחיקה", + "deleting": "מוחק..." + } + } } }, "general": { @@ -1082,7 +1154,8 @@ "manage-events": "ניהול אירועים", "manage-event-details": "ניהול פרטי אירועים", "manage-teams": "ניהול קבוצות", - "view-insights": "צפייה בתובנות" + "view-insights": "צפייה בתובנות", + "manage-faq": "ניהול שאלות נפוצות" } }, "layouts": { @@ -1093,6 +1166,7 @@ "events": "אירועים", "teams": "קבוצות", "insights": "תובנות", + "faqs": "שאלות נפוצות", "graphql": "GraphQL" }, "user-menu": { diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx new file mode 100644 index 000000000..304f11f4e --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx @@ -0,0 +1,79 @@ +import { IconButton, Tooltip, Box, Typography } from '@mui/material'; +import { Palette } from '@mui/icons-material'; +import { ColorPicker } from '@lems/shared'; +import { HsvaColor, hsvaToHex } from '@uiw/react-color'; +interface ColorPaletteProps { + textColor: HsvaColor; + usedColors: string[]; + onColorChange: (color: string) => void; + onSaveSelection: () => void; + disabled?: boolean; +} + +export function ColorPalette({ + textColor, + usedColors, + onColorChange, + onSaveSelection, + disabled = false +}: ColorPaletteProps) { + const currentColor = hsvaToHex(textColor); + + return ( + + onColorChange(hsvaToHex(color))}> + onColorChange(currentColor)} + disabled={disabled} + sx={{ + border: '1px solid', + borderColor: 'divider', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + {usedColors + .filter(color => color.toLowerCase() !== currentColor.toLowerCase()) + .map(color => ( + + onColorChange(color)} + sx={{ + width: 18, + height: 18, + borderRadius: '50%', + bgcolor: color, + cursor: disabled ? 'default' : 'pointer', + border: '1px solid', + borderColor: 'divider' + }} + /> + + ))} + + + `0 0 0 2px ${theme.palette.primary.main}` + }} + /> + + {currentColor} + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx new file mode 100644 index 000000000..d03bcfbfd --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Alert, + CircularProgress +} from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { mutate } from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { apiFetch } from '@lems/shared'; + +interface DeleteConfirmDialogProps { + open: boolean; + faq: FaqResponse; + onClose: () => void; +} + +export function DeleteConfirmDialog({ open, faq, onClose }: DeleteConfirmDialogProps) { + const t = useTranslations('pages.faqs.delete'); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const handleDelete = async () => { + setIsDeleting(true); + setError(null); + + try { + const result = await apiFetch(`/admin/faqs/${faq.id}`, { + method: 'DELETE' + }); + + if (!result.ok) { + throw new Error('Failed to delete FAQ'); + } + + // Refresh FAQ lists + await Promise.all([ + mutate('/admin/faqs'), + mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) + ]); + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t('error')); + } finally { + setIsDeleting(false); + } + }; + + return ( + + {t('title')} + + {error && ( + + {error} + + )} + {t('message')} + + {t('question')}: {faq.question} + + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx new file mode 100644 index 000000000..355d4ca35 --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -0,0 +1,300 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, + CircularProgress, + Box, + Paper, + Typography +} from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { mutate } from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { Season } from '@lems/database'; +import { apiFetch } from '@lems/shared'; +import { HsvaColor, hexToHsva } from '@uiw/react-color'; +import { RichTextToolbar } from './rich-text-toolbar'; +import { ColorPalette } from './color-palette'; +import { MediaUploadButton } from './media-upload-button'; +import { useMediaUpload } from './use-media-upload'; + +interface FaqEditorDialogProps { + open: boolean; + faq: FaqResponse | null; + seasons: Season[]; + onClose: () => void; +} + +export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialogProps) { + const t = useTranslations('pages.faqs.editor'); + const [seasonId, setSeasonId] = useState(faq?.seasonId || ''); + const [question, setQuestion] = useState(faq?.question || ''); + const [answer, setAnswer] = useState(faq?.answer || ''); + const [displayOrder, setDisplayOrder] = useState(faq?.displayOrder?.toString() || ''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [textColor, setTextColor] = useState(hexToHsva('#000000')); + const [usedColors, setUsedColors] = useState(['#000000']); + const editorRef = useRef(null); + const selectionRef = useRef(null); + + useEffect(() => { + if (faq) { + setSeasonId(faq.seasonId); + setQuestion(faq.question); + setAnswer(faq.answer); + setDisplayOrder(faq.displayOrder.toString()); + } else { + setSeasonId(seasons[0]?.id || ''); + setQuestion(''); + setAnswer(''); + setDisplayOrder(''); + } + }, [faq, seasons]); + + useEffect(() => { + if (editorRef.current && editorRef.current.innerHTML !== answer) { + editorRef.current.innerHTML = answer; + } + }, [answer]); + + useEffect(() => { + const colors = extractColors(answer); + setUsedColors(colors.length > 0 ? colors : ['#000000']); + }, [answer]); + + const saveSelection = () => { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + selectionRef.current = selection.getRangeAt(0); + } + }; + + const restoreSelection = () => { + const selection = window.getSelection(); + if (selectionRef.current && selection) { + selection.removeAllRanges(); + selection.addRange(selectionRef.current); + } + }; + + const applyCommand = (command: string, value?: string) => { + if (!editorRef.current) return; + editorRef.current.focus(); + restoreSelection(); + document.execCommand(command, false, value); + setAnswer(editorRef.current.innerHTML); + }; + + const applyColor = (color: string) => { + setTextColor(hexToHsva(color)); + applyCommand('foreColor', color); + }; + + const extractColors = (html: string) => { + const colors = new Set(); + const colorRegexes = [/color\s*=\s*"?([#a-fA-F0-9]{3,7})"?/g, /color\s*:\s*([^;"']+)/g]; + + colorRegexes.forEach(regex => { + let match = regex.exec(html); + while (match) { + const color = match[1].trim(); + if (color.startsWith('#')) { + colors.add(color.toLowerCase()); + } + match = regex.exec(html); + } + }); + + return Array.from(colors); + }; + + const sanitizeFaqHtml = (html: string): string => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // Remove all delete links + tempDiv.querySelectorAll('a.media-delete-link').forEach(link => link.remove()); + + // Unwrap media from wrappers + tempDiv.querySelectorAll('div.media-wrapper').forEach(wrapper => { + const media = wrapper.querySelector('img, video'); + if (media && wrapper.parentNode) { + wrapper.parentNode.replaceChild(media, wrapper); + } + }); + + return tempDiv.innerHTML; + }; + + const { isUploadingImage, isUploadingVideo, error, setError, handleMediaUpload } = useMediaUpload( + { + editorRef, + onAnswerChange: setAnswer, + restoreSelection + } + ); + + const handleSubmit = async () => { + if (!seasonId || !question.trim() || !answer.trim()) { + setError(t('errors.required-fields')); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const body: { + question: string; + answer: string; + seasonId?: string; + displayOrder?: number; + } = { + question: question.trim(), + answer: sanitizeFaqHtml(answer.trim()) + }; + + if (!faq) { + body.seasonId = seasonId; + } + + if (displayOrder) { + body.displayOrder = parseInt(displayOrder); + } + + const url = faq ? `/admin/faqs/${faq.id}` : '/admin/faqs'; + const method = faq ? 'PUT' : 'POST'; + + const result = await apiFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!result.ok) { + throw new Error('Failed to save FAQ'); + } + + await Promise.all([ + mutate('/admin/faqs'), + mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) + ]); + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.save-failed')); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {faq ? t('title-edit') : t('title-create')} + + + {error && {error}} + + + {t('fields.season')} + + + + setQuestion(e.target.value)} + fullWidth + required + multiline + rows={2} + disabled={isSubmitting} + /> + + + + {t('fields.answer')} + + + + + + + + setAnswer(editorRef.current?.innerHTML ?? '')} + onBlur={() => setAnswer(editorRef.current?.innerHTML ?? '')} + onMouseUp={saveSelection} + onKeyUp={saveSelection} + sx={{ + outline: 'none', + minHeight: 140, + '&:empty:before': { + content: '""' + } + }} + /> + + + + setDisplayOrder(e.target.value)} + type="number" + fullWidth + helperText={t('fields.display-order-help')} + disabled={isSubmitting} + /> + + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/media-upload-button.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/media-upload-button.tsx new file mode 100644 index 000000000..764e2c2ad --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/media-upload-button.tsx @@ -0,0 +1,52 @@ +import { useRef } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import { Image as ImageIcon } from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; + +interface MediaUploadButtonProps { + onFileSelect: (file: File) => void; + disabled?: boolean; +} + +export function MediaUploadButton({ onFileSelect, disabled = false }: MediaUploadButtonProps) { + const t = useTranslations('pages.faqs.editor.toolbar'); + const fileInputRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + onFileSelect(file); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + return ( + <> + + + fileInputRef.current?.click()} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx new file mode 100644 index 000000000..93a9b4c0d --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx @@ -0,0 +1,65 @@ +import { IconButton, Tooltip, Box } from '@mui/material'; +import { FormatBold, FormatItalic, FormatListBulleted } from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; + +interface RichTextToolbarProps { + onCommand: (command: string, value?: string) => void; + disabled?: boolean; +} + +export function RichTextToolbar({ onCommand, disabled = false }: RichTextToolbarProps) { + const t = useTranslations('pages.faqs.editor.toolbar'); + + return ( + + + + onCommand('bold')} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + onCommand('italic')} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + onCommand('insertUnorderedList')} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx new file mode 100644 index 000000000..a4a872f83 --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react'; +import { apiFetch } from '@lems/shared'; +import { useTranslations } from 'next-intl'; + +interface UseMediaUploadProps { + editorRef: React.RefObject; + onAnswerChange: (html: string) => void; + restoreSelection: () => void; +} + +export function useMediaUpload({ + editorRef, + onAnswerChange, + restoreSelection +}: UseMediaUploadProps) { + const t = useTranslations('pages.faqs.editor'); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const [isUploadingVideo, setIsUploadingVideo] = useState(false); + const [error, setError] = useState(null); + + const createDeleteLink = (type: 'image' | 'video', onDelete: () => void) => { + const deleteLink = document.createElement('a'); + deleteLink.innerHTML = `🗑️ ${t(`toolbar.delete-${type}`)}`; + deleteLink.href = '#'; + deleteLink.className = 'media-delete-link'; + + Object.assign(deleteLink.style, { + display: 'block', + marginTop: '4px', + fontSize: '12px', + color: '#d32f2f', + textDecoration: 'none', + cursor: 'pointer', + opacity: '0', + transition: 'opacity 0.2s' + }); + + deleteLink.onclick = e => { + e.preventDefault(); + e.stopPropagation(); + if (confirm(t(`toolbar.confirm-delete-${type}`))) { + onDelete(); + } + }; + + return deleteLink; + }; + + const insertMediaElement = ( + element: HTMLImageElement | HTMLVideoElement, + type: 'image' | 'video' + ) => { + if (!editorRef.current) return; + + editorRef.current.focus(); + restoreSelection(); + + const wrapper = document.createElement('div'); + wrapper.className = 'media-wrapper'; + Object.assign(wrapper.style, { + display: 'inline-block', + maxWidth: '100%', + margin: '16px 0', + padding: '8px', + border: '1px solid transparent', + borderRadius: '4px', + transition: 'border-color 0.2s' + }); + + Object.assign(element.style, { + maxWidth: '100%', + height: 'auto', + display: 'block', + borderRadius: '4px' + }); + + const deleteLink = createDeleteLink(type, () => { + wrapper.remove(); + onAnswerChange(editorRef.current?.innerHTML ?? ''); + }); + + // Show delete link on hover + wrapper.onmouseenter = () => { + wrapper.style.borderColor = '#e0e0e0'; + deleteLink.style.opacity = '1'; + }; + + wrapper.onmouseleave = () => { + wrapper.style.borderColor = 'transparent'; + deleteLink.style.opacity = '0'; + }; + + wrapper.appendChild(element); + wrapper.appendChild(deleteLink); + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(wrapper); + + const br = document.createElement('br'); + range.collapse(false); + range.insertNode(br); + + range.setStartAfter(br); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + editorRef.current.appendChild(wrapper); + const br = document.createElement('br'); + editorRef.current.appendChild(br); + } + + onAnswerChange(editorRef.current.innerHTML); + }; + + const handleImageUpload = async (file: File) => { + if ( + !file.type.startsWith('image/') || + (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) + ) { + setError(t('errors.invalid-image-type')); + return; + } + + if (file.size > 2 * 1024 * 1024) { + setError(t('errors.image-too-large')); + return; + } + + setIsUploadingImage(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('image', file); + + const result = await apiFetch('/admin/faqs/upload-image', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const imageUrl = (result.data as { url: string }).url; + const img = document.createElement('img'); + img.src = imageUrl; + insertMediaElement(img, 'image'); + } else { + setError(t('errors.image-upload-failed')); + } + } catch (err) { + console.error('Error uploading image:', err); + setError(t('errors.image-upload-failed')); + } finally { + setIsUploadingImage(false); + } + }; + + const handleVideoUpload = async (file: File) => { + if ( + !file.type.startsWith('video/') || + (!file.name.endsWith('.mp4') && !file.name.endsWith('.webm')) + ) { + setError(t('errors.invalid-video-type')); + return; + } + + if (file.size > 50 * 1024 * 1024) { + setError(t('errors.video-too-large')); + return; + } + + setIsUploadingVideo(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('video', file); + + const result = await apiFetch('/admin/faqs/upload-video', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const videoUrl = (result.data as { url: string }).url; + const video = document.createElement('video'); + video.src = videoUrl; + video.controls = true; + insertMediaElement(video, 'video'); + } else { + setError(t('errors.video-upload-failed')); + } + } catch (err) { + console.error('Error uploading video:', err); + setError(t('errors.video-upload-failed')); + } finally { + setIsUploadingVideo(false); + } + }; + + const handleMediaUpload = async (file: File) => { + if (file.type.startsWith('video/')) { + await handleVideoUpload(file); + } else { + await handleImageUpload(file); + } + }; + + return { + isUploadingImage, + isUploadingVideo, + error, + setError, + handleMediaUpload + }; +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx new file mode 100644 index 000000000..1cbecdff0 --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useState } from 'react'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Button, + Typography, + Box, + Chip, + Alert, + CircularProgress, + FormControl, + InputLabel, + Select, + MenuItem +} from '@mui/material'; +import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon } from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import useSWR from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { Season } from '@lems/database'; +import { FaqEditorDialog } from './components/faq-editor-dialog'; +import { DeleteConfirmDialog } from './components/delete-confirm-dialog'; + +export default function FaqsPage() { + const t = useTranslations('pages.faqs'); + const [selectedSeasonId, setSelectedSeasonId] = useState('all'); + const [editingFaq, setEditingFaq] = useState(null); + const [deletingFaq, setDeletingFaq] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const { data: seasons, error: seasonsError } = useSWR('/admin/seasons'); + const { data: faqs, error: faqsError } = useSWR( + selectedSeasonId === 'all' ? '/admin/faqs' : `/admin/faqs/season/${selectedSeasonId}` + ); + + const loading = !faqs || !seasons; + const error = faqsError || seasonsError; + + const handleEdit = (faq: FaqResponse) => { + setEditingFaq(faq); + }; + + const handleDelete = (faq: FaqResponse) => { + setDeletingFaq(faq); + }; + + const handleCreate = () => { + setIsCreating(true); + }; + + const handleCloseDialog = () => { + setEditingFaq(null); + setIsCreating(false); + }; + + const handleCloseDeleteDialog = () => { + setDeletingFaq(null); + }; + + const getSeasonName = (seasonId: string) => { + const season = seasons?.find(s => s.id === seasonId); + return season?.name || seasonId; + }; + + const stripHtml = (html: string) => { + const tmp = document.createElement('div'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + }; + + return ( + + + {t('title')} + + + + + + {t('filter.season')} + + + + + {error && ( + + {t('errors.load-failed')} + + )} + + {loading ? ( + + + + ) : ( + + + + + {t('table.question')} + {t('table.answer')} + {t('table.season')} + {t('table.created-by')} + {t('table.order')} + {t('table.actions')} + + + + {faqs && faqs.length === 0 ? ( + + + + {t('empty-state')} + + + + ) : ( + faqs?.map(faq => ( + + + + {faq.question} + + + + + {stripHtml(faq.answer)} + + + + + + + {faq.createdBy.name} + + {faq.displayOrder} + + handleEdit(faq)} color="primary"> + + + handleDelete(faq)} color="error"> + + + + + )) + )} + +
+
+ )} + + {(isCreating || editingFaq) && ( + + )} + + {deletingFaq && ( + + )} +
+ ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/layout.tsx b/apps/admin/src/app/[locale]/(dashboard)/layout.tsx index dd263883b..6e4ec6396 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/layout.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/layout.tsx @@ -17,7 +17,8 @@ import { EmojiEventsOutlined, GroupOutlined, InsightsOutlined, - DataObjectOutlined + DataObjectOutlined, + QuestionAnswer } from '@mui/icons-material'; import { PermissionType } from '@lems/database'; import { @@ -41,7 +42,13 @@ type Navigator = { }; }; -const navigator: Navigator = { +type NavItem = { + icon: React.ReactNode; + label: string; + route: string; +}; + +const permissionBasedNavigator: Navigator = { MANAGE_SEASONS: { icon: , label: 'seasons', @@ -69,6 +76,14 @@ const navigator: Navigator = { } }; +const alwaysVisibleNav: NavItem[] = [ + { + icon: , + label: 'faqs', + route: 'faqs' + } +]; + interface AppBarProps { width: number; permissions: PermissionType[]; @@ -102,11 +117,11 @@ const AppBar: React.FC = ({ width, permissions, user }) => { - {Object.keys(navigator).map(permissionKey => { + {Object.keys(permissionBasedNavigator).map(permissionKey => { const permission = permissionKey as PermissionType; if (!permissions.includes(permission)) return null; - const navItem = navigator[permission]; + const navItem = permissionBasedNavigator[permission]; if (!navItem) return null; return ( @@ -120,6 +135,16 @@ const AppBar: React.FC = ({ width, permissions, user }) => { ); })} + {alwaysVisibleNav.map(navItem => ( + + + + {navItem.icon} + + + + + ))} diff --git a/apps/backend/src/lib/blob-storage/upload.ts b/apps/backend/src/lib/blob-storage/upload.ts index 975afa5d5..4ceacae7a 100644 --- a/apps/backend/src/lib/blob-storage/upload.ts +++ b/apps/backend/src/lib/blob-storage/upload.ts @@ -3,6 +3,7 @@ import { s3Client } from './s3-client'; export const uploadFile = async (data: Buffer, key: string) => { const bucket = process.env.DIGITALOCEAN_SPACE; + const endpoint = process.env.DIGITALOCEAN_ENDPOINT; const bucketParams = { ACL: 'public-read' as ObjectCannedACL, Bucket: bucket, @@ -11,5 +12,5 @@ export const uploadFile = async (data: Buffer, key: string) => { }; await s3Client.send(new PutObjectCommand(bucketParams)); - return `${bucket}/${key}`; + return `https://${bucket}.${endpoint}/${key}`; }; diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts new file mode 100644 index 000000000..3d60d1273 --- /dev/null +++ b/apps/backend/src/routers/admin/faqs.ts @@ -0,0 +1,235 @@ +import express from 'express'; +import fileUpload from 'express-fileupload'; +import { CreateFaqRequestSchema, UpdateFaqRequestSchema } from '@lems/types/api/admin'; +import { Faq } from '@lems/database'; +import db from '../../lib/database'; +import { uploadFile } from '../../lib/blob-storage/upload'; +import { AdminRequest } from '../../types/express'; + +const router = express.Router(); + +const IMAGE_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB +const VIDEO_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB + +//to handle errors consistently +const handleError = (res: express.Response, error: unknown, context: string) => { + console.error(`Error ${context}:`, error); + res.status(500).json({ error: 'Internal server error' }); +}; + +//to format FAQ response +const formatFaqResponse = (faq: Faq) => ({ + id: faq._id?.toString() || '', + seasonId: faq.seasonId, + question: faq.question, + answer: faq.answer, + displayOrder: faq.displayOrder, + createdBy: { + id: faq.createdBy.id, + name: `${faq.createdBy.firstName} ${faq.createdBy.lastName}` + }, + createdAt: faq.createdAt.toISOString(), + updatedAt: faq.updatedAt.toISOString() +}); + +router.get('/', async (req: AdminRequest, res) => { + try { + const faqs = await db.faqs.all().getAll(); + res.json(faqs.map(formatFaqResponse)); + } catch (error) { + handleError(res, error, 'fetching FAQs'); + } +}); + +router.get('/season/:seasonId', async (req: AdminRequest, res) => { + try { + const { seasonId } = req.params; + const faqs = await db.faqs.bySeason(seasonId).getAll(); + res.json(faqs.map(formatFaqResponse)); + } catch (error) { + handleError(res, error, 'fetching FAQs by season'); + } +}); + +router.get('/:id', async (req: AdminRequest, res) => { + try { + const { id } = req.params; + const faq = await db.faqs.byId(id).get(); + + if (!faq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + res.json(formatFaqResponse(faq)); + } catch (error) { + handleError(res, error, 'fetching FAQ'); + } +}); + +router.post('/', async (req: AdminRequest, res) => { + try { + const validation = CreateFaqRequestSchema.safeParse(req.body); + + if (!validation.success) { + res.status(400).json({ error: 'Invalid request data', details: validation.error.issues }); + return; + } + + const { seasonId, question, answer, displayOrder } = validation.data; + + const order = displayOrder ?? (await db.faqs.getMaxDisplayOrder(seasonId)) + 1; + + // Get admin info for creator + const admin = await db.admins.byId(req.userId).get(); + if (!admin) { + res.status(401).json({ error: 'Admin not found' }); + return; + } + + const faq = await db.faqs.create({ + seasonId, + question, + answer, + displayOrder: order, + createdBy: { + id: req.userId, + firstName: admin.first_name, + lastName: admin.last_name + } + }); + + res.status(201).json(formatFaqResponse(faq)); + } catch (error) { + handleError(res, error, 'creating FAQ'); + } +}); + +router.put('/:id', async (req: AdminRequest, res) => { + try { + const { id } = req.params; + const validation = UpdateFaqRequestSchema.safeParse(req.body); + + if (!validation.success) { + res.status(400).json({ error: 'Invalid request data', details: validation.error.issues }); + return; + } + + const existingFaq = await db.faqs.byId(id).get(); + if (!existingFaq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + const updates: { question?: string; answer?: string; displayOrder?: number } = {}; + if (validation.data.question !== undefined) updates.question = validation.data.question; + if (validation.data.answer !== undefined) updates.answer = validation.data.answer; + if (validation.data.displayOrder !== undefined) updates.displayOrder = validation.data.displayOrder; + + const updatedFaq = await db.faqs.byId(id).update(updates); + res.json(formatFaqResponse(updatedFaq)); + } catch (error) { + handleError(res, error, 'updating FAQ'); + } +}); + +router.delete('/:id', async (req: AdminRequest, res) => { + try { + const { id } = req.params; + + const existingFaq = await db.faqs.byId(id).get(); + if (!existingFaq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + await db.faqs.byId(id).delete(); + res.status(204).send(); + } catch (error) { + handleError(res, error, 'deleting FAQ'); + } +}); + +router.post( + '/upload-image', + fileUpload(), + async (req: AdminRequest, res) => { + if (!req.files || !req.files.image) { + res.status(400).json({ error: 'No image file provided' }); + return; + } + + const imageFile = req.files.image as fileUpload.UploadedFile; + + if ( + !imageFile.mimetype?.startsWith('image/') || + (!imageFile.name.endsWith('.jpg') && + !imageFile.name.endsWith('.jpeg') && + !imageFile.name.endsWith('.png')) + ) { + res.status(400).json({ error: 'Image must be a JPG or PNG file' }); + return; + } + + if (imageFile.size > IMAGE_SIZE_LIMIT) { + res.status(400).json({ error: 'Image file size must not exceed 2 MB' }); + return; + } + + try { + const timestamp = Date.now(); + const extension = imageFile.name.split('.').pop(); + const filename = `faq-${timestamp}.${extension}`; + const key = `faqs/${filename}`; + + const imageUrl = await uploadFile(imageFile.data, key); + + res.status(200).json({ url: imageUrl }); + } catch (error) { + console.error('Error uploading FAQ image:', error); + res.status(500).json({ error: 'Failed to upload image' }); + } + } +); + +router.post( + '/upload-video', + fileUpload(), + async (req: AdminRequest, res) => { + if (!req.files || !req.files.video) { + res.status(400).json({ error: 'No video file provided' }); + return; + } + + const videoFile = req.files.video as fileUpload.UploadedFile; + + if ( + !videoFile.mimetype?.startsWith('video/') || + (!videoFile.name.endsWith('.mp4') && !videoFile.name.endsWith('.webm')) + ) { + res.status(400).json({ error: 'Video must be an MP4 or WebM file' }); + return; + } + + if (videoFile.size > VIDEO_SIZE_LIMIT) { + res.status(400).json({ error: 'Video file size must not exceed 50 MB' }); + return; + } + + try { + const timestamp = Date.now(); + const extension = videoFile.name.split('.').pop(); + const filename = `faq-video-${timestamp}.${extension}`; + const key = `faqs/${filename}`; + + const videoUrl = await uploadFile(videoFile.data, key); + + res.status(200).json({ url: videoUrl }); + } catch (error) { + console.error('Error uploading FAQ video:', error); + res.status(500).json({ error: 'Failed to upload video' }); + } + } +); + +export default router; diff --git a/apps/backend/src/routers/admin/index.ts b/apps/backend/src/routers/admin/index.ts index c175d22e7..3912a9ec6 100644 --- a/apps/backend/src/routers/admin/index.ts +++ b/apps/backend/src/routers/admin/index.ts @@ -5,6 +5,7 @@ import authRouter from './auth'; import seasonsRouter from './seasons'; import teamsRouter from './teams'; import eventsRouter from './events'; +import faqsRouter from './faqs'; const router = express.Router({ mergeParams: true }); @@ -15,5 +16,6 @@ router.use('/users', usersRouter); router.use('/seasons', seasonsRouter); router.use('/teams', teamsRouter); router.use('/events', eventsRouter); +router.use('/faqs', faqsRouter); export default router; diff --git a/apps/backend/src/routers/portal/faqs.ts b/apps/backend/src/routers/portal/faqs.ts new file mode 100644 index 000000000..cdc28bd7d --- /dev/null +++ b/apps/backend/src/routers/portal/faqs.ts @@ -0,0 +1,65 @@ +import express from 'express'; +import { Faq } from '@lems/database'; +import db from '../../lib/database'; + +const router = express.Router(); + +// Helper function to handle errors consistently +const handleError = (res: express.Response, error: unknown, context: string) => { + console.error(`Error ${context}:`, error); + res.status(500).json({ error: 'Internal server error' }); +}; + +// Format FAQ response for portal - excludes creator info, timestamps, and seasonId for public consumption +const formatPortalFaqResponse = (faq: Faq) => ({ + id: faq._id?.toString() || '', + question: faq.question, + answer: faq.answer, + displayOrder: faq.displayOrder +}); + +// GET /portal/faqs - Get all FAQs +router.get('/', async (req, res) => { + try { + const faqs = await db.faqs.all().getAll(); + res.json(faqs.map(formatPortalFaqResponse)); + } catch (error) { + handleError(res, error, 'fetching FAQs'); + } +}); + +// GET /portal/faqs/season/:seasonId - Get FAQs by season +router.get('/season/:seasonId', async (req, res) => { + try { + const { seasonId } = req.params; + const faqs = await db.faqs.bySeason(seasonId).getAll(); + res.json(faqs.map(formatPortalFaqResponse)); + } catch (error) { + handleError(res, error, 'fetching FAQs by season'); + } +}); + +// GET /portal/faqs/search - Search FAQs +router.get('/search', async (req, res) => { + try { + const { q, seasonId } = req.query; + + if (!q || typeof q !== 'string') { + res.status(400).json({ error: 'Search query is required' }); + return; + } + + let faqs: Faq[]; + if (seasonId && typeof seasonId === 'string') { + faqs = await db.faqs.bySeason(seasonId).search(q); + } else { + faqs = await db.faqs.all().search(q); + } + + res.json(faqs.map(formatPortalFaqResponse)); + } catch (error) { + handleError(res, error, 'searching FAQs'); + } +}); + +export default router; diff --git a/apps/backend/src/routers/portal/index.ts b/apps/backend/src/routers/portal/index.ts index 8bef62569..a0cbc3192 100644 --- a/apps/backend/src/routers/portal/index.ts +++ b/apps/backend/src/routers/portal/index.ts @@ -5,6 +5,7 @@ import eventsRouter from './events'; import teamsRouter from './teams'; import divisionsRouter from './divisions'; import searchRouter from './search'; +import faqsRouter from './faqs'; const router = express.Router({ mergeParams: true }); @@ -15,5 +16,6 @@ router.use('/events', eventsRouter); router.use('/teams', teamsRouter); router.use('/divisions', divisionsRouter); router.use('/search', searchRouter); +router.use('/faqs', faqsRouter); export default router; diff --git a/apps/frontend/src/app/[locale]/lems/(integration)/layout.tsx b/apps/frontend/src/app/[locale]/lems/(integration)/layout.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/portal/locale/en.json b/apps/portal/locale/en.json index 70ae492e1..155a9d3e0 100644 --- a/apps/portal/locale/en.json +++ b/apps/portal/locale/en.json @@ -266,6 +266,19 @@ "mobile-placeholder": "Choose value...", "mobile-feedback": "Feedback" } + }, + "faqs": { + "title": "Robot Game Rules FAQ", + "subtitle": "Frequently Asked Questions about FIRST LEGO League Challenge Robot Game Rules", + "search-placeholder": "Search questions and answers...", + "results-count": "{count} questions", + "search-active": "Searching", + "no-results": "No questions found", + "empty-state": "No FAQs available yet", + "try-different-search": "Try searching with different keywords", + "errors": { + "load-failed": "Failed to load FAQs. Please try again later." + } } }, "layouts": { @@ -274,7 +287,8 @@ "teams": "Teams", "events": "Events", "scorer": "Scorer", - "rubrics": "Rubrics" + "rubrics": "Rubrics", + "faqs": "FAQ" } } } diff --git a/apps/portal/locale/he.json b/apps/portal/locale/he.json index eec12c817..d42884d01 100644 --- a/apps/portal/locale/he.json +++ b/apps/portal/locale/he.json @@ -266,6 +266,19 @@ "mobile-placeholder": "סמנו ניקוד...", "mobile-feedback": "משוב" } + }, + "faqs": { + "title": "שאלות נפוצות - חוקי משחק הרובוט", + "subtitle": "שאלות נפוצות על חוקי משחק הרובוט של FIRST LEGO League Challenge", + "search-placeholder": "חיפוש שאלות ותשובות...", + "results-count": "{count} שאלות", + "search-active": "מחפש", + "no-results": "לא נמצאו שאלות", + "empty-state": "אין עדיין שאלות נפוצות זמינות", + "try-different-search": "נסה לחפש עם מילות מפתח שונות", + "errors": { + "load-failed": "טעינת השאלות הנפוצות נכשלה. אנא נסה שוב מאוחר יותר." + } } }, "layouts": { @@ -274,7 +287,8 @@ "teams": "קבוצות", "events": "אירועים", "scorer": "מחשבון ניקוד", - "rubrics": "מחווני שיפוט" + "rubrics": "מחווני שיפוט", + "faqs": "שאלות נפוצות" } } } diff --git a/apps/portal/src/app/[locale]/components/app-bar.tsx b/apps/portal/src/app/[locale]/components/app-bar.tsx index 81200dc82..ada96a025 100644 --- a/apps/portal/src/app/[locale]/components/app-bar.tsx +++ b/apps/portal/src/app/[locale]/components/app-bar.tsx @@ -20,7 +20,8 @@ import { CalculateOutlined, Group, Event, - MenuRounded + MenuRounded, + QuestionAnswer } from '@mui/icons-material'; import { ResponsiveComponent } from '@lems/shared'; import { Link } from '../../../i18n/navigation'; @@ -31,7 +32,8 @@ const pages = [ { name: 'teams', href: '/teams', icon: }, { name: 'events', href: '/events', icon: }, { name: 'scorer', href: '/tools/scorer', icon: }, - { name: 'rubrics', href: '/tools/rubrics', icon: } + { name: 'rubrics', href: '/tools/rubrics', icon: }, + { name: 'faqs', href: '/faqs', icon: } ]; interface PortalAppBarProps { diff --git a/apps/portal/src/app/[locale]/faqs/page.tsx b/apps/portal/src/app/[locale]/faqs/page.tsx new file mode 100644 index 000000000..a889e8bfe --- /dev/null +++ b/apps/portal/src/app/[locale]/faqs/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { + Container, + Paper, + Typography, + Box, + TextField, + InputAdornment, + Accordion, + AccordionSummary, + AccordionDetails, + CircularProgress, + Alert, + Chip, + Stack +} from '@mui/material'; +import { + Search as SearchIcon, + ExpandMore as ExpandMoreIcon, + QuestionAnswer as QuestionAnswerIcon +} from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import DOMPurify from 'dompurify'; +import useSWR from 'swr'; +import { PortalFaqResponse } from '@lems/types/api/portal'; + +export default function FaqsPage() { + const t = useTranslations('pages.faqs'); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedId, setExpandedId] = useState(false); + + const { data: faqs, error } = useSWR('/portal/faqs'); + + const filteredFaqs = useMemo(() => { + if (!faqs) return []; + if (!searchQuery.trim()) return faqs; + + const query = searchQuery.toLowerCase(); + return faqs.filter( + faq => faq.question.toLowerCase().includes(query) || faq.answer.toLowerCase().includes(query) + ); + }, [faqs, searchQuery]); + + const handleAccordionChange = (id: string) => (_: React.SyntheticEvent, isExpanded: boolean) => { + setExpandedId(isExpanded ? id : false); + }; + + const loading = !faqs && !error; + + return ( + + + {t('title')} + + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ) + }} + /> + + + {error && ( + + {t('errors.load-failed')} + + )} + + {loading ? ( + + + + ) : filteredFaqs.length === 0 ? ( + + + + {searchQuery ? t('no-results') : t('empty-state')} + + {searchQuery && ( + + {t('try-different-search')} + + )} + + ) : ( + + + + {t('results-count', { count: filteredFaqs.length })} + + {searchQuery && ( + setSearchQuery('')} + color="primary" + size="small" + /> + )} + + + + {filteredFaqs.map(faq => ( + `3px solid ${theme.palette.primary.main}`, + bgcolor: theme => `${theme.palette.primary.main}08` + }, + '&.Mui-expanded': { + boxShadow: 3 + } + }} + > + } + sx={{ + '& .MuiAccordionSummary-content': { + my: 2 + } + }} + > + + {faq.question} + + + + + + + ))} + + + )} + + ); +} diff --git a/libs/database/src/database.ts b/libs/database/src/database.ts index ffc46cbbd..dc2826762 100644 --- a/libs/database/src/database.ts +++ b/libs/database/src/database.ts @@ -18,6 +18,7 @@ import { RubricsRepository } from './repositories/rubrics'; import { ScoresheetsRepository } from './repositories/scoresheets'; import { JudgingDeliberationsRepository } from './repositories/judging-deliberations'; import { FinalDeliberationsRepository } from './repositories/final-deliberations'; +import { FaqsRepository } from './repositories/faqs-mongo'; const IS_PRODUCTION = process.env.NODE_ENV === 'production'; @@ -72,6 +73,7 @@ export class Database { public scoresheets: ScoresheetsRepository; public awards: AwardsRepository; + public faqs: FaqsRepository; /** * Direct access to low-level database connections for advanced queries. @@ -138,6 +140,7 @@ export class Database { this.scoresheets = new ScoresheetsRepository(this.kysely, this.mongoDb); this.awards = new AwardsRepository(this.kysely); + this.faqs = new FaqsRepository(this.mongoDb); } async connect(): Promise { diff --git a/libs/database/src/migrations/026_add_manage_faq_permission.ts b/libs/database/src/migrations/026_add_manage_faq_permission.ts new file mode 100644 index 000000000..1377571a7 --- /dev/null +++ b/libs/database/src/migrations/026_add_manage_faq_permission.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Add MANAGE_FAQ to the permission_type enum + await sql`ALTER TYPE permission_type ADD VALUE 'MANAGE_FAQ'`.execute(db); +} + +export async function down(_db: Kysely): Promise { + void _db; + // Note: PostgreSQL doesn't support removing enum values directly + // This would require recreating the enum type and all dependent objects + // For safety, we'll leave the enum value in place + console.warn('Cannot remove enum value MANAGE_FAQ - PostgreSQL limitation'); +} diff --git a/libs/database/src/migrations/027_create_faqs_table.ts b/libs/database/src/migrations/027_create_faqs_table.ts new file mode 100644 index 000000000..f112b6df8 --- /dev/null +++ b/libs/database/src/migrations/027_create_faqs_table.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Create the faqs table + await db.schema + .createTable('faqs') + .addColumn('pk', 'serial', col => col.primaryKey()) + .addColumn('id', 'uuid', col => col.notNull().defaultTo(sql`gen_random_uuid()`).unique()) + .addColumn('season_id', 'uuid', col => col.notNull()) + .addColumn('question', 'text', col => col.notNull()) + .addColumn('answer', 'text', col => col.notNull()) + .addColumn('display_order', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('created_at', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .addColumn('updated_at', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .execute(); + + // Add foreign key constraint to seasons table + await db.schema + .alterTable('faqs') + .addForeignKeyConstraint('fk_faqs_season_id', ['season_id'], 'seasons', ['id']) + .onDelete('cascade') + .execute(); + + // Create indexes for faster lookups + await db.schema + .createIndex('idx_faqs_season_id') + .on('faqs') + .column('season_id') + .execute(); + + await db.schema + .createIndex('idx_faqs_display_order') + .on('faqs') + .columns(['season_id', 'display_order']) + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop the indexes + await db.schema.dropIndex('idx_faqs_season_id').ifExists().execute(); + await db.schema.dropIndex('idx_faqs_display_order').ifExists().execute(); + + // Drop the table + await db.schema.dropTable('faqs').ifExists().execute(); +} diff --git a/libs/database/src/migrations/028_add_faq_created_by.ts b/libs/database/src/migrations/028_add_faq_created_by.ts new file mode 100644 index 000000000..6b2d2378f --- /dev/null +++ b/libs/database/src/migrations/028_add_faq_created_by.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Add created_by column as nullable first + await db.schema + .alterTable('faqs') + .addColumn('created_by', 'uuid') + .execute(); + + const firstAdmin = await db + .selectFrom('admins') + .select('id') + .orderBy('created_at', 'asc') + .executeTakeFirst(); + + if (firstAdmin) { + // Update existing FAQs with the first admin as creator + await db + .updateTable('faqs') + .set({ created_by: firstAdmin.id }) + .where('created_by', 'is', null) + .execute(); + } + + // Now make the column NOT NULL + await db.schema + .alterTable('faqs') + .alterColumn('created_by', col => col.setNotNull()) + .execute(); + + // Add foreign key constraint to admins table + await db.schema + .alterTable('faqs') + .addForeignKeyConstraint('fk_faqs_created_by', ['created_by'], 'admins', ['id']) + .onDelete('restrict') + .execute(); + + // Create index for created_by lookups + await db.schema + .createIndex('idx_faqs_created_by') + .on('faqs') + .column('created_by') + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop index and constraints + await db.schema.dropIndex('idx_faqs_created_by').ifExists().execute(); + + await db.schema + .alterTable('faqs') + .dropConstraint('fk_faqs_created_by') + .ifExists() + .execute(); + + await db.schema + .alterTable('faqs') + .dropColumn('created_by') + .execute(); +} diff --git a/libs/database/src/repositories/faqs-mongo.ts b/libs/database/src/repositories/faqs-mongo.ts new file mode 100644 index 000000000..1091e93f8 --- /dev/null +++ b/libs/database/src/repositories/faqs-mongo.ts @@ -0,0 +1,147 @@ +import { Db, ObjectId } from 'mongodb'; +import { Faq } from '../schema/documents/faq'; + +const COLLECTION_NAME = 'faqs'; + +interface CreateFaqInput { + seasonId: string; + question: string; + answer: string; + displayOrder: number; + createdBy: { + id: string; + firstName: string; + lastName: string; + }; +} + +interface UpdateFaqInput { + question?: string; + answer?: string; + displayOrder?: number; +} + +class FaqSelector { + constructor( + private db: Db, + private faqId: string + ) {} + + async get(): Promise { + const collection = this.db.collection(COLLECTION_NAME); + const faq = await collection.findOne({ _id: new ObjectId(this.faqId) }); + return faq; + } + + async update(updates: UpdateFaqInput): Promise { + const collection = this.db.collection(COLLECTION_NAME); + + const updateDoc: Partial = { + ...updates, + updatedAt: new Date() + }; + + await collection.updateOne( + { _id: new ObjectId(this.faqId) }, + { $set: updateDoc } + ); + + const updatedFaq = await this.get(); + if (!updatedFaq) { + throw new Error('FAQ not found after update'); + } + return updatedFaq; + } + + async delete(): Promise { + const collection = this.db.collection(COLLECTION_NAME); + await collection.deleteOne({ _id: new ObjectId(this.faqId) }); + } +} + +class FaqsSelector { + constructor( + private db: Db, + private seasonId?: string + ) {} + + private getBaseQuery() { + if (this.seasonId) { + return { seasonId: this.seasonId }; + } + return {}; + } + + async getAll(): Promise { + const collection = this.db.collection(COLLECTION_NAME); + return await collection + .find(this.getBaseQuery()) + .sort({ displayOrder: 1 }) + .toArray(); + } + + async search(searchTerm: string): Promise { + const collection = this.db.collection(COLLECTION_NAME); + const query = { + ...this.getBaseQuery(), + $or: [ + { question: { $regex: searchTerm, $options: 'i' } }, + { answer: { $regex: searchTerm, $options: 'i' } } + ] + }; + + return await collection + .find(query) + .sort({ displayOrder: 1 }) + .toArray(); + } +} + +export class FaqsRepository { + constructor(private db: Db) {} + + byId(faqId: string): FaqSelector { + return new FaqSelector(this.db, faqId); + } + + bySeason(seasonId: string): FaqsSelector { + return new FaqsSelector(this.db, seasonId); + } + + all(): FaqsSelector { + return new FaqsSelector(this.db); + } + + async create(input: CreateFaqInput): Promise { + const collection = this.db.collection(COLLECTION_NAME); + + const faq: Omit = { + seasonId: input.seasonId, + question: input.question, + answer: input.answer, + displayOrder: input.displayOrder, + createdBy: input.createdBy, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = await collection.insertOne(faq as Faq); + const createdFaq = await collection.findOne({ _id: result.insertedId }); + + if (!createdFaq) { + throw new Error('FAQ not found after creation'); + } + return createdFaq; + } + + async getMaxDisplayOrder(seasonId: string): Promise { + const collection = this.db.collection(COLLECTION_NAME); + const result = await collection + .find({ seasonId }) + .sort({ displayOrder: -1 }) + .limit(1) + .toArray(); + + return result.length > 0 ? result[0].displayOrder : 0; + } +} diff --git a/libs/database/src/schema/documents/faq.ts b/libs/database/src/schema/documents/faq.ts new file mode 100644 index 000000000..106b52d1a --- /dev/null +++ b/libs/database/src/schema/documents/faq.ts @@ -0,0 +1,16 @@ +import { ObjectId } from 'mongodb'; + +export interface Faq { + _id?: ObjectId; + seasonId: string; + question: string; + answer: string; + displayOrder: number; + createdBy: { + id: string; + firstName: string; + lastName: string; + }; + createdAt: Date; + updatedAt: Date; +} diff --git a/libs/database/src/schema/index.ts b/libs/database/src/schema/index.ts index 4ba8add82..6ce3622c7 100644 --- a/libs/database/src/schema/index.ts +++ b/libs/database/src/schema/index.ts @@ -33,6 +33,7 @@ export * from './documents/robot-game-match-state'; export * from './documents/rubric'; export * from './documents/scoresheet'; export * from './documents/final-deliberation'; +export * from './documents/faq'; export * from './tables/judging-deliberation'; export * from './tables/awards'; diff --git a/libs/database/src/schema/tables/admin-permissions.ts b/libs/database/src/schema/tables/admin-permissions.ts index e2cd8f585..27ea6d8f5 100644 --- a/libs/database/src/schema/tables/admin-permissions.ts +++ b/libs/database/src/schema/tables/admin-permissions.ts @@ -6,7 +6,8 @@ export type PermissionType = | 'MANAGE_EVENTS' | 'MANAGE_EVENT_DETAILS' | 'MANAGE_TEAMS' - | 'VIEW_INSIGHTS'; + | 'VIEW_INSIGHTS' + | 'MANAGE_FAQ'; export interface AdminPermissionTable { pk: ColumnType; // Serial primary key diff --git a/libs/types/src/lib/api/admin/faqs.ts b/libs/types/src/lib/api/admin/faqs.ts new file mode 100644 index 000000000..7611e835f --- /dev/null +++ b/libs/types/src/lib/api/admin/faqs.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const FaqResponseSchema = z.object({ + id: z.string(), + seasonId: z.string(), + question: z.string(), + answer: z.string(), + displayOrder: z.number(), + createdBy: z.object({ + id: z.string(), + name: z.string() + }), + createdAt: z.string(), + updatedAt: z.string() +}); + +export type FaqResponse = z.infer; + +export const FaqsResponseSchema = z.array(FaqResponseSchema); + +export const CreateFaqRequestSchema = z.object({ + seasonId: z.string(), + question: z.string().min(1, 'Question is required'), + answer: z.string().min(1, 'Answer is required'), + displayOrder: z.number().optional() +}); + +export type CreateFaqRequest = z.infer; + +export const UpdateFaqRequestSchema = z.object({ + question: z.string().min(1, 'Question is required').optional(), + answer: z.string().min(1, 'Answer is required').optional(), + displayOrder: z.number().optional() +}); + +export type UpdateFaqRequest = z.infer; diff --git a/libs/types/src/lib/api/admin/index.ts b/libs/types/src/lib/api/admin/index.ts index d3ec28ab7..2e2b8bf58 100644 --- a/libs/types/src/lib/api/admin/index.ts +++ b/libs/types/src/lib/api/admin/index.ts @@ -7,3 +7,4 @@ export * from './tables'; export * from './rooms'; export * from './awards'; export * from './schedule'; +export * from './faqs'; diff --git a/libs/types/src/lib/api/admin/users.ts b/libs/types/src/lib/api/admin/users.ts index 05bdf3b1d..3ae9ec3ea 100644 --- a/libs/types/src/lib/api/admin/users.ts +++ b/libs/types/src/lib/api/admin/users.ts @@ -19,7 +19,8 @@ export const ALL_ADMIN_PERMISSIONS = [ 'MANAGE_EVENTS', 'MANAGE_EVENT_DETAILS', 'MANAGE_TEAMS', - 'VIEW_INSIGHTS' + 'VIEW_INSIGHTS', + 'MANAGE_FAQ' ] as const satisfies readonly PermissionType[]; export const AdminUserPermissionsResponseSchema = z.array(z.enum(ALL_ADMIN_PERMISSIONS)); diff --git a/libs/types/src/lib/api/portal/faqs.ts b/libs/types/src/lib/api/portal/faqs.ts new file mode 100644 index 000000000..c417499af --- /dev/null +++ b/libs/types/src/lib/api/portal/faqs.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const PortalFaqResponseSchema = z.object({ + id: z.string(), + question: z.string(), + answer: z.string(), + displayOrder: z.number() +}); + +export type PortalFaqResponse = z.infer; + +export const PortalFaqsResponseSchema = z.array(PortalFaqResponseSchema); + +export type PortalFaqsResponse = z.infer; diff --git a/libs/types/src/lib/api/portal/index.ts b/libs/types/src/lib/api/portal/index.ts index 9d2868411..a6123f456 100644 --- a/libs/types/src/lib/api/portal/index.ts +++ b/libs/types/src/lib/api/portal/index.ts @@ -2,3 +2,4 @@ export * from './seasons'; export * from './events'; export * from './teams'; export * from './divisions'; +export * from './faqs';