-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add search & filter + tags system for notes #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,10 +3,31 @@ import Note from "../models/Note.js"; | |
|
|
||
| /** | ||
| * Get all notes belonging to the authenticated user. | ||
| * Supports optional search query and tag filtering. | ||
| */ | ||
| export async function getAllNotes(req, res) { | ||
| try { | ||
| const notes = await Note.find({ userId: req.user._id }).sort({ createdAt: -1 }); | ||
| const { search, tag } = req.query; | ||
| const filter = { userId: req.user._id }; | ||
|
|
||
| // Search filter: match title or content (case-insensitive) | ||
| if (search && typeof search === "string" && search.trim()) { | ||
| const regex = new RegExp(search.trim(), "i"); | ||
| filter.$or = [ | ||
| { title: regex }, | ||
| { content: regex }, | ||
| ]; | ||
| } | ||
|
|
||
| // Tag filter: match any of the provided tags (comma-separated) | ||
| if (tag && typeof tag === "string" && tag.trim()) { | ||
| const tags = tag.split(",").map((t) => t.trim()).filter(Boolean); | ||
| if (tags.length > 0) { | ||
| filter.tags = { $in: tags }; | ||
| } | ||
| } | ||
|
|
||
| const notes = await Note.find(filter).sort({ createdAt: -1 }); | ||
|
|
||
| res.status(200).json(notes); | ||
| } catch (error) { | ||
|
|
@@ -51,10 +72,11 @@ export async function getNoteById(req, res) { | |
|
|
||
| /** | ||
| * Create a new note associated with the authenticated user's ID. | ||
| * Supports an optional tags array. | ||
| */ | ||
| export async function createNote(req, res) { | ||
| try { | ||
| const { title, content } = req.body; | ||
| const { title, content, tags } = req.body; | ||
|
|
||
| if (typeof title !== "string" || typeof content !== "string") { | ||
| return res.status(400).json({ message: "Title and content must be strings" }); | ||
|
|
@@ -64,10 +86,22 @@ export async function createNote(req, res) { | |
| return res.status(400).json({ message: "Title is required" }); | ||
| } | ||
|
|
||
| // Validate tags if provided | ||
| let parsedTags = []; | ||
| if (tags !== undefined) { | ||
| if (!Array.isArray(tags)) { | ||
| return res.status(400).json({ message: "Tags must be an array of strings" }); | ||
| } | ||
| parsedTags = tags | ||
| .map((t) => (typeof t === "string" ? t.trim().toLowerCase() : "")) | ||
| .filter((t) => t.length > 0); | ||
| } | ||
|
Comment on lines
+89
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Enforce tag limits and uniqueness on the backend. Although the frontend limits users to 10 tags and prevents duplicates, this validation should also be enforced in the backend controllers to maintain data integrity and prevent malformed API requests from bypassing frontend constraints.
📍 Affects 1 file
🤖 Prompt for AI Agents |
||
|
|
||
| const note = new Note({ | ||
| userId: req.user._id, | ||
| title: title.trim(), | ||
| content: content.trim(), | ||
| tags: parsedTags, | ||
| }); | ||
|
|
||
| const savedNote = await note.save(); | ||
|
|
@@ -84,11 +118,12 @@ export async function createNote(req, res) { | |
|
|
||
| /** | ||
| * Update a specific note after verifying ownership by the authenticated user. | ||
| * Supports updating title, content, and tags. | ||
| */ | ||
| export async function updateNote(req, res) { | ||
| try { | ||
| const { id } = req.params; | ||
| const { title, content } = req.body; | ||
| const { title, content, tags } = req.body; | ||
|
|
||
| if (process.env.MONGO_URI && !mongoose.Types.ObjectId.isValid(id)) { | ||
| return res.status(400).json({ | ||
|
|
@@ -102,14 +137,26 @@ export async function updateNote(req, res) { | |
| if (!title.trim()) { | ||
| return res.status(400).json({ message: "Title is required" }); | ||
| } | ||
|
|
||
|
|
||
| // Build update object | ||
| const updateData = { | ||
| title: title.trim(), | ||
| content: content.trim(), | ||
| }; | ||
|
|
||
| // Handle tags update if provided | ||
| if (tags !== undefined) { | ||
| if (!Array.isArray(tags)) { | ||
| return res.status(400).json({ message: "Tags must be an array of strings" }); | ||
| } | ||
| updateData.tags = tags | ||
| .map((t) => (typeof t === "string" ? t.trim().toLowerCase() : "")) | ||
| .filter((t) => t.length > 0); | ||
| } | ||
|
|
||
| const updatedNote = await Note.findOneAndUpdate( | ||
| { _id: id, userId: req.user._id }, | ||
| { | ||
| title: title.trim(), | ||
| content: content.trim(), | ||
| }, | ||
| updateData, | ||
| { | ||
| new: true, | ||
| runValidators: true, | ||
|
|
@@ -164,3 +211,29 @@ export async function deleteNote(req, res) { | |
| }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get all unique tags used by the authenticated user. | ||
| */ | ||
| export async function getUserTags(req, res) { | ||
| try { | ||
| const result = await Note.aggregate([ | ||
| { $match: { userId: req.user._id } }, | ||
| { $unwind: "$tags" }, | ||
| { $group: { _id: "$tags", count: { $sum: 1 } } }, | ||
| { $sort: { count: -1, _id: 1 } }, | ||
| ]); | ||
|
|
||
| const tags = result.map((t) => ({ | ||
| name: t._id, | ||
| count: t.count, | ||
| })); | ||
|
|
||
| res.status(200).json(tags); | ||
| } catch (error) { | ||
| console.error("Error in getUserTags controller:", error); | ||
| res.status(500).json({ | ||
| message: "Internal server error", | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { SearchIcon, XIcon } from "lucide-react"; | ||
|
|
||
| const SearchBar = ({ value, onChange, placeholder = "Search notes..." }) => { | ||
| return ( | ||
| <div className="relative w-full max-w-md"> | ||
| <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-400 dark:text-gray-500" /> | ||
| <input | ||
| type="text" | ||
| placeholder={placeholder} | ||
| value={value} | ||
| onChange={(e) => onChange(e.target.value)} | ||
| className="w-full pl-10 pr-10 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" | ||
| /> | ||
| {value && ( | ||
| <button | ||
| onClick={() => onChange("")} | ||
| className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" | ||
| > | ||
| <XIcon size={16} /> | ||
| </button> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default SearchBar; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { XIcon } from "lucide-react"; | ||
|
|
||
| const TAG_COLORS = [ | ||
| "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", | ||
| "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300", | ||
| "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300", | ||
| "bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300", | ||
| "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", | ||
| "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", | ||
| "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300", | ||
| "bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300", | ||
| "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300", | ||
| "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300", | ||
| ]; | ||
|
|
||
| const getTagColor = (tag) => { | ||
| let hash = 0; | ||
| for (let i = 0; i < tag.length; i++) { | ||
| hash = tag.charCodeAt(i) + ((hash << 5) - hash); | ||
| } | ||
| return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]; | ||
| }; | ||
|
|
||
| const TagBadge = ({ tag, onRemove, onClick, active = false, size = "sm" }) => { | ||
| const sizeClasses = size === "sm" | ||
| ? "text-xs px-2 py-0.5" | ||
| : "text-sm px-3 py-1"; | ||
|
|
||
| const activeClasses = active | ||
| ? "ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-800" | ||
| : ""; | ||
|
|
||
| return ( | ||
| <span | ||
| className={`inline-flex items-center gap-1 rounded-full font-medium ${sizeClasses} ${getTagColor(tag)} ${activeClasses} ${onClick ? "cursor-pointer hover:opacity-80 transition-opacity" : ""}`} | ||
| onClick={onClick ? (e) => { e.stopPropagation(); onClick(tag); } : undefined} | ||
| > | ||
| #{tag} | ||
| {onRemove && ( | ||
| <button | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| onRemove(tag); | ||
| }} | ||
| className="ml-0.5 hover:opacity-70 transition-opacity" | ||
| > | ||
| <XIcon size={size === "sm" ? 10 : 12} /> | ||
| </button> | ||
| )} | ||
| </span> | ||
| ); | ||
| }; | ||
|
|
||
| export default TagBadge; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { useState, useEffect } from "react"; | ||
| import api from "../lib/axios"; | ||
| import TagBadge from "./TagBadge"; | ||
| import { TagsIcon, XIcon } from "lucide-react"; | ||
|
|
||
| const TagFilter = ({ selectedTag, onSelectTag }) => { | ||
| const [tags, setTags] = useState([]); | ||
| const [loading, setLoading] = useState(true); | ||
|
|
||
| useEffect(() => { | ||
| const fetchTags = async () => { | ||
| try { | ||
| const res = await api.get("/notes/tags"); | ||
| setTags(res.data); | ||
| } catch (error) { | ||
| console.log("Error fetching tags:", error); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
| fetchTags(); | ||
| }, []); | ||
|
|
||
| if (loading) return null; | ||
|
|
||
| if (tags.length === 0) return null; | ||
|
|
||
| return ( | ||
| <div className="flex flex-wrap items-center gap-2"> | ||
| <TagsIcon size={16} className="text-gray-400 dark:text-gray-500" /> | ||
|
|
||
| {/* "All" button */} | ||
| {selectedTag && ( | ||
| <button | ||
| onClick={() => onSelectTag(null)} | ||
| className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" | ||
| > | ||
| <XIcon size={12} /> | ||
| Clear filter | ||
| </button> | ||
| )} | ||
|
|
||
| {tags.map((tag) => ( | ||
| <TagBadge | ||
| key={tag.name} | ||
| tag={tag.name} | ||
| active={selectedTag === tag.name} | ||
| onClick={() => onSelectTag(selectedTag === tag.name ? null : tag.name)} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default TagFilter; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Escape special characters in the search string to prevent ReDoS and application errors.
Passing user input directly to
new RegExpwithout escaping special characters can lead to Regular Expression Denial of Service (ReDoS) or cause 500 errors if the user searches for characters like[,*, or?.Please escape the search string before using it in the regular expression.
🛡️ Proposed fix to escape regex characters
if (search && typeof search === "string" && search.trim()) { - const regex = new RegExp(search.trim(), "i"); + const escapedSearch = search.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedSearch, "i"); filter.$or = [ { title: regex }, { content: regex }, ]; }📝 Committable suggestion
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] 14-14: Detects non-literal values in regular expressions
Context: new RegExp(search.trim(), "i")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).
(detect-non-literal-regexp)
🤖 Prompt for AI Agents
Source: Linters/SAST tools