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/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 new file mode 100644 index 00000000..70b1d8f7 --- /dev/null +++ b/client/src/shared/components/organisms/feedback-modal/index.tsx @@ -0,0 +1,180 @@ +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + FormHelperText, + Typography, +} from '@mui/material'; + +import A2ZModal from '../../atoms/modal'; +import A2ZTypography from '../../atoms/typography'; +import { FeedbackCategory } from '../../../../infra/rest/apis/feedback/typing'; +import useFeedbackModal from './hooks'; + +const FeedbackModal = ({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) => { + const { + title, + setTitle, + details, + setDetails, + category, + setCategory, + reproduceSteps, + setReproduceSteps, + attachment, + setAttachment, + errors, + setErrors, + fileInputRef, + isSubmitting, + handleFileChange, + handleSubmit, + handleClose, + } = useFeedbackModal({ onClose }); + + return ( + + + + + { + setTitle(e.target.value); + setErrors(prev => ({ ...prev, title: '' })); + }} + 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); + setErrors(prev => ({ ...prev, details: '' })); + }} + 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..0bd9edf4 --- /dev/null +++ b/client/src/shared/components/organisms/navbar/components/feedback-menu.tsx @@ -0,0 +1,83 @@ +import { 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'; + +const FeedbackMenu = ({ + feedbackOpen, + feedbackAnchorEl, + onClose, +}: { + feedbackOpen: boolean; + feedbackAnchorEl: HTMLElement | null; + onClose: () => void; +}) => { + 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(); + }; + + return ( + <> + + + + + + + + + Feedback + + + + + + + Request a feature + + + + + + + setIsFeedbackModalOpen(prev => !prev)} + /> + + ); +}; + +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..6c365677 100644 --- a/client/src/shared/components/organisms/navbar/index.tsx +++ b/client/src/shared/components/organisms/navbar/index.tsx @@ -1,65 +1,95 @@ +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); + }; return ( - - + - - - + + + - - - setTheme(theme === THEME.DARK ? THEME.LIGHT : THEME.DARK), + - - {theme === THEME.DARK ? : } - - - - - + + setTheme(theme === THEME.DARK ? THEME.LIGHT : THEME.DARK), + }} + > + + {theme === THEME.DARK ? : } + + + + + + + + + + + + + setFeedbackOpen(prev => !prev)} + /> + ); }; diff --git a/client/src/shared/hooks/useFileToBase64.ts b/client/src/shared/hooks/useFileToBase64.ts new file mode 100644 index 00000000..a6616981 --- /dev/null +++ b/client/src/shared/hooks/useFileToBase64.ts @@ -0,0 +1,10 @@ +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); + }); +}; + +export default fileToBase64; diff --git a/docs/Code A2Z.postman_collection.json b/docs/Code A2Z.postman_collection.json index 8d51411a..fe037db3 100644 --- a/docs/Code A2Z.postman_collection.json +++ b/docs/Code A2Z.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "37ce35ba-a1a6-45e7-b5fb-2a9b9975f785", + "_postman_id": "f6bfbb7e-3069-4e03-85e8-3563cb852338", "name": "Code A2Z", "description": "### **Code A2Z API Collection**\n\nThis collection contains all endpoints for the **Code A2Z platform**, a collaborative blogging and project-sharing platform. It provides APIs to manage users, projects, notifications, likes, comments, collections, and collaborations.\n\nThe APIs are organized into folders based on feature domains:\n\n---\n\n#### **1\\. User**\n\nEndpoints for **user search, profile fetching, and profile updates**.\n\n- Search users by username.\n \n- Fetch a user profile by username.\n \n- Update authenticated user’s profile details and profile image.\n \n\n---\n\n#### **2\\. Projects**\n\nEndpoints to **create, fetch, update, delete, and manage projects**.\n\n- Create, edit, and delete projects.\n \n- Fetch all projects, trending projects, search projects, or user-written projects.\n \n- Paginate and filter projects with counts.\n \n\n---\n\n#### **3\\. Notifications**\n\nEndpoints to **handle user notifications**.\n\n- Fetch notifications (with pagination and filters).\n \n- Check if new notifications are available.\n \n- Get total notifications count.\n \n- Marks notifications as seen when fetched.\n \n\n---\n\n#### **4\\. Likes**\n\nEndpoints to **like or unlike a project and check like status**.\n\n- Like or unlike a project.\n \n- Check if a project is liked by the authenticated user.\n \n\n---\n\n#### **5\\. Comments**\n\nEndpoints to **add, fetch, delete, and reply to comments** on projects.\n\n- Add comments or replies.\n \n- Fetch comments with pagination.\n \n- Fetch replies of a comment.\n \n- Delete a comment along with its replies (if authorized).\n \n\n---\n\n#### **6\\. Collections**\n\nEndpoints to **manage user project collections**.\n\n- Create or delete collections.\n \n- Save projects to a collection or remove them.\n \n- Fetch projects of a collection and sort them by likes, newest, or oldest.\n \n\n---\n\n#### **7\\. Collaborators**\n\nEndpoints to **manage project collaboration**.\n\n- Invite a user to collaborate on a project.\n \n- Accept or reject collaboration invitations using a secure token.\n \n- Fetch the list of collaborators for a project (project author only).\n \n\n---\n\n**Notes:**\n\n- All endpoints requiring authentication need a valid user token.\n \n- Endpoints follow **RESTful standards** with proper HTTP methods for GET, POST, PATCH, and DELETE.\n \n- Responses are standardized with `status`, `message`, and `data`.\n \n- Sensitive user information (like passwords) is never exposed.\n \n- Pagination, filtering, and sorting are implemented wherever applicable for efficient data handling.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -35,7 +35,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -144,7 +144,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -277,7 +277,7 @@ }, "status": "Created", "code": 201, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -389,7 +389,7 @@ }, "status": "Bad Request", "code": 400, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -493,7 +493,7 @@ }, "status": "Internal Server Error", "code": 500, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -620,7 +620,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -745,7 +745,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -848,7 +848,7 @@ }, "status": "Unauthorized", "code": 401, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -965,7 +965,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1100,7 +1100,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1204,7 +1204,7 @@ }, "status": "Forbidden", "code": 403, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1308,7 +1308,7 @@ }, "status": "Too Many Requests", "code": 429, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1441,7 +1441,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1568,7 +1568,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1676,10 +1676,6 @@ "originalRequest": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{baseURL}}/api/subscriber/all", "host": ["{{baseURL}}"], @@ -1688,7 +1684,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1811,7 +1807,7 @@ { "key": "image", "type": "file", - "src": "/Users/asspl/Downloads/img1.jpeg" + "src": ["/Users/asspl/Downloads/img1.jpeg"] } ] }, @@ -1823,7 +1819,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -1916,7 +1912,7 @@ { "key": "image", "type": "file", - "src": "/Users/asspl/Downloads/img1.jpeg" + "src": ["/Users/asspl/Downloads/img1.jpeg"] } ] }, @@ -1928,7 +1924,7 @@ }, "status": "Unauthorized", "code": 401, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2053,15 +2049,6 @@ "originalRequest": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { "raw": "{{baseURL}}/api/user/search?q=hello", "host": ["{{baseURL}}"], @@ -2076,7 +2063,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2195,15 +2182,6 @@ "originalRequest": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { "raw": "{{baseURL}}/api/user/profile?username=hello", "host": ["{{baseURL}}"], @@ -2218,7 +2196,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2345,7 +2323,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2449,7 +2427,7 @@ }, "status": "Bad Request", "code": 400, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2553,7 +2531,7 @@ }, "status": "Unauthorized", "code": 401, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2680,7 +2658,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2813,7 +2791,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -2934,7 +2912,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3043,7 +3021,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3204,7 +3182,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3313,7 +3291,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3450,7 +3428,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3571,7 +3549,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3708,7 +3686,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3837,7 +3815,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -3946,7 +3924,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4041,7 +4019,7 @@ }, "status": "Not Found", "code": 404, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4174,7 +4152,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4295,7 +4273,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4428,7 +4406,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4557,7 +4535,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4686,7 +4664,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4795,7 +4773,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -4890,7 +4868,7 @@ }, "status": "Not Found", "code": 404, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5033,7 +5011,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5142,7 +5120,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5263,7 +5241,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5396,7 +5374,7 @@ }, "status": "Created", "code": 201, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5500,7 +5478,7 @@ }, "status": "Bad Request", "code": 400, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5627,7 +5605,7 @@ }, "status": "Created", "code": 201, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5731,7 +5709,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5860,7 +5838,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -5987,7 +5965,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6091,7 +6069,7 @@ }, "status": "Not Found", "code": 404, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6218,7 +6196,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6322,7 +6300,7 @@ }, "status": "Not Found", "code": 404, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6455,7 +6433,7 @@ }, "status": "Bad Request", "code": 400, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6559,7 +6537,7 @@ }, "status": "Internal Server Error", "code": 500, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6703,6 +6681,312 @@ ], "description": "This folder contains endpoints related to **project collaboration management**, including sending collaboration invitations, accepting/rejecting invitations, and fetching collaborators for a project.\n\n**Endpoints:**\n\n1. **`/invite`** **(POST)** – Send a collaboration invitation to a project author.\n \n - Requires authentication.\n \n - Accepts `project_id` in the request body.\n \n - Sends an email invitation to the project author and creates a pending collaboration record in the database.\n \n2. **`/accept/:token`** **(POST)** – Accept a collaboration invitation.\n \n - Requires authentication.\n \n - Accepts `token` as a route parameter.\n \n - Marks the collaboration request as accepted and invalidates the token.\n \n3. **`/reject/:token`** **(POST)** – Reject a collaboration invitation.\n \n - Requires authentication.\n \n - Accepts `token` as a route parameter.\n \n - Marks the collaboration request as rejected and invalidates the token.\n \n4. **`/list/:project_id`** **(GET)** – Fetch the list of collaborators for a project.\n \n - Requires authentication.\n \n - Accepts `project_id` as a route parameter.\n \n - Returns all collaborators where the authenticated user is the project author.\n \n\n**Notes:**\n\n- Only authenticated users can send, accept, or reject collaboration invitations.\n \n- Tokens are invalidated after accepting or rejecting an invitation to prevent reuse.\n \n- Fetching collaborators is restricted to the project author to ensure privacy." }, + { + "name": "Feedback", + "item": [ + { + "name": "Submit Feedback", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "title", + "value": "Sample Feedback Title", + "type": "text", + "uuid": "244c4d99-aa8e-41c0-b703-6eba90bb6584" + }, + { + "key": "details", + "value": "Sample Feedback Details with atmost 2000 characters", + "type": "text", + "uuid": "ad66dee1-ccc7-4072-a9be-3113a8566658" + }, + { + "key": "category", + "value": "articles", + "type": "text", + "uuid": "8f9dd525-909d-4454-b816-3c7ad338f0c4" + }, + { + "key": "reproduce_steps", + "value": "This is the reproduce steps of the faced bug", + "type": "text", + "uuid": "5edb7c8e-5ea8-4d66-b455-66e852579aae" + }, + { + "key": "attachment", + "type": "file", + "uuid": "93d75608-b35f-436e-8d2d-7ce016258495", + "src": "/Users/asspl/Downloads/2e9748eb-daf1-4cd8-9971-bc5ce6d11ec4.jpg" + } + ] + }, + "url": { + "raw": "{{baseURL}}/api/feedback/submit", + "host": ["{{baseURL}}"], + "path": ["api", "feedback", "submit"] + } + }, + "response": [ + { + "name": "201", + "originalRequest": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "title", + "value": "Sample Feedback Title", + "type": "text", + "uuid": "244c4d99-aa8e-41c0-b703-6eba90bb6584" + }, + { + "key": "details", + "value": "Sample Feedback Details with atmost 2000 characters", + "type": "text", + "uuid": "ad66dee1-ccc7-4072-a9be-3113a8566658" + }, + { + "key": "category", + "value": "articles", + "type": "text", + "uuid": "8f9dd525-909d-4454-b816-3c7ad338f0c4" + }, + { + "key": "reproduce_steps", + "value": "This is the reproduce steps of the faced bug", + "type": "text", + "uuid": "5edb7c8e-5ea8-4d66-b455-66e852579aae" + }, + { + "key": "attachment", + "type": "file", + "uuid": "93d75608-b35f-436e-8d2d-7ce016258495", + "src": "/Users/asspl/Downloads/2e9748eb-daf1-4cd8-9971-bc5ce6d11ec4.jpg" + } + ], + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/api/feedback/submit", + "host": ["{{baseURL}}"], + "path": ["api", "feedback", "submit"] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-origin" + }, + { + "key": "Origin-Agent-Cluster", + "value": "?1" + }, + { + "key": "Referrer-Policy", + "value": "no-referrer" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-DNS-Prefetch-Control", + "value": "off" + }, + { + "key": "X-Download-Options", + "value": "noopen" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Permitted-Cross-Domain-Policies", + "value": "none" + }, + { + "key": "X-XSS-Protection", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "694" + }, + { + "key": "ETag", + "value": "W/\"2b6-vZG0P1XlxKpTj6zqo1+Tz8gOvls\"" + }, + { + "key": "Date", + "value": "Fri, 23 Jan 2026 18:05:04 GMT" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "Keep-Alive", + "value": "timeout=5" + } + ], + "cookie": [], + "body": "{\n \"status\": \"success\",\n \"message\": \"Thanks for sharing your feedback\",\n \"data\": {\n \"feedback\": {\n \"user_id\": \"6973b7ae0397bcb6997068f3\",\n \"title\": \"Sample Feedback Title\",\n \"details\": \"Sample Feedback Details with atmost 2000 characters\",\n \"category\": \"articles\",\n \"reproduce_steps\": \"This is the reproduce steps of the faced bug\",\n \"attachment_url\": \"https://res.cloudinary.com/avdhesh-varshney/image/upload/v1769191502/feedback_attachments/feedback-hdG4DLZfpbEFimFcxJ1LK-1769191495818.jpg\",\n \"attachment_public_id\": \"feedback_attachments/feedback-hdG4DLZfpbEFimFcxJ1LK-1769191495818\",\n \"status\": \"pending\",\n \"_id\": \"6973b8500397bcb6997068f7\",\n \"createdAt\": \"2026-01-23T18:05:04.103Z\",\n \"updatedAt\": \"2026-01-23T18:05:04.103Z\",\n \"__v\": 0\n }\n }\n}" + } + ] + }, + { + "name": "Get User Feedback", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/feedback/user", + "host": ["{{baseURL}}"], + "path": ["api", "feedback", "user"] + } + }, + "response": [ + { + "name": "200", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseURL}}/api/feedback/user", + "host": ["{{baseURL}}"], + "path": ["api", "feedback", "user"] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": null, + "header": [ + { + "key": "Vary", + "value": "Origin" + }, + { + "key": "Access-Control-Allow-Credentials", + "value": "true" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-origin" + }, + { + "key": "Origin-Agent-Cluster", + "value": "?1" + }, + { + "key": "Referrer-Policy", + "value": "no-referrer" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-DNS-Prefetch-Control", + "value": "off" + }, + { + "key": "X-Download-Options", + "value": "noopen" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Permitted-Cross-Domain-Policies", + "value": "none" + }, + { + "key": "X-XSS-Protection", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "691" + }, + { + "key": "ETag", + "value": "W/\"2b3-Ez+QhvBtdXSSKGYwQnm681+G5VU\"" + }, + { + "key": "Date", + "value": "Fri, 23 Jan 2026 18:06:49 GMT" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "Keep-Alive", + "value": "timeout=5" + } + ], + "cookie": [], + "body": "{\n \"status\": \"success\",\n \"message\": \"User feedback fetched successfully\",\n \"data\": {\n \"feedbacks\": [\n {\n \"_id\": \"6973b8500397bcb6997068f7\",\n \"user_id\": \"6973b7ae0397bcb6997068f3\",\n \"title\": \"Sample Feedback Title\",\n \"details\": \"Sample Feedback Details with atmost 2000 characters\",\n \"category\": \"articles\",\n \"reproduce_steps\": \"This is the reproduce steps of the faced bug\",\n \"attachment_url\": \"https://res.cloudinary.com/avdhesh-varshney/image/upload/v1769191502/feedback_attachments/feedback-hdG4DLZfpbEFimFcxJ1LK-1769191495818.jpg\",\n \"attachment_public_id\": \"feedback_attachments/feedback-hdG4DLZfpbEFimFcxJ1LK-1769191495818\",\n \"status\": \"pending\",\n \"createdAt\": \"2026-01-23T18:05:04.103Z\",\n \"updatedAt\": \"2026-01-23T18:05:04.103Z\"\n }\n ]\n }\n}" + } + ] + } + ] + }, { "name": "First Route", "protocolProfileBehavior": { @@ -6727,10 +7011,6 @@ "originalRequest": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{baseURL}}/", "host": ["{{baseURL}}"], @@ -6739,7 +7019,7 @@ }, "status": "OK", "code": 200, - "_postman_previewlanguage": null, + "_postman_previewlanguage": "Text", "header": [ { "key": "Access-Control-Allow-Origin", @@ -6824,6 +7104,16 @@ ] } ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNjk3M2I3YWUwMzk3YmNiNjk5NzA2OGYzIiwic3Vic2NyaWJlcl9pZCI6IjY5NzNiN2FlMDM5N2JjYjY5OTcwNjhmMSIsImlhdCI6MTc2OTE5MTM3NSwiZXhwIjoxNzY5MTkyMjc1fQ.rKQDwowd-hHaFxewoxIOy6HbxuHzCi62tpkNTSwgXgU", + "type": "string" + } + ] + }, "event": [ { "listen": "prerequest", @@ -6847,8 +7137,7 @@ "variable": [ { "key": "baseURL", - "value": "http://localhost:8000", - "type": "string" + "value": "http://localhost:8000" } ] } 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 new file mode 100644 index 00000000..dfc55de3 --- /dev/null +++ b/server/src/controllers/feedback/get-user-feedback.js @@ -0,0 +1,25 @@ +/** + * GET /api/feedback/user - Get all feedback submitted by the authenticated user + * @returns {Object} List of user feedbacks + */ + +import Feedback from '../../models/feedback.model.js'; +import { sendResponse } from '../../utils/response.js'; + +const getUserFeedback = async (req, res) => { + const userId = req.user.user_id; + try { + const feedbacks = await Feedback.find({ user_id: userId }) + .sort({ createdAt: -1 }) + .select('-__v'); + + return sendResponse(res, 200, 'User feedback fetched successfully', { + feedbacks, + }); + } catch (error) { + console.error('Error fetching user feedback:', error); + return sendResponse(res, 500, 'Server Error', { 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..234c006e --- /dev/null +++ b/server/src/controllers/feedback/submit-feedback.js @@ -0,0 +1,76 @@ +/** + * 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_STATUS, FEEDBACK_CATEGORY } from '../../typings/index.js'; + +const submitFeedback = async (req, res) => { + const { title, details, category, reproduce_steps } = req.body; + const file = req.file; + + 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'); + } + + try { + let attachment_url = ''; + let attachment_public_id = ''; + + if (file) { + try { + const uniqueFileName = `feedback-${nanoid()}-${Date.now()}`; + const result = await cloudinary.uploader.upload(file.path, { + 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) { + console.error('Error submitting feedback:', error); + return sendResponse(res, 500, 'Server Error', { error: error.message }); + } +}; + +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..1517d8a1 --- /dev/null +++ b/server/src/models/feedback.model.js @@ -0,0 +1,7 @@ +import { model } from 'mongoose'; +import FEEDBACK_SCHEMA from '../schemas/feedback.schema.js'; +import { COLLECTION_NAMES } from '../constants/db.js'; + +const FEEDBACK = model(COLLECTION_NAMES.FEEDBACK, FEEDBACK_SCHEMA); + +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..c3d983b3 --- /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 '../typings/index.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; diff --git a/server/src/typings/index.js b/server/src/typings/index.js index ac7dbfe3..2f9094ec 100644 --- a/server/src/typings/index.js +++ b/server/src/typings/index.js @@ -32,3 +32,16 @@ export const COLLABORATION_STATUS = { ACCEPTED: 'accepted', REJECTED: 'rejected', }; + +export const FEEDBACK_STATUS = { + PENDING: 'pending', + REVIEWED: 'reviewed', + RESOLVED: 'resolved', + ARCHIVED: 'archived', +}; + +export const FEEDBACK_CATEGORY = { + ARTICLES: 'articles', + CHATS: 'chats', + CODE: 'code', +};