From 3866f1f9a4742f6b5885e9d5b7a8b9e2b2e5bf0f Mon Sep 17 00:00:00 2001 From: roshankumar0036singh Date: Fri, 23 Jan 2026 07:44:42 +0530 Subject: [PATCH 1/3] feat: Implement Feedback System & Navbar Integration --- client/src/infra/rest/apis/feedback/index.ts | 22 ++ client/src/infra/rest/apis/feedback/typing.ts | 27 ++ .../src/modules/home/v1/components/index.tsx | 6 +- client/src/modules/home/v1/hooks/index.ts | 6 +- .../organisms/feedback-modal/index.tsx | 250 ++++++++++++++++++ .../navbar/components/feedback-menu.tsx | 81 ++++++ .../components/organisms/navbar/index.tsx | 37 +++ server/src/constants/feedback.js | 12 + .../controllers/feedback/get-user-feedback.js | 29 ++ .../controllers/feedback/submit-feedback.js | 86 ++++++ server/src/models/feedback.model.js | 61 +++++ server/src/routes/api/feedback.routes.js | 21 ++ server/src/routes/index.js | 2 + server/src/schemas/feedback.schema.js | 54 ++++ 14 files changed, 688 insertions(+), 6 deletions(-) create mode 100644 client/src/infra/rest/apis/feedback/index.ts create mode 100644 client/src/infra/rest/apis/feedback/typing.ts create mode 100644 client/src/shared/components/organisms/feedback-modal/index.tsx create mode 100644 client/src/shared/components/organisms/navbar/components/feedback-menu.tsx create mode 100644 server/src/constants/feedback.js create mode 100644 server/src/controllers/feedback/get-user-feedback.js create mode 100644 server/src/controllers/feedback/submit-feedback.js create mode 100644 server/src/models/feedback.model.js create mode 100644 server/src/routes/api/feedback.routes.js create mode 100644 server/src/schemas/feedback.schema.js diff --git a/client/src/infra/rest/apis/feedback/index.ts b/client/src/infra/rest/apis/feedback/index.ts new file mode 100644 index 00000000..bae5f6f2 --- /dev/null +++ b/client/src/infra/rest/apis/feedback/index.ts @@ -0,0 +1,22 @@ +import { get, post } from '../../index'; +import type { ApiResponse } from '../../typings'; +import type { FeedbackItem, SubmitFeedbackPayload } from './typing'; + +export const submitFeedback = async (payload: SubmitFeedbackPayload) => { + return post>( + '/api/feedback/submit', + true, + payload + ); +}; + +export const getUserFeedback = async (params?: { + limit?: number; + skip?: number; +}) => { + const { limit = 10, skip = 0 } = params || {}; + return get< + undefined, + ApiResponse<{ feedback: FeedbackItem[]; total: number; hasMore: boolean }> + >(`/api/feedback/user?limit=${limit}&skip=${skip}`, true); +}; diff --git a/client/src/infra/rest/apis/feedback/typing.ts b/client/src/infra/rest/apis/feedback/typing.ts new file mode 100644 index 00000000..2bb96aa9 --- /dev/null +++ b/client/src/infra/rest/apis/feedback/typing.ts @@ -0,0 +1,27 @@ +export enum FeedbackCategory { + ARTICLES = 'articles', + CHATS = 'chats', + CODE = 'code', +} + +export interface SubmitFeedbackPayload { + title: string; + details: string; + category: FeedbackCategory; + reproduce_steps?: string; + attachment?: string; +} + +export interface FeedbackItem { + _id: string; + user_id: string; + title: string; + details: string; + category: FeedbackCategory; + reproduce_steps?: string; + attachment_url?: string; + attachment_public_id?: string; + status: 'pending' | 'reviewed' | 'resolved'; + createdAt: string; + updatedAt: string; +} diff --git a/client/src/modules/home/v1/components/index.tsx b/client/src/modules/home/v1/components/index.tsx index a00d751a..9f082306 100644 --- a/client/src/modules/home/v1/components/index.tsx +++ b/client/src/modules/home/v1/components/index.tsx @@ -101,8 +101,8 @@ const HomeContent = ({ sx={{ minWidth: { lg: 400 }, maxWidth: 400, - maxHeight: 'calc(100vh - 130px)', - overflowY: 'auto', + maxHeight: 'calc(100vh - 130px)', + overflowY: 'auto', borderLeft: theme => `1px solid ${theme.palette.divider}`, pl: 4, pt: 1, @@ -123,7 +123,7 @@ const HomeContent = ({ key={i} onClick={() => { setSelectedCategory( - selectedCategory === category ? null : category, + selectedCategory === category ? null : category ); }} > diff --git a/client/src/modules/home/v1/hooks/index.ts b/client/src/modules/home/v1/hooks/index.ts index 037cdca2..719f7d2c 100644 --- a/client/src/modules/home/v1/hooks/index.ts +++ b/client/src/modules/home/v1/hooks/index.ts @@ -36,7 +36,7 @@ const useHomeV1 = () => { setProjects(response.data); } }, - [setProjects] + [setProjects, isHomePage] ); const fetchTrendingProjects = useCallback(async () => { @@ -45,7 +45,7 @@ const useHomeV1 = () => { if (response.data) { setTrendingProjects(response.data); } - }, [setTrendingProjects]); + }, [setTrendingProjects, isHomePage]); const fetchProjectsByCategory = useCallback( async ({ @@ -76,7 +76,7 @@ const useHomeV1 = () => { setProjects(response.data); } }, - [setProjects] + [setProjects, isHomePage] ); const searchTerm = useMemo(() => { diff --git a/client/src/shared/components/organisms/feedback-modal/index.tsx b/client/src/shared/components/organisms/feedback-modal/index.tsx new file mode 100644 index 00000000..a55db46a --- /dev/null +++ b/client/src/shared/components/organisms/feedback-modal/index.tsx @@ -0,0 +1,250 @@ +import React, { useState, useRef } from 'react'; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + FormHelperText, + Typography, +} from '@mui/material'; + +import A2ZModal from '../../atoms/modal'; +import A2ZTypography from '../../atoms/typography'; +import { submitFeedback } from '../../../../infra/rest/apis/feedback'; +import { FeedbackCategory } from '../../../../infra/rest/apis/feedback/typing'; +import { useNotifications } from '../../../hooks/use-notification'; + +interface FeedbackModalProps { + open: boolean; + onClose: () => void; +} + +const FeedbackModal = ({ open, onClose }: FeedbackModalProps) => { + const [title, setTitle] = useState(''); + const [details, setDetails] = useState(''); + const [category, setCategory] = useState(''); + const [reproduceSteps, setReproduceSteps] = useState(''); + const [attachment, setAttachment] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const fileInputRef = useRef(null); + const { addNotification } = useNotifications(); + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + if (title.length < 5 || title.length > 200) { + newErrors.title = 'Title must be between 5 and 200 characters'; + } + + if (details.length < 10 || details.length > 2000) { + newErrors.details = 'Details must be between 10 and 2000 characters'; + } + + if (!category) { + newErrors.category = 'Please select a category'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = error => reject(error); + }); + }; + + const handleSubmit = async () => { + if (!validate()) return; + + setIsSubmitting(true); + try { + let attachmentBase64 = undefined; + if (attachment) { + attachmentBase64 = await fileToBase64(attachment); + } + + await submitFeedback({ + title, + details, + category: category as FeedbackCategory, + reproduce_steps: reproduceSteps, + attachment: attachmentBase64, + }); + + addNotification({ + message: 'Feedback submitted successfully!', + type: 'success', + }); + handleClose(); + } catch (error: unknown) { + const message = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any).response?.data?.message || 'Failed to submit feedback'; + addNotification({ + message, + type: 'error', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + setTitle(''); + setDetails(''); + setCategory(''); + setReproduceSteps(''); + setAttachment(null); + setErrors({}); + onClose(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + if (file.size > 5 * 1024 * 1024) { + addNotification({ + message: 'File size must be less than 5MB', + type: 'error', + }); + return; + } + setAttachment(file); + } + }; + + return ( + + + + + setTitle(e.target.value)} + error={!!errors.title} + helperText={errors.title || `${title.length}/200`} + FormHelperTextProps={{ sx: { textAlign: 'right' } }} + inputProps={{ maxLength: 200 }} + /> + + + Category + + {errors.category && ( + {errors.category} + )} + + + setDetails(e.target.value)} + error={!!errors.details} + helperText={errors.details || `${details.length}/2000`} + FormHelperTextProps={{ sx: { textAlign: 'right' } }} + inputProps={{ maxLength: 2000 }} + /> + + setReproduceSteps(e.target.value)} + placeholder="1. Go to page X 2. Click button Y..." + /> + + + + + {attachment && ( + + Attached: {attachment.name} + + + )} + + + + + + + + + ); +}; + +export default FeedbackModal; diff --git a/client/src/shared/components/organisms/navbar/components/feedback-menu.tsx b/client/src/shared/components/organisms/navbar/components/feedback-menu.tsx new file mode 100644 index 00000000..e15805bf --- /dev/null +++ b/client/src/shared/components/organisms/navbar/components/feedback-menu.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { + Popper, + Paper, + ClickAwayListener, + MenuList, + MenuItem, + ListItemIcon, + ListItemText, +} from '@mui/material'; +import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; +import LightbulbIcon from '@mui/icons-material/Lightbulb'; +import FeedbackModal from '../../feedback-modal'; + +interface FeedbackMenuProps { + feedbackOpen: boolean; + feedbackAnchorEl: HTMLElement | null; + onClose: () => void; +} + +const FeedbackMenu = ({ + feedbackOpen, + feedbackAnchorEl, + onClose, +}: FeedbackMenuProps) => { + const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false); + + const handleFeedbackModalOpen = () => { + setIsFeedbackModalOpen(true); + onClose(); + }; + + const handleRequestFeatureClick = () => { + window.open( + 'https://github.com/Code-A2Z/code-a2z/issues/new?template=feature-request.yml', + '_blank' + ); + onClose(); + }; + + // Sync internal modal state request with external prop if needed, or handle exclusively here + // Actually, keeping Modal local is fine, but the menu trigger is external now. + + return ( + <> + + + + + + + + + Feedback + + + + + + Request a feature + + + + + + + setIsFeedbackModalOpen(false)} + /> + + ); +}; + +export default FeedbackMenu; diff --git a/client/src/shared/components/organisms/navbar/index.tsx b/client/src/shared/components/organisms/navbar/index.tsx index a159acb1..e6feb843 100644 --- a/client/src/shared/components/organisms/navbar/index.tsx +++ b/client/src/shared/components/organisms/navbar/index.tsx @@ -1,14 +1,30 @@ +import { useState } from 'react'; import { AppBar, Toolbar, Box, Badge } from '@mui/material'; import LightModeIcon from '@mui/icons-material/LightMode'; import DarkModeIcon from '@mui/icons-material/DarkMode'; +import SupportAgentIcon from '@mui/icons-material/SupportAgent'; import A2ZIconButton from '../../atoms/icon-button'; import Logo from '../../atoms/logo'; import { useA2ZTheme } from '../../../hooks/use-theme'; import { THEME } from '../../../states/theme'; import { NAVBAR_HEIGHT } from './constants'; +import FeedbackMenu from './components/feedback-menu'; const Navbar = () => { const { theme, setTheme } = useA2ZTheme(); + const [feedbackOpen, setFeedbackOpen] = useState(false); + const [feedbackAnchorEl, setFeedbackAnchorEl] = useState( + null + ); + + const handleFeedbackToggle = (event: React.MouseEvent) => { + setFeedbackAnchorEl(event.currentTarget); + setFeedbackOpen(prev => !prev); + }; + + const handleFeedbackClose = () => { + setFeedbackOpen(false); + }; return ( { {theme === THEME.DARK ? : } + + + + + + + + + + diff --git a/server/src/constants/feedback.js b/server/src/constants/feedback.js new file mode 100644 index 00000000..254b5462 --- /dev/null +++ b/server/src/constants/feedback.js @@ -0,0 +1,12 @@ +export const FEEDBACK_STATUS = { + PENDING: 'pending', + REVIEWED: 'reviewed', + RESOLVED: 'resolved', + ARCHIVED: 'archived', +}; + +export const FEEDBACK_CATEGORY = { + ARTICLES: 'articles', + CHATS: 'chats', + CODE: 'code', +}; diff --git a/server/src/controllers/feedback/get-user-feedback.js b/server/src/controllers/feedback/get-user-feedback.js new file mode 100644 index 00000000..bd74ec15 --- /dev/null +++ b/server/src/controllers/feedback/get-user-feedback.js @@ -0,0 +1,29 @@ +import Feedback from '../../models/feedback.model.js'; + +/** + * Get all feedback submitted by the authenticated user + */ +const getUserFeedback = async (req, res) => { + try { + const userId = req.user.user_id; + + const feedbacks = await Feedback.find({ user_id: userId }) + .sort({ createdAt: -1 }) + .select('-__v'); + + res.status(200).json({ + success: true, + count: feedbacks.length, + data: feedbacks, + }); + } catch (error) { + console.error('Error fetching user feedback:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch feedback', + error: error.message, + }); + } +}; + +export default getUserFeedback; diff --git a/server/src/controllers/feedback/submit-feedback.js b/server/src/controllers/feedback/submit-feedback.js new file mode 100644 index 00000000..1000ed73 --- /dev/null +++ b/server/src/controllers/feedback/submit-feedback.js @@ -0,0 +1,86 @@ +/** + * POST /api/feedback/submit - Submit user feedback + * @param {string} title - Short and descriptive title (5-200 chars) + * @param {string} details - Detailed description (10-2000 chars) + * @param {string} category - Feedback category ('articles', 'chats', 'code') + * @param {string} reproduce_steps - Optional steps to reproduce issue + * @param {file} attachment - Optional image file upload + * @returns {Object} Created feedback object + */ + +import Feedback from '../../models/feedback.model.js'; +import { sendResponse } from '../../utils/response.js'; +import cloudinary from '../../config/cloudinary.js'; +import { nanoid } from 'nanoid'; +import { + FEEDBACK_CATEGORY, + FEEDBACK_STATUS, +} from '../../constants/feedback.js'; + +const submitFeedback = async (req, res, next) => { + try { + const { title, details, category, reproduce_steps, attachment } = req.body; + + // Validation + if (!title || title.length < 5 || title.length > 200) { + return sendResponse( + res, + 400, + 'Title must be between 5 and 200 characters' + ); + } + if (!details || details.length < 10 || details.length > 2000) { + return sendResponse( + res, + 400, + 'Details must be between 10 and 2000 characters' + ); + } + if (!Object.values(FEEDBACK_CATEGORY).includes(category)) { + return sendResponse(res, 400, 'Invalid category'); + } + + let attachment_url = ''; + let attachment_public_id = ''; + + if (attachment) { + try { + const date = new Date(); + const uniqueFileName = `feedback-${nanoid()}-${date.getTime()}`; + + // Upload Base64 image directly to Cloudinary + const result = await cloudinary.uploader.upload(attachment, { + public_id: uniqueFileName, + folder: 'feedback_attachments', + resource_type: 'image', + }); + + attachment_url = result.secure_url; + attachment_public_id = result.public_id; + } catch (error) { + console.error('Cloudinary upload error:', error); + return sendResponse(res, 500, 'Failed to upload attachment'); + } + } + + const feedback = await Feedback.create({ + user_id: req.user.user_id, + + title, + details, + category, + reproduce_steps, + attachment_url, + attachment_public_id, + status: FEEDBACK_STATUS.PENDING, + }); + + return sendResponse(res, 201, 'Thanks for sharing your feedback', { + feedback, + }); + } catch (error) { + next(error); + } +}; + +export default submitFeedback; diff --git a/server/src/models/feedback.model.js b/server/src/models/feedback.model.js new file mode 100644 index 00000000..9188e8bf --- /dev/null +++ b/server/src/models/feedback.model.js @@ -0,0 +1,61 @@ +import mongoose from 'mongoose'; +import { FEEDBACK_CATEGORY, FEEDBACK_STATUS } from '../constants/feedback.js'; + +const feedbackSchema = new mongoose.Schema( + { + user_id: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + title: { + type: String, + required: true, + trim: true, + minlength: 5, + maxlength: 200, + }, + details: { + type: String, + required: true, + trim: true, + minlength: 10, + maxlength: 2000, + }, + category: { + type: String, + required: true, + enum: Object.values(FEEDBACK_CATEGORY), + }, + reproduce_steps: { + type: String, + trim: true, + maxlength: 1000, + }, + attachment_url: { + type: String, + default: '', + }, + attachment_public_id: { + type: String, + default: '', + }, + status: { + type: String, + enum: Object.values(FEEDBACK_STATUS), + default: FEEDBACK_STATUS.PENDING, + }, + }, + { + timestamps: true, + } +); + +// Index for efficient querying +feedbackSchema.index({ user_id: 1, createdAt: -1 }); +feedbackSchema.index({ status: 1, createdAt: -1 }); + +const Feedback = mongoose.model('Feedback', feedbackSchema); + +export default Feedback; diff --git a/server/src/routes/api/feedback.routes.js b/server/src/routes/api/feedback.routes.js new file mode 100644 index 00000000..efc43fe3 --- /dev/null +++ b/server/src/routes/api/feedback.routes.js @@ -0,0 +1,21 @@ +import express from 'express'; + +import authenticateUser from '../../middlewares/auth.middleware.js'; +import upload from '../../middlewares/multer.middleware.js'; + +import submitFeedback from '../../controllers/feedback/submit-feedback.js'; +import getUserFeedback from '../../controllers/feedback/get-user-feedback.js'; + +const feedbackRoutes = express.Router(); + +// Route checks authentication and handles single file upload logic for 'attachment' field +feedbackRoutes.post( + '/submit', + authenticateUser, + upload.single('attachment'), + submitFeedback +); + +feedbackRoutes.get('/user', authenticateUser, getUserFeedback); + +export default feedbackRoutes; diff --git a/server/src/routes/index.js b/server/src/routes/index.js index edd87bce..d0a675f0 100644 --- a/server/src/routes/index.js +++ b/server/src/routes/index.js @@ -15,6 +15,7 @@ import commentRoutes from './api/comment.routes.js'; import notificationRoutes from './api/notification.routes.js'; import collectionRoutes from './api/collections.routes.js'; import collaborationRoutes from './api/collaboration.routes.js'; +import feedbackRoutes from './api/feedback.routes.js'; const router = express.Router(); @@ -28,5 +29,6 @@ router.use('/comment', generalLimiter, commentRoutes); router.use('/notification', generalLimiter, notificationRoutes); router.use('/collection', generalLimiter, collectionRoutes); router.use('/collaboration', generalLimiter, collaborationRoutes); +router.use('/feedback', generalLimiter, feedbackRoutes); export default router; diff --git a/server/src/schemas/feedback.schema.js b/server/src/schemas/feedback.schema.js new file mode 100644 index 00000000..0be7bf34 --- /dev/null +++ b/server/src/schemas/feedback.schema.js @@ -0,0 +1,54 @@ +import { Schema } from 'mongoose'; +import { COLLECTION_NAMES } from '../constants/db.js'; +import { FEEDBACK_STATUS, FEEDBACK_CATEGORY } from '../constants/feedback.js'; + +const FEEDBACK_SCHEMA = Schema( + { + user_id: { + type: Schema.Types.ObjectId, + ref: COLLECTION_NAMES.USERS, + required: true, + index: true, + }, + title: { + type: String, + required: true, + minlength: [5, 'Title must be at least 5 characters long'], + maxlength: [200, 'Title cannot exceed 200 characters'], + }, + details: { + type: String, + required: true, + minlength: [10, 'Details must be at least 10 characters long'], + maxlength: [2000, 'Details cannot exceed 2000 characters'], + }, + category: { + type: String, + required: true, + enum: Object.values(FEEDBACK_CATEGORY), + }, + reproduce_steps: { + type: String, + default: '', + }, + attachment_url: { + type: String, + default: '', + }, + attachment_public_id: { + type: String, + default: '', + }, + status: { + type: String, + enum: Object.values(FEEDBACK_STATUS), + default: FEEDBACK_STATUS.PENDING, + index: true, + }, + }, + { + timestamps: true, + } +); + +export default FEEDBACK_SCHEMA; From e67a986237fd344758f694f313656e81cc4e5164 Mon Sep 17 00:00:00 2001 From: roshankumar0036singh Date: Fri, 23 Jan 2026 23:47:26 +0530 Subject: [PATCH 2/3] refactor: use sendResponse and fix duplicate feedback schema --- server/src/constants/db.js | 1 + .../controllers/feedback/get-user-feedback.js | 21 +++---- server/src/models/feedback.model.js | 62 ++----------------- 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/server/src/constants/db.js b/server/src/constants/db.js index 33705ebe..aff54297 100644 --- a/server/src/constants/db.js +++ b/server/src/constants/db.js @@ -6,4 +6,5 @@ export const COLLECTION_NAMES = { NOTIFICATIONS: 'notifications', COLLABORATION: 'collaborations', COLLECTIONS: 'collections', + FEEDBACK: 'feedbacks', }; diff --git a/server/src/controllers/feedback/get-user-feedback.js b/server/src/controllers/feedback/get-user-feedback.js index bd74ec15..1a9f9fa9 100644 --- a/server/src/controllers/feedback/get-user-feedback.js +++ b/server/src/controllers/feedback/get-user-feedback.js @@ -1,9 +1,10 @@ import Feedback from '../../models/feedback.model.js'; +import { sendResponse } from '../../utils/response.js'; /** * Get all feedback submitted by the authenticated user */ -const getUserFeedback = async (req, res) => { +const getUserFeedback = async (req, res, next) => { try { const userId = req.user.user_id; @@ -11,18 +12,14 @@ const getUserFeedback = async (req, res) => { .sort({ createdAt: -1 }) .select('-__v'); - res.status(200).json({ - success: true, - count: feedbacks.length, - data: feedbacks, - }); + return sendResponse( + res, + 200, + 'User feedback fetched successfully', + feedbacks + ); } catch (error) { - console.error('Error fetching user feedback:', error); - res.status(500).json({ - success: false, - message: 'Failed to fetch feedback', - error: error.message, - }); + next(error); } }; diff --git a/server/src/models/feedback.model.js b/server/src/models/feedback.model.js index 9188e8bf..42e369fc 100644 --- a/server/src/models/feedback.model.js +++ b/server/src/models/feedback.model.js @@ -1,61 +1,7 @@ -import mongoose from 'mongoose'; -import { FEEDBACK_CATEGORY, FEEDBACK_STATUS } from '../constants/feedback.js'; +import { model } from 'mongoose'; +import FEEDBACK_SCHEMA from '../schemas/feedback.schema.js'; +import { COLLECTION_NAMES } from '../constants/db.js'; -const feedbackSchema = new mongoose.Schema( - { - user_id: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true, - index: true, - }, - title: { - type: String, - required: true, - trim: true, - minlength: 5, - maxlength: 200, - }, - details: { - type: String, - required: true, - trim: true, - minlength: 10, - maxlength: 2000, - }, - category: { - type: String, - required: true, - enum: Object.values(FEEDBACK_CATEGORY), - }, - reproduce_steps: { - type: String, - trim: true, - maxlength: 1000, - }, - attachment_url: { - type: String, - default: '', - }, - attachment_public_id: { - type: String, - default: '', - }, - status: { - type: String, - enum: Object.values(FEEDBACK_STATUS), - default: FEEDBACK_STATUS.PENDING, - }, - }, - { - timestamps: true, - } -); - -// Index for efficient querying -feedbackSchema.index({ user_id: 1, createdAt: -1 }); -feedbackSchema.index({ status: 1, createdAt: -1 }); - -const Feedback = mongoose.model('Feedback', feedbackSchema); +const Feedback = model(COLLECTION_NAMES.FEEDBACK, FEEDBACK_SCHEMA); export default Feedback; From fcc8bc8608353a7d4c1bb74ead58a3e0a02db909 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney Date: Sat, 24 Jan 2026 00:16:05 +0530 Subject: [PATCH 3/3] pr review changes & api docs updated --- .../organisms/feedback-modal/hooks/index.ts | 117 +++++ .../organisms/feedback-modal/index.tsx | 148 ++---- .../navbar/components/feedback-menu.tsx | 28 +- .../components/organisms/navbar/index.tsx | 109 ++--- client/src/shared/hooks/useFileToBase64.ts | 10 + docs/Code A2Z.postman_collection.json | 463 ++++++++++++++---- server/src/constants/feedback.js | 12 - .../controllers/feedback/get-user-feedback.js | 25 +- .../controllers/feedback/submit-feedback.js | 60 +-- server/src/models/feedback.model.js | 4 +- server/src/schemas/feedback.schema.js | 2 +- server/src/typings/index.js | 13 + 12 files changed, 661 insertions(+), 330 deletions(-) create mode 100644 client/src/shared/components/organisms/feedback-modal/hooks/index.ts create mode 100644 client/src/shared/hooks/useFileToBase64.ts delete mode 100644 server/src/constants/feedback.js diff --git a/client/src/shared/components/organisms/feedback-modal/hooks/index.ts b/client/src/shared/components/organisms/feedback-modal/hooks/index.ts new file mode 100644 index 00000000..4495d8e0 --- /dev/null +++ b/client/src/shared/components/organisms/feedback-modal/hooks/index.ts @@ -0,0 +1,117 @@ +import { useRef, useState } from 'react'; +import { submitFeedback } from '../../../../../infra/rest/apis/feedback'; +import { useNotifications } from '../../../../hooks/use-notification'; +import fileToBase64 from '../../../../hooks/useFileToBase64'; +import { FeedbackCategory } from '../../../../../infra/rest/apis/feedback/typing'; + +const useFeedbackModal = ({ onClose }: { onClose: () => void }) => { + const { addNotification } = useNotifications(); + + const [title, setTitle] = useState(''); + const [details, setDetails] = useState(''); + const [category, setCategory] = useState(''); + const [reproduceSteps, setReproduceSteps] = useState(''); + const [attachment, setAttachment] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const fileInputRef = useRef(null); + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + if (title.length < 5 || title.length > 200) { + newErrors.title = 'Title must be between 5 and 200 characters'; + } + + if (details.length < 10 || details.length > 2000) { + newErrors.details = 'Details must be between 10 and 2000 characters'; + } + + if (!category) { + newErrors.category = 'Please select a category'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validate()) return; + + setIsSubmitting(true); + try { + let attachmentBase64 = undefined; + if (attachment) { + attachmentBase64 = await fileToBase64(attachment); + } + + await submitFeedback({ + title, + details, + category: category as FeedbackCategory, + reproduce_steps: reproduceSteps, + attachment: attachmentBase64, + }); + + addNotification({ + message: 'Feedback submitted successfully!', + type: 'success', + }); + handleClose(); + } catch (error) { + console.error('Feedback submission error:', error); + addNotification({ + message: 'Failed to submit feedback. Please try again later.', + type: 'error', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + setTitle(''); + setDetails(''); + setCategory(''); + setReproduceSteps(''); + setAttachment(null); + setErrors({}); + onClose(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + if (file.size > 5 * 1024 * 1024) { + addNotification({ + message: 'File size must be less than 5MB', + type: 'error', + }); + return; + } + setAttachment(file); + } + }; + + return { + title, + setTitle, + details, + setDetails, + category, + setCategory, + reproduceSteps, + setReproduceSteps, + attachment, + setAttachment, + errors, + setErrors, + fileInputRef, + isSubmitting, + handleSubmit, + handleClose, + handleFileChange, + }; +}; + +export default useFeedbackModal; diff --git a/client/src/shared/components/organisms/feedback-modal/index.tsx b/client/src/shared/components/organisms/feedback-modal/index.tsx index a55db46a..70b1d8f7 100644 --- a/client/src/shared/components/organisms/feedback-modal/index.tsx +++ b/client/src/shared/components/organisms/feedback-modal/index.tsx @@ -1,4 +1,3 @@ -import React, { useState, useRef } from 'react'; import { Box, Button, @@ -13,113 +12,35 @@ import { import A2ZModal from '../../atoms/modal'; import A2ZTypography from '../../atoms/typography'; -import { submitFeedback } from '../../../../infra/rest/apis/feedback'; import { FeedbackCategory } from '../../../../infra/rest/apis/feedback/typing'; -import { useNotifications } from '../../../hooks/use-notification'; +import useFeedbackModal from './hooks'; -interface FeedbackModalProps { +const FeedbackModal = ({ + open, + onClose, +}: { open: boolean; onClose: () => void; -} - -const FeedbackModal = ({ open, onClose }: FeedbackModalProps) => { - const [title, setTitle] = useState(''); - const [details, setDetails] = useState(''); - const [category, setCategory] = useState(''); - const [reproduceSteps, setReproduceSteps] = useState(''); - const [attachment, setAttachment] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errors, setErrors] = useState<{ [key: string]: string }>({}); - const fileInputRef = useRef(null); - const { addNotification } = useNotifications(); - - const validate = () => { - const newErrors: { [key: string]: string } = {}; - - if (title.length < 5 || title.length > 200) { - newErrors.title = 'Title must be between 5 and 200 characters'; - } - - if (details.length < 10 || details.length > 2000) { - newErrors.details = 'Details must be between 10 and 2000 characters'; - } - - if (!category) { - newErrors.category = 'Please select a category'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = error => reject(error); - }); - }; - - const handleSubmit = async () => { - if (!validate()) return; - - setIsSubmitting(true); - try { - let attachmentBase64 = undefined; - if (attachment) { - attachmentBase64 = await fileToBase64(attachment); - } - - await submitFeedback({ - title, - details, - category: category as FeedbackCategory, - reproduce_steps: reproduceSteps, - attachment: attachmentBase64, - }); - - addNotification({ - message: 'Feedback submitted successfully!', - type: 'success', - }); - handleClose(); - } catch (error: unknown) { - const message = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).response?.data?.message || 'Failed to submit feedback'; - addNotification({ - message, - type: 'error', - }); - } finally { - setIsSubmitting(false); - } - }; - - const handleClose = () => { - setTitle(''); - setDetails(''); - setCategory(''); - setReproduceSteps(''); - setAttachment(null); - setErrors({}); - onClose(); - }; - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - const file = e.target.files[0]; - if (file.size > 5 * 1024 * 1024) { - addNotification({ - message: 'File size must be less than 5MB', - type: 'error', - }); - return; - } - setAttachment(file); - } - }; +}) => { + const { + title, + setTitle, + details, + setDetails, + category, + setCategory, + reproduceSteps, + setReproduceSteps, + attachment, + setAttachment, + errors, + setErrors, + fileInputRef, + isSubmitting, + handleFileChange, + handleSubmit, + handleClose, + } = useFeedbackModal({ onClose }); return ( @@ -147,7 +68,10 @@ const FeedbackModal = ({ open, onClose }: FeedbackModalProps) => { label="Short and descriptive title" fullWidth value={title} - onChange={e => setTitle(e.target.value)} + onChange={e => { + setTitle(e.target.value); + setErrors(prev => ({ ...prev, title: '' })); + }} error={!!errors.title} helperText={errors.title || `${title.length}/200`} FormHelperTextProps={{ sx: { textAlign: 'right' } }} @@ -159,7 +83,10 @@ const FeedbackModal = ({ open, onClose }: FeedbackModalProps) => {