Standardize UID implementation across backend

- Add unified UID column migration for all entities
- Create centralized UID generation utility
- Update all models to use standardized UID hooks
- Fix route handlers to support UID-based lookups
- Update slug utilities for consistent UID extraction
- Fix tag tests to use query parameters instead of path params
- Configure Jest for better TypeScript support
This commit is contained in:
antanst 2025-08-05 19:29:03 +03:00 committed by Chris
parent b62e322a24
commit c3e8449a25
21 changed files with 279 additions and 577 deletions

View file

@ -21,6 +21,6 @@ module.exports = {
testTimeout: 30000,
maxWorkers: '100%',
moduleNameMapper: {
'^nanoid/non-secure$': '<rootDir>/tests/mocks/nanoid.js',
'^nanoid$': '<rootDir>/tests/mocks/nanoid.js',
},
};

View file

@ -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');
},
};

View file

@ -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');
},
};

View file

@ -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');
},
};

View file

@ -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');
},
};

View file

@ -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');
},
};

View file

@ -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);
}
}
},
};

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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'],
},
],
});

View file

@ -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: [] },
},
],

View file

@ -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) {

View file

@ -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,
},
],

View file

@ -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');

View file

@ -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,
};

11
backend/utils/uid.js Normal file
View file

@ -0,0 +1,11 @@
const nanoid = require('nanoid');
function uid() {
const generate = nanoid.customAlphabet(
'0123456789abcdefghijkmnpqrstuvwxyz',
15
);
return generate();
}
module.exports = { uid };