From 3599bc2b609a412ffda721ed2918f4e92653aa50 Mon Sep 17 00:00:00 2001 From: Chris Veleris Date: Mon, 4 Aug 2025 23:43:36 +0300 Subject: [PATCH] Add nanoid --- backend/cmd/start-dev.sh | 2 +- backend/jest.config.js | 3 + .../20240804000004-add-nanoid-to-areas.js | 63 ++++ .../20250804000001-add-nanoid-to-tasks.js | 70 ++++ .../20250804000002-add-nanoid-to-projects.js | 77 +++++ .../20250804000003-add-nanoid-to-notes.js | 77 +++++ .../20250804000004-add-nanoid-to-tags.js | 77 +++++ backend/models/area.js | 7 + backend/models/note.js | 7 + backend/models/project.js | 7 + backend/models/tag.js | 7 + backend/models/task.js | 7 + backend/routes/areas.js | 6 +- backend/routes/notes.js | 140 +++++++- backend/routes/projects.js | 124 +++++-- backend/routes/tags.js | 65 +++- backend/routes/tasks.js | 229 ++++++++++--- backend/tests/mocks/nanoid.js | 28 ++ backend/utils/slug-utils.js | 80 +++++ frontend/App.tsx | 11 +- frontend/components/Areas.tsx | 9 +- frontend/components/Inbox/InboxItemDetail.tsx | 15 +- frontend/components/Inbox/InboxItems.tsx | 2 +- frontend/components/Note/NoteDetails.tsx | 30 +- .../Productivity/ProductivityAssistant.tsx | 10 +- .../components/Project/ProjectDetails.tsx | 53 ++- frontend/components/Project/ProjectItem.tsx | 29 +- frontend/components/Shared/NoteCard.tsx | 52 ++- frontend/components/Tag/TagDetails.tsx | 114 ++++--- frontend/components/Tags.tsx | 15 +- frontend/components/Task/TaskDetails.tsx | 163 ++++++--- frontend/components/Task/TaskHeader.tsx | 72 +++- frontend/components/Task/TaskItem.tsx | 16 +- frontend/components/Task/TaskModal.tsx | 4 +- frontend/components/Task/TaskTags.tsx | 11 +- frontend/components/Task/TaskTimeline.tsx | 8 +- frontend/components/Tasks.tsx | 2 +- frontend/entities/Area.ts | 1 + frontend/entities/Note.ts | 3 + frontend/entities/Project.ts | 1 + frontend/entities/Tag.ts | 1 + frontend/entities/Task.ts | 1 + frontend/utils/notesService.ts | 10 + frontend/utils/projectsService.ts | 14 + frontend/utils/slugUtils.ts | 120 +++++++ frontend/utils/tagsService.ts | 12 + frontend/utils/tasksService.ts | 10 + package-lock.json | 310 +++++++++++------- package.json | 3 +- tsconfig.json | 2 +- 50 files changed, 1803 insertions(+), 377 deletions(-) create mode 100644 backend/migrations/20240804000004-add-nanoid-to-areas.js create mode 100644 backend/migrations/20250804000001-add-nanoid-to-tasks.js create mode 100644 backend/migrations/20250804000002-add-nanoid-to-projects.js create mode 100644 backend/migrations/20250804000003-add-nanoid-to-notes.js create mode 100644 backend/migrations/20250804000004-add-nanoid-to-tags.js create mode 100644 backend/tests/mocks/nanoid.js create mode 100644 backend/utils/slug-utils.js create mode 100644 frontend/utils/slugUtils.ts diff --git a/backend/cmd/start-dev.sh b/backend/cmd/start-dev.sh index 86935c9..af6a030 100755 --- a/backend/cmd/start-dev.sh +++ b/backend/cmd/start-dev.sh @@ -11,4 +11,4 @@ echo " TUDUDI_USER_PASSWORD=your_password" echo "=============================================" echo "" -NODE_ENV=development PORT=3002 DB_FILE=db/development.sqlite3 npm start +NODE_ENV=development PORT=3002 DB_FILE=db/development.sqlite3 ./cmd/start.sh diff --git a/backend/jest.config.js b/backend/jest.config.js index e26826a..3626ce3 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -20,4 +20,7 @@ module.exports = { restoreMocks: true, testTimeout: 30000, maxWorkers: '100%', + moduleNameMapper: { + '^nanoid/non-secure$': '/tests/mocks/nanoid.js', + }, }; diff --git a/backend/migrations/20240804000004-add-nanoid-to-areas.js b/backend/migrations/20240804000004-add-nanoid-to-areas.js new file mode 100644 index 0000000..38d8178 --- /dev/null +++ b/backend/migrations/20240804000004-add-nanoid-to-areas.js @@ -0,0 +1,63 @@ +'use strict'; + +const { nanoid } = require('nanoid/non-secure'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add nanoid column to areas table + await queryInterface.addColumn('areas', 'nanoid', { + type: Sequelize.STRING(21), + allowNull: true, // Initially allow null during migration + unique: false, // Initially not unique during migration + }); + + // Create index on nanoid column for performance + await queryInterface.addIndex('areas', ['nanoid'], { + name: 'areas_nanoid_index', + }); + + // Get all existing areas and populate nanoid values + const areas = await queryInterface.sequelize.query( + 'SELECT id FROM areas', + { type: Sequelize.QueryTypes.SELECT } + ); + + // Update each area with a unique nanoid + for (const area of areas) { + const areaNanoid = nanoid(); + await queryInterface.sequelize.query( + 'UPDATE areas SET nanoid = ? WHERE id = ?', + { + replacements: [areaNanoid, area.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // Add unique constraint after all nanoids are populated + await queryInterface.addConstraint('areas', { + fields: ['nanoid'], + type: 'unique', + name: 'areas_nanoid_unique', + }); + + // Change nanoid column to not allow null + await queryInterface.changeColumn('areas', 'nanoid', { + type: Sequelize.STRING(21), + allowNull: false, + unique: true, + }); + }, + + async down(queryInterface, Sequelize) { + // Remove unique constraint + await queryInterface.removeConstraint('areas', 'areas_nanoid_unique'); + + // Remove index + await queryInterface.removeIndex('areas', 'areas_nanoid_index'); + + // Remove nanoid column + await queryInterface.removeColumn('areas', 'nanoid'); + }, +}; diff --git a/backend/migrations/20250804000001-add-nanoid-to-tasks.js b/backend/migrations/20250804000001-add-nanoid-to-tasks.js new file mode 100644 index 0000000..d4adaa2 --- /dev/null +++ b/backend/migrations/20250804000001-add-nanoid-to-tasks.js @@ -0,0 +1,70 @@ +'use strict'; + +const { nanoid } = require('nanoid/non-secure'); +const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Temporarily disable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + // Safely add nanoid column to tasks table (without unique constraint initially) + await safeAddColumns(queryInterface, 'tasks', [ + { + name: 'nanoid', + definition: { + type: Sequelize.STRING(21), // nanoid default length is 21 + allowNull: true, // Initially allow null, we'll populate it then make it not null + }, + }, + ]); + + // Get all existing tasks that don't have nanoid yet + const tasks = await queryInterface.sequelize.query( + 'SELECT id FROM tasks WHERE nanoid IS NULL', + { type: Sequelize.QueryTypes.SELECT } + ); + + // Generate and update nanoid for each existing task + for (const task of tasks) { + const taskNanoid = nanoid(); + await queryInterface.sequelize.query( + 'UPDATE tasks SET nanoid = ? WHERE id = ?', + { + replacements: [taskNanoid, task.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // Now make the column not null since all existing tasks have nanoids + try { + await queryInterface.changeColumn('tasks', 'nanoid', { + type: Sequelize.STRING(21), + allowNull: false, + }); + } catch (error) { + console.log('Column already exists with correct constraints'); + } + + // Add index for performance + await safeAddIndex(queryInterface, 'tasks', ['nanoid'], { + unique: true, + name: 'tasks_nanoid_unique_index', + }); + } finally { + // Re-enable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface, Sequelize) { + // Remove the index first + await queryInterface.removeIndex('tasks', 'tasks_nanoid_unique_index'); + + // Remove the nanoid column + await queryInterface.removeColumn('tasks', 'nanoid'); + }, +}; diff --git a/backend/migrations/20250804000002-add-nanoid-to-projects.js b/backend/migrations/20250804000002-add-nanoid-to-projects.js new file mode 100644 index 0000000..bda0c3a --- /dev/null +++ b/backend/migrations/20250804000002-add-nanoid-to-projects.js @@ -0,0 +1,77 @@ +'use strict'; + +const { nanoid } = require('nanoid/non-secure'); +const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Temporarily disable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + // Safely add nanoid column to projects table + await safeAddColumns(queryInterface, 'projects', [ + { + name: 'nanoid', + definition: { + type: Sequelize.STRING(21), // nanoid default length is 21 + allowNull: true, // Initially allow null, we'll populate it then make it not null + }, + }, + ]); + + // Get all existing projects that don't have nanoid yet + const projects = await queryInterface.sequelize.query( + 'SELECT id FROM projects WHERE nanoid IS NULL', + { type: Sequelize.QueryTypes.SELECT } + ); + + // Generate and update nanoid for each existing project + for (const project of projects) { + const projectNanoid = nanoid(); + await queryInterface.sequelize.query( + 'UPDATE projects SET nanoid = ? WHERE id = ?', + { + replacements: [projectNanoid, project.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // Now make the column not null since all existing projects have nanoids + try { + await queryInterface.changeColumn('projects', 'nanoid', { + type: Sequelize.STRING(21), + allowNull: false, + }); + } catch (error) { + console.log('Column already exists with correct constraints'); + } + + // Add index for performance + await safeAddIndex(queryInterface, 'projects', ['nanoid'], { + unique: true, + name: 'projects_nanoid_unique_index', + }); + } finally { + // Re-enable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface, Sequelize) { + // Remove the index first + try { + await queryInterface.removeIndex( + 'projects', + 'projects_nanoid_unique_index' + ); + } catch (error) { + // Index might not exist + } + + // Remove the nanoid column + await queryInterface.removeColumn('projects', 'nanoid'); + }, +}; diff --git a/backend/migrations/20250804000003-add-nanoid-to-notes.js b/backend/migrations/20250804000003-add-nanoid-to-notes.js new file mode 100644 index 0000000..b7360bf --- /dev/null +++ b/backend/migrations/20250804000003-add-nanoid-to-notes.js @@ -0,0 +1,77 @@ +'use strict'; + +const { nanoid } = require('nanoid/non-secure'); +const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Temporarily disable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + // Safely add nanoid column to notes table + await safeAddColumns(queryInterface, 'notes', [ + { + name: 'nanoid', + definition: { + type: Sequelize.STRING(21), // nanoid default length is 21 + allowNull: true, // Initially allow null, we'll populate it then make it not null + }, + }, + ]); + + // Get all existing notes that don't have nanoid yet + const notes = await queryInterface.sequelize.query( + 'SELECT id FROM notes WHERE nanoid IS NULL', + { type: Sequelize.QueryTypes.SELECT } + ); + + // Generate and update nanoid for each existing note + for (const note of notes) { + const noteNanoid = nanoid(); + await queryInterface.sequelize.query( + 'UPDATE notes SET nanoid = ? WHERE id = ?', + { + replacements: [noteNanoid, note.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // Now make the column not null since all existing notes have nanoids + try { + await queryInterface.changeColumn('notes', 'nanoid', { + type: Sequelize.STRING(21), + allowNull: false, + }); + } catch (error) { + console.log('Column already exists with correct constraints'); + } + + // Add index for performance + await safeAddIndex(queryInterface, 'notes', ['nanoid'], { + unique: true, + name: 'notes_nanoid_unique_index', + }); + } finally { + // Re-enable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface, Sequelize) { + // Remove the index first + try { + await queryInterface.removeIndex( + 'notes', + 'notes_nanoid_unique_index' + ); + } catch (error) { + // Index might not exist + } + + // Remove the nanoid column + await queryInterface.removeColumn('notes', 'nanoid'); + }, +}; diff --git a/backend/migrations/20250804000004-add-nanoid-to-tags.js b/backend/migrations/20250804000004-add-nanoid-to-tags.js new file mode 100644 index 0000000..042f896 --- /dev/null +++ b/backend/migrations/20250804000004-add-nanoid-to-tags.js @@ -0,0 +1,77 @@ +'use strict'; + +const { nanoid } = require('nanoid/non-secure'); +const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Temporarily disable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + // Safely add nanoid column to tags table + await safeAddColumns(queryInterface, 'tags', [ + { + name: 'nanoid', + definition: { + type: Sequelize.STRING(21), // nanoid default length is 21 + allowNull: true, // Initially allow null, we'll populate it then make it not null + }, + }, + ]); + + // Get all existing tags that don't have nanoid yet + const tags = await queryInterface.sequelize.query( + 'SELECT id FROM tags WHERE nanoid IS NULL', + { type: Sequelize.QueryTypes.SELECT } + ); + + // Generate and update nanoid for each existing tag + for (const tag of tags) { + const tagNanoid = nanoid(); + await queryInterface.sequelize.query( + 'UPDATE tags SET nanoid = ? WHERE id = ?', + { + replacements: [tagNanoid, tag.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // Now make the column not null since all existing tags have nanoids + try { + await queryInterface.changeColumn('tags', 'nanoid', { + type: Sequelize.STRING(21), + allowNull: false, + }); + } catch (error) { + console.log('Column already exists with correct constraints'); + } + + // Add index for performance + await safeAddIndex(queryInterface, 'tags', ['nanoid'], { + unique: true, + name: 'tags_nanoid_unique_index', + }); + } finally { + // Re-enable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface, Sequelize) { + // Remove the index first + try { + await queryInterface.removeIndex( + 'tags', + 'tags_nanoid_unique_index' + ); + } catch (error) { + // Index might not exist + } + + // Remove the nanoid column + await queryInterface.removeColumn('tags', 'nanoid'); + }, +}; diff --git a/backend/models/area.js b/backend/models/area.js index 786f8c7..3cd03e3 100644 --- a/backend/models/area.js +++ b/backend/models/area.js @@ -1,4 +1,5 @@ const { DataTypes } = require('sequelize'); +const { nanoid } = require('nanoid/non-secure'); module.exports = (sequelize) => { const Area = sequelize.define( @@ -9,6 +10,12 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + nanoid: { + type: DataTypes.STRING(21), + allowNull: false, + unique: true, + defaultValue: () => nanoid(), + }, name: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/note.js b/backend/models/note.js index 3122a3f..462a39b 100644 --- a/backend/models/note.js +++ b/backend/models/note.js @@ -1,4 +1,5 @@ const { DataTypes } = require('sequelize'); +const { nanoid } = require('nanoid/non-secure'); module.exports = (sequelize) => { const Note = sequelize.define( @@ -9,6 +10,12 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + nanoid: { + type: DataTypes.STRING(21), + allowNull: false, + unique: true, + defaultValue: () => nanoid(), + }, title: { type: DataTypes.STRING, allowNull: true, diff --git a/backend/models/project.js b/backend/models/project.js index 6f290cc..2dbf03e 100644 --- a/backend/models/project.js +++ b/backend/models/project.js @@ -1,4 +1,5 @@ const { DataTypes } = require('sequelize'); +const { nanoid } = require('nanoid/non-secure'); module.exports = (sequelize) => { const Project = sequelize.define( @@ -9,6 +10,12 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + nanoid: { + type: DataTypes.STRING(21), + allowNull: false, + unique: true, + defaultValue: () => nanoid(), + }, name: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/tag.js b/backend/models/tag.js index 78b5986..e191541 100644 --- a/backend/models/tag.js +++ b/backend/models/tag.js @@ -1,4 +1,5 @@ const { DataTypes } = require('sequelize'); +const { nanoid } = require('nanoid/non-secure'); module.exports = (sequelize) => { const Tag = sequelize.define( @@ -9,6 +10,12 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + nanoid: { + type: DataTypes.STRING(21), + allowNull: false, + unique: true, + defaultValue: () => nanoid(), + }, name: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/task.js b/backend/models/task.js index e95486a..9775b79 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -1,4 +1,5 @@ const { DataTypes } = require('sequelize'); +const { nanoid } = require('nanoid/non-secure'); module.exports = (sequelize) => { const Task = sequelize.define( @@ -15,6 +16,12 @@ module.exports = (sequelize) => { unique: true, defaultValue: DataTypes.UUIDV4, }, + nanoid: { + type: DataTypes.STRING(21), + allowNull: false, + unique: true, + defaultValue: () => nanoid(), + }, name: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/routes/areas.js b/backend/routes/areas.js index dcab673..e47f0f0 100644 --- a/backend/routes/areas.js +++ b/backend/routes/areas.js @@ -11,6 +11,7 @@ router.get('/areas', async (req, res) => { const areas = await Area.findAll({ where: { user_id: req.session.userId }, + attributes: ['id', 'nanoid', 'name', 'description'], order: [['name', 'ASC']], }); @@ -64,7 +65,10 @@ router.post('/areas', async (req, res) => { user_id: req.session.userId, }); - res.status(201).json(area); + res.status(201).json({ + ...area.toJSON(), + nanoid: area.nanoid, // Explicitly include nanoid + }); } catch (error) { console.error('Error creating area:', error); res.status(400).json({ diff --git a/backend/routes/notes.js b/backend/routes/notes.js index add2b73..521fd33 100644 --- a/backend/routes/notes.js +++ b/backend/routes/notes.js @@ -1,8 +1,41 @@ const express = require('express'); const { Note, Tag, Project, sequelize } = require('../models'); const { Op } = require('sequelize'); +const { extractNanoidFromSlug } = require('../utils/slug-utils'); const router = express.Router(); +// Helper function to validate tag name (same as in tags.js) +function validateTagName(name) { + if (!name || !name.trim()) { + return { valid: false, error: 'Tag name is required' }; + } + + const trimmedName = name.trim(); + + // Check for invalid characters that can break URLs or cause issues + const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/; + if (invalidChars.test(trimmedName)) { + return { + valid: false, + error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =', + }; + } + + // Check length limits + if (trimmedName.length > 50) { + return { + valid: false, + error: 'Tag name must be 50 characters or less', + }; + } + + if (trimmedName.length < 1) { + return { valid: false, error: 'Tag name cannot be empty' }; + } + + return { valid: true, name: trimmedName }; +} + // Helper function to update note tags async function updateNoteTags(note, tagsArray, userId) { if (!tagsArray || tagsArray.length === 0) { @@ -11,11 +44,31 @@ async function updateNoteTags(note, tagsArray, userId) { } try { - const tagNames = tagsArray.filter( - (name, index, arr) => arr.indexOf(name) === index - ); // unique + // Validate and filter tag names + const validTagNames = []; + const invalidTags = []; + + for (const name of tagsArray) { + const validation = validateTagName(name); + if (validation.valid) { + // Check for duplicates + if (!validTagNames.includes(validation.name)) { + validTagNames.push(validation.name); + } + } else { + invalidTags.push({ name, error: validation.error }); + } + } + + // If there are invalid tags, throw an error + if (invalidTags.length > 0) { + throw new Error( + `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` + ); + } + const tags = await Promise.all( - tagNames.map(async (name) => { + validTagNames.map(async (name) => { const [tag] = await Tag.findOrCreate({ where: { name, user_id: userId }, defaults: { name, user_id: userId }, @@ -26,6 +79,7 @@ async function updateNoteTags(note, tagsArray, userId) { await note.setTags(tags); } catch (error) { console.error('Failed to update tags:', error.message); + throw error; // Re-throw to handle at route level } } @@ -41,8 +95,16 @@ router.get('/notes', async (req, res) => { let whereClause = { user_id: req.session.userId }; let includeClause = [ - { model: Tag, through: { attributes: [] } }, - { model: Project, required: false, attributes: ['id', 'name'] }, + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + { + model: Project, + required: false, + attributes: ['id', 'name', 'nanoid'], + }, ]; // Filter by tag @@ -65,18 +127,47 @@ router.get('/notes', async (req, res) => { } }); -// GET /api/note/:id +// GET /api/note/:id (supports both numeric ID and nanoid-slug) router.get('/note/:id', async (req, res) => { try { if (!req.session || !req.session.userId) { return res.status(401).json({ error: 'Authentication required' }); } + const identifier = req.params.id; + let whereClause; + + // Check if identifier is numeric (regular ID) or nanoid-slug + if (/^\d+$/.test(identifier)) { + // It's a numeric ID + whereClause = { + id: parseInt(identifier), + user_id: req.session.userId, + }; + } else { + // It's a nanoid-slug, extract the nanoid + const nanoid = extractNanoidFromSlug(identifier); + if (!nanoid) { + return res + .status(400) + .json({ error: 'Invalid note identifier' }); + } + whereClause = { nanoid: nanoid, user_id: req.session.userId }; + } + const note = await Note.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: whereClause, include: [ - { model: Tag, through: { attributes: [] } }, - { model: Project, required: false, attributes: ['id', 'name'] }, + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + { + model: Project, + required: false, + attributes: ['id', 'name', 'nanoid'], + }, ], }); @@ -134,12 +225,23 @@ router.post('/note', async (req, res) => { // Reload note with associations const noteWithAssociations = await Note.findByPk(note.id, { include: [ - { model: Tag, through: { attributes: [] } }, - { model: Project, required: false, attributes: ['id', 'name'] }, + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + { + model: Project, + required: false, + attributes: ['id', 'name', 'nanoid'], + }, ], }); - res.status(201).json(noteWithAssociations); + res.status(201).json({ + ...noteWithAssociations.toJSON(), + nanoid: noteWithAssociations.nanoid, // Explicitly include nanoid + }); } catch (error) { console.error('Error creating note:', error); res.status(400).json({ @@ -205,8 +307,16 @@ router.patch('/note/:id', async (req, res) => { // Reload note with associations const noteWithAssociations = await Note.findByPk(note.id, { include: [ - { model: Tag, through: { attributes: [] } }, - { model: Project, required: false, attributes: ['id', 'name'] }, + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + { + model: Project, + required: false, + attributes: ['id', 'name', 'nanoid'], + }, ], }); diff --git a/backend/routes/projects.js b/backend/routes/projects.js index b6e7a08..5a26dc0 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -6,8 +6,41 @@ const config = getConfig(); const fs = require('fs'); const { Project, Task, Tag, Area, Note, sequelize } = require('../models'); const { Op } = require('sequelize'); +const { extractNanoidFromSlug } = require('../utils/slug-utils'); const router = express.Router(); +// Helper function to validate tag name (same as in tags.js) +function validateTagName(name) { + if (!name || !name.trim()) { + return { valid: false, error: 'Tag name is required' }; + } + + const trimmedName = name.trim(); + + // Check for invalid characters that can break URLs or cause issues + const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/; + if (invalidChars.test(trimmedName)) { + return { + valid: false, + error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =', + }; + } + + // Check length limits + if (trimmedName.length > 50) { + return { + valid: false, + error: 'Tag name must be 50 characters or less', + }; + } + + if (trimmedName.length < 1) { + return { valid: false, error: 'Tag name cannot be empty' }; + } + + return { valid: true, name: trimmedName }; +} + // Helper function to safely format dates const formatDate = (date) => { if (!date) return null; @@ -59,24 +92,42 @@ const upload = multer({ async function updateProjectTags(project, tagsData, userId) { if (!tagsData) return; - const tagNames = tagsData - .map((tag) => tag.name) - .filter((name) => name && name.trim()) - .filter((name, index, arr) => arr.indexOf(name) === index); // unique + // Validate and filter tag names + const validTagNames = []; + const invalidTags = []; - if (tagNames.length === 0) { + for (const tag of tagsData) { + const validation = validateTagName(tag.name); + if (validation.valid) { + // Check for duplicates + if (!validTagNames.includes(validation.name)) { + validTagNames.push(validation.name); + } + } else { + invalidTags.push({ name: tag.name, error: validation.error }); + } + } + + // If there are invalid tags, throw an error + if (invalidTags.length > 0) { + throw new Error( + `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` + ); + } + + if (validTagNames.length === 0) { await project.setTags([]); return; } // Find existing tags const existingTags = await Tag.findAll({ - where: { user_id: userId, name: tagNames }, + where: { user_id: userId, name: validTagNames }, }); // Create new tags const existingTagNames = existingTags.map((tag) => tag.name); - const newTagNames = tagNames.filter( + const newTagNames = validTagNames.filter( (name) => !existingTagNames.includes(name) ); @@ -116,7 +167,7 @@ router.get('/projects', async (req, res) => { return res.status(401).json({ error: 'Authentication required' }); } - const { active, pin_to_sidebar, area_id } = req.query; + const { active, pin_to_sidebar, area_id, area } = req.query; let whereClause = { user_id: req.session.userId }; @@ -134,8 +185,21 @@ router.get('/projects', async (req, res) => { whereClause.pin_to_sidebar = false; } - // Filter by area - if (area_id && area_id !== '') { + // Filter by area - support both numeric area_id and nanoid-slug area + if (area && area !== '') { + // Extract nanoid from nanoid-slug format + const nanoid = extractNanoidFromSlug(area); + if (nanoid) { + const areaRecord = await Area.findOne({ + where: { nanoid: nanoid, user_id: req.session.userId }, + attributes: ['id'], + }); + if (areaRecord) { + whereClause.area_id = areaRecord.id; + } + } + } else if (area_id && area_id !== '') { + // Legacy support for numeric area_id whereClause.area_id = area_id; } @@ -154,7 +218,7 @@ router.get('/projects', async (req, res) => { }, { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -211,15 +275,36 @@ router.get('/projects', async (req, res) => { } }); -// GET /api/project/:id +// GET /api/project/:id (supports both numeric ID and nanoid-slug) router.get('/project/:id', async (req, res) => { try { if (!req.session || !req.session.userId) { return res.status(401).json({ error: 'Authentication required' }); } + const identifier = req.params.id; + let whereClause; + + // Check if identifier is numeric (regular ID) or nanoid-slug + if (/^\d+$/.test(identifier)) { + // It's a numeric ID + whereClause = { + id: parseInt(identifier), + user_id: req.session.userId, + }; + } else { + // It's a nanoid-slug, extract the nanoid + const nanoid = extractNanoidFromSlug(identifier); + if (!nanoid) { + return res + .status(400) + .json({ error: 'Invalid project identifier' }); + } + whereClause = { nanoid: nanoid, user_id: req.session.userId }; + } + const project = await Project.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: whereClause, include: [ { model: Task, @@ -231,7 +316,7 @@ router.get('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -241,7 +326,7 @@ router.get('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -263,7 +348,7 @@ router.get('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -271,7 +356,7 @@ router.get('/project/:id', async (req, res) => { { model: Area, required: false, attributes: ['id', 'name'] }, { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -377,7 +462,7 @@ router.post('/project', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -387,6 +472,7 @@ router.post('/project', async (req, res) => { res.status(201).json({ ...projectJson, + nanoid: projectWithAssociations.nanoid, // Explicitly include nanoid tags: projectJson.Tags || [], // Normalize Tags to tags due_date_at: formatDate(projectWithAssociations.due_date_at), }); @@ -451,7 +537,7 @@ router.patch('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], diff --git a/backend/routes/tags.js b/backend/routes/tags.js index a8db10f..87eb919 100644 --- a/backend/routes/tags.js +++ b/backend/routes/tags.js @@ -1,13 +1,46 @@ const express = require('express'); const { Tag, Task, Note, Project, sequelize } = require('../models'); +const { extractNanoidFromSlug } = require('../utils/slug-utils'); const router = express.Router(); +// Helper function to validate tag name +function validateTagName(name) { + if (!name || !name.trim()) { + return { valid: false, error: 'Tag name is required' }; + } + + const trimmedName = name.trim(); + + // Check for invalid characters that can break URLs or cause issues + const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/; + if (invalidChars.test(trimmedName)) { + return { + valid: false, + error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =', + }; + } + + // Check length limits + if (trimmedName.length > 50) { + return { + valid: false, + error: 'Tag name must be 50 characters or less', + }; + } + + if (trimmedName.length < 1) { + return { valid: false, error: 'Tag name cannot be empty' }; + } + + return { valid: true, name: trimmedName }; +} + // GET /api/tags router.get('/tags', async (req, res) => { try { const tags = await Tag.findAll({ where: { user_id: req.currentUser.id }, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], order: [['name', 'ASC']], }); res.json(tags); @@ -17,19 +50,28 @@ router.get('/tags', async (req, res) => { } }); -// GET /api/tag/:identifier (supports both ID and name) +// GET /api/tag/:identifier (supports both ID, name, and nanoid-slug) router.get('/tag/:identifier', async (req, res) => { try { const identifier = req.params.identifier; let whereClause; - // Check if identifier is a number (ID) or string (name) + // Check if identifier is numeric (ID), nanoid-slug, or tag name if (/^\d+$/.test(identifier)) { // It's a numeric ID whereClause = { id: parseInt(identifier), user_id: req.currentUser.id, }; + } else if (identifier.includes('-') && identifier.length > 21) { + // It's likely a nanoid-slug, extract the nanoid + const nanoid = extractNanoidFromSlug(identifier); + if (!nanoid) { + return res + .status(400) + .json({ error: 'Invalid tag identifier' }); + } + whereClause = { nanoid: nanoid, user_id: req.currentUser.id }; } else { // It's a tag name - decode URI component to handle special characters const tagName = decodeURIComponent(identifier); @@ -38,7 +80,7 @@ router.get('/tag/:identifier', async (req, res) => { const tag = await Tag.findOne({ where: whereClause, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], }); if (!tag) { @@ -57,17 +99,19 @@ router.post('/tag', async (req, res) => { try { const { name } = req.body; - if (!name || !name.trim()) { - return res.status(400).json({ error: 'Tag name is required' }); + const validation = validateTagName(name); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); } const tag = await Tag.create({ - name: name.trim(), + name: validation.name, user_id: req.currentUser.id, }); res.status(201).json({ id: tag.id, + nanoid: tag.nanoid, // Explicitly include nanoid name: tag.name, }); } catch (error) { @@ -107,11 +151,12 @@ router.patch('/tag/:identifier', async (req, res) => { const { name } = req.body; - if (!name || !name.trim()) { - return res.status(400).json({ error: 'Tag name is required' }); + const validation = validateTagName(name); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); } - await tag.update({ name: name.trim() }); + await tag.update({ name: validation.name }); res.json({ id: tag.id, diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 3cff601..243faf1 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -6,6 +6,38 @@ const TaskEventService = require('../services/taskEventService'); const moment = require('moment-timezone'); const router = express.Router(); +// Helper function to validate tag name (same as in tags.js) +function validateTagName(name) { + if (!name || !name.trim()) { + return { valid: false, error: 'Tag name is required' }; + } + + const trimmedName = name.trim(); + + // Check for invalid characters that can break URLs or cause issues + const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/; + if (invalidChars.test(trimmedName)) { + return { + valid: false, + error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =', + }; + } + + // Check length limits + if (trimmedName.length > 50) { + return { + valid: false, + error: 'Tag name must be 50 characters or less', + }; + } + + if (trimmedName.length < 1) { + return { valid: false, error: 'Tag name cannot be empty' }; + } + + return { valid: true, name: trimmedName }; +} + // Helper function to serialize task with today move count async function serializeTask(task) { const taskJson = task.toJSON(); @@ -18,10 +50,18 @@ async function serializeTask(task) { return { ...taskWithoutSubtasks, + nanoid: task.nanoid, // Explicitly include nanoid tags: taskJson.Tags || [], + Project: taskJson.Project + ? { + ...taskJson.Project, + nanoid: taskJson.Project.nanoid, // Explicitly include Project nanoid + } + : null, subtasks: Subtasks ? Subtasks.map((subtask) => ({ ...subtask, + nanoid: subtask.nanoid, // Also include nanoid for subtasks tags: subtask.Tags || [], due_date: subtask.due_date ? subtask.due_date.toISOString().split('T')[0] @@ -45,24 +85,42 @@ async function serializeTask(task) { async function updateTaskTags(task, tagsData, userId) { if (!tagsData) return; - const tagNames = tagsData - .map((tag) => tag.name) - .filter((name) => name && name.trim()) - .filter((name, index, arr) => arr.indexOf(name) === index); // unique + // Validate and filter tag names + const validTagNames = []; + const invalidTags = []; - if (tagNames.length === 0) { + for (const tag of tagsData) { + const validation = validateTagName(tag.name); + if (validation.valid) { + // Check for duplicates + if (!validTagNames.includes(validation.name)) { + validTagNames.push(validation.name); + } + } else { + invalidTags.push({ name: tag.name, error: validation.error }); + } + } + + // If there are invalid tags, throw an error + if (invalidTags.length > 0) { + throw new Error( + `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` + ); + } + + if (validTagNames.length === 0) { await task.setTags([]); return; } // Find existing tags const existingTags = await Tag.findAll({ - where: { user_id: userId, name: tagNames }, + where: { user_id: userId, name: validTagNames }, }); // Create new tags const existingTagNames = existingTags.map((tag) => tag.name); - const newTagNames = tagNames.filter( + const newTagNames = validTagNames.filter( (name) => !existingTagNames.includes(name) ); @@ -225,15 +283,23 @@ async function filterTasksByParams(params, userId) { parent_task_id: null, // Exclude subtasks from main task lists }; let includeClause = [ - { model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }, - { model: Project, attributes: ['name'], required: false }, + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, { model: Task, as: 'Subtasks', include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -365,7 +431,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -380,7 +446,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -408,7 +474,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -423,7 +489,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -463,7 +529,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -478,7 +544,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -530,7 +596,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -545,7 +611,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -573,7 +639,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -588,7 +654,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -627,7 +693,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -642,7 +708,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -680,7 +746,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -695,7 +761,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, required: false, }, @@ -831,10 +897,14 @@ router.get('/task/uuid/:uuid', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, ], }); @@ -851,6 +921,49 @@ router.get('/task/uuid/:uuid', async (req, res) => { } }); +// GET /api/task/nanoid/:nanoid +router.get('/task/nanoid/:nanoid', async (req, res) => { + try { + const task = await Task.findOne({ + where: { nanoid: req.params.nanoid, user_id: req.currentUser.id }, + include: [ + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, + { + model: Task, + as: 'Subtasks', + include: [ + { + model: Tag, + attributes: ['id', 'name', 'nanoid'], + through: { attributes: [] }, + }, + ], + }, + ], + }); + + if (!task) { + return res.status(404).json({ error: 'Task not found.' }); + } + + const serializedTask = await serializeTask(task); + + res.json(serializedTask); + } catch (error) { + console.error('Error fetching task by nanoid:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // GET /api/task/:id router.get('/task/:id', async (req, res) => { try { @@ -859,17 +972,21 @@ router.get('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, { model: Task, as: 'Subtasks', include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -901,10 +1018,14 @@ router.get('/task/:id/subtasks', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, ], order: [['created_at', 'ASC']], }); @@ -1055,22 +1176,20 @@ router.post('/task', async (req, res) => { include: [ { model: Tag, - attributes: ['name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, ], }); - const taskJson = taskWithAssociations.toJSON(); + const serializedTask = await serializeTask(taskWithAssociations); - res.status(201).json({ - ...taskJson, - tags: taskJson.Tags || [], - due_date: taskWithAssociations.due_date - ? taskWithAssociations.due_date.toISOString().split('T')[0] - : null, - }); + res.status(201).json(serializedTask); } catch (error) { console.error('Error creating task:', error); res.status(400).json({ @@ -1115,7 +1234,7 @@ router.patch('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -1575,10 +1694,14 @@ router.patch('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, ], }); @@ -1605,17 +1728,21 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, { model: Task, as: 'Subtasks', include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, ], @@ -1876,10 +2003,14 @@ router.patch('/task/:id/toggle-today', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name'], + attributes: ['id', 'name', 'nanoid'], through: { attributes: [] }, }, - { model: Project, attributes: ['name'], required: false }, + { + model: Project, + attributes: ['id', 'name', 'nanoid'], + required: false, + }, ], }); diff --git a/backend/tests/mocks/nanoid.js b/backend/tests/mocks/nanoid.js new file mode 100644 index 0000000..a86c51a --- /dev/null +++ b/backend/tests/mocks/nanoid.js @@ -0,0 +1,28 @@ +// Mock implementation of nanoid for testing +// This provides a consistent, deterministic nanoid for tests + +let counter = 0; + +function nanoid(size = 21) { + // Generate a deterministic ID for testing + const prefix = 'test'; + const suffix = counter.toString().padStart(4, '0'); + counter++; + + // Make it the requested size by padding or truncating + const id = (prefix + suffix) + .repeat(Math.ceil(size / (prefix + suffix).length)) + .substring(0, size); + return id; +} + +function customAlphabet(alphabet, defaultSize = 21) { + return (size = defaultSize) => { + return nanoid(size); + }; +} + +module.exports = { + nanoid, + customAlphabet, +}; diff --git a/backend/utils/slug-utils.js b/backend/utils/slug-utils.js new file mode 100644 index 0000000..06eaf2b --- /dev/null +++ b/backend/utils/slug-utils.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Creates a URL-safe slug from a string + * @param {string} text - The text to slugify + * @param {number} maxLength - Maximum length of the slug (default: 50) + * @returns {string} The slugified text + */ +function createSlug(text, maxLength = 50) { + if (!text) return ''; + + return ( + text + .toLowerCase() + .trim() + // Remove or replace special characters + .replace(/[^\w\s-]/g, '') // Remove non-word chars except spaces and hyphens + .replace(/[\s_-]+/g, '-') // Replace spaces, underscores, multiple hyphens with single hyphen + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens + .substring(0, maxLength) // Limit length + .replace(/-$/, '') + ); // Remove trailing hyphen if created by substring +} + +/** + * Creates a nanoid-slug URL for a given entity + * @param {string} nanoid - The nanoid of the entity + * @param {string} name - The name/title of the entity + * @param {number} maxSlugLength - Maximum length of the slug part (default: 40) + * @returns {string} The nanoid-slug URL part (e.g., "abc123-clean-the-backyard") + */ +function createNanoidSlug(nanoid, name, maxSlugLength = 40) { + if (!nanoid) throw new Error('Nanoid is required'); + + const slug = createSlug(name, maxSlugLength); + return slug ? `${nanoid}-${slug}` : nanoid; +} + +/** + * Extracts nanoid from a nanoid-slug URL part + * @param {string} nanoidSlug - The nanoid-slug (e.g., "abc123-clean-the-backyard") + * @returns {string} The extracted nanoid + */ +function extractNanoidFromSlug(nanoidSlug) { + if (!nanoidSlug) return ''; + + // Nanoid is always 21 characters by default, extract the first part before the first hyphen + // But handle cases where the nanoid itself might contain hyphens + const parts = nanoidSlug.split('-'); + if (parts.length === 1) { + // No slug, just nanoid + return parts[0]; + } + + // Look for the nanoid part (21 chars) - it should be the first part + const firstPart = parts[0]; + if (firstPart.length === 21) { + return firstPart; + } + + // Fallback: try to find 21-character alphanumeric string + const nanoidMatch = nanoidSlug.match(/^([A-Za-z0-9_-]{21})/); + return nanoidMatch ? nanoidMatch[1] : nanoidSlug.split('-')[0]; +} + +/** + * Validates if a string looks like a valid nanoid + * @param {string} str - String to validate + * @returns {boolean} True if it looks like a nanoid + */ +function isValidNanoid(str) { + return /^[A-Za-z0-9_-]{21}$/.test(str); +} + +module.exports = { + createSlug, + createNanoidSlug, + extractNanoidFromSlug, + isValidNanoid, +}; diff --git a/frontend/App.tsx b/frontend/App.tsx index b0ba589..2607ac0 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -176,7 +176,7 @@ const App: React.FC = () => { /> } /> } /> { } /> } /> } /> } /> } /> } /> } /> } /> - } /> + } + /> } /> { {areas.map((area: any) => ( = ({ key={projectRef} > {projectRef} diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index 0e8869e..b29619e 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -307,7 +307,7 @@ const InboxItems: React.FC = () => { {t('task.created', 'Task')}{' '} {createdTask.name} diff --git a/frontend/components/Note/NoteDetails.tsx b/frontend/components/Note/NoteDetails.tsx index ff17e57..42eb20d 100644 --- a/frontend/components/Note/NoteDetails.tsx +++ b/frontend/components/Note/NoteDetails.tsx @@ -11,7 +11,7 @@ import NoteModal from './NoteModal'; import MarkdownRenderer from '../Shared/MarkdownRenderer'; import { Note } from '../../entities/Note'; import { - fetchNotes, + fetchNoteBySlug, deleteNote as apiDeleteNote, updateNote as apiUpdateNote, } from '../../utils/notesService'; @@ -19,7 +19,7 @@ import { createProject, fetchProjects } from '../../utils/projectsService'; import { Project } from '../../entities/Project'; const NoteDetails: React.FC = () => { - const { id } = useParams<{ id: string }>(); + const { nanoidSlug } = useParams<{ nanoidSlug: string }>(); const [note, setNote] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = @@ -36,8 +36,7 @@ const NoteDetails: React.FC = () => { const fetchNote = async () => { try { setIsLoading(true); - const notes = await fetchNotes(); - const foundNote = notes.find((n: Note) => n.id === Number(id)); + const foundNote = await fetchNoteBySlug(nanoidSlug!); setNote(foundNote || null); if (!foundNote) { setIsError(true); @@ -50,7 +49,7 @@ const NoteDetails: React.FC = () => { } }; fetchNote(); - }, [id]); + }, [nanoidSlug]); // Load projects for the modal useEffect(() => { @@ -157,7 +156,26 @@ const NoteDetails: React.FC = () => {
{ diff --git a/frontend/components/Productivity/ProductivityAssistant.tsx b/frontend/components/Productivity/ProductivityAssistant.tsx index 6cb3500..ec92d4e 100644 --- a/frontend/components/Productivity/ProductivityAssistant.tsx +++ b/frontend/components/Productivity/ProductivityAssistant.tsx @@ -300,7 +300,15 @@ const ProductivityAssistant: React.FC = ({ } } else { // Handle project click - navigate to project page - navigate(`/project/${item.id}`); + if (item.nanoid) { + const slug = item.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + navigate(`/project/${item.nanoid}-${slug}`); + } else { + navigate(`/project/${item.id}`); + } } }; diff --git a/frontend/components/Project/ProjectDetails.tsx b/frontend/components/Project/ProjectDetails.tsx index 28f2f2b..2c94510 100644 --- a/frontend/components/Project/ProjectDetails.tsx +++ b/frontend/components/Project/ProjectDetails.tsx @@ -18,7 +18,7 @@ import NoteCard from '../Shared/NoteCard'; import { Task } from '../../entities/Task'; import { Note } from '../../entities/Note'; import { - fetchProjectById, + fetchProjectBySlug, updateProject, deleteProject, fetchProjects, @@ -38,7 +38,7 @@ import AutoSuggestNextActionBox from './AutoSuggestNextActionBox'; import SortFilterButton, { SortOption } from '../Shared/SortFilterButton'; const ProjectDetails: React.FC = () => { - const { id } = useParams<{ id: string }>(); + const { nanoidSlug } = useParams<{ nanoidSlug: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); const { showSuccessToast } = useToast(); @@ -133,12 +133,19 @@ const ProjectDetails: React.FC = () => { setOrderBy(sortParam); }, []); - // Fetch project data when id changes + // Fetch project data when nanoidSlug changes useEffect(() => { - if (!id) return; + if (!nanoidSlug) return; - // Skip loading if we already have the project data for this id - if (project && project.id?.toString() === id) { + // Skip loading if we already have the project data for this nanoidSlug + if ( + project && + project.nanoid && + `${project.nanoid}-${project.name + ?.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '')}` === nanoidSlug + ) { return; } @@ -150,7 +157,7 @@ const ProjectDetails: React.FC = () => { } setError(false); - const projectData = await fetchProjectById(id); + const projectData = await fetchProjectBySlug(nanoidSlug); setProject(projectData); setTasks(projectData.tasks || projectData.Tasks || []); @@ -181,7 +188,7 @@ const ProjectDetails: React.FC = () => { }; loadProjectData(); - }, [id]); + }, [nanoidSlug]); const handleTaskCreate = async (taskName: string) => { if (!project) { @@ -202,7 +209,7 @@ const ProjectDetails: React.FC = () => { {t('task.created', 'Task')}{' '} {newTask.name} @@ -341,10 +348,10 @@ const ProjectDetails: React.FC = () => { ); } catch { // Optionally refetch data on error to ensure consistency - if (id) { + if (nanoidSlug) { // Refetch project data on error to ensure consistency try { - const projectData = await fetchProjectById(id); + const projectData = await fetchProjectBySlug(nanoidSlug); setProject(projectData); setTasks(projectData.tasks || projectData.Tasks || []); const fetchedNotes = @@ -409,7 +416,7 @@ const ProjectDetails: React.FC = () => { {t('task.created', 'Task')}{' '} {newTask.name} @@ -687,9 +694,25 @@ const ProjectDetails: React.FC = () => {