diff --git a/backend/jest.config.js b/backend/jest.config.js index 3626ce3..01affd3 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -21,6 +21,6 @@ module.exports = { testTimeout: 30000, maxWorkers: '100%', moduleNameMapper: { - '^nanoid/non-secure$': '/tests/mocks/nanoid.js', + '^nanoid$': '/tests/mocks/nanoid.js', }, }; diff --git a/backend/migrations/20240804000004-add-nanoid-to-areas.js b/backend/migrations/20240804000004-add-nanoid-to-areas.js deleted file mode 100644 index 38d8178..0000000 --- a/backend/migrations/20240804000004-add-nanoid-to-areas.js +++ /dev/null @@ -1,63 +0,0 @@ -'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 deleted file mode 100644 index d4adaa2..0000000 --- a/backend/migrations/20250804000001-add-nanoid-to-tasks.js +++ /dev/null @@ -1,70 +0,0 @@ -'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 deleted file mode 100644 index bda0c3a..0000000 --- a/backend/migrations/20250804000002-add-nanoid-to-projects.js +++ /dev/null @@ -1,77 +0,0 @@ -'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 deleted file mode 100644 index b7360bf..0000000 --- a/backend/migrations/20250804000003-add-nanoid-to-notes.js +++ /dev/null @@ -1,77 +0,0 @@ -'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 deleted file mode 100644 index 042f896..0000000 --- a/backend/migrations/20250804000004-add-nanoid-to-tags.js +++ /dev/null @@ -1,77 +0,0 @@ -'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/migrations/20250805000001-standardize-uid-columns.js b/backend/migrations/20250805000001-standardize-uid-columns.js new file mode 100644 index 0000000..90d86b3 --- /dev/null +++ b/backend/migrations/20250805000001-standardize-uid-columns.js @@ -0,0 +1,94 @@ +'use strict'; + +const { uid } = require('../utils/uid'); +const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Temporarily disable foreign key constraints for SQLite + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + // Add uid columns to all tables + const tables = [ + { name: 'areas', hasUid: false }, + { name: 'projects', hasUid: false }, + { name: 'notes', hasUid: false }, + { name: 'tags', hasUid: false }, + { name: 'tasks', hasUid: false } // Keep existing uuid column, add new uid column + ]; + + // 1. Add uid columns to all tables + for (const table of tables) { + await safeAddColumns(queryInterface, table.name, [ + { + name: 'uid', + definition: { + type: Sequelize.STRING, + allowNull: true, // Initially allow null during population + }, + }, + ]); + } + + // 2. Populate uid values for all tables + for (const table of tables) { + // Get records without uid values + const records = await queryInterface.sequelize.query( + `SELECT id FROM ${table.name} WHERE uid IS NULL`, + { type: Sequelize.QueryTypes.SELECT } + ); + + // Generate uid for each record + for (const record of records) { + const uniqueId = uid(); + await queryInterface.sequelize.query( + `UPDATE ${table.name} SET uid = ? WHERE id = ?`, + { + replacements: [uniqueId, record.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // Make uid column not null and unique + await queryInterface.changeColumn(table.name, 'uid', { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }); + + // Add unique index for performance + await safeAddIndex(queryInterface, table.name, ['uid'], { + unique: true, + name: `${table.name}_uid_unique_index`, + }); + } + + } finally { + // Re-enable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface, Sequelize) { + // Remove unique indexes and uid columns + const tables = ['areas', 'projects', 'notes', 'tags', 'tasks']; + + for (const tableName of tables) { + try { + await queryInterface.removeIndex(tableName, `${tableName}_uid_unique_index`); + } catch (error) { + // Index might not exist + console.log(`${tableName}_uid_unique_index not found, skipping removal`); + } + + try { + await queryInterface.removeColumn(tableName, 'uid'); + } catch (error) { + console.log(`Error removing uid column from ${tableName}:`, error.message); + } + } + }, +}; \ No newline at end of file diff --git a/backend/models/area.js b/backend/models/area.js index 3cd03e3..45b90f7 100644 --- a/backend/models/area.js +++ b/backend/models/area.js @@ -1,5 +1,5 @@ const { DataTypes } = require('sequelize'); -const { nanoid } = require('nanoid/non-secure'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const Area = sequelize.define( @@ -10,11 +10,11 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, - nanoid: { - type: DataTypes.STRING(21), + uid: { + type: DataTypes.STRING, allowNull: false, unique: true, - defaultValue: () => nanoid(), + defaultValue: uid, }, name: { type: DataTypes.STRING, diff --git a/backend/models/calendar_token.js b/backend/models/calendar_token.js index e7354cd..d5982fd 100644 --- a/backend/models/calendar_token.js +++ b/backend/models/calendar_token.js @@ -1,5 +1,6 @@ const { DataTypes } = require('sequelize'); const { sequelize } = require('./models'); +const { uid } = require('../utils/uid'); const CalendarToken = sequelize.define( 'CalendarToken', @@ -9,6 +10,12 @@ const CalendarToken = sequelize.define( primaryKey: true, autoIncrement: true, }, + uid: { + type: DataTypes.STRING(), + allowNull: false, + unique: true, + defaultValue: uid, + }, user_id: { type: DataTypes.INTEGER, allowNull: false, diff --git a/backend/models/note.js b/backend/models/note.js index 462a39b..babdb06 100644 --- a/backend/models/note.js +++ b/backend/models/note.js @@ -1,5 +1,5 @@ const { DataTypes } = require('sequelize'); -const { nanoid } = require('nanoid/non-secure'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const Note = sequelize.define( @@ -10,11 +10,11 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, - nanoid: { - type: DataTypes.STRING(21), + uid: { + type: DataTypes.STRING, allowNull: false, unique: true, - defaultValue: () => nanoid(), + defaultValue: uid, }, title: { type: DataTypes.STRING, diff --git a/backend/models/project.js b/backend/models/project.js index 2dbf03e..a65d20b 100644 --- a/backend/models/project.js +++ b/backend/models/project.js @@ -1,5 +1,5 @@ const { DataTypes } = require('sequelize'); -const { nanoid } = require('nanoid/non-secure'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const Project = sequelize.define( @@ -10,11 +10,11 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, - nanoid: { - type: DataTypes.STRING(21), + uid: { + type: DataTypes.STRING, allowNull: false, unique: true, - defaultValue: () => nanoid(), + defaultValue: uid, }, name: { type: DataTypes.STRING, diff --git a/backend/models/tag.js b/backend/models/tag.js index e191541..ff9ce9d 100644 --- a/backend/models/tag.js +++ b/backend/models/tag.js @@ -1,5 +1,5 @@ const { DataTypes } = require('sequelize'); -const { nanoid } = require('nanoid/non-secure'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const Tag = sequelize.define( @@ -10,11 +10,11 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, - nanoid: { - type: DataTypes.STRING(21), + uid: { + type: DataTypes.STRING, allowNull: false, unique: true, - defaultValue: () => nanoid(), + defaultValue: uid, }, name: { type: DataTypes.STRING, diff --git a/backend/models/task.js b/backend/models/task.js index 9775b79..a40681d 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -1,5 +1,5 @@ const { DataTypes } = require('sequelize'); -const { nanoid } = require('nanoid/non-secure'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const Task = sequelize.define( @@ -10,18 +10,18 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: uid, + }, uuid: { type: DataTypes.UUID, allowNull: false, 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 e47f0f0..fd1d13c 100644 --- a/backend/routes/areas.js +++ b/backend/routes/areas.js @@ -11,7 +11,7 @@ router.get('/areas', async (req, res) => { const areas = await Area.findAll({ where: { user_id: req.session.userId }, - attributes: ['id', 'nanoid', 'name', 'description'], + attributes: ['id', 'uid', 'name', 'description'], order: [['name', 'ASC']], }); @@ -67,7 +67,7 @@ router.post('/areas', async (req, res) => { res.status(201).json({ ...area.toJSON(), - nanoid: area.nanoid, // Explicitly include nanoid + uid: area.uid, // Explicitly include uid }); } catch (error) { console.error('Error creating area:', error); diff --git a/backend/routes/notes.js b/backend/routes/notes.js index 521fd33..d700ea3 100644 --- a/backend/routes/notes.js +++ b/backend/routes/notes.js @@ -1,7 +1,7 @@ const express = require('express'); const { Note, Tag, Project, sequelize } = require('../models'); const { Op } = require('sequelize'); -const { extractNanoidFromSlug } = require('../utils/slug-utils'); +const { extractUidFromSlug } = require('../utils/slug-utils'); const router = express.Router(); // Helper function to validate tag name (same as in tags.js) @@ -97,13 +97,13 @@ router.get('/notes', async (req, res) => { let includeClause = [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, required: false, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], }, ]; @@ -127,7 +127,7 @@ router.get('/notes', async (req, res) => { } }); -// GET /api/note/:id (supports both numeric ID and nanoid-slug) +// GET /api/note/:id (supports both numeric ID and uid-slug) router.get('/note/:id', async (req, res) => { try { if (!req.session || !req.session.userId) { @@ -137,7 +137,7 @@ router.get('/note/:id', async (req, res) => { const identifier = req.params.id; let whereClause; - // Check if identifier is numeric (regular ID) or nanoid-slug + // Check if identifier is numeric (regular ID) or uid-slug if (/^\d+$/.test(identifier)) { // It's a numeric ID whereClause = { @@ -145,14 +145,14 @@ router.get('/note/:id', async (req, res) => { user_id: req.session.userId, }; } else { - // It's a nanoid-slug, extract the nanoid - const nanoid = extractNanoidFromSlug(identifier); - if (!nanoid) { + // It's a uid-slug, extract the uid + const uid = extractUidFromSlug(identifier); + if (!uid) { return res .status(400) .json({ error: 'Invalid note identifier' }); } - whereClause = { nanoid: nanoid, user_id: req.session.userId }; + whereClause = { uid: uid, user_id: req.session.userId }; } const note = await Note.findOne({ @@ -160,13 +160,13 @@ router.get('/note/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, required: false, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], }, ], }); @@ -227,20 +227,20 @@ router.post('/note', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, required: false, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], }, ], }); res.status(201).json({ ...noteWithAssociations.toJSON(), - nanoid: noteWithAssociations.nanoid, // Explicitly include nanoid + uid: noteWithAssociations.uid, // Explicitly include uid }); } catch (error) { console.error('Error creating note:', error); @@ -309,13 +309,13 @@ router.patch('/note/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, required: false, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], }, ], }); diff --git a/backend/routes/projects.js b/backend/routes/projects.js index 5a26dc0..bd73bf2 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -6,7 +6,7 @@ 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 { extractUidFromSlug } = require('../utils/slug-utils'); const router = express.Router(); // Helper function to validate tag name (same as in tags.js) @@ -185,13 +185,13 @@ router.get('/projects', async (req, res) => { whereClause.pin_to_sidebar = false; } - // Filter by area - support both numeric area_id and nanoid-slug area + // Filter by area - support both numeric area_id and uid-slug area if (area && area !== '') { - // Extract nanoid from nanoid-slug format - const nanoid = extractNanoidFromSlug(area); - if (nanoid) { + // Extract uid from uid-slug format + const uid = extractUidFromSlug(area); + if (uid) { const areaRecord = await Area.findOne({ - where: { nanoid: nanoid, user_id: req.session.userId }, + where: { uid: uid, user_id: req.session.userId }, attributes: ['id'], }); if (areaRecord) { @@ -218,7 +218,7 @@ router.get('/projects', async (req, res) => { }, { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -275,7 +275,7 @@ router.get('/projects', async (req, res) => { } }); -// GET /api/project/:id (supports both numeric ID and nanoid-slug) +// GET /api/project/:id (supports both numeric ID and uid-slug) router.get('/project/:id', async (req, res) => { try { if (!req.session || !req.session.userId) { @@ -285,7 +285,7 @@ router.get('/project/:id', async (req, res) => { const identifier = req.params.id; let whereClause; - // Check if identifier is numeric (regular ID) or nanoid-slug + // Check if identifier is numeric (regular ID) or uid-slug if (/^\d+$/.test(identifier)) { // It's a numeric ID whereClause = { @@ -293,14 +293,14 @@ router.get('/project/:id', async (req, res) => { user_id: req.session.userId, }; } else { - // It's a nanoid-slug, extract the nanoid - const nanoid = extractNanoidFromSlug(identifier); - if (!nanoid) { + // It's a uid-slug, extract the uid + const uid = extractUidFromSlug(identifier); + if (!uid) { return res .status(400) .json({ error: 'Invalid project identifier' }); } - whereClause = { nanoid: nanoid, user_id: req.session.userId }; + whereClause = { uid: uid, user_id: req.session.userId }; } const project = await Project.findOne({ @@ -316,7 +316,7 @@ router.get('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -326,7 +326,7 @@ router.get('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -348,7 +348,7 @@ router.get('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -356,7 +356,7 @@ router.get('/project/:id', async (req, res) => { { model: Area, required: false, attributes: ['id', 'name'] }, { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -462,7 +462,7 @@ router.post('/project', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -472,7 +472,7 @@ router.post('/project', async (req, res) => { res.status(201).json({ ...projectJson, - nanoid: projectWithAssociations.nanoid, // Explicitly include nanoid + uid: projectWithAssociations.uid, // Explicitly include uid tags: projectJson.Tags || [], // Normalize Tags to tags due_date_at: formatDate(projectWithAssociations.due_date_at), }); @@ -537,7 +537,7 @@ router.patch('/project/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], diff --git a/backend/routes/tags.js b/backend/routes/tags.js index 87eb919..be7e547 100644 --- a/backend/routes/tags.js +++ b/backend/routes/tags.js @@ -1,7 +1,8 @@ const express = require('express'); const { Tag, Task, Note, Project, sequelize } = require('../models'); -const { extractNanoidFromSlug } = require('../utils/slug-utils'); +const { extractUidFromSlug } = require('../utils/slug-utils'); const router = express.Router(); +const _ = require('lodash'); // Helper function to validate tag name function validateTagName(name) { @@ -40,7 +41,7 @@ router.get('/tags', async (req, res) => { try { const tags = await Tag.findAll({ where: { user_id: req.currentUser.id }, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], order: [['name', 'ASC']], }); res.json(tags); @@ -50,37 +51,27 @@ router.get('/tags', async (req, res) => { } }); -// GET /api/tag/:identifier (supports both ID, name, and nanoid-slug) -router.get('/tag/:identifier', async (req, res) => { +// GET /api/tag/:identifier (supports both ID, name, and uid-slug) +router.get('/tag', async (req, res) => { try { - const identifier = req.params.identifier; - let whereClause; + const { id, uid, name } = req.query; - // 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); - whereClause = { name: tagName, user_id: req.currentUser.id }; + let whereClause = { + user_id: req.currentUser.id, + }; + if (!_.isEmpty(id)) { + whereClause.id = parseInt(id, 10); + } + if (!_.isEmpty(uid)) { + whereClause.uid = uid; + } + if (!_.isEmpty(name)) { + whereClause.name = decodeURIComponent(name); } const tag = await Tag.findOne({ where: whereClause, - attributes: ['id', 'name', 'nanoid'], + attributes: ['name', 'uid'], }); if (!tag) { @@ -111,7 +102,7 @@ router.post('/tag', async (req, res) => { res.status(201).json({ id: tag.id, - nanoid: tag.nanoid, // Explicitly include nanoid + uid: tag.uid, // Explicitly include uid name: tag.name, }); } catch (error) { diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index b3c2081..7d56f87 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -50,18 +50,18 @@ async function serializeTask(task) { return { ...taskWithoutSubtasks, - nanoid: task.nanoid, // Explicitly include nanoid + uid: task.uid, // Explicitly include uid tags: taskJson.Tags || [], Project: taskJson.Project ? { ...taskJson.Project, - nanoid: taskJson.Project.nanoid, // Explicitly include Project nanoid + uid: taskJson.Project.uid, // Explicitly include Project uid } : null, subtasks: Subtasks ? Subtasks.map((subtask) => ({ ...subtask, - nanoid: subtask.nanoid, // Also include nanoid for subtasks + uid: subtask.uid, // Also include uid for subtasks tags: subtask.Tags || [], due_date: subtask.due_date ? subtask.due_date.toISOString().split('T')[0] @@ -285,12 +285,12 @@ async function filterTasksByParams(params, userId) { let includeClause = [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -299,7 +299,7 @@ async function filterTasksByParams(params, userId) { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -431,13 +431,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -446,7 +446,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -474,13 +474,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -489,7 +489,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -529,13 +529,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -544,7 +544,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -596,13 +596,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -611,7 +611,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -639,13 +639,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -654,7 +654,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -693,13 +693,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -708,7 +708,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -746,13 +746,13 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, { model: Project, - attributes: ['id', 'name', 'active', 'nanoid'], + attributes: ['id', 'name', 'active', 'uid'], required: false, }, { @@ -761,7 +761,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, required: false, }, @@ -889,20 +889,26 @@ router.get('/tasks', async (req, res) => { } }); -// GET /api/task/uuid/:uuid -router.get('/task/uuid/:uuid', async (req, res) => { +// GET /api/task?uid=... +router.get('/task', async (req, res) => { try { + const { uid } = req.query; + + if (_.isEmpty(uid)) { + return res.status(400).json({ error: 'uid query parameter is required' }); + } + const task = await Task.findOne({ - where: { uuid: req.params.uuid, user_id: req.currentUser.id }, + where: { uid: uid, user_id: req.currentUser.id }, include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, ], @@ -916,53 +922,11 @@ router.get('/task/uuid/:uuid', async (req, res) => { res.json(serializedTask); } catch (error) { - console.error('Error fetching task by UUID:', error); + console.error('Error fetching task by UID:', error); res.status(500).json({ error: 'Internal server error' }); } }); -// 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) => { @@ -972,12 +936,12 @@ router.get('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -986,7 +950,7 @@ router.get('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -1018,12 +982,12 @@ router.get('/task/:id/subtasks', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, ], @@ -1176,12 +1140,12 @@ router.post('/task', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, ], @@ -1234,7 +1198,7 @@ router.patch('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -1694,12 +1658,12 @@ router.patch('/task/:id', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, ], @@ -1728,12 +1692,12 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -1742,7 +1706,7 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, ], @@ -2003,12 +1967,12 @@ router.patch('/task/:id/toggle-today', async (req, res) => { include: [ { model: Tag, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], through: { attributes: [] }, }, { model: Project, - attributes: ['id', 'name', 'nanoid'], + attributes: ['id', 'name', 'uid'], required: false, }, ], diff --git a/backend/tests/integration/tags.test.js b/backend/tests/integration/tags.test.js index aa88f88..b852839 100644 --- a/backend/tests/integration/tags.test.js +++ b/backend/tests/integration/tags.test.js @@ -93,7 +93,7 @@ describe('Tags Routes', () => { }); }); - describe('GET /api/tag/:id', () => { + describe('GET /api/tag', () => { let tag; beforeEach(async () => { @@ -104,15 +104,14 @@ describe('Tags Routes', () => { }); it('should get tag by id', async () => { - const response = await agent.get(`/api/tag/${tag.id}`); + const response = await agent.get(`/api/tag?id=${tag.id}`); expect(response.status).toBe(200); - expect(response.body.id).toBe(tag.id); expect(response.body.name).toBe(tag.name); }); it('should return 404 for non-existent tag', async () => { - const response = await agent.get('/api/tag/999999'); + const response = await agent.get('/api/tag?id=999999'); expect(response.status).toBe(404); expect(response.body.error).toBe('Tag not found'); @@ -130,14 +129,14 @@ describe('Tags Routes', () => { user_id: otherUser.id, }); - const response = await agent.get(`/api/tag/${otherTag.id}`); + const response = await agent.get(`/api/tag?id=${otherTag.id}`); expect(response.status).toBe(404); expect(response.body.error).toBe('Tag not found'); }); it('should require authentication', async () => { - const response = await request(app).get(`/api/tag/${tag.id}`); + const response = await request(app).get(`/api/tag?id=${tag.id}`); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); diff --git a/backend/utils/slug-utils.js b/backend/utils/slug-utils.js index 06eaf2b..638e327 100644 --- a/backend/utils/slug-utils.js +++ b/backend/utils/slug-utils.js @@ -23,58 +23,58 @@ function createSlug(text, maxLength = 50) { } /** - * Creates a nanoid-slug URL for a given entity - * @param {string} nanoid - The nanoid of the entity + * Creates a uid-slug URL for a given entity + * @param {string} uid - The uid 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") + * @returns {string} The uid-slug URL part (e.g., "abc123-clean-the-backyard") */ -function createNanoidSlug(nanoid, name, maxSlugLength = 40) { - if (!nanoid) throw new Error('Nanoid is required'); +function createUidSlug(uid, name, maxSlugLength = 40) { + if (!uid) throw new Error('UID is required'); const slug = createSlug(name, maxSlugLength); - return slug ? `${nanoid}-${slug}` : nanoid; + return slug ? `${uid}-${slug}` : uid; } /** - * 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 + * Extracts uid from a uid-slug URL part + * @param {string} uidSlug - The uid-slug (e.g., "abc123-clean-the-backyard") + * @returns {string} The extracted uid */ -function extractNanoidFromSlug(nanoidSlug) { - if (!nanoidSlug) return ''; +function extractUidFromSlug(uidSlug) { + if (!uidSlug) 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('-'); + // UID is always 15 characters by our custom implementation, extract the first part before the first hyphen + // But handle cases where the uid itself might contain hyphens + const parts = uidSlug.split('-'); if (parts.length === 1) { - // No slug, just nanoid + // No slug, just uid return parts[0]; } - // Look for the nanoid part (21 chars) - it should be the first part + // Look for the uid part (15 chars) - it should be the first part const firstPart = parts[0]; - if (firstPart.length === 21) { + if (firstPart.length === 15) { 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]; + // Fallback: try to find 15-character alphanumeric string + const uidMatch = uidSlug.match(/^([0-9abcdefghijkmnpqrstuvwxyz]{15})/); + return uidMatch ? uidMatch[1] : uidSlug.split('-')[0]; } /** - * Validates if a string looks like a valid nanoid + * Validates if a string looks like a valid uid * @param {string} str - String to validate - * @returns {boolean} True if it looks like a nanoid + * @returns {boolean} True if it looks like a uid */ -function isValidNanoid(str) { - return /^[A-Za-z0-9_-]{21}$/.test(str); +function isValidUid(str) { + return /^[0-9abcdefghijkmnpqrstuvwxyz]{15}$/.test(str); } module.exports = { createSlug, - createNanoidSlug, - extractNanoidFromSlug, - isValidNanoid, + createUidSlug, + extractUidFromSlug, + isValidUid, }; diff --git a/backend/utils/uid.js b/backend/utils/uid.js new file mode 100644 index 0000000..6efd1d0 --- /dev/null +++ b/backend/utils/uid.js @@ -0,0 +1,11 @@ +const nanoid = require('nanoid'); + +function uid() { + const generate = nanoid.customAlphabet( + '0123456789abcdefghijkmnpqrstuvwxyz', + 15 + ); + return generate(); +} + +module.exports = { uid };