diff --git a/.gitignore b/.gitignore index d3a3665..823da3b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ certs/ .DS_Store .cursor +AGENTS.md CLAUDE.local.md .byebug_history diff --git a/backend/app.js b/backend/app.js index 8267f6c..193d220 100644 --- a/backend/app.js +++ b/backend/app.js @@ -90,6 +90,7 @@ app.use('/api/uploads', express.static(config.uploadPath)); // Authentication middleware const { requireAuth } = require('./middleware/auth'); +const { logError } = require('./services/logService'); // Health check (before auth middleware) - ensure it's completely bypassed app.get('/api/health', (req, res) => { @@ -136,12 +137,15 @@ app.get('*', (req, res) => { } }); -// Error handling +// Error handling fallback. +// We shouldn't be here normally! +// Each route should properly handle +// and log its own errors. app.use((err, req, res, next) => { - console.error(err.stack); + logError(err); res.status(500).json({ error: 'Internal Server Error', - message: err.message, + // message: err.message, }); }); diff --git a/backend/jest.config.js b/backend/jest.config.js index 01affd3..5c70546 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -13,7 +13,7 @@ module.exports = { ], coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html'], - verbose: true, + verbose: false, forceExit: true, clearMocks: true, resetMocks: true, diff --git a/backend/migrations/20250920074825-add-project-state-column.js b/backend/migrations/20250920074825-add-project-state-column.js new file mode 100644 index 0000000..7ac7e7e --- /dev/null +++ b/backend/migrations/20250920074825-add-project-state-column.js @@ -0,0 +1,28 @@ +'use strict'; + +const { safeAddColumns } = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeAddColumns(queryInterface, 'projects', [ + { + name: 'state', + definition: { + type: Sequelize.ENUM( + 'idea', + 'planned', + 'in_progress', + 'blocked', + 'completed' + ), + allowNull: false, + defaultValue: 'idea', + }, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('projects', 'state'); + }, +}; diff --git a/backend/migrations/20250920075905-convert-active-to-states.js b/backend/migrations/20250920075905-convert-active-to-states.js new file mode 100644 index 0000000..f3bd449 --- /dev/null +++ b/backend/migrations/20250920075905-convert-active-to-states.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Update all projects: active=true -> state='in_progress', active=false -> state='idea' + await queryInterface.sequelize.query(` + UPDATE projects + SET state = CASE + WHEN active = 1 THEN 'in_progress' + ELSE 'idea' + END + `); + }, + + down: async (queryInterface, Sequelize) => { + // Reverse the conversion: state='in_progress' -> active=true, others -> active=false + await queryInterface.sequelize.query(` + UPDATE projects + SET active = CASE + WHEN state = 'in_progress' THEN 1 + ELSE 0 + END + `); + }, +}; diff --git a/backend/migrations/20250920075916-remove-active-column.js b/backend/migrations/20250920075916-remove-active-column.js new file mode 100644 index 0000000..0202b11 --- /dev/null +++ b/backend/migrations/20250920075916-remove-active-column.js @@ -0,0 +1,36 @@ +'use strict'; + +const { + safeRemoveColumn, + safeAddColumns, +} = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + // Remove the active column from projects table + await safeRemoveColumn(queryInterface, 'projects', 'active'); + }, + + down: async (queryInterface, Sequelize) => { + // Add the active column back + await safeAddColumns(queryInterface, 'projects', [ + { + name: 'active', + definition: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + }, + ]); + + // Restore active values based on state + await queryInterface.sequelize.query(` + UPDATE projects + SET active = CASE + WHEN state = 'in_progress' THEN 1 + ELSE 0 + END + `); + }, +}; diff --git a/backend/migrations/20250924000001-add-uid-to-inbox-items.js b/backend/migrations/20250924000001-add-uid-to-inbox-items.js new file mode 100644 index 0000000..17d5c39 --- /dev/null +++ b/backend/migrations/20250924000001-add-uid-to-inbox-items.js @@ -0,0 +1,83 @@ +'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 { + // 1. Add uid column to inbox_items table + await safeAddColumns(queryInterface, 'inbox_items', [ + { + name: 'uid', + definition: { + type: Sequelize.STRING, + allowNull: true, // Initially allow null during population + }, + }, + ]); + + // 2. Populate uid values for existing inbox items + const records = await queryInterface.sequelize.query( + 'SELECT id FROM inbox_items 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 inbox_items SET uid = ? WHERE id = ?', + { + replacements: [uniqueId, record.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + // 3. Make uid column not null and unique + await queryInterface.changeColumn('inbox_items', 'uid', { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }); + + // 4. Add unique index for performance + await safeAddIndex(queryInterface, 'inbox_items', ['uid'], { + unique: true, + name: 'inbox_items_uid_unique_index', + }); + } finally { + // Re-enable foreign key constraints + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface, Sequelize) { + try { + // Remove unique index + await queryInterface.removeIndex( + 'inbox_items', + 'inbox_items_uid_unique_index' + ); + } catch (error) { + console.log( + 'inbox_items_uid_unique_index not found, skipping removal' + ); + } + + try { + // Remove uid column + await queryInterface.removeColumn('inbox_items', 'uid'); + } catch (error) { + console.log( + 'Error removing uid column from inbox_items:', + error.message + ); + } + }, +}; diff --git a/backend/migrations/20250925000001-add-uid-to-users.js b/backend/migrations/20250925000001-add-uid-to-users.js new file mode 100644 index 0000000..325b71b --- /dev/null +++ b/backend/migrations/20250925000001-add-uid-to-users.js @@ -0,0 +1,66 @@ +'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) { + await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF'); + + try { + await safeAddColumns(queryInterface, 'users', [ + { + name: 'uid', + definition: { + type: Sequelize.STRING, + allowNull: true, + }, + }, + ]); + + const users = await queryInterface.sequelize.query( + 'SELECT id FROM users WHERE uid IS NULL', + { type: Sequelize.QueryTypes.SELECT } + ); + + for (const user of users) { + const uniqueId = uid(); + await queryInterface.sequelize.query( + 'UPDATE users SET uid = ? WHERE id = ?', + { + replacements: [uniqueId, user.id], + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + + await queryInterface.changeColumn('users', 'uid', { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }); + + await safeAddIndex(queryInterface, 'users', ['uid'], { + unique: true, + name: 'users_uid_unique_index', + }); + } finally { + await queryInterface.sequelize.query('PRAGMA foreign_keys = ON'); + } + }, + + async down(queryInterface) { + try { + await queryInterface.removeIndex('users', 'users_uid_unique_index'); + } catch (error) { + console.log('users_uid_unique_index not found, skipping removal'); + } + + try { + await queryInterface.removeColumn('users', 'uid'); + } catch (error) { + console.log('Error removing uid column from users:', error.message); + } + }, +}; diff --git a/backend/models/inbox_item.js b/backend/models/inbox_item.js index dfa82f7..18475ff 100644 --- a/backend/models/inbox_item.js +++ b/backend/models/inbox_item.js @@ -1,4 +1,5 @@ const { DataTypes } = require('sequelize'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const InboxItem = sequelize.define( @@ -9,6 +10,12 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: uid, + }, content: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/project.js b/backend/models/project.js index 6a75df3..3b5d4c7 100644 --- a/backend/models/project.js +++ b/backend/models/project.js @@ -24,11 +24,6 @@ module.exports = (sequelize) => { type: DataTypes.TEXT, allowNull: true, }, - active: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: true, - }, pin_to_sidebar: { type: DataTypes.BOOLEAN, allowNull: false, @@ -76,6 +71,17 @@ module.exports = (sequelize) => { allowNull: true, defaultValue: 'created_at:desc', }, + state: { + type: DataTypes.ENUM( + 'idea', + 'planned', + 'in_progress', + 'blocked', + 'completed' + ), + allowNull: false, + defaultValue: 'idea', + }, }, { tableName: 'projects', diff --git a/backend/models/user.js b/backend/models/user.js index 128d502..fbe3c3b 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -1,5 +1,6 @@ const { DataTypes } = require('sequelize'); const bcrypt = require('bcrypt'); +const { uid } = require('../utils/uid'); module.exports = (sequelize) => { const User = sequelize.define( @@ -10,6 +11,12 @@ module.exports = (sequelize) => { primaryKey: true, autoIncrement: true, }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: uid, + }, name: { type: DataTypes.STRING, allowNull: true, diff --git a/backend/routes/areas.js b/backend/routes/areas.js index fd1d13c..2ed6c82 100644 --- a/backend/routes/areas.js +++ b/backend/routes/areas.js @@ -1,17 +1,15 @@ const express = require('express'); const { Area } = require('../models'); +const { isValidUid } = require('../utils/slug-utils'); +const _ = require('lodash'); const router = express.Router(); // GET /api/areas router.get('/areas', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - const areas = await Area.findAll({ where: { user_id: req.session.userId }, - attributes: ['id', 'uid', 'name', 'description'], + attributes: ['uid', 'name', 'description'], order: [['name', 'ASC']], }); @@ -22,18 +20,17 @@ router.get('/areas', async (req, res) => { } }); -// GET /api/areas/:id -router.get('/areas/:id', async (req, res) => { +// GET /api/areas/:uid +router.get('/areas/:uid', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - + if (!isValidUid(req.params.uid)) + return res.status(400).json({ error: 'Invalid UID' }); const area = await Area.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, + attributes: ['uid', 'name', 'description'], }); - if (!area) { + if (_.isEmpty(area)) { return res.status(404).json({ error: "Area not found or doesn't belong to the current user.", }); @@ -49,13 +46,9 @@ router.get('/areas/:id', async (req, res) => { // POST /api/areas router.post('/areas', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - const { name, description } = req.body; - if (!name || !name.trim()) { + if (!name || _.isEmpty(name.trim())) { return res.status(400).json({ error: 'Area name is required.' }); } @@ -65,10 +58,7 @@ router.post('/areas', async (req, res) => { user_id: req.session.userId, }); - res.status(201).json({ - ...area.toJSON(), - uid: area.uid, // Explicitly include uid - }); + res.status(201).json(_.pick(area, ['uid', 'name', 'description'])); } catch (error) { console.error('Error creating area:', error); res.status(400).json({ @@ -80,15 +70,13 @@ router.post('/areas', async (req, res) => { } }); -// PATCH /api/areas/:id -router.patch('/areas/:id', async (req, res) => { +// PATCH /api/areas/:uid +router.patch('/areas/:uid', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - + if (!isValidUid(req.params.uid)) + return res.status(400).json({ error: 'Invalid UID' }); const area = await Area.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, }); if (!area) { @@ -102,7 +90,7 @@ router.patch('/areas/:id', async (req, res) => { if (description !== undefined) updateData.description = description; await area.update(updateData); - res.json(area); + res.json(_.pick(area, ['uid', 'name', 'description'])); } catch (error) { console.error('Error updating area:', error); res.status(400).json({ @@ -114,15 +102,14 @@ router.patch('/areas/:id', async (req, res) => { } }); -// DELETE /api/areas/:id -router.delete('/areas/:id', async (req, res) => { +// DELETE /api/areas/:uid +router.delete('/areas/:uid', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } + if (!isValidUid(req.params.uid)) + return res.status(400).json({ error: 'Invalid UID' }); const area = await Area.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, }); if (!area) { @@ -130,7 +117,7 @@ router.delete('/areas/:id', async (req, res) => { } await area.destroy(); - res.status(204).send(); + return res.status(204).send(); } catch (error) { console.error('Error deleting area:', error); res.status(400).json({ diff --git a/backend/routes/auth.js b/backend/routes/auth.js index f20330d..ebc51b8 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -13,7 +13,15 @@ router.get('/version', (req, res) => { router.get('/current_user', async (req, res) => { try { if (req.session && req.session.userId) { - const user = await User.findByPk(req.session.userId); + const user = await User.findByPk(req.session.userId, { + attributes: [ + 'uid', + 'email', + 'language', + 'appearance', + 'timezone', + ], + }); if (user) { const admin = await isAdmin(user.id); return res.json({ @@ -70,7 +78,7 @@ router.post('/login', async (req, res) => { const admin = await isAdmin(user.id); res.json({ user: { - id: user.id, + uid: user.uid, email: user.email, language: user.language, appearance: user.appearance, diff --git a/backend/routes/inbox.js b/backend/routes/inbox.js index ee6fdc2..ab1d349 100644 --- a/backend/routes/inbox.js +++ b/backend/routes/inbox.js @@ -1,23 +1,22 @@ const express = require('express'); const { InboxItem } = require('../models'); const { processInboxItem } = require('../services/inboxProcessingService'); +const { isValidUid } = require('../utils/slug-utils'); +const _ = require('lodash'); +const { logError } = require('../services/logService'); const router = express.Router(); // GET /api/inbox router.get('/inbox', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - // Check if pagination parameters are provided const hasPagination = - req.query.limit !== undefined || req.query.offset !== undefined; + !_.isEmpty(req.query.limit) || !_.isEmpty(req.query.offset); if (hasPagination) { // Parse pagination parameters - const limit = parseInt(req.query.limit) || 20; // Default to 20 items - const offset = parseInt(req.query.offset) || 0; + const limit = parseInt(req.query.limit, 10) || 20; // Default to 20 items + const offset = parseInt(req.query.offset, 10) || 0; // Get total count for pagination info const totalCount = await InboxItem.count({ @@ -59,7 +58,7 @@ router.get('/inbox', async (req, res) => { res.json(items); } } catch (error) { - console.error('Error fetching inbox items:', error); + logError('Error fetching inbox items:', error); res.status(500).json({ error: 'Internal server error' }); } }); @@ -67,13 +66,9 @@ router.get('/inbox', async (req, res) => { // POST /api/inbox router.post('/inbox', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - const { content, source } = req.body; - if (!content || !content.trim()) { + if (!content || _.isEmpty(content.trim())) { return res.status(400).json({ error: 'Content is required' }); } @@ -86,9 +81,18 @@ router.post('/inbox', async (req, res) => { user_id: req.session.userId, }); - res.status(201).json(item); + res.status(201).json( + _.pick(item, [ + 'uid', + 'content', + 'status', + 'source', + 'created_at', + 'updated_at', + ]) + ); } catch (error) { - console.error('Error creating inbox item:', error); + logError('Error creating inbox item:', error); res.status(400).json({ error: 'There was a problem creating the inbox item.', details: error.errors @@ -98,53 +102,70 @@ router.post('/inbox', async (req, res) => { } }); -// GET /api/inbox/:id -router.get('/inbox/:id', async (req, res) => { +// GET /api/inbox/:uid +router.get('/inbox/:uid', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); + if (!isValidUid(req.params.uid)) { + return res.status(400).json({ error: 'Invalid UID' }); } const item = await InboxItem.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, + attributes: [ + 'uid', + 'content', + 'status', + 'source', + 'created_at', + 'updated_at', + ], }); - if (!item) { + if (_.isEmpty(item)) { return res.status(404).json({ error: 'Inbox item not found.' }); } res.json(item); } catch (error) { - console.error('Error fetching inbox item:', error); + logError('Error fetching inbox item:', error); res.status(500).json({ error: 'Internal server error' }); } }); -// PATCH /api/inbox/:id -router.patch('/inbox/:id', async (req, res) => { +// PATCH /api/inbox/:uid +router.patch('/inbox/:uid', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); + if (!isValidUid(req.params.uid)) { + return res.status(400).json({ error: 'Invalid UID' }); } const item = await InboxItem.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, }); - if (!item) { + if (_.isEmpty(item)) { return res.status(404).json({ error: 'Inbox item not found.' }); } const { content, status } = req.body; const updateData = {}; - if (content !== undefined) updateData.content = content; - if (status !== undefined) updateData.status = status; + if (content != null) updateData.content = content; + if (status != null) updateData.status = status; await item.update(updateData); - res.json(item); + res.json( + _.pick(item, [ + 'uid', + 'content', + 'status', + 'source', + 'created_at', + 'updated_at', + ]) + ); } catch (error) { - console.error('Error updating inbox item:', error); + logError('Error updating inbox item:', error); res.status(400).json({ error: 'There was a problem updating the inbox item.', details: error.errors @@ -154,18 +175,18 @@ router.patch('/inbox/:id', async (req, res) => { } }); -// DELETE /api/inbox/:id -router.delete('/inbox/:id', async (req, res) => { +// DELETE /api/inbox/:uid +router.delete('/inbox/:uid', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); + if (!isValidUid(req.params.uid)) { + return res.status(400).json({ error: 'Invalid UID' }); } const item = await InboxItem.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, }); - if (!item) { + if (_.isEmpty(item)) { return res.status(404).json({ error: 'Inbox item not found.' }); } @@ -173,32 +194,41 @@ router.delete('/inbox/:id', async (req, res) => { await item.update({ status: 'deleted' }); res.json({ message: 'Inbox item successfully deleted' }); } catch (error) { - console.error('Error deleting inbox item:', error); + logError('Error deleting inbox item:', error); res.status(400).json({ error: 'There was a problem deleting the inbox item.', }); } }); -// PATCH /api/inbox/:id/process -router.patch('/inbox/:id/process', async (req, res) => { +// PATCH /api/inbox/:uid/process +router.patch('/inbox/:uid/process', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); + if (!isValidUid(req.params.uid)) { + return res.status(400).json({ error: 'Invalid UID' }); } const item = await InboxItem.findOne({ - where: { id: req.params.id, user_id: req.session.userId }, + where: { uid: req.params.uid, user_id: req.session.userId }, }); - if (!item) { + if (_.isEmpty(item)) { return res.status(404).json({ error: 'Inbox item not found.' }); } await item.update({ status: 'processed' }); - res.json(item); + res.json( + _.pick(item, [ + 'uid', + 'content', + 'status', + 'source', + 'created_at', + 'updated_at', + ]) + ); } catch (error) { - console.error('Error processing inbox item:', error); + logError('Error processing inbox item:', error); res.status(400).json({ error: 'There was a problem processing the inbox item.', }); @@ -208,10 +238,6 @@ router.patch('/inbox/:id/process', async (req, res) => { // POST /api/inbox/analyze-text router.post('/inbox/analyze-text', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - const { content } = req.body; if (!content || typeof content !== 'string') { @@ -225,7 +251,7 @@ router.post('/inbox/analyze-text', async (req, res) => { res.json(result); } catch (error) { - console.error('Error analyzing inbox text:', error); + logError('Error analyzing inbox text:', error); res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/routes/notes.js b/backend/routes/notes.js index 519be07..69c66cd 100644 --- a/backend/routes/notes.js +++ b/backend/routes/notes.js @@ -1,15 +1,16 @@ -const express = require('express'); -const { Note, Tag, Project, sequelize } = require('../models'); -const { Op } = require('sequelize'); -const { extractUidFromSlug } = require('../utils/slug-utils'); -const { validateTagName } = require('../utils/validation'); +const express = require("express"); +const { Note, Tag, Project } = require("../models"); +const { extractUidFromSlug } = require("../utils/slug-utils"); +const { validateTagName } = require("../services/tagsService"); const router = express.Router(); -const permissionsService = require('../services/permissionsService'); -const { hasAccess } = require('../middleware/authorize'); +const permissionsService = require("../services/permissionsService"); +const { hasAccess } = require("../middleware/authorize"); +const _ = require("lodash"); +const { logError } = require("../services/logService"); // Helper function to update note tags async function updateNoteTags(note, tagsArray, userId) { - if (!tagsArray || tagsArray.length === 0) { + if (_.isEmpty(tagsArray)) { await note.setTags([]); return; } @@ -31,10 +32,9 @@ async function updateNoteTags(note, tagsArray, userId) { } } - // 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(', ')}` + `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(", ")}` ); } @@ -42,43 +42,39 @@ async function updateNoteTags(note, tagsArray, userId) { validTagNames.map(async (name) => { const [tag] = await Tag.findOrCreate({ where: { name, user_id: userId }, - defaults: { name, user_id: userId }, + defaults: { name, user_id: userId } }); return tag; }) ); await note.setTags(tags); } catch (error) { - console.error('Failed to update tags:', error.message); + logError("Failed to update tags:", error.message); throw error; // Re-throw to handle at route level } } // GET /api/notes -router.get('/notes', async (req, res) => { +router.get("/notes", async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const orderBy = req.query.order_by || 'title:asc'; - const [orderColumn, orderDirection] = orderBy.split(':'); + const orderBy = req.query.order_by || "title:asc"; + const [orderColumn, orderDirection] = orderBy.split(":"); const whereClause = await permissionsService.ownershipOrPermissionWhere( - 'note', + "note", req.session.userId ); let includeClause = [ { model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, + attributes: ["name", "uid"], + through: { attributes: [] } }, { model: Project, required: false, - attributes: ['id', 'name', 'uid'], - }, + attributes: ["name", "uid"] + } ]; // Filter by tag @@ -91,220 +87,176 @@ router.get('/notes', async (req, res) => { where: whereClause, include: includeClause, order: [[orderColumn, orderDirection.toUpperCase()]], - distinct: true, + distinct: true }); res.json(notes); } catch (error) { - console.error('Error fetching notes:', error); - res.status(500).json({ error: 'Internal server error' }); + logError("Error fetching notes:", error); + res.status(500).json({ error: "Internal server error" }); } }); -// GET /api/note/:id (supports both numeric ID and uid-slug) -router.get( - '/note/:id', +router.get("/note/:uidSlug", hasAccess( - 'ro', - 'note', + "ro", + "note", async (req) => { - const identifier = req.params.id; - if (/^\d+$/.test(identifier)) { - const n = await Note.findOne({ - where: { id: parseInt(identifier) }, - attributes: ['uid'], - }); - return n?.uid; - } - const uid = extractUidFromSlug(identifier); - return uid; + return extractUidFromSlug(req.params.uidSlug); }, - { notFoundMessage: 'Note not found.' } + { notFoundMessage: "Note not found." } ), async (req, res) => { try { - const identifier = req.params.id; - let whereClause; - if (/^\d+$/.test(identifier)) { - whereClause = { id: parseInt(identifier) }; - } else { - const uid = extractUidFromSlug(identifier); - if (!uid) { - return res - .status(400) - .json({ error: 'Invalid note identifier' }); - } - whereClause = { uid }; - } - const note = await Note.findOne({ - where: whereClause, + where: { uid: extractUidFromSlug(req.params.uidSlug) }, include: [ { model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, + attributes: ["name", "uid"], + through: { attributes: [] } }, { model: Project, required: false, - attributes: ['id', 'name', 'uid'], - }, - ], + attributes: ["name", "uid"] + } + ] }); - if (!note) { - return res.status(404).json({ error: 'Note not found.' }); - } - // access ensured by middleware - res.json(note); } catch (error) { - console.error('Error fetching note:', error); - res.status(500).json({ error: 'Internal server error' }); + logError("Error fetching note:", error); + res.status(500).json({ error: "Internal server error" }); } } ); // POST /api/note -router.post('/note', async (req, res) => { - try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const { title, content, project_id, tags } = req.body; - - const noteAttributes = { - title, - content, - user_id: req.session.userId, - }; - - // Handle project assignment - if (project_id && project_id.toString().trim()) { - const project = await Project.findOne({ - where: { id: project_id }, - }); - if (!project) { - return res.status(400).json({ error: 'Invalid project.' }); - } - const projectAccess = await permissionsService.getAccess( - req.session.userId, - 'project', - project.uid - ); - const isOwner = project.user_id === req.session.userId; - const canWrite = - isOwner || projectAccess === 'rw' || projectAccess === 'admin'; - if (!canWrite) { - return res.status(403).json({ error: 'Forbidden' }); - } - noteAttributes.project_id = project_id; - } - - const note = await Note.create(noteAttributes); - - // Handle tags - can be array of strings or array of objects with name property - let tagNames = []; - if (Array.isArray(tags)) { - if (tags.every((t) => typeof t === 'string')) { - tagNames = tags; - } else if (tags.every((t) => typeof t === 'object' && t.name)) { - tagNames = tags.map((t) => t.name); - } - } - - await updateNoteTags(note, tagNames, req.session.userId); - - // Reload note with associations - const noteWithAssociations = await Note.findByPk(note.id, { - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - { - model: Project, - required: false, - attributes: ['id', 'name', 'uid'], - }, - ], - }); - - res.status(201).json({ - ...noteWithAssociations.toJSON(), - uid: noteWithAssociations.uid, // Explicitly include uid - }); - } catch (error) { - console.error('Error creating note:', error); - res.status(400).json({ - error: 'There was a problem creating the note.', - details: error.errors - ? error.errors.map((e) => e.message) - : [error.message], - }); - } -}); - -// PATCH /api/note/:id -router.patch( - '/note/:id', +router.post("/note", hasAccess( - 'rw', - 'note', + "rw", + "project", async (req) => { - const n = await Note.findOne({ - where: { id: req.params.id }, - attributes: ['uid'], - }); - return n?.uid; + const { project_uid } = req.body; + if (!project_uid || _.isEmpty(project_uid.toString().trim())) { + return null; + } + return project_uid.toString().trim(); }, - { notFoundMessage: 'Note not found.' } + { notFoundMessage: "Note project not found" } + ), + async (req, res) => { + try { + const { title, content, project_uid, tags } = req.body; + + const noteAttributes = { + title, + content, + user_id: req.session.userId + }; + + // project_uid is already validated by hasAccess middleware + const project = await Project.findOne({ + where: { uid: project_uid.toString().trim() } + }); + + noteAttributes.project_id = project.id; + + const note = await Note.create(noteAttributes); + + // Handle tags - can be an array of strings + // or array of objects with name property + let tagNames = []; + if (Array.isArray(tags)) { + if (tags.every((t) => typeof t === "string")) { + tagNames = tags; + } else if (tags.every((t) => typeof t === "object" && t.name)) { + tagNames = tags.map((t) => t.name); + } + } + + await updateNoteTags(note, tagNames, req.session.userId); + + // Reload note with associations + const noteWithAssociations = await Note.findByPk(note.id, { + include: [ + { + model: Tag, + attributes: ["name", "uid"], + through: { attributes: [] } + }, + { + model: Project, + required: false, + attributes: ["name", "uid"] + } + ] + }); + + res.status(201).json({ + ...noteWithAssociations.toJSON(), + uid: noteWithAssociations.uid + }); + } catch (error) { + logError("Error creating note:", error); + res.status(400).json({ + error: "There was a problem creating the note.", + details: error.errors + ? error.errors.map((e) => e.message) + : [error.message] + }); + } + }); + +router.patch( + "/note/:uid", + hasAccess( + "rw", + "note", + async (req) => { + return extractUidFromSlug(req.params.uid); + }, + { notFoundMessage: "Note not found." } ), async (req, res) => { try { const note = await Note.findOne({ - where: { id: req.params.id }, + where: { uid: req.params.uid } }); - if (!note) { - return res.status(404).json({ error: 'Note not found.' }); - } - // access ensured by middleware - - const { title, content, project_id, tags } = req.body; + const { title, content, project_uid, tags } = req.body; const updateData = {}; if (title !== undefined) updateData.title = title; if (content !== undefined) updateData.content = content; // Handle project assignment - if (project_id !== undefined) { - if (project_id && project_id.toString().trim()) { + if (project_uid !== undefined) { + if (project_uid && typeof project_uid === 'string' && project_uid.trim()) { + const projectUidValue = project_uid.trim(); const project = await Project.findOne({ - where: { id: project_id }, + where: { uid: projectUidValue } }); if (!project) { return res .status(400) - .json({ error: 'Invalid project.' }); + .json({ error: "Invalid project." }); } const projectAccess = await permissionsService.getAccess( req.session.userId, - 'project', + "project", project.uid ); const isOwner = project.user_id === req.session.userId; const canWrite = isOwner || - projectAccess === 'rw' || - projectAccess === 'admin'; + projectAccess === "rw" || + projectAccess === "admin"; if (!canWrite) { - return res.status(403).json({ error: 'Forbidden' }); + return res.status(403).json({ error: "Forbidden" }); } - updateData.project_id = project_id; + updateData.project_id = project.id; } else { updateData.project_id = null; } @@ -316,10 +268,10 @@ router.patch( if (tags !== undefined) { let tagNames = []; if (Array.isArray(tags)) { - if (tags.every((t) => typeof t === 'string')) { + if (tags.every((t) => typeof t === "string")) { tagNames = tags; } else if ( - tags.every((t) => typeof t === 'object' && t.name) + tags.every((t) => typeof t === "object" && t.name) ) { tagNames = tags.map((t) => t.name); } @@ -332,63 +284,51 @@ router.patch( include: [ { model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, + attributes: ["id", "name", "uid"], + through: { attributes: [] } }, { model: Project, required: false, - attributes: ['id', 'name', 'uid'], - }, - ], + attributes: ["id", "name", "uid"] + } + ] }); res.json(noteWithAssociations); } catch (error) { - console.error('Error updating note:', error); + logError("Error updating note:", error); res.status(400).json({ - error: 'There was a problem updating the note.', + error: "There was a problem updating the note.", details: error.errors ? error.errors.map((e) => e.message) - : [error.message], + : [error.message] }); } } ); -// DELETE /api/note/:id router.delete( - '/note/:id', + "/note/:uid", hasAccess( - 'rw', - 'note', + "rw", + "note", async (req) => { - const n = await Note.findOne({ - where: { id: req.params.id }, - attributes: ['uid'], - }); - return n?.uid; + return extractUidFromSlug(req.params.uid); }, - { notFoundMessage: 'Note not found.' } + { notFoundMessage: "Note not found." } ), async (req, res) => { try { const note = await Note.findOne({ - where: { id: req.params.id }, + where: { uid: req.params.uid } }); - if (!note) { - return res.status(404).json({ error: 'Note not found.' }); - } - // access ensured by middleware - await note.destroy(); - res.json({ message: 'Note deleted successfully.' }); + res.json({ message: "Note deleted successfully." }); } catch (error) { - console.error('Error deleting note:', error); - res.status(400).json({ - error: 'There was a problem deleting the note.', - }); + logError("Error deleting note:", error); + res.status(500).json({ error: "Internal server error" }); } } ); diff --git a/backend/routes/projects.js b/backend/routes/projects.js index ef517da..df9a71e 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -8,8 +8,9 @@ const { Project, Task, Tag, Area, Note, sequelize } = require('../models'); const permissionsService = require('../services/permissionsService'); const { Op } = require('sequelize'); const { extractUidFromSlug } = require('../utils/slug-utils'); -const { validateTagName } = require('../utils/validation'); +const { validateTagName } = require('../services/tagsService'); const { uid } = require('../utils/uid'); +const { logError } = require('../services/logService'); const router = express.Router(); const { hasAccess } = require('../middleware/authorize'); @@ -115,10 +116,6 @@ async function updateProjectTags(project, tagsData, userId) { // POST /api/upload/project-image router.post('/upload/project-image', upload.single('image'), (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - if (!req.file) { return res.status(400).json({ error: 'No image file provided' }); } @@ -127,7 +124,7 @@ router.post('/upload/project-image', upload.single('image'), (req, res) => { const imageUrl = `/api/uploads/projects/${req.file.filename}`; res.json({ imageUrl }); } catch (error) { - console.error('Error uploading image:', error); + logError('Error uploading image:', error); res.status(500).json({ error: 'Failed to upload image' }); } }); @@ -135,11 +132,7 @@ router.post('/upload/project-image', upload.single('image'), (req, res) => { // GET /api/projects router.get('/projects', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const { active, pin_to_sidebar, area_id, area } = req.query; + const { state, active, pin_to_sidebar, area_id, area } = req.query; // Base: owned or shared projects const ownedOrShared = @@ -149,11 +142,22 @@ router.get('/projects', async (req, res) => { ); let whereClause = ownedOrShared; - // Filter by active status + // Filter by state (new primary filter) + if (state && state !== 'all') { + if (Array.isArray(state)) { + whereClause.state = { [Op.in]: state }; + } else { + whereClause.state = state; + } + } + + // Legacy support for active filter - map to states if (active === 'true') { - whereClause.active = true; + whereClause.state = { + [Op.in]: ['planned', 'in_progress', 'blocked'], + }; } else if (active === 'false') { - whereClause.active = false; + whereClause.state = { [Op.in]: ['idea', 'completed'] }; } // Filter by pinned status @@ -252,7 +256,7 @@ router.get('/projects', async (req, res) => { }); } } catch (error) { - console.error('Error fetching projects:', error); + logError('Error fetching projects:', error); res.status(500).json({ error: 'Internal server error' }); } }); @@ -264,19 +268,13 @@ router.get( 'ro', 'project', async (req) => { - const uidPart = req.params.uidSlug.split('-')[0]; - const p = await Project.findOne({ - where: { uid: uidPart }, - attributes: ['uid'], - }); - return p?.uid; + return extractUidFromSlug(req.params.uidSlug); }, { notFoundMessage: 'Project not found' } ), async (req, res) => { try { - // Extract UID from slug and fetch full project with associations - const uidPart = req.params.uidSlug.split('-')[0]; + const uidPart = extractUidFromSlug(req.params.uidSlug); const project = await Project.findOne({ where: { uid: uidPart }, include: [ @@ -285,8 +283,7 @@ router.get( required: false, where: { parent_task_id: null, - recurring_parent_id: null, // Exclude recurring task instances, only show templates - // Include ALL tasks regardless of status for client-side filtering + recurring_parent_id: null, }, include: [ { @@ -342,10 +339,6 @@ router.get( ], }); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - const projectJson = project.toJSON(); // Normalize task data to match frontend expectations @@ -390,7 +383,7 @@ router.get( res.json(result); } catch (error) { - console.error('Error fetching project:', error); + logError('Error fetching project:', error); res.status(500).json({ error: 'Internal server error' }); } } @@ -399,18 +392,14 @@ router.get( // POST /api/project router.post('/project', async (req, res) => { try { - if (!req.session || !req.session.userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - const { name, description, area_id, - active, priority, due_date_at, image_url, + state, tags, Tags, } = req.body; @@ -430,11 +419,11 @@ router.post('/project', async (req, res) => { name: name.trim(), description: description || '', area_id: area_id || null, - active: active !== undefined ? active : true, pin_to_sidebar: false, priority: priority || null, due_date_at: due_date_at || null, image_url: image_url || null, + state: state || 'idea', user_id: req.session.userId, }; @@ -445,7 +434,7 @@ router.post('/project', async (req, res) => { try { await updateProjectTags(project, tagsData, req.session.userId); } catch (tagError) { - console.warn( + logError( 'Tag update failed, but project created successfully:', tagError.message ); @@ -458,7 +447,7 @@ router.post('/project', async (req, res) => { due_date_at: formatDate(project.due_date_at), }); } catch (error) { - console.error('Error creating project:', error); + logError('Error creating project:', error); res.status(400).json({ error: 'There was a problem creating the project.', details: error.errors @@ -468,38 +457,32 @@ router.post('/project', async (req, res) => { } }); -// PATCH /api/project/:id +// PATCH /api/project/:uid router.patch( - '/project/:id', + '/project/:uid', hasAccess( 'rw', 'project', async (req) => { - const p = await Project.findByPk(req.params.id, { - attributes: ['uid'], - }); - return p?.uid; + return extractUidFromSlug(req.params.uid); }, { notFoundMessage: 'Project not found.' } ), async (req, res) => { try { - // Load project and check RW access (owner/admin or shared rw) - const project = await Project.findByPk(req.params.id); - if (!project) { - return res.status(404).json({ error: 'Project not found.' }); - } - // access ensured by middleware + const project = await Project.findOne({ + where: { uid: req.params.uid }, + }); const { name, description, area_id, - active, pin_to_sidebar, priority, due_date_at, image_url, + state, tags, Tags, } = req.body; @@ -511,12 +494,12 @@ router.patch( if (name !== undefined) updateData.name = name; if (description !== undefined) updateData.description = description; if (area_id !== undefined) updateData.area_id = area_id; - if (active !== undefined) updateData.active = active; if (pin_to_sidebar !== undefined) updateData.pin_to_sidebar = pin_to_sidebar; if (priority !== undefined) updateData.priority = priority; if (due_date_at !== undefined) updateData.due_date_at = due_date_at; if (image_url !== undefined) updateData.image_url = image_url; + if (state !== undefined) updateData.state = state; await project.update(updateData); await updateProjectTags(project, tagsData, req.session.userId); @@ -536,11 +519,11 @@ router.patch( res.json({ ...projectJson, - tags: projectJson.Tags || [], // Normalize Tags to tags + tags: projectJson.Tags || [], due_date_at: formatDate(projectWithAssociations.due_date_at), }); } catch (error) { - console.error('Error updating project:', error); + logError('Error updating project:', error); res.status(400).json({ error: 'There was a problem updating the project.', details: error.errors @@ -551,34 +534,27 @@ router.patch( } ); -// DELETE /api/project/:id +// DELETE /api/project/:uid router.delete( - '/project/:id', + '/project/:uid', hasAccess( 'rw', 'project', async (req) => { - const p = await Project.findByPk(req.params.id, { - attributes: ['uid'], - }); - return p?.uid; + return extractUidFromSlug(req.params.uid); }, { notFoundMessage: 'Project not found.' } ), async (req, res) => { try { - const project = await Project.findByPk(req.params.id); - if (!project) { - return res.status(404).json({ error: 'Project not found.' }); - } - // access ensured by middleware + const project = await Project.findOne({ + where: { uid: req.params.uid }, + }); // Use a transaction to ensure atomicity await sequelize.transaction(async (transaction) => { // Disable foreign key constraints for this operation - await sequelize.query('PRAGMA foreign_keys = OFF', { - transaction, - }); + await sequelize.query('PRAGMA foreign_keys = OFF', { transaction }); try { // First, orphan all tasks associated with this project by setting project_id to NULL @@ -586,7 +562,7 @@ router.delete( { project_id: null }, { where: { - project_id: req.params.id, + project_id: project.id, user_id: req.session.userId, }, transaction, @@ -605,7 +581,7 @@ router.delete( res.json({ message: 'Project successfully deleted' }); } catch (error) { - console.error('Error deleting project:', error); + logError('Error deleting project:', error); res.status(400).json({ error: 'There was a problem deleting the project.', }); diff --git a/backend/routes/tags.js b/backend/routes/tags.js index ed17754..32cce6a 100644 --- a/backend/routes/tags.js +++ b/backend/routes/tags.js @@ -1,36 +1,35 @@ const express = require('express'); const { Tag, Task, Note, Project, sequelize } = require('../models'); const { extractUidFromSlug } = require('../utils/slug-utils'); -const { validateTagName } = require('../utils/validation'); +const { validateTagName } = require('../services/tagsService'); const router = express.Router(); const _ = require('lodash'); +const { Op } = require('sequelize'); +const { logError } = require('../services/logService'); // GET /api/tags router.get('/tags', async (req, res) => { try { const tags = await Tag.findAll({ where: { user_id: req.currentUser.id }, - attributes: ['id', 'name', 'uid'], + attributes: ['name', 'uid'], order: [['name', 'ASC']], }); res.json(tags); } catch (error) { - console.error('Error fetching tags:', error); + logError('Error fetching tags:', error); res.status(500).json({ error: 'Internal server error' }); } }); -// GET /api/tag/:identifier (supports both ID, name, and uid-slug) +// GET /api/tag/:identifier (supports name and uid) router.get('/tag', async (req, res) => { try { - const { id, uid, name } = req.query; + const { uid, name } = req.query; let whereClause = { user_id: req.currentUser.id, }; - if (!_.isEmpty(id)) { - whereClause.id = parseInt(id, 10); - } if (!_.isEmpty(uid)) { whereClause.uid = uid; } @@ -43,13 +42,13 @@ router.get('/tag', async (req, res) => { attributes: ['name', 'uid'], }); - if (!tag) { + if (_.isEmpty(tag)) { return res.status(404).json({ error: 'Tag not found' }); } res.json(tag); } catch (error) { - console.error('Error fetching tag:', error); + logError('Error fetching tag:', error); res.status(500).json({ error: 'Internal server error' }); } }); @@ -70,12 +69,11 @@ router.post('/tag', async (req, res) => { }); res.status(201).json({ - id: tag.id, - uid: tag.uid, // Explicitly include uid + uid: tag.uid, name: tag.name, }); } catch (error) { - console.error('Error creating tag:', error); + logError('Error creating tag:', error); res.status(400).json({ error: 'There was a problem creating the tag.', }); @@ -85,21 +83,13 @@ router.post('/tag', async (req, res) => { // PATCH /api/tag/:identifier (supports both ID and name) router.patch('/tag/:identifier', async (req, res) => { try { - const identifier = req.params.identifier; - let whereClause; - - // Check if identifier is a number (ID) or string (name) - if (/^\d+$/.test(identifier)) { - // It's a numeric ID - whereClause = { - id: parseInt(identifier), - 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 }; - } + const param = decodeURIComponent(req.params.identifier); + let whereClause = { + [Op.or]: [ + { name: param, user_id: req.currentUser.id }, + { uid: param, user_id: req.currentUser.id }, + ], + }; const tag = await Tag.findOne({ where: whereClause, @@ -110,7 +100,6 @@ router.patch('/tag/:identifier', async (req, res) => { } const { name } = req.body; - const validation = validateTagName(name); if (!validation.valid) { return res.status(400).json({ error: validation.error }); @@ -123,90 +112,62 @@ router.patch('/tag/:identifier', async (req, res) => { name: tag.name, }); } catch (error) { - console.error('Error updating tag:', error); + logError('Error updating tag:', error); res.status(400).json({ error: 'There was a problem updating the tag.', }); } }); -// DELETE /api/tag/:identifier (supports both ID and name) +// DELETE /api/tag/:identifier (supports uid and name) router.delete('/tag/:identifier', async (req, res) => { const transaction = await sequelize.transaction(); try { - const identifier = req.params.identifier; - let whereClause; - - // Check if identifier is a number (ID) or string (name) - if (/^\d+$/.test(identifier)) { - // It's a numeric ID - whereClause = { - id: parseInt(identifier), - 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 }; - } + const param = decodeURIComponent(req.params.identifier); + let whereClause = { + [Op.or]: [ + { name: param, user_id: req.currentUser.id }, + { uid: param, user_id: req.currentUser.id }, + ], + }; const tag = await Tag.findOne({ where: whereClause, }); - if (!tag) { + if (_.isEmpty(tag)) { await transaction.rollback(); return res.status(404).json({ error: 'Tag not found' }); } - // Use transaction to ensure all deletions happen atomically // Remove all associations before deleting the tag by manually deleting from junction tables // Only delete from tables that exist - try { - await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', { + await Promise.all([ + sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', { replacements: [tag.id], type: sequelize.QueryTypes.DELETE, transaction, - }); - } catch (error) { - // Ignore if table doesn't exist - console.log('tasks_tags table not found, skipping'); - } - - try { - await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', { + }), + sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', { replacements: [tag.id], type: sequelize.QueryTypes.DELETE, transaction, - }); - } catch (error) { - // Ignore if table doesn't exist - console.log('notes_tags table not found, skipping'); - } + }), + sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', { + replacements: [tag.id], + type: sequelize.QueryTypes.DELETE, + transaction, + }), + ]); - try { - await sequelize.query( - 'DELETE FROM projects_tags WHERE tag_id = ?', - { - replacements: [tag.id], - type: sequelize.QueryTypes.DELETE, - transaction, - } - ); - } catch (error) { - // Ignore if table doesn't exist - console.log('projects_tags table not found, skipping'); - } - - // Now safely delete the tag await tag.destroy({ transaction }); - await transaction.commit(); + res.json({ message: 'Tag successfully deleted' }); } catch (error) { await transaction.rollback(); - console.error('Error deleting tag:', error); + logError('Error deleting tag:', error); res.status(400).json({ error: 'There was a problem deleting the tag.', }); diff --git a/backend/routes/task-events.js b/backend/routes/task-events.js index 839da11..16a26df 100644 --- a/backend/routes/task-events.js +++ b/backend/routes/task-events.js @@ -1,34 +1,53 @@ const express = require('express'); -const { TaskEvent } = require('../models'); +const { Task, TaskEvent } = require('../models'); +const { isValidUid } = require('../utils/slug-utils'); const { getTaskTimeline, getTaskCompletionTime, getUserProductivityMetrics, getTaskActivitySummary, } = require('../services/taskEventService'); +const { logError } = require('../services/logService'); const router = express.Router(); -// GET /api/task/:id/timeline - Get task event timeline -router.get('/task/:id/timeline', async (req, res) => { +// GET /api/task/:uid/timeline - Get task event timeline +router.get('/task/:uid/timeline', async (req, res) => { try { - const timeline = await getTaskTimeline(req.params.id); + if (!isValidUid(req.params.uid)) + return res.status(400).json({ error: 'Invalid UID' }); - // Filter to only show events for tasks owned by the current user - const userTimeline = timeline.filter( - (event) => event.user_id === req.currentUser.id - ); + const task = await Task.findOne({ + where: { uid: req.params.uid, user_id: req.currentUser.id }, + }); - res.json(userTimeline); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + const timeline = await getTaskTimeline(task.id); + + res.json(timeline); } catch (error) { - console.error('Error fetching task timeline:', error); + logError('Error fetching task timeline:', error); res.status(500).json({ error: 'Failed to fetch task timeline' }); } }); -// GET /api/task/:id/completion-time - Get task completion analytics -router.get('/task/:id/completion-time', async (req, res) => { +// GET /api/task/:uid/completion-time - Get task completion analytics +router.get('/task/:uid/completion-time', async (req, res) => { try { - const completionTime = await getTaskCompletionTime(req.params.id); + if (!isValidUid(req.params.uid)) + return res.status(400).json({ error: 'Invalid UID' }); + + const task = await Task.findOne({ + where: { uid: req.params.uid, user_id: req.currentUser.id }, + }); + + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + const completionTime = await getTaskCompletionTime(task.id); if (!completionTime) { return res @@ -38,7 +57,7 @@ router.get('/task/:id/completion-time', async (req, res) => { res.json(completionTime); } catch (error) { - console.error('Error fetching task completion time:', error); + logError('Error fetching task completion time:', error); res.status(500).json({ error: 'Failed to fetch task completion time' }); } }); @@ -56,7 +75,7 @@ router.get('/user/productivity-metrics', async (req, res) => { res.json(metrics); } catch (error) { - console.error('Error fetching productivity metrics:', error); + logError('Error fetching productivity metrics:', error); res.status(500).json({ error: 'Failed to fetch productivity metrics' }); } }); @@ -80,7 +99,7 @@ router.get('/user/activity-summary', async (req, res) => { res.json(activitySummary); } catch (error) { - console.error('Error fetching activity summary:', error); + logError('Error fetching activity summary:', error); res.status(500).json({ error: 'Failed to fetch activity summary' }); } }); @@ -88,7 +107,7 @@ router.get('/user/activity-summary', async (req, res) => { // GET /api/tasks/completion-analytics - Get completion time analytics for multiple tasks router.get('/tasks/completion-analytics', async (req, res) => { try { - const { limit = 50, offset = 0, projectId } = req.query; + const { limit = 50, offset = 0, projectUid } = req.query; // Get completed tasks for the user const { Task, Project } = require('../models'); @@ -99,8 +118,21 @@ router.get('/tasks/completion-analytics', async (req, res) => { status: 2, // completed }; - if (projectId) { - whereClause.project_id = projectId; + // If projectUid is provided, find the project and filter by its ID + if (projectUid) { + if (!isValidUid(projectUid)) { + return res.status(400).json({ error: 'Invalid project UID' }); + } + + const project = await Project.findOne({ + where: { uid: projectUid, user_id: req.currentUser.id }, + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + whereClause.project_id = project.id; } const completedTasks = await Task.findAll({ @@ -163,7 +195,7 @@ router.get('/tasks/completion-analytics', async (req, res) => { summary, }); } catch (error) { - console.error('Error fetching completion analytics:', error); + logError('Error fetching completion analytics:', error); res.status(500).json({ error: 'Failed to fetch completion analytics' }); } }); diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 947e017..426988d 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -20,7 +20,7 @@ const { logTaskUpdate, getTaskTodayMoveCount, } = require('../services/taskEventService'); -const { validateTagName } = require('../utils/validation'); +const { validateTagName } = require('../services/tagsService'); const { getSafeTimezone, getUpcomingRangeInUTC, @@ -532,7 +532,7 @@ async function filterTasksByParams(params, userId, userTimezone) { }, { model: Project, - attributes: ['id', 'name', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -742,7 +742,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -787,7 +787,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -845,7 +845,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { @@ -914,7 +914,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -959,7 +959,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -1015,7 +1015,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'uid'], required: false, }, { @@ -1069,7 +1069,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') { }, { model: Project, - attributes: ['id', 'name', 'active', 'uid'], + attributes: ['id', 'name', 'state', 'uid'], required: false, }, { diff --git a/backend/routes/users.js b/backend/routes/users.js index 5b97fbf..7edb9d3 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -23,7 +23,7 @@ router.get('/profile', async (req, res) => { const user = await User.findByPk(req.session.userId, { attributes: [ - 'id', + 'uid', 'email', 'appearance', 'language', @@ -172,7 +172,7 @@ router.patch('/profile', async (req, res) => { // Return updated user with limited fields const updatedUser = await User.findByPk(user.id, { attributes: [ - 'id', + 'uid', 'email', 'appearance', 'language', diff --git a/backend/seeders/dev-seeder.js b/backend/seeders/dev-seeder.js index 47506e7..0b9f4d0 100644 --- a/backend/seeders/dev-seeder.js +++ b/backend/seeders/dev-seeder.js @@ -65,7 +65,7 @@ async function seedDatabase() { description: 'Complete overhaul of company website', user_id: testUser.id, area_id: areas[1].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now }, { @@ -73,14 +73,14 @@ async function seedDatabase() { description: 'Master mobile app development', user_id: testUser.id, area_id: areas[3].id, - active: true, + state: 'in_progress', }, { name: 'Home Renovation', description: 'Kitchen and bathroom updates', user_id: testUser.id, area_id: areas[4].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), // 60 days from now }, { @@ -88,7 +88,7 @@ async function seedDatabase() { description: '90-day fitness transformation', user_id: testUser.id, area_id: areas[2].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now }, { @@ -96,14 +96,14 @@ async function seedDatabase() { description: 'Launch online consulting service', user_id: testUser.id, area_id: areas[1].id, - active: true, + state: 'in_progress', }, { name: 'Investment Portfolio', description: 'Build diversified investment portfolio', user_id: testUser.id, area_id: areas[5].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 120 * 24 * 60 * 60 * 1000), // 120 days from now }, { @@ -111,7 +111,7 @@ async function seedDatabase() { description: 'Plan and execute 3-week European vacation', user_id: testUser.id, area_id: areas[6].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000), // 180 days from now }, { @@ -119,14 +119,14 @@ async function seedDatabase() { description: 'Learn advanced photography techniques', user_id: testUser.id, area_id: areas[7].id, - active: true, + state: 'in_progress', }, { name: 'Professional Certification', description: 'Get AWS Solutions Architect certification', user_id: testUser.id, area_id: areas[9].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 150 * 24 * 60 * 60 * 1000), // 150 days from now }, { @@ -134,7 +134,7 @@ async function seedDatabase() { description: 'Transform backyard into productive garden', user_id: testUser.id, area_id: areas[4].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), // 45 days from now }, { @@ -142,21 +142,21 @@ async function seedDatabase() { description: 'Start personal tech blog', user_id: testUser.id, area_id: areas[0].id, - active: true, + state: 'in_progress', }, { name: 'Language Learning Spanish', description: 'Become conversational in Spanish', user_id: testUser.id, area_id: areas[3].id, - active: false, // Paused project + state: 'blocked', // Paused project }, { name: 'Wedding Planning', description: 'Plan and organize wedding ceremony', user_id: testUser.id, area_id: areas[8].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now }, { @@ -164,14 +164,14 @@ async function seedDatabase() { description: 'Establish weekly meal preparation routine', user_id: testUser.id, area_id: areas[2].id, - active: true, + state: 'in_progress', }, { name: 'Smart Home Setup', description: 'Install and configure smart home devices', user_id: testUser.id, area_id: areas[4].id, - active: true, + state: 'in_progress', due_date_at: new Date(Date.now() + 21 * 24 * 60 * 60 * 1000), // 21 days from now }, ]); diff --git a/backend/services/logService.js b/backend/services/logService.js new file mode 100644 index 0000000..69d7696 --- /dev/null +++ b/backend/services/logService.js @@ -0,0 +1,9 @@ +const logError = console.error; +const logInfo = console.log; +const logDebug = console.log; + +module.exports = { + logError, + logInfo, + logDebug, +}; diff --git a/backend/utils/validation.js b/backend/services/tagsService.js similarity index 100% rename from backend/utils/validation.js rename to backend/services/tagsService.js diff --git a/backend/services/taskEventService.js b/backend/services/taskEventService.js index 6a7ef39..77e16e5 100644 --- a/backend/services/taskEventService.js +++ b/backend/services/taskEventService.js @@ -1,11 +1,5 @@ const { TaskEvent } = require('../models'); -// Helper function to add default source to metadata -const addDefaultSource = (metadata) => ({ - source: 'web', - ...metadata, -}); - // Helper function to create value object const createValueObject = (fieldName, value) => value ? { [fieldName || 'value']: value } : null; @@ -31,7 +25,10 @@ const logEvent = async ({ metadata = {}, }) => { try { - const finalMetadata = addDefaultSource(metadata); + const finalMetadata = { + source: 'web', + ...metadata, + }; const event = await TaskEvent.create({ task_id: taskId, diff --git a/backend/tests/integration/areas.test.js b/backend/tests/integration/areas.test.js index 4741124..c0de85b 100644 --- a/backend/tests/integration/areas.test.js +++ b/backend/tests/integration/areas.test.js @@ -31,7 +31,8 @@ describe('Areas Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe(areaData.name); expect(response.body.description).toBe(areaData.description); - expect(response.body.user_id).toBe(user.id); + expect(response.body.uid).toBeDefined(); + expect(typeof response.body.uid).toBe('string'); }); it('should require authentication', async () => { @@ -81,8 +82,8 @@ describe('Areas Routes', () => { expect(response.status).toBe(200); expect(response.body).toHaveLength(2); - expect(response.body.map((a) => a.id)).toContain(area1.id); - expect(response.body.map((a) => a.id)).toContain(area2.id); + expect(response.body.map((a) => a.uid)).toContain(area1.uid); + expect(response.body.map((a) => a.uid)).toContain(area2.uid); }); it('should order areas by name', async () => { @@ -101,7 +102,7 @@ describe('Areas Routes', () => { }); }); - describe('GET /api/areas/:id', () => { + describe('GET /api/areas/:uid', () => { let area; beforeEach(async () => { @@ -112,17 +113,24 @@ describe('Areas Routes', () => { }); }); - it('should get area by id', async () => { - const response = await agent.get(`/api/areas/${area.id}`); + it('should get area by uid', async () => { + const response = await agent.get(`/api/areas/${area.uid}`); expect(response.status).toBe(200); - expect(response.body.id).toBe(area.id); + expect(response.body.uid).toBe(area.uid); expect(response.body.name).toBe(area.name); expect(response.body.description).toBe(area.description); }); + it('should return 400 for invalid uid format', async () => { + const response = await agent.get('/api/areas/invalid-uid'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + it('should return 404 for non-existent area', async () => { - const response = await agent.get('/api/areas/999999'); + const response = await agent.get('/api/areas/abcd1234efghijk'); expect(response.status).toBe(404); expect(response.body.error).toBe( @@ -142,7 +150,7 @@ describe('Areas Routes', () => { user_id: otherUser.id, }); - const response = await agent.get(`/api/areas/${otherArea.id}`); + const response = await agent.get(`/api/areas/${otherArea.uid}`); expect(response.status).toBe(404); expect(response.body.error).toBe( @@ -151,14 +159,14 @@ describe('Areas Routes', () => { }); it('should require authentication', async () => { - const response = await request(app).get(`/api/areas/${area.id}`); + const response = await request(app).get(`/api/areas/${area.uid}`); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); }); }); - describe('PATCH /api/areas/:id', () => { + describe('PATCH /api/areas/:uid', () => { let area; beforeEach(async () => { @@ -176,7 +184,7 @@ describe('Areas Routes', () => { }; const response = await agent - .patch(`/api/areas/${area.id}`) + .patch(`/api/areas/${area.uid}`) .send(updateData); expect(response.status).toBe(200); @@ -184,9 +192,18 @@ describe('Areas Routes', () => { expect(response.body.description).toBe(updateData.description); }); + it('should return 400 for invalid uid format', async () => { + const response = await agent + .patch('/api/areas/invalid-uid') + .send({ name: 'Updated' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + it('should return 404 for non-existent area', async () => { const response = await agent - .patch('/api/areas/999999') + .patch('/api/areas/abcd1234efghijk') .send({ name: 'Updated' }); expect(response.status).toBe(404); @@ -206,7 +223,7 @@ describe('Areas Routes', () => { }); const response = await agent - .patch(`/api/areas/${otherArea.id}`) + .patch(`/api/areas/${otherArea.uid}`) .send({ name: 'Updated' }); expect(response.status).toBe(404); @@ -215,7 +232,7 @@ describe('Areas Routes', () => { it('should require authentication', async () => { const response = await request(app) - .patch(`/api/areas/${area.id}`) + .patch(`/api/areas/${area.uid}`) .send({ name: 'Updated' }); expect(response.status).toBe(401); @@ -223,7 +240,7 @@ describe('Areas Routes', () => { }); }); - describe('DELETE /api/areas/:id', () => { + describe('DELETE /api/areas/:uid', () => { let area; beforeEach(async () => { @@ -234,17 +251,26 @@ describe('Areas Routes', () => { }); it('should delete area', async () => { - const response = await agent.delete(`/api/areas/${area.id}`); + const response = await agent.delete(`/api/areas/${area.uid}`); expect(response.status).toBe(204); // Verify area is deleted - const deletedArea = await Area.findByPk(area.id); + const deletedArea = await Area.findOne({ + where: { uid: area.uid }, + }); expect(deletedArea).toBeNull(); }); + it('should return 400 for invalid uid format', async () => { + const response = await agent.delete('/api/areas/invalid-uid'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + it('should return 404 for non-existent area', async () => { - const response = await agent.delete('/api/areas/999999'); + const response = await agent.delete('/api/areas/abcd1234efghijk'); expect(response.status).toBe(404); expect(response.body.error).toBe('Area not found.'); @@ -262,14 +288,16 @@ describe('Areas Routes', () => { user_id: otherUser.id, }); - const response = await agent.delete(`/api/areas/${otherArea.id}`); + const response = await agent.delete(`/api/areas/${otherArea.uid}`); expect(response.status).toBe(404); expect(response.body.error).toBe('Area not found.'); }); it('should require authentication', async () => { - const response = await request(app).delete(`/api/areas/${area.id}`); + const response = await request(app).delete( + `/api/areas/${area.uid}` + ); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); diff --git a/backend/tests/integration/auth.test.js b/backend/tests/integration/auth.test.js index 43e19e0..76ed618 100644 --- a/backend/tests/integration/auth.test.js +++ b/backend/tests/integration/auth.test.js @@ -22,7 +22,8 @@ describe('Auth Routes', () => { expect(response.status).toBe(200); expect(response.body.user).toBeDefined(); expect(response.body.user.email).toBe('test@example.com'); - expect(response.body.user.id).toBe(user.id); + expect(response.body.user.uid).toBe(user.uid); + expect(response.body.user).not.toHaveProperty('id'); expect(response.body.user.language).toBe('en'); expect(response.body.user.appearance).toBe('light'); expect(response.body.user.timezone).toBe('UTC'); @@ -91,7 +92,8 @@ describe('Auth Routes', () => { expect(response.status).toBe(200); expect(response.body.user).toBeDefined(); expect(response.body.user.email).toBe('test@example.com'); - expect(response.body.user.id).toBe(user.id); + expect(response.body.user.uid).toBe(user.uid); + expect(response.body.user).not.toHaveProperty('id'); }); it('should return null user when not logged in', async () => { diff --git a/backend/tests/integration/inbox-no-tags.test.js b/backend/tests/integration/inbox-no-tags.test.js index ada0b32..7e53483 100644 --- a/backend/tests/integration/inbox-no-tags.test.js +++ b/backend/tests/integration/inbox-no-tags.test.js @@ -52,15 +52,16 @@ describe('Inbox Routes - No Tags Scenario', () => { expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBe(2); - expect(response.body.map((item) => item.id)).toContain( - inboxItem1.id + expect(response.body.map((item) => item.uid)).toContain( + inboxItem1.uid ); - expect(response.body.map((item) => item.id)).toContain( - inboxItem2.id + expect(response.body.map((item) => item.uid)).toContain( + inboxItem2.uid ); expect(response.body[0].content).toBeDefined(); expect(response.body[0].status).toBe('added'); - expect(response.body[0].user_id).toBe(user.id); + expect(response.body[0].uid).toBeDefined(); + expect(typeof response.body[0].uid).toBe('string'); }); it('should handle mixed inbox items when no tags exist', async () => { @@ -90,7 +91,7 @@ describe('Inbox Routes - No Tags Scenario', () => { expect(response.status).toBe(200); expect(response.body.length).toBe(1); // Only 'added' items should be returned - expect(response.body[0].id).toBe(addedItem.id); + expect(response.body[0].uid).toBe(addedItem.uid); expect(response.body[0].status).toBe('added'); }); }); @@ -139,7 +140,8 @@ describe('Inbox Routes - No Tags Scenario', () => { expect(response.body.content).toBe(inboxData.content); expect(response.body.source).toBe(inboxData.source); expect(response.body.status).toBe('added'); - expect(response.body.user_id).toBe(user.id); + expect(response.body.uid).toBeDefined(); + expect(typeof response.body.uid).toBe('string'); }); it('should handle multiple inbox items creation when no tags exist', async () => { @@ -164,7 +166,7 @@ describe('Inbox Routes - No Tags Scenario', () => { }); }); - describe('PATCH /api/inbox/:id - No Tags Scenario', () => { + describe('PATCH /api/inbox/:uid - No Tags Scenario', () => { let inboxItem; beforeEach(async () => { @@ -183,7 +185,7 @@ describe('Inbox Routes - No Tags Scenario', () => { }; const response = await agent - .patch(`/api/inbox/${inboxItem.id}`) + .patch(`/api/inbox/${inboxItem.uid}`) .send(updateData); expect(response.status).toBe(200); @@ -192,7 +194,7 @@ describe('Inbox Routes - No Tags Scenario', () => { }); }); - describe('PATCH /api/inbox/:id/process - No Tags Scenario', () => { + describe('PATCH /api/inbox/:uid/process - No Tags Scenario', () => { let inboxItem; beforeEach(async () => { @@ -206,7 +208,7 @@ describe('Inbox Routes - No Tags Scenario', () => { it('should process inbox items when no tags exist', async () => { const response = await agent.patch( - `/api/inbox/${inboxItem.id}/process` + `/api/inbox/${inboxItem.uid}/process` ); expect(response.status).toBe(200); @@ -214,7 +216,7 @@ describe('Inbox Routes - No Tags Scenario', () => { }); }); - describe('DELETE /api/inbox/:id - No Tags Scenario', () => { + describe('DELETE /api/inbox/:uid - No Tags Scenario', () => { let inboxItem; beforeEach(async () => { @@ -227,7 +229,7 @@ describe('Inbox Routes - No Tags Scenario', () => { }); it('should delete inbox items when no tags exist', async () => { - const response = await agent.delete(`/api/inbox/${inboxItem.id}`); + const response = await agent.delete(`/api/inbox/${inboxItem.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe( @@ -235,7 +237,9 @@ describe('Inbox Routes - No Tags Scenario', () => { ); // Verify item status is updated to deleted - const deletedItem = await InboxItem.findByPk(inboxItem.id); + const deletedItem = await InboxItem.findOne({ + where: { uid: inboxItem.uid }, + }); expect(deletedItem).not.toBeNull(); expect(deletedItem.status).toBe('deleted'); }); @@ -253,24 +257,24 @@ describe('Inbox Routes - No Tags Scenario', () => { .post('/api/inbox') .send({ content: 'Complete workflow test', source: 'web' }); expect(createResponse.status).toBe(201); - const itemId = createResponse.body.id; + const itemUid = createResponse.body.uid; // Step 3: Retrieve inbox items const getResponse = await agent.get('/api/inbox'); expect(getResponse.status).toBe(200); expect(getResponse.body.length).toBe(1); - expect(getResponse.body[0].id).toBe(itemId); + expect(getResponse.body[0].uid).toBe(itemUid); // Step 4: Update inbox item const updateResponse = await agent - .patch(`/api/inbox/${itemId}`) + .patch(`/api/inbox/${itemUid}`) .send({ content: 'Updated workflow test' }); expect(updateResponse.status).toBe(200); expect(updateResponse.body.content).toBe('Updated workflow test'); // Step 5: Process inbox item const processResponse = await agent.patch( - `/api/inbox/${itemId}/process` + `/api/inbox/${itemUid}/process` ); expect(processResponse.status).toBe(200); expect(processResponse.body.status).toBe('processed'); @@ -303,9 +307,11 @@ describe('Inbox Routes - No Tags Scenario', () => { expect(getResponse.body.length).toBe(5); // Process all items concurrently - const itemIds = createResponses.map((response) => response.body.id); - const processPromises = itemIds.map((id) => - agent.patch(`/api/inbox/${id}/process`) + const itemUids = createResponses.map( + (response) => response.body.uid + ); + const processPromises = itemUids.map((uid) => + agent.patch(`/api/inbox/${uid}/process`) ); const processResponses = await Promise.all(processPromises); @@ -326,26 +332,28 @@ describe('Inbox Routes - No Tags Scenario', () => { describe('Error Handling - No Tags Scenario', () => { it('should handle invalid inbox item operations gracefully when no tags exist', async () => { // Try to get non-existent item - const getResponse = await agent.get('/api/inbox/999999'); - expect(getResponse.status).toBe(404); - expect(getResponse.body.error).toBe('Inbox item not found.'); + const getResponse = await agent.get('/api/inbox/invalid-uid'); + expect(getResponse.status).toBe(400); + expect(getResponse.body.error).toBe('Invalid UID'); // Try to update non-existent item const updateResponse = await agent - .patch('/api/inbox/999999') + .patch('/api/inbox/abcd1234efghijk') .send({ content: 'Updated' }); expect(updateResponse.status).toBe(404); expect(updateResponse.body.error).toBe('Inbox item not found.'); // Try to process non-existent item const processResponse = await agent.patch( - '/api/inbox/999999/process' + '/api/inbox/abcd1234efghijk/process' ); expect(processResponse.status).toBe(404); expect(processResponse.body.error).toBe('Inbox item not found.'); // Try to delete non-existent item - const deleteResponse = await agent.delete('/api/inbox/999999'); + const deleteResponse = await agent.delete( + '/api/inbox/abcd1234efghijk' + ); expect(deleteResponse.status).toBe(404); expect(deleteResponse.body.error).toBe('Inbox item not found.'); }); diff --git a/backend/tests/integration/inbox.test.js b/backend/tests/integration/inbox.test.js index 3f00166..3956daf 100644 --- a/backend/tests/integration/inbox.test.js +++ b/backend/tests/integration/inbox.test.js @@ -32,7 +32,8 @@ describe('Inbox Routes', () => { expect(response.body.content).toBe(inboxData.content); expect(response.body.source).toBe(inboxData.source); expect(response.body.status).toBe('added'); - expect(response.body.user_id).toBe(user.id); + expect(response.body.uid).toBeDefined(); + expect(typeof response.body.uid).toBe('string'); }); it('should require authentication', async () => { @@ -83,7 +84,7 @@ describe('Inbox Routes', () => { expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBe(1); // Only items with status 'added' are returned - expect(response.body.map((i) => i.id)).toContain(inboxItem1.id); + expect(response.body.map((i) => i.uid)).toContain(inboxItem1.uid); }); it('should only return items with added status', async () => { @@ -91,7 +92,7 @@ describe('Inbox Routes', () => { expect(response.status).toBe(200); expect(response.body.length).toBe(1); - expect(response.body[0].id).toBe(inboxItem1.id); + expect(response.body[0].uid).toBe(inboxItem1.uid); expect(response.body[0].status).toBe('added'); }); @@ -129,9 +130,9 @@ describe('Inbox Routes', () => { expect(response.body.length).toBe(4); // Including the item from beforeEach // Check that items are ordered by newest first - expect(response.body[0].id).toBe(item3.id); - expect(response.body[1].id).toBe(item2.id); - expect(response.body[2].id).toBe(item1.id); + expect(response.body[0].uid).toBe(item3.uid); + expect(response.body[1].uid).toBe(item2.uid); + expect(response.body[2].uid).toBe(item1.uid); // Verify the content matches expected order expect(response.body[0].content).toBe('Third item (newest)'); @@ -147,7 +148,7 @@ describe('Inbox Routes', () => { }); }); - describe('GET /api/inbox/:id', () => { + describe('GET /api/inbox/:uid', () => { let inboxItem; beforeEach(async () => { @@ -158,16 +159,23 @@ describe('Inbox Routes', () => { }); }); - it('should get inbox item by id', async () => { - const response = await agent.get(`/api/inbox/${inboxItem.id}`); + it('should get inbox item by uid', async () => { + const response = await agent.get(`/api/inbox/${inboxItem.uid}`); expect(response.status).toBe(200); - expect(response.body.id).toBe(inboxItem.id); + expect(response.body.uid).toBe(inboxItem.uid); expect(response.body.content).toBe(inboxItem.content); }); + it('should return 400 for invalid uid format', async () => { + const response = await agent.get('/api/inbox/invalid-uid'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + it('should return 404 for non-existent inbox item', async () => { - const response = await agent.get('/api/inbox/999999'); + const response = await agent.get('/api/inbox/abcd1234efghijk'); expect(response.status).toBe(404); expect(response.body.error).toBe('Inbox item not found.'); @@ -186,7 +194,9 @@ describe('Inbox Routes', () => { user_id: otherUser.id, }); - const response = await agent.get(`/api/inbox/${otherInboxItem.id}`); + const response = await agent.get( + `/api/inbox/${otherInboxItem.uid}` + ); expect(response.status).toBe(404); expect(response.body.error).toBe('Inbox item not found.'); @@ -194,7 +204,7 @@ describe('Inbox Routes', () => { it('should require authentication', async () => { const response = await request(app).get( - `/api/inbox/${inboxItem.id}` + `/api/inbox/${inboxItem.uid}` ); expect(response.status).toBe(401); @@ -202,7 +212,7 @@ describe('Inbox Routes', () => { }); }); - describe('PATCH /api/inbox/:id', () => { + describe('PATCH /api/inbox/:uid', () => { let inboxItem; beforeEach(async () => { @@ -221,7 +231,7 @@ describe('Inbox Routes', () => { }; const response = await agent - .patch(`/api/inbox/${inboxItem.id}`) + .patch(`/api/inbox/${inboxItem.uid}`) .send(updateData); expect(response.status).toBe(200); @@ -229,9 +239,18 @@ describe('Inbox Routes', () => { expect(response.body.status).toBe(updateData.status); }); + it('should return 400 for invalid uid format', async () => { + const response = await agent + .patch('/api/inbox/invalid-uid') + .send({ content: 'Updated' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + it('should return 404 for non-existent inbox item', async () => { const response = await agent - .patch('/api/inbox/999999') + .patch('/api/inbox/abcd1234efghijk') .send({ content: 'Updated' }); expect(response.status).toBe(404); @@ -240,7 +259,7 @@ describe('Inbox Routes', () => { it('should require authentication', async () => { const response = await request(app) - .patch(`/api/inbox/${inboxItem.id}`) + .patch(`/api/inbox/${inboxItem.uid}`) .send({ content: 'Updated' }); expect(response.status).toBe(401); @@ -248,7 +267,7 @@ describe('Inbox Routes', () => { }); }); - describe('DELETE /api/inbox/:id', () => { + describe('DELETE /api/inbox/:uid', () => { let inboxItem; beforeEach(async () => { @@ -260,7 +279,7 @@ describe('Inbox Routes', () => { }); it('should delete inbox item', async () => { - const response = await agent.delete(`/api/inbox/${inboxItem.id}`); + const response = await agent.delete(`/api/inbox/${inboxItem.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe( @@ -268,13 +287,22 @@ describe('Inbox Routes', () => { ); // Verify inbox item status is updated to deleted - const deletedItem = await InboxItem.findByPk(inboxItem.id); + const deletedItem = await InboxItem.findOne({ + where: { uid: inboxItem.uid }, + }); expect(deletedItem).not.toBeNull(); expect(deletedItem.status).toBe('deleted'); }); + it('should return 400 for invalid uid format', async () => { + const response = await agent.delete('/api/inbox/invalid-uid'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + it('should return 404 for non-existent inbox item', async () => { - const response = await agent.delete('/api/inbox/999999'); + const response = await agent.delete('/api/inbox/abcd1234efghijk'); expect(response.status).toBe(404); expect(response.body.error).toBe('Inbox item not found.'); @@ -282,7 +310,7 @@ describe('Inbox Routes', () => { it('should require authentication', async () => { const response = await request(app).delete( - `/api/inbox/${inboxItem.id}` + `/api/inbox/${inboxItem.uid}` ); expect(response.status).toBe(401); @@ -290,7 +318,7 @@ describe('Inbox Routes', () => { }); }); - describe('PATCH /api/inbox/:id/process', () => { + describe('PATCH /api/inbox/:uid/process', () => { let inboxItem; beforeEach(async () => { @@ -304,7 +332,7 @@ describe('Inbox Routes', () => { it('should process inbox item', async () => { const response = await agent.patch( - `/api/inbox/${inboxItem.id}/process` + `/api/inbox/${inboxItem.uid}/process` ); expect(response.status).toBe(200); @@ -312,7 +340,18 @@ describe('Inbox Routes', () => { }); it('should return 404 for non-existent inbox item', async () => { - const response = await agent.patch('/api/inbox/999999/process'); + const response = await agent.patch( + '/api/inbox/invalid-uid/process' + ); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid UID'); + }); + + it('should return 404 for non-existent item', async () => { + const response = await agent.patch( + '/api/inbox/abcd1234efghijk/process' + ); expect(response.status).toBe(404); expect(response.body.error).toBe('Inbox item not found.'); @@ -320,7 +359,7 @@ describe('Inbox Routes', () => { it('should require authentication', async () => { const response = await request(app).patch( - `/api/inbox/${inboxItem.id}/process` + `/api/inbox/${inboxItem.uid}/process` ); expect(response.status).toBe(401); diff --git a/backend/tests/integration/notes.test.js b/backend/tests/integration/notes.test.js index 29734c4..15bf5d4 100644 --- a/backend/tests/integration/notes.test.js +++ b/backend/tests/integration/notes.test.js @@ -29,7 +29,7 @@ describe('Notes Routes', () => { const noteData = { title: 'Test Note', content: 'This is a test note content', - project_id: project.id, + project_uid: project.uid, }; const response = await agent.post('/api/note').send(noteData); @@ -140,7 +140,7 @@ describe('Notes Routes', () => { }); it('should get note by id', async () => { - const response = await agent.get(`/api/note/${note.id}`); + const response = await agent.get(`/api/note/${note.uid}`); expect(response.status).toBe(200); expect(response.body.id).toBe(note.id); @@ -149,7 +149,7 @@ describe('Notes Routes', () => { }); it('should return 404 for non-existent note', async () => { - const response = await agent.get('/api/note/999999'); + const response = await agent.get('/api/note/abcd1234efghijk'); expect(response.status).toBe(404); expect(response.body.error).toBe('Note not found.'); @@ -167,14 +167,14 @@ describe('Notes Routes', () => { user_id: otherUser.id, }); - const response = await agent.get(`/api/note/${otherNote.id}`); + const response = await agent.get(`/api/note/${otherNote.uid}`); expect(response.status).toBe(403); expect(response.body.error).toBe('Forbidden'); }); it('should require authentication', async () => { - const response = await request(app).get(`/api/note/${note.id}`); + const response = await request(app).get(`/api/note/${note.uid}`); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); @@ -196,11 +196,11 @@ describe('Notes Routes', () => { const updateData = { title: 'Updated Note', content: 'Updated content', - project_id: project.id, + project_uid: project.uid, }; const response = await agent - .patch(`/api/note/${note.id}`) + .patch(`/api/note/${note.uid}`) .send(updateData); expect(response.status).toBe(200); @@ -211,7 +211,7 @@ describe('Notes Routes', () => { it('should return 404 for non-existent note', async () => { const response = await agent - .patch('/api/note/999999') + .patch('/api/note/abcd1234efghijk') .send({ title: 'Updated' }); expect(response.status).toBe(404); @@ -231,7 +231,7 @@ describe('Notes Routes', () => { }); const response = await agent - .patch(`/api/note/${otherNote.id}`) + .patch(`/api/note/${otherNote.uid}`) .send({ title: 'Updated' }); expect(response.status).toBe(403); @@ -240,7 +240,7 @@ describe('Notes Routes', () => { it('should require authentication', async () => { const response = await request(app) - .patch(`/api/note/${note.id}`) + .patch(`/api/note/${note.uid}`) .send({ title: 'Updated' }); expect(response.status).toBe(401); @@ -259,7 +259,7 @@ describe('Notes Routes', () => { }); it('should delete note', async () => { - const response = await agent.delete(`/api/note/${note.id}`); + const response = await agent.delete(`/api/note/${note.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe('Note deleted successfully.'); @@ -270,7 +270,7 @@ describe('Notes Routes', () => { }); it('should return 404 for non-existent note', async () => { - const response = await agent.delete('/api/note/999999'); + const response = await agent.delete('/api/note/abcd1234efghijk'); expect(response.status).toBe(404); expect(response.body.error).toBe('Note not found.'); @@ -288,14 +288,14 @@ describe('Notes Routes', () => { user_id: otherUser.id, }); - const response = await agent.delete(`/api/note/${otherNote.id}`); + const response = await agent.delete(`/api/note/${otherNote.uid}`); expect(response.status).toBe(403); expect(response.body.error).toBe('Forbidden'); }); it('should require authentication', async () => { - const response = await request(app).delete(`/api/note/${note.id}`); + const response = await request(app).delete(`/api/note/${note.uid}`); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); diff --git a/backend/tests/integration/projects.test.js b/backend/tests/integration/projects.test.js index fd65e4b..b8c0454 100644 --- a/backend/tests/integration/projects.test.js +++ b/backend/tests/integration/projects.test.js @@ -29,7 +29,7 @@ describe('Projects Routes', () => { const projectData = { name: 'Test Project', description: 'Test Description', - active: true, + state: 'planned', pin_to_sidebar: false, priority: 1, area_id: area.id, @@ -40,7 +40,7 @@ describe('Projects Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe(projectData.name); expect(response.body.description).toBe(projectData.description); - expect(response.body.active).toBe(projectData.active); + expect(response.body.state).toBe(projectData.state); expect(response.body.pin_to_sidebar).toBe( projectData.pin_to_sidebar ); @@ -217,7 +217,7 @@ describe('Projects Routes', () => { project = await Project.create({ name: 'Test Project', description: 'Test Description', - active: false, + state: 'idea', priority: 0, user_id: user.id, }); @@ -227,24 +227,24 @@ describe('Projects Routes', () => { const updateData = { name: 'Updated Project', description: 'Updated Description', - active: true, + state: 'in_progress', priority: 2, }; const response = await agent - .patch(`/api/project/${project.id}`) + .patch(`/api/project/${project.uid}`) .send(updateData); expect(response.status).toBe(200); expect(response.body.name).toBe(updateData.name); expect(response.body.description).toBe(updateData.description); - expect(response.body.active).toBe(updateData.active); + expect(response.body.state).toBe(updateData.state); expect(response.body.priority).toBe(updateData.priority); }); it('should return 404 for non-existent project', async () => { const response = await agent - .patch('/api/project/999999') + .patch('/api/project/nonexistentuid') .send({ name: 'Updated' }); expect(response.status).toBe(404); @@ -264,7 +264,7 @@ describe('Projects Routes', () => { }); const response = await agent - .patch(`/api/project/${otherProject.id}`) + .patch(`/api/project/${otherProject.uid}`) .send({ name: 'Updated' }); expect(response.status).toBe(403); @@ -273,7 +273,7 @@ describe('Projects Routes', () => { it('should require authentication', async () => { const response = await request(app) - .patch(`/api/project/${project.id}`) + .patch(`/api/project/${project.uid}`) .send({ name: 'Updated' }); expect(response.status).toBe(401); @@ -292,7 +292,7 @@ describe('Projects Routes', () => { }); it('should delete project', async () => { - const response = await agent.delete(`/api/project/${project.id}`); + const response = await agent.delete(`/api/project/${project.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe('Project successfully deleted'); @@ -303,7 +303,7 @@ describe('Projects Routes', () => { }); it('should return 404 for non-existent project', async () => { - const response = await agent.delete('/api/project/999999'); + const response = await agent.delete('/api/project/nonexistentuid'); expect(response.status).toBe(404); expect(response.body.error).toBe('Project not found.'); @@ -322,7 +322,7 @@ describe('Projects Routes', () => { }); const response = await agent.delete( - `/api/project/${otherProject.id}` + `/api/project/${otherProject.uid}` ); expect(response.status).toBe(403); @@ -331,7 +331,7 @@ describe('Projects Routes', () => { it('should require authentication', async () => { const response = await request(app).delete( - `/api/project/${project.id}` + `/api/project/${project.uid}` ); expect(response.status).toBe(401); @@ -355,7 +355,7 @@ describe('Projects Routes', () => { }); // Delete the project - const response = await agent.delete(`/api/project/${project.id}`); + const response = await agent.delete(`/api/project/${project.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe('Project successfully deleted'); @@ -387,7 +387,7 @@ describe('Projects Routes', () => { }); // Delete the project - const response = await agent.delete(`/api/project/${project.id}`); + const response = await agent.delete(`/api/project/${project.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe('Project successfully deleted'); @@ -427,7 +427,7 @@ describe('Projects Routes', () => { }); // Delete the project - const response = await agent.delete(`/api/project/${project.id}`); + const response = await agent.delete(`/api/project/${project.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe('Project successfully deleted'); diff --git a/backend/tests/integration/tags.test.js b/backend/tests/integration/tags.test.js index b7d5174..593e070 100644 --- a/backend/tests/integration/tags.test.js +++ b/backend/tests/integration/tags.test.js @@ -29,7 +29,7 @@ describe('Tags Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe(tagData.name); - expect(response.body.id).toBeDefined(); + expect(response.body.uid).toBeDefined(); }); it('should require authentication', async () => { @@ -61,7 +61,7 @@ describe('Tags Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe('project:frontend'); - expect(response.body.id).toBeDefined(); + expect(response.body.uid).toBeDefined(); }); it('should allow hyphen (-) in tag names', async () => { @@ -73,7 +73,7 @@ describe('Tags Routes', () => { expect(response.status).toBe(201); expect(response.body.name).toBe('project-frontend'); - expect(response.body.id).toBeDefined(); + expect(response.body.uid).toBeDefined(); }); it('should reject tags with invalid characters', async () => { @@ -108,8 +108,8 @@ describe('Tags Routes', () => { expect(response.status).toBe(200); expect(response.body).toHaveLength(2); - expect(response.body.map((t) => t.id)).toContain(tag1.id); - expect(response.body.map((t) => t.id)).toContain(tag2.id); + expect(response.body.map((t) => t.uid)).toContain(tag1.uid); + expect(response.body.map((t) => t.uid)).toContain(tag2.uid); }); it('should order tags by name', async () => { @@ -138,15 +138,15 @@ describe('Tags Routes', () => { }); }); - it('should get tag by id', async () => { - const response = await agent.get(`/api/tag?id=${tag.id}`); + it('should get tag by uid', async () => { + const response = await agent.get(`/api/tag?uid=${tag.uid}`); expect(response.status).toBe(200); expect(response.body.name).toBe(tag.name); }); it('should return 404 for non-existent tag', async () => { - const response = await agent.get('/api/tag?id=999999'); + const response = await agent.get('/api/tag?uid=non-existent-uid'); expect(response.status).toBe(404); expect(response.body.error).toBe('Tag not found'); @@ -164,14 +164,14 @@ describe('Tags Routes', () => { user_id: otherUser.id, }); - const response = await agent.get(`/api/tag?id=${otherTag.id}`); + const response = await agent.get(`/api/tag?uid=${otherTag.uid}`); 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?id=${tag.id}`); + const response = await request(app).get(`/api/tag?uid=${tag.uid}`); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); @@ -194,7 +194,7 @@ describe('Tags Routes', () => { }; const response = await agent - .patch(`/api/tag/${tag.id}`) + .patch(`/api/tag/${tag.uid}`) .send(updateData); expect(response.status).toBe(200); @@ -203,7 +203,7 @@ describe('Tags Routes', () => { it('should return 404 for non-existent tag', async () => { const response = await agent - .patch('/api/tag/999999') + .patch('/api/tag/non-existent-uid') .send({ name: 'Updated' }); expect(response.status).toBe(404); @@ -223,7 +223,7 @@ describe('Tags Routes', () => { }); const response = await agent - .patch(`/api/tag/${otherTag.id}`) + .patch(`/api/tag/${otherTag.uid}`) .send({ name: 'Updated' }); expect(response.status).toBe(404); @@ -232,7 +232,7 @@ describe('Tags Routes', () => { it('should require authentication', async () => { const response = await request(app) - .patch(`/api/tag/${tag.id}`) + .patch(`/api/tag/${tag.uid}`) .send({ name: 'Updated' }); expect(response.status).toBe(401); @@ -251,7 +251,7 @@ describe('Tags Routes', () => { }); it('should delete tag', async () => { - const response = await agent.delete(`/api/tag/${tag.id}`); + const response = await agent.delete(`/api/tag/${tag.uid}`); expect(response.status).toBe(200); expect(response.body.message).toBe('Tag successfully deleted'); @@ -262,7 +262,7 @@ describe('Tags Routes', () => { }); it('should return 404 for non-existent tag', async () => { - const response = await agent.delete('/api/tag/999999'); + const response = await agent.delete('/api/tag/non-existent-uid'); expect(response.status).toBe(404); expect(response.body.error).toBe('Tag not found'); @@ -280,14 +280,14 @@ describe('Tags Routes', () => { user_id: otherUser.id, }); - const response = await agent.delete(`/api/tag/${otherTag.id}`); + const response = await agent.delete(`/api/tag/${otherTag.uid}`); expect(response.status).toBe(404); expect(response.body.error).toBe('Tag not found'); }); it('should require authentication', async () => { - const response = await request(app).delete(`/api/tag/${tag.id}`); + const response = await request(app).delete(`/api/tag/${tag.uid}`); expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); diff --git a/backend/tests/integration/users.test.js b/backend/tests/integration/users.test.js index 52296f9..65acc61 100644 --- a/backend/tests/integration/users.test.js +++ b/backend/tests/integration/users.test.js @@ -24,7 +24,8 @@ describe('Users Routes', () => { const response = await agent.get('/api/profile'); expect(response.status).toBe(200); - expect(response.body.id).toBe(user.id); + expect(response.body.uid).toBe(user.uid); + expect(response.body).not.toHaveProperty('id'); expect(response.body.email).toBe(user.email); expect(response.body).toHaveProperty('appearance'); expect(response.body).toHaveProperty('language'); @@ -66,6 +67,8 @@ describe('Users Routes', () => { const response = await agent.patch('/api/profile').send(updateData); expect(response.status).toBe(200); + expect(response.body.uid).toBe(user.uid); + expect(response.body).not.toHaveProperty('id'); expect(response.body.appearance).toBe(updateData.appearance); expect(response.body.language).toBe(updateData.language); expect(response.body.timezone).toBe(updateData.timezone); diff --git a/backend/tests/unit/models/project.test.js b/backend/tests/unit/models/project.test.js index 4b15394..0964df8 100644 --- a/backend/tests/unit/models/project.test.js +++ b/backend/tests/unit/models/project.test.js @@ -21,7 +21,7 @@ describe('Project Model', () => { const projectData = { name: 'Test Project', description: 'Test Description', - active: true, + state: 'planned', pin_to_sidebar: false, priority: 1, user_id: user.id, @@ -32,7 +32,7 @@ describe('Project Model', () => { expect(project.name).toBe(projectData.name); expect(project.description).toBe(projectData.description); - expect(project.active).toBe(projectData.active); + expect(project.state).toBe(projectData.state); expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar); expect(project.priority).toBe(projectData.priority); expect(project.user_id).toBe(user.id); @@ -85,7 +85,7 @@ describe('Project Model', () => { user_id: user.id, }); - expect(project.active).toBe(true); + expect(project.state).toBe('idea'); expect(project.pin_to_sidebar).toBe(false); expect(project.task_show_completed).toBe(false); expect(project.task_sort_order).toBe('created_at:desc'); diff --git a/backend/tests/unit/utils/validation.test.js b/backend/tests/unit/utils/validation.test.js index b3c4d78..f7bde24 100644 --- a/backend/tests/unit/utils/validation.test.js +++ b/backend/tests/unit/utils/validation.test.js @@ -1,4 +1,4 @@ -const { validateTagName } = require('../../../utils/validation'); +const { validateTagName } = require('../../../services/tagsService'); describe('validation utils', () => { describe('validateTagName', () => { diff --git a/backend/utils/migration-utils.js b/backend/utils/migration-utils.js index cb580d6..b57151b 100644 --- a/backend/utils/migration-utils.js +++ b/backend/utils/migration-utils.js @@ -55,8 +55,122 @@ async function safeAddIndex(queryInterface, tableName, fields, options = {}) { } } +async function safeRemoveColumn(queryInterface, tableName, columnName) { + try { + const tableInfo = await queryInterface.describeTable(tableName); + + if (!(columnName in tableInfo)) { + console.log( + `Column ${columnName} does not exist in ${tableName}, skipping removal` + ); + return; + } + + const dialect = queryInterface.sequelize.getDialect(); + + // SQLite doesn't support DROP COLUMN, so we need to recreate the table + if (dialect === 'sqlite') { + try { + // Get all columns except the one to remove + const columns = Object.keys(tableInfo).filter( + (col) => col !== columnName + ); + + // Build column definitions for new table + const columnDefs = columns + .map((col) => { + const info = tableInfo[col]; + let def = `${col} ${info.type}`; + + if (info.primaryKey) { + def += ' PRIMARY KEY'; + } + if (info.autoIncrement) { + def += ' AUTOINCREMENT'; + } + if (!info.allowNull) { + def += ' NOT NULL'; + } + if (info.unique) { + def += ' UNIQUE'; + } + if ( + info.defaultValue !== undefined && + info.defaultValue !== null + ) { + // Properly quote string defaults + const defaultVal = + typeof info.defaultValue === 'string' + ? `'${info.defaultValue.replace(/'/g, "''")}'` + : info.defaultValue; + def += ` DEFAULT ${defaultVal}`; + } + + return def; + }) + .join(', '); + + const columnList = columns.join(', '); + + // Execute operations separately as SQLite doesn't support multiple statements + await queryInterface.sequelize.query( + 'PRAGMA foreign_keys = OFF;' + ); + + await queryInterface.sequelize.query( + `CREATE TABLE ${tableName}_new (${columnDefs});` + ); + + await queryInterface.sequelize.query( + `INSERT INTO ${tableName}_new (${columnList}) SELECT ${columnList} FROM ${tableName};` + ); + + await queryInterface.sequelize.query( + `DROP TABLE ${tableName};` + ); + + await queryInterface.sequelize.query( + `ALTER TABLE ${tableName}_new RENAME TO ${tableName};` + ); + + await queryInterface.sequelize.query( + 'PRAGMA foreign_keys = ON;' + ); + + console.log( + `Successfully removed column ${columnName} from ${tableName}` + ); + } catch (error) { + // Ensure foreign keys are re-enabled even on error + try { + await queryInterface.sequelize.query( + 'PRAGMA foreign_keys = ON;' + ); + } catch (pragmaError) { + // Ignore pragma errors during cleanup + } + console.log( + `Migration error removing column ${columnName} from ${tableName}:`, + error.message + ); + throw error; + } + } else { + // For other databases, use standard removeColumn + await queryInterface.removeColumn(tableName, columnName); + } + } catch (error) { + console.log( + `Migration error removing column ${columnName} from ${tableName}:`, + error.message + ); + throw error; + } +} + module.exports = { safeAddColumns, safeCreateTable, safeAddIndex, + safeRemoveColumn, }; diff --git a/e2e/bin/run-e2e.sh b/e2e/bin/run-e2e.sh index e1f3674..28667f0 100755 --- a/e2e/bin/run-e2e.sh +++ b/e2e/bin/run-e2e.sh @@ -107,9 +107,9 @@ bash -c ' if [ "${E2E_MODE:-}" = "ui" ]; then npm run test:ui elif [ "${E2E_MODE:-}" = "headed" ]; then - # Respect E2E_SLOWMO and run only Firefox - npx playwright test --headed --project=Firefox + # Respect E2E_SLOWMO and run only Firefox sequentially + npx playwright test --headed --project=Firefox --workers=1 else - npm test + npx playwright test --workers=10 fi ' diff --git a/e2e/bin/run-single-test.sh b/e2e/bin/run-single-test.sh new file mode 100755 index 0000000..95936a9 --- /dev/null +++ b/e2e/bin/run-single-test.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./run-single-test.sh "test name pattern" [browser] +# Example: ./run-single-test.sh "delete an existing project" firefox + +if [ $# -lt 1 ]; then + echo "Usage: $0 [browser]" + echo "Example: $0 'delete an existing project' firefox" + exit 1 +fi + +TEST_PATTERN="$1" +BROWSER="${2:-Chromium}" + +# Config +APP_URL_DEFAULT="http://localhost:8080" +BACKEND_URL="http://localhost:3002" +BACKEND_HEALTH="${BACKEND_URL}/api/health" +FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}" + +# Colors +red() { printf "\033[31m%s\033[0m\n" "$*"; } +green() { printf "\033[32m%s\033[0m\n" "$*"; } +yellow() { printf "\033[33m%s\033[0m\n" "$*"; } + +# Setup paths +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +E2E_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ROOT_DIR="$(cd "$E2E_DIR/.." && pwd)" + +cd "$E2E_DIR" +if [ ! -f package.json ]; then + red "e2e/package.json not found" + exit 1 +fi + +# Install e2e deps if needed +if [ ! -d node_modules ]; then + yellow "Installing e2e dependencies..." + npm ci +fi + +# Start backend and frontend +cd "$ROOT_DIR" + +yellow "Starting backend..." +TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \ +TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \ +SEQUELIZE_LOGGING=false \ +npm run backend:start >/dev/null 2>&1 & +BACKEND_PID=$! + +cleanup() { + yellow "Stopping background processes..." + # Kill by PIDs + if [ -n "${FRONTEND_PID:-}" ]; then kill -TERM -$FRONTEND_PID >/dev/null 2>&1 || true; fi + if [ -n "${BACKEND_PID:-}" ]; then kill -TERM -$BACKEND_PID >/dev/null 2>&1 || true; fi + + # Kill by ports (best-effort) + if command -v lsof >/dev/null 2>&1; then + FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true) + BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true) + if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi + if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi + fi + + # Fallback direct kill + if [ -n "${FRONTEND_PID:-}" ] && ps -p $FRONTEND_PID >/dev/null 2>&1; then kill $FRONTEND_PID || true; fi + if [ -n "${BACKEND_PID:-}" ] && ps -p $BACKEND_PID >/dev/null 2>&1; then kill $BACKEND_PID || true; fi +} +trap cleanup EXIT INT TERM + +# Wait for backend health +yellow "Waiting for backend to be ready at ${BACKEND_HEALTH}..." +for i in {1..60}; do + if curl -sf "$BACKEND_HEALTH" >/dev/null; then + green "Backend is ready" + break + fi + sleep 1 + if [ "$i" -eq 60 ]; then + red "Backend did not become ready in time" + exit 1 + fi +done + +yellow "Starting frontend dev server..." +npm run frontend:dev >/dev/null 2>&1 & +FRONTEND_PID=$! + +# Wait for frontend +yellow "Waiting for frontend at ${FRONTEND_URL}..." +for i in {1..60}; do + if curl -sf "$FRONTEND_URL" >/dev/null; then + green "Frontend is ready" + break + fi + sleep 1 + if [ "$i" -eq 60 ]; then + red "Frontend did not become ready in time" + exit 1 + fi +done + +# Run tests +cd "$E2E_DIR" + +yellow "Running Playwright tests matching: ${TEST_PATTERN} on ${BROWSER}..." +APP_URL="$FRONTEND_URL" \ +E2E_EMAIL="${E2E_EMAIL:-test@tududi.com}" \ +E2E_PASSWORD="${E2E_PASSWORD:-password123}" \ +npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER" \ No newline at end of file diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 4eb16a3..db15069 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, projects: [ { name: 'Chromium', use: { ...devices['Desktop Chrome'] } }, - { name: 'Firefox', use: { ...devices['Desktop Firefox'] } }, - { name: 'WebKit', use: { ...devices['Desktop Safari'] } }, + // { name: 'Firefox', use: { ...devices['Desktop Firefox'] } }, + // { name: 'WebKit', use: { ...devices['Desktop Safari'] } }, ], }); diff --git a/e2e/tests/inbox.spec.ts b/e2e/tests/inbox.spec.ts index 3c5bdee..9d4b543 100644 --- a/e2e/tests/inbox.spec.ts +++ b/e2e/tests/inbox.spec.ts @@ -176,8 +176,8 @@ test('user can create project from inbox item', async ({ page, baseURL }) => { const projectNameInput = page.locator('input[name="name"], input[placeholder*="project" i], input[placeholder*="name" i]').first(); await expect(projectNameInput).toHaveValue(testContent); - // Save the project - Find submit button by looking for buttons in form context, force click through backdrop - await page.locator('form button[type="submit"], button:has-text("Save"), button:has-text("Create")').first().click({ force: true }); + // Save the project - Use the specific test ID + await page.locator('[data-testid="project-save-button"]').click(); // Wait for success message or modal to close await expect(page.locator('input[name="name"], input[placeholder*="project" i], input[placeholder*="name" i]')).not.toBeVisible({ timeout: 10000 }); @@ -191,16 +191,12 @@ test('user can create project from inbox item', async ({ page, baseURL }) => { // Navigate to projects page to verify the project was created there await page.goto(appUrl + '/projects'); await expect(page).toHaveURL(/\/projects$/); - - // Wait a moment for the page to load, then check if project exists (more lenient check) + + // Wait a moment for the page to load await page.waitForTimeout(2000); - const projectExists = await page.locator('*').filter({ hasText: testContent }).count() > 0; - if (!projectExists) { - // If exact match fails, just verify we're on projects page and there are projects - await expect(page.locator('h1, h2, h3').filter({ hasText: /projects/i }).first()).toBeVisible(); - } else { - await expect(page.locator('*').filter({ hasText: testContent })).toBeVisible(); - } + + // Verify the created project appears - use a more specific selector + await expect(page.getByRole('link', { name: new RegExp(testContent) }).first()).toBeVisible({ timeout: 10000 }); }); test('user can create note from inbox item', async ({ page, baseURL }) => { diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 7eea09a..8f2d521 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -149,15 +149,15 @@ const Layout: React.FC = ({ const handleSaveNote = async (noteData: Note) => { try { let result: Note; - if (noteData.id) { - result = await updateNote(noteData.id, noteData); + if (noteData.uid) { + result = await updateNote(noteData.uid, noteData); // Update existing note in global store const currentNotes = useStore.getState().notesStore.notes; useStore .getState() .notesStore.setNotes( currentNotes.map((note) => - note.id === result.id ? result : note + note.uid === result.uid ? result : note ) ); } else { @@ -223,7 +223,7 @@ const Layout: React.FC = ({ try { const newProject = await createProject({ name, - active: true, + state: 'planned', }); return newProject; } catch (error) { @@ -234,8 +234,8 @@ const Layout: React.FC = ({ const handleSaveProject = async (projectData: Project) => { try { - if (projectData.id) { - await updateProject(projectData.id, projectData); + if (projectData.uid) { + await updateProject(projectData.uid, projectData); } else { await createProject(projectData); } @@ -255,15 +255,15 @@ const Layout: React.FC = ({ const handleSaveArea = async (areaData: Partial) => { try { let result: Area; - if (areaData.id) { - result = await updateArea(areaData.id, areaData); + if (areaData.uid) { + result = await updateArea(areaData.uid, areaData); // Update existing area in global store const currentAreas = useStore.getState().areasStore.areas; useStore .getState() .areasStore.setAreas( currentAreas.map((area) => - area.id === result.id ? result : area + area.uid === result.uid ? result : area ) ); } else { @@ -288,15 +288,15 @@ const Layout: React.FC = ({ const handleSaveTag = async (tagData: Tag) => { try { let result: Tag; - if (tagData.id) { - result = await updateTag(tagData.id, tagData); + if (tagData.uid) { + result = await updateTag(tagData.uid, tagData); // Update existing tag in global store const currentTags = useStore.getState().tagsStore.tags; useStore .getState() .tagsStore.setTags( currentTags.map((tag) => - tag.id === result.id ? result : tag + tag.uid === result.uid ? result : tag ) ); } else { @@ -474,12 +474,12 @@ const Layout: React.FC = ({ isOpen={isProjectModalOpen} onClose={closeProjectModal} onSave={handleSaveProject} - onDelete={async (projectId) => { + onDelete={async (projectUid) => { try { const { deleteProject } = await import( './utils/projectsService' ); - await deleteProject(projectId); + await deleteProject(projectUid); // Update global projects store const currentProjects = @@ -488,7 +488,7 @@ const Layout: React.FC = ({ .getState() .projectsStore.setProjects( currentProjects.filter( - (p) => p.id !== projectId + (p) => p.uid !== projectUid ) ); diff --git a/frontend/components/Area/AreaModal.tsx b/frontend/components/Area/AreaModal.tsx index 1909f77..d5f5ff1 100644 --- a/frontend/components/Area/AreaModal.tsx +++ b/frontend/components/Area/AreaModal.tsx @@ -8,7 +8,7 @@ interface AreaModalProps { isOpen: boolean; onClose: () => void; onSave: (areaData: Partial) => Promise; - onDelete?: (areaId: number) => Promise; + onDelete?: (areaUid: string) => Promise; area?: Area | null; } @@ -22,6 +22,7 @@ const AreaModal: React.FC = ({ const { t } = useTranslation(); const [formData, setFormData] = useState({ id: area?.id || 0, + uid: area?.uid || '', name: area?.name || '', description: area?.description || '', }); @@ -38,6 +39,7 @@ const AreaModal: React.FC = ({ if (isOpen) { setFormData({ id: area?.id || 0, + uid: area?.uid || '', name: area?.name || '', description: area?.description || '', }); @@ -109,7 +111,7 @@ const AreaModal: React.FC = ({ try { await onSave(formData); showSuccessToast( - formData.id + formData.uid ? t('success.areaUpdated') : t('success.areaCreated') ); @@ -131,9 +133,9 @@ const AreaModal: React.FC = ({ }; const handleDeleteArea = async () => { - if (formData.id && formData.id !== 0 && onDelete) { + if (formData.uid && onDelete) { try { - await onDelete(formData.id); + await onDelete(formData.uid); showSuccessToast( t('success.areaDeleted', 'Area deleted successfully!') ); @@ -225,22 +227,16 @@ const AreaModal: React.FC = ({
{/* Left side: Delete and Cancel */}
- {area && - area.id && - area.id !== 0 && - onDelete && ( - - )} + {area && area.uid && onDelete && ( + + )} - {dropdownOpen === area.id && ( + {dropdownOpen === area.uid && (
@@ -255,7 +255,7 @@ const Areas: React.FC = () => { setDropdownOpen(null); }} className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left rounded-b-md" - data-testid={`area-delete-${area.id}`} + data-testid={`area-delete-${area.uid}`} > {t('areas.delete', 'Delete')} @@ -273,9 +273,9 @@ const Areas: React.FC = () => { isOpen={isAreaModalOpen} onClose={() => setIsAreaModalOpen(false)} onSave={handleSaveArea} - onDelete={async (areaId) => { + onDelete={async (areaUid: string) => { try { - await deleteArea(areaId); + await deleteArea(areaUid); const updatedAreas = await fetchAreas(); useStore .getState() diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index af90012..254e400 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { InboxItem } from '../../entities/InboxItem'; import { useTranslation } from 'react-i18next'; @@ -9,6 +9,7 @@ import { FolderIcon, ClipboardDocumentListIcon, TagIcon, + EllipsisVerticalIcon, } from '@heroicons/react/24/outline'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; @@ -18,12 +19,12 @@ import { useStore } from '../../store/useStore'; interface InboxItemDetailProps { item: InboxItem; - onProcess: (id: number) => void; - onDelete: (id: number) => void; - onUpdate?: (id: number) => Promise; - openTaskModal: (task: Task, inboxItemId?: number) => void; - openProjectModal: (project: Project | null, inboxItemId?: number) => void; - openNoteModal: (note: Note | null, inboxItemId?: number) => void; + onProcess: (uid: string) => void; + onDelete: (uid: string) => void; + onUpdate?: (uid: string) => Promise; + openTaskModal: (task: Task, inboxItemUid?: string) => void; + openProjectModal: (project: Project | null, inboxItemUid?: string) => void; + openNoteModal: (note: Note | null, inboxItemUid?: string) => void; projects: Project[]; } @@ -44,6 +45,52 @@ const InboxItemDetail: React.FC = ({ const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [loading, setLoading] = useState(false); const [isHovered, setIsHovered] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const buttonRef = useRef(null); + const dropdownId = useRef( + `dropdown-${Math.random().toString(36).substr(2, 9)}` + ).current; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isDropdownOpen && buttonRef.current) { + const target = event.target as Node; + const isOutsideButton = !buttonRef.current.contains(target); + const currentDropdown = document.querySelector( + `[data-dropdown-id="${dropdownId}"]` + ); + const isOutsideDropdown = !currentDropdown?.contains(target); + + if (isOutsideButton && isOutsideDropdown) { + setIsDropdownOpen(false); + } + } + }; + + // Listen for custom event to close this dropdown when another opens + const handleCloseOtherDropdowns = (event: CustomEvent) => { + if (event.detail.dropdownId !== dropdownId && isDropdownOpen) { + setIsDropdownOpen(false); + } + }; + + if (isDropdownOpen) { + document.addEventListener('click', handleClickOutside); + document.addEventListener( + 'closeOtherDropdowns', + handleCloseOtherDropdowns as EventListener + ); + } + + return () => { + document.removeEventListener('click', handleClickOutside); + document.removeEventListener( + 'closeOtherDropdowns', + handleCloseOtherDropdowns as EventListener + ); + }; + }, [isDropdownOpen, dropdownId]); // Helper function to parse hashtags from text (consecutive groups anywhere) const parseHashtags = (text: string): string[] => { @@ -250,8 +297,8 @@ const InboxItemDetail: React.FC = ({ completed_at: null, }; - if (item.id !== undefined) { - openTaskModal(newTask, item.id); + if (item.uid !== undefined) { + openTaskModal(newTask, item.uid); } else { openTaskModal(newTask); } @@ -275,12 +322,12 @@ const InboxItemDetail: React.FC = ({ const newProject: Project = { name: cleanedContent || item.content, description: '', - active: true, + state: 'planned', tags: projectTags, }; - if (item.id !== undefined) { - openProjectModal(newProject, item.id); + if (item.uid !== undefined) { + openProjectModal(newProject, item.uid); } else { openProjectModal(newProject); } @@ -372,11 +419,11 @@ const InboxItemDetail: React.FC = ({ title: finalTitle, content: finalContent, tags: tagObjects, - project_id: projectId, + project_uid: projectId, }; - if (item.id !== undefined) { - openNoteModal(newNote, item.id); + if (item.uid !== undefined) { + openNoteModal(newNote, item.uid); } else { openNoteModal(newNote); } @@ -387,8 +434,8 @@ const InboxItemDetail: React.FC = ({ }; const confirmDelete = () => { - if (item.id !== undefined) { - onDelete(item.id); + if (item.uid !== undefined) { + onDelete(item.uid); } setShowConfirmDialog(false); }; @@ -399,12 +446,12 @@ const InboxItemDetail: React.FC = ({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
-
+
+
-
+ {/* Desktop view (md and larger) */} +
{loading &&
} {/* Edit Button */}
+ + {/* Mobile 3-dot dropdown menu */} +
+ {loading &&
} + + + {/* Dropdown Menu - Positioned Relatively */} + {isDropdownOpen && ( +
+ window.innerHeight + ? 'translateY(-100%) translateY(-8px)' + : 'none', + }} + onClick={(e) => e.stopPropagation()} + > +
+ {/* Edit Button */} + + + {/* Convert to Task Button */} + + + {/* Convert to Project Button */} + + + {/* Convert to Note Button */} + + + {/* Delete Button */} + +
+
+ )} +
{showConfirmDialog && ( { const [projectToEdit, setProjectToEdit] = useState(null); const [noteToEdit, setNoteToEdit] = useState(null); - // Track the current inbox item ID being converted (for task/project/note conversion) - const [currentConversionItemId, setCurrentConversionItemId] = useState< - number | null + // Track the current inbox item UID being converted (for task/project/note conversion) + const [currentConversionItemUid, setCurrentConversionItemUid] = useState< + string | null >(null); // Track the current inbox item being edited - const [itemToEdit, setItemToEdit] = useState(null); + const [itemToEdit, setItemToEdit] = useState(null); // Create stable default task object to prevent infinite re-renders const defaultTask = useMemo( @@ -182,9 +182,12 @@ const InboxItems: React.FC = () => { }; }, [t, showSuccessToast]); // Include dependencies that are actually used - const handleProcessItem = async (id: number, showToast: boolean = true) => { + const handleProcessItem = async ( + uid: string, + showToast: boolean = true + ) => { try { - await processInboxItemWithStore(id); + await processInboxItemWithStore(uid); if (showToast) { showSuccessToast(t('inbox.itemProcessed')); } @@ -194,9 +197,9 @@ const InboxItems: React.FC = () => { } }; - const handleUpdateItem = async (id: number): Promise => { + const handleUpdateItem = async (uid: string): Promise => { // When edit button is clicked, we open the InboxModal instead of doing inline editing - setItemToEdit(id); + setItemToEdit(uid); setIsEditModalOpen(true); }; @@ -214,9 +217,9 @@ const InboxItems: React.FC = () => { } }; - const handleDeleteItem = async (id: number) => { + const handleDeleteItem = async (uid: string) => { try { - await deleteInboxItemWithStore(id); + await deleteInboxItemWithStore(uid); showSuccessToast(t('inbox.itemDeleted')); } catch (error) { console.error('Failed to delete inbox item:', error); @@ -225,7 +228,7 @@ const InboxItems: React.FC = () => { }; // Modal handlers - const handleOpenTaskModal = async (task: Task, inboxItemId?: number) => { + const handleOpenTaskModal = async (task: Task, inboxItemUid?: string) => { try { // Load projects first before opening the modal try { @@ -242,8 +245,8 @@ const InboxItems: React.FC = () => { setTaskToEdit(task); - if (inboxItemId) { - setCurrentConversionItemId(inboxItemId); + if (inboxItemUid) { + setCurrentConversionItemUid(inboxItemUid); } setIsTaskModalOpen(true); @@ -254,7 +257,7 @@ const InboxItems: React.FC = () => { const handleOpenProjectModal = async ( project: Project | null, - inboxItemId?: number + inboxItemUid?: string ) => { try { // Load areas first before opening the modal (similar to task modal) @@ -269,8 +272,8 @@ const InboxItems: React.FC = () => { setProjectToEdit(project); - if (inboxItemId) { - setCurrentConversionItemId(inboxItemId); + if (inboxItemUid) { + setCurrentConversionItemUid(inboxItemUid); } setIsProjectModalOpen(true); @@ -281,7 +284,7 @@ const InboxItems: React.FC = () => { const handleOpenNoteModal = async ( note: Note | null, - inboxItemId?: number + inboxItemUid?: string ) => { // Set up the note data first if (note && note.content && isUrl(note.content.trim())) { @@ -294,8 +297,8 @@ const InboxItems: React.FC = () => { setNoteToEdit(note); - if (inboxItemId) { - setCurrentConversionItemId(inboxItemId); + if (inboxItemUid) { + setCurrentConversionItemUid(inboxItemUid); } // Projects are already available from the store @@ -321,9 +324,9 @@ const InboxItems: React.FC = () => { showSuccessToast(taskLink); // Process the inbox item after successful task creation - if (currentConversionItemId !== null) { - await handleProcessItem(currentConversionItemId, false); - setCurrentConversionItemId(null); + if (currentConversionItemUid !== null) { + await handleProcessItem(currentConversionItemUid, false); + setCurrentConversionItemUid(null); } setIsTaskModalOpen(false); @@ -339,12 +342,12 @@ const InboxItems: React.FC = () => { showSuccessToast(t('project.createSuccess')); // Process the inbox item after successful project creation - if (currentConversionItemId !== null) { - await handleProcessItem(currentConversionItemId, false); - setCurrentConversionItemId(null); + if (currentConversionItemUid !== null) { + await handleProcessItem(currentConversionItemUid, false); + setCurrentConversionItemUid(null); } - setIsProjectModalOpen(false); + // Don't set isProjectModalOpen here - the modal handles its own closing via handleClose() } catch (error) { console.error('Failed to create project:', error); showErrorToast(t('project.createError')); @@ -378,9 +381,9 @@ const InboxItems: React.FC = () => { ); // Process the inbox item after successful note creation - if (currentConversionItemId !== null) { - await handleProcessItem(currentConversionItemId, false); - setCurrentConversionItemId(null); + if (currentConversionItemUid !== null) { + await handleProcessItem(currentConversionItemUid, false); + setCurrentConversionItemUid(null); } setIsNoteModalOpen(false); @@ -392,7 +395,7 @@ const InboxItems: React.FC = () => { const handleCreateProject = async (name: string): Promise => { try { - const project = await createProject({ name, active: true }); + const project = await createProject({ name, state: 'planned' }); showSuccessToast(t('project.createSuccess')); return project; } catch (error) { @@ -510,7 +513,7 @@ const InboxItems: React.FC = () => {
{inboxItems.map((item) => ( { onSave={handleSaveTask} onSaveNote={handleSaveNote} initialText={ - inboxItems.find((item) => item.id === itemToEdit) + inboxItems.find((item) => item.uid === itemToEdit) ?.content || '' } editMode={true} diff --git a/frontend/components/Inbox/InboxModal.tsx b/frontend/components/Inbox/InboxModal.tsx index 3e68283..3155951 100644 --- a/frontend/components/Inbox/InboxModal.tsx +++ b/frontend/components/Inbox/InboxModal.tsx @@ -926,7 +926,7 @@ const InboxModal: React.FC = ({ try { await createProject({ name: projectName, - active: true, + state: 'planned', }); // Projects are managed by the parent component through props // No need to update local state @@ -1644,7 +1644,11 @@ const InboxModal: React.FC = ({ > {filteredTags.map((tag, index) => (
- {/* Tags Display - Bottom Left */} - {project.tags && project.tags.length > 0 && ( -
+ {/* State, Tags and Area Display - Bottom Left */} +
+ {/* Project State Display */} + {project.state && ( +
+ {getStateIcon(project.state)} +
+ + + {t( + `projects.states.${project.state}` + )} + + +
+
+ )} + + {/* Tags Display */} + {project.tags && project.tags.length > 0 && (
{project.tags.map((tag, index) => ( - +
-
- )} + )} + + {/* Area Display */} + {(project.area || (project as any).Area) && ( +
+ +
+ + + +
+
+ )} +
{/* Edit/Delete Buttons - Bottom Right */}
)} @@ -741,26 +728,27 @@ const ProjectModal: React.FC = ({
{/* Left side: Section icons */}
- {/* Active Status Toggle - First */} + {/* State Toggle - First */} {/* Tags Toggle */} diff --git a/frontend/components/Projects.tsx b/frontend/components/Projects.tsx index b96c33d..fd6dac3 100644 --- a/frontend/components/Projects.tsx +++ b/frontend/components/Projects.tsx @@ -66,7 +66,7 @@ const Projects: React.FC = () => { const [orderBy, setOrderBy] = useState('created_at:desc'); const [searchParams, setSearchParams] = useSearchParams(); - const activeFilter = searchParams.get('active') || 'all'; + const stateFilter = searchParams.get('state') || 'all'; // Handle both 'area_id' and 'area' parameters from URL const getAreaIdFromParams = () => { @@ -99,8 +99,17 @@ const Projects: React.FC = () => { // Filter options for dropdowns const statusOptions: FilterOption[] = [ { value: 'all', label: t('projects.filters.all') }, - { value: 'true', label: t('projects.filters.active') }, - { value: 'false', label: t('projects.filters.inactive') }, + { value: 'idea', label: t('projects.states.idea', 'Idea') }, + { value: 'planned', label: t('projects.states.planned', 'Planned') }, + { + value: 'in_progress', + label: t('projects.states.in_progress', 'In Progress'), + }, + { value: 'blocked', label: t('projects.states.blocked', 'Blocked') }, + { + value: 'completed', + label: t('projects.states.completed', 'Completed'), + }, ]; const areaOptions: FilterOption[] = [ @@ -169,8 +178,8 @@ const Projects: React.FC = () => { const handleSaveProject = async (project: Project) => { setProjectsLoading(true); try { - if (project.id) { - await updateProject(project.id, project); + if (project.uid) { + await updateProject(project.uid, project); } else { await createProject(project); } @@ -200,15 +209,15 @@ const Projects: React.FC = () => { if (!projectToDelete) return; try { - if (projectToDelete.id !== undefined) { + if (projectToDelete.uid !== undefined) { setProjectsLoading(true); - await deleteProject(projectToDelete.id); + await deleteProject(projectToDelete.uid); // Update global state const projectsData = await fetchProjects(); setProjects(projectsData); } else { - console.error('Cannot delete project: ID is undefined.'); + console.error('Cannot delete project: UID is undefined.'); } } catch (error: any) { console.error('Error deleting project:', error); @@ -231,13 +240,13 @@ const Projects: React.FC = () => { return (project as any).completion_percentage || 0; }; - const handleActiveFilterChange = (value: string) => { + const handleStateFilterChange = (value: string) => { const params = new URLSearchParams(searchParams); if (value === 'all') { - params.delete('active'); + params.delete('state'); } else { - params.set('active', value); + params.set('state', value); } setSearchParams(params); }; @@ -265,11 +274,10 @@ const Projects: React.FC = () => { const displayProjects = useMemo(() => { let filteredProjects = [...projects]; - // Apply active filter - if (activeFilter !== 'all') { - const isActive = activeFilter === 'true'; + // Apply state filter + if (stateFilter !== 'all') { filteredProjects = filteredProjects.filter( - (project) => project.active === isActive + (project) => project.state === stateFilter ); } @@ -340,7 +348,7 @@ const Projects: React.FC = () => { }); return filteredProjects; - }, [projects, activeFilter, actualAreaFilter, searchQuery, orderBy]); + }, [projects, stateFilter, actualAreaFilter, searchQuery, orderBy]); if (isLoading) { return ( @@ -419,8 +427,8 @@ const Projects: React.FC = () => {
@@ -508,13 +516,13 @@ const Projects: React.FC = () => { setModalState({ isOpen: false, projectToEdit: null }); }} onSave={handleSaveProject} - onDelete={async (projectId) => { + onDelete={async (projectUid) => { try { - await deleteProject(projectId); + await deleteProject(projectUid); // Update both local and global state const updatedProjects = projects.filter( - (p: Project) => p.id !== projectId + (p: Project) => p.uid !== projectUid ); setProjects(updatedProjects); diff --git a/frontend/components/Shared/AreaDropdown.tsx b/frontend/components/Shared/AreaDropdown.tsx index e28c16f..33a98a9 100644 --- a/frontend/components/Shared/AreaDropdown.tsx +++ b/frontend/components/Shared/AreaDropdown.tsx @@ -123,8 +123,8 @@ const AreaDropdown: React.FC = ({ {/* Area options */} {areas.map((area) => ( @@ -227,7 +217,7 @@ const NoteCard: React.FC = ({ setDropdownOpen(false); }} className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left rounded-t-md" - data-testid={`note-edit-${note.id}`} + data-testid={`note-edit-${noteIdentifier}`} > {t('notes.edit', 'Edit')} diff --git a/frontend/components/Shared/ProjectStateDropdown.tsx b/frontend/components/Shared/ProjectStateDropdown.tsx new file mode 100644 index 0000000..0c4318f --- /dev/null +++ b/frontend/components/Shared/ProjectStateDropdown.tsx @@ -0,0 +1,223 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { + ChevronDownIcon, + LightBulbIcon, + ClipboardDocumentListIcon, + PlayIcon, + ExclamationTriangleIcon, + CheckCircleIcon, +} from '@heroicons/react/24/outline'; +import { ProjectState } from '../../entities/Project'; +import { useTranslation } from 'react-i18next'; + +interface ProjectStateDropdownProps { + value: ProjectState; + onChange: (value: ProjectState) => void; +} + +const ProjectStateDropdown: React.FC = ({ + value, + onChange, +}) => { + const { t } = useTranslation(); + + const states = [ + { + value: 'idea' as ProjectState, + label: t('projects.states.idea', 'Idea'), + description: t( + 'projects.states.idea_desc', + 'captured but not planned yet' + ), + icon: , + }, + { + value: 'planned' as ProjectState, + label: t('projects.states.planned', 'Planned'), + description: t( + 'projects.states.planned_desc', + 'scoped and ready to start' + ), + icon: ( + + ), + }, + { + value: 'in_progress' as ProjectState, + label: t('projects.states.in_progress', 'In Progress'), + description: t( + 'projects.states.in_progress_desc', + 'active work happening' + ), + icon: , + }, + { + value: 'blocked' as ProjectState, + label: t('projects.states.blocked', 'Blocked'), + description: t( + 'projects.states.blocked_desc', + 'temporarily paused or stuck' + ), + icon: , + }, + { + value: 'completed' as ProjectState, + label: t('projects.states.completed', 'Completed'), + description: t( + 'projects.states.completed_desc', + 'finished and done' + ), + icon: , + }, + ]; + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const handleToggle = () => { + setIsOpen(!isOpen); + + // Scroll dropdown into view when opening to ensure options are visible + if (!isOpen && dropdownRef.current) { + setTimeout(() => { + // Find the dropdown options container + const dropdownOptions = + dropdownRef.current?.querySelector('.absolute.z-10'); + if (dropdownOptions) { + dropdownOptions.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } else { + // Fallback to scrolling the dropdown container itself + dropdownRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + }, 150); // Increased timeout to ensure dropdown is rendered + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + const handleSelect = (state: ProjectState) => { + onChange(state); + setIsOpen(false); + }; + + useEffect(() => { + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + + // Ensure dropdown is visible after opening + setTimeout(() => { + const dropdownOptions = + dropdownRef.current?.querySelector('.absolute.z-10'); + if (dropdownOptions) { + // Try to scroll the parent modal container to show the dropdown + const modalScrollContainer = + document.querySelector( + '.absolute.inset-0.overflow-y-auto' + ) || + document.querySelector('[style*="overflow-y"]') || + document.querySelector('.overflow-y-auto'); + + if (modalScrollContainer) { + const rect = dropdownOptions.getBoundingClientRect(); + const containerRect = + modalScrollContainer.getBoundingClientRect(); + + // Check if dropdown is below visible area + if (rect.bottom > containerRect.bottom) { + modalScrollContainer.scrollTo({ + top: + modalScrollContainer.scrollTop + + (rect.bottom - containerRect.bottom) + + 20, + behavior: 'smooth', + }); + } + } else { + // Fallback to scrollIntoView + dropdownOptions.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + } + }, 200); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const selectedState = states.find((s) => s.value === value); + + return ( +
+ + + {isOpen && ( +
+ {states.map((state) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProjectStateDropdown; diff --git a/frontend/components/Sidebar/SidebarProjects.tsx b/frontend/components/Sidebar/SidebarProjects.tsx index 5abea68..2748656 100644 --- a/frontend/components/Sidebar/SidebarProjects.tsx +++ b/frontend/components/Sidebar/SidebarProjects.tsx @@ -31,7 +31,7 @@ const SidebarProjects: React.FC = ({ )}`} onClick={() => handleNavClick( - '/projects?active=true', + '/projects', 'Projects', ) diff --git a/frontend/components/Tag/TagDetails.tsx b/frontend/components/Tag/TagDetails.tsx index b59a0d9..c5ba4ee 100644 --- a/frontend/components/Tag/TagDetails.tsx +++ b/frontend/components/Tag/TagDetails.tsx @@ -39,7 +39,7 @@ const TagDetails: React.FC = () => { // State for ProjectItem components const [activeDropdown, setActiveDropdown] = useState(null); - const [hoveredNoteId, setHoveredNoteId] = useState(null); + const [hoveredNoteId, setHoveredNoteId] = useState(null); const [, setProjectToDelete] = useState(null); const [, setIsConfirmDialogOpen] = useState(false); const navigate = useNavigate(); @@ -260,10 +260,10 @@ const TagDetails: React.FC = () => {
    {notes.map((note) => (
  • - setHoveredNoteId(note.id || null) + setHoveredNoteId(note.uid || null) } onMouseLeave={() => setHoveredNoteId(null)} > @@ -282,7 +282,9 @@ const TagDetails: React.FC = () => { /^-|-$/g, '' )}` - : `/note/${note.id}` + : note.uid + ? `/note/${note.uid}` + : '#' } className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline" > @@ -324,7 +326,7 @@ const TagDetails: React.FC = () => { onClick={ () => {} // Edit functionality not implemented yet } - className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`} + className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.uid ? 'opacity-100' : 'opacity-0'}`} aria-label={`Edit ${note.title}`} title={`Edit ${note.title}`} > @@ -334,7 +336,7 @@ const TagDetails: React.FC = () => { onClick={ () => {} // Delete functionality not implemented yet } - className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`} + className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.uid ? 'opacity-100' : 'opacity-0'}`} aria-label={`Delete ${note.title}`} title={`Delete ${note.title}`} > diff --git a/frontend/components/Tag/TagInput.tsx b/frontend/components/Tag/TagInput.tsx index 30f11d5..8109f91 100644 --- a/frontend/components/Tag/TagInput.tsx +++ b/frontend/components/Tag/TagInput.tsx @@ -6,12 +6,14 @@ interface TagInputProps { initialTags: string[]; onTagsChange: (tags: string[]) => void; availableTags: Tag[]; + onFocus?: () => void; } const TagInput: React.FC = ({ initialTags, onTagsChange, availableTags, + onFocus, }) => { const { t } = useTranslation(); const [inputValue, setInputValue] = useState(''); @@ -229,6 +231,7 @@ const TagInput: React.FC = ({ placeholder={t('tags.typeToAdd')} className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100" onFocus={() => { + onFocus?.(); if (filteredTags.length > 0) setIsDropdownOpen(true); }} style={{ minWidth: '150px' }} @@ -248,7 +251,7 @@ const TagInput: React.FC = ({ > {filteredTags.map((tag, index) => ( @@ -385,7 +385,7 @@ const Tags: React.FC = () => { className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`} aria-label={`Delete ${tag.name}`} title={`Delete ${tag.name}`} - data-testid={`tag-delete-${tag.id}`} + data-testid={`tag-delete-${tag.uid || tag.id}`} > @@ -409,14 +409,16 @@ const Tags: React.FC = () => { setSelectedTag(null); }} onSave={handleSaveTag} - onDelete={async (tagId) => { + onDelete={async (tagUid) => { try { - await apiDeleteTag(tagId); - setTags(tags.filter((tag) => tag.id !== tagId)); + await apiDeleteTag(tagUid); + setTags( + tags.filter((tag) => tag.uid !== tagUid) + ); setTagMetrics((prev) => { const newMetrics = { ...prev }; const deletedTag = tags.find( - (t) => t.id === tagId + (t) => t.uid === tagUid ); if (deletedTag) { delete newMetrics[deletedTag.name]; diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index eab5e61..f40526f 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -484,6 +484,7 @@ const TaskDetails: React.FC = () => { ) => ( {
    diff --git a/frontend/components/Task/TaskTags.tsx b/frontend/components/Task/TaskTags.tsx index a668e19..fbfed9c 100644 --- a/frontend/components/Task/TaskTags.tsx +++ b/frontend/components/Task/TaskTags.tsx @@ -5,7 +5,7 @@ import { TagIcon, XMarkIcon } from '@heroicons/react/24/solid'; interface TaskTagsProps { tags: Tag[]; - onTagRemove?: (tagId: string | number | undefined) => void; + onTagRemove?: (tagUid: string | undefined) => void; className?: string; } @@ -38,7 +38,7 @@ const TaskTags: React.FC = ({
    {tags.map((tag, index) => (
    - +
    )} diff --git a/frontend/entities/InboxItem.ts b/frontend/entities/InboxItem.ts index de5f87b..0f3736b 100644 --- a/frontend/entities/InboxItem.ts +++ b/frontend/entities/InboxItem.ts @@ -1,5 +1,6 @@ export interface InboxItem { id?: number; + uid?: string; content: string; status?: string; // 'added' | 'processed' | 'deleted' source?: string; // 'telegram' diff --git a/frontend/entities/Note.ts b/frontend/entities/Note.ts index bc261c6..c1d7432 100644 --- a/frontend/entities/Note.ts +++ b/frontend/entities/Note.ts @@ -7,7 +7,8 @@ export interface Note { content: string; created_at?: string; updated_at?: string; - project_id?: number; // Foreign key for project + project_id?: number; // Foreign key for project (deprecated, use project_uid) + project_uid?: string; // Foreign key for project by uid tags?: Tag[]; Tags?: Tag[]; // Sequelize association naming (capitalized) project?: { diff --git a/frontend/entities/Project.ts b/frontend/entities/Project.ts index 4e2b819..0d79c99 100644 --- a/frontend/entities/Project.ts +++ b/frontend/entities/Project.ts @@ -3,15 +3,22 @@ import { Tag } from './Tag'; import { PriorityType, Task } from './Task'; import { Note } from './Note'; +export type ProjectState = + | 'idea' + | 'planned' + | 'in_progress' + | 'blocked' + | 'completed'; + export interface Project { id?: number; uid?: string; name: string; description?: string; - active: boolean; pin_to_sidebar?: boolean; area?: Area; area_id?: number | null; + area_uid?: string | null; tags?: Tag[]; priority?: PriorityType; tasks?: Task[]; @@ -22,6 +29,7 @@ export interface Project { image_url?: string; task_show_completed?: boolean; task_sort_order?: string; + state?: ProjectState; created_at?: string; updated_at?: string; } diff --git a/frontend/entities/User.ts b/frontend/entities/User.ts index cbb43a8..95fade3 100644 --- a/frontend/entities/User.ts +++ b/frontend/entities/User.ts @@ -1,5 +1,5 @@ export interface User { - id: number; + uid: string; email: string; language: string; appearance: string; diff --git a/frontend/hooks/usePersistedModal.ts b/frontend/hooks/usePersistedModal.ts new file mode 100644 index 0000000..85a7657 --- /dev/null +++ b/frontend/hooks/usePersistedModal.ts @@ -0,0 +1,78 @@ +import { useState, useEffect, useRef } from 'react'; + +interface PersistedModalState { + isOpen: boolean; + projectId?: number; + timestamp?: number; +} + +const MODAL_STATE_KEY = 'project-modal-state'; +const MODAL_TIMEOUT = 5000; // 5 seconds timeout to prevent stale states + +export const usePersistedModal = (projectId?: number) => { + const [isOpen, setIsOpen] = useState(false); + const timeoutRef = useRef(); + + // Load persisted state on mount + useEffect(() => { + const savedState = sessionStorage.getItem(MODAL_STATE_KEY); + if (savedState) { + try { + const state: PersistedModalState = JSON.parse(savedState); + const now = Date.now(); + + // Check if state is recent and for the same project + if ( + state.timestamp && + now - state.timestamp < MODAL_TIMEOUT && + state.projectId === projectId && + state.isOpen + ) { + setIsOpen(true); + } + } catch (error) { + console.error('Error parsing modal state:', error); + } + } + }, [projectId]); + + // Clear timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const openModal = () => { + const state: PersistedModalState = { + isOpen: true, + projectId, + timestamp: Date.now(), + }; + + sessionStorage.setItem(MODAL_STATE_KEY, JSON.stringify(state)); + setIsOpen(true); + + // Clear the persisted state after timeout + timeoutRef.current = setTimeout(() => { + sessionStorage.removeItem(MODAL_STATE_KEY); + }, MODAL_TIMEOUT); + }; + + const closeModal = () => { + sessionStorage.removeItem(MODAL_STATE_KEY); + setIsOpen(false); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + + return { + isOpen, + openModal, + closeModal, + }; +}; diff --git a/frontend/store/useStore.ts b/frontend/store/useStore.ts index 024da40..6769d6b 100644 --- a/frontend/store/useStore.ts +++ b/frontend/store/useStore.ts @@ -95,6 +95,7 @@ interface InboxStore { addInboxItem: (inboxItem: InboxItem) => void; updateInboxItem: (inboxItem: InboxItem) => void; removeInboxItem: (id: number) => void; + removeInboxItemByUid: (uid: string) => void; setLoading: (isLoading: boolean) => void; setError: (isError: boolean) => void; resetPagination: () => void; @@ -629,7 +630,10 @@ export const useStore = create((set: any) => ({ inboxStore: { ...state.inboxStore, inboxItems: state.inboxStore.inboxItems.map((item) => - item.id === inboxItem.id ? inboxItem : item + (inboxItem.uid && item.uid === inboxItem.uid) || + (inboxItem.id && item.id === inboxItem.id) + ? inboxItem + : item ), }, })), @@ -649,6 +653,22 @@ export const useStore = create((set: any) => ({ }, }, })), + removeInboxItemByUid: (uid) => + set((state) => ({ + inboxStore: { + ...state.inboxStore, + inboxItems: state.inboxStore.inboxItems.filter( + (item) => item.uid !== uid + ), + pagination: { + ...state.inboxStore.pagination, + total: Math.max( + 0, + state.inboxStore.pagination.total - 1 + ), + }, + }, + })), setLoading: (isLoading) => set((state) => ({ inboxStore: { ...state.inboxStore, isLoading }, diff --git a/frontend/utils/areasService.ts b/frontend/utils/areasService.ts index 1953e87..a1d3f2b 100644 --- a/frontend/utils/areasService.ts +++ b/frontend/utils/areasService.ts @@ -28,10 +28,10 @@ export const createArea = async (areaData: Partial): Promise => { }; export const updateArea = async ( - areaId: number, + areaUid: string, areaData: Partial ): Promise => { - const response = await fetch(`/api/areas/${areaId}`, { + const response = await fetch(`/api/areas/${areaUid}`, { method: 'PATCH', credentials: 'include', headers: { @@ -45,8 +45,8 @@ export const updateArea = async ( return await response.json(); }; -export const deleteArea = async (areaId: number): Promise => { - const response = await fetch(`/api/areas/${areaId}`, { +export const deleteArea = async (areaUid: string): Promise => { + const response = await fetch(`/api/areas/${areaUid}`, { method: 'DELETE', credentials: 'include', headers: { diff --git a/frontend/utils/inboxService.ts b/frontend/utils/inboxService.ts index 2f4d675..215d6ca 100644 --- a/frontend/utils/inboxService.ts +++ b/frontend/utils/inboxService.ts @@ -70,10 +70,10 @@ export const createInboxItem = async ( }; export const updateInboxItem = async ( - itemId: number, + itemUid: string, content: string ): Promise => { - const response = await fetch(`/api/inbox/${itemId}`, { + const response = await fetch(`/api/inbox/${itemUid}`, { method: 'PATCH', credentials: 'include', headers: { @@ -87,8 +87,8 @@ export const updateInboxItem = async ( return await response.json(); }; -export const processInboxItem = async (itemId: number): Promise => { - const response = await fetch(`/api/inbox/${itemId}/process`, { +export const processInboxItem = async (itemUid: string): Promise => { + const response = await fetch(`/api/inbox/${itemUid}/process`, { method: 'PATCH', credentials: 'include', headers: { @@ -100,8 +100,8 @@ export const processInboxItem = async (itemId: number): Promise => { return await response.json(); }; -export const deleteInboxItem = async (itemId: number): Promise => { - const response = await fetch(`/api/inbox/${itemId}`, { +export const deleteInboxItem = async (itemUid: string): Promise => { + const response = await fetch(`/api/inbox/${itemUid}`, { method: 'DELETE', credentials: 'include', headers: { @@ -132,15 +132,15 @@ export const loadInboxItemsToStore = async ( // Check for new items since last check (only for non-initial loads) if (!isInitialLoad) { - const currentItemIds = new Set( - inboxStore.inboxItems.map((item) => item.id) + const currentItemUids = new Set( + inboxStore.inboxItems.map((item) => item.uid).filter(Boolean) ); // New telegram items const newTelegramItems = items.filter( (item) => - item.id && - !currentItemIds.has(item.id) && + item.uid && + !currentItemUids.has(item.uid) && item.source === 'telegram' ); @@ -224,13 +224,13 @@ export const createInboxItemWithStore = async ( }; export const updateInboxItemWithStore = async ( - itemId: number, + itemUid: string, content: string ): Promise => { const inboxStore = useStore.getState().inboxStore; try { - const updatedItem = await updateInboxItem(itemId, content); + const updatedItem = await updateInboxItem(itemUid, content); inboxStore.updateInboxItem(updatedItem); return updatedItem; } catch (error) { @@ -240,13 +240,13 @@ export const updateInboxItemWithStore = async ( }; export const processInboxItemWithStore = async ( - itemId: number + itemUid: string ): Promise => { const inboxStore = useStore.getState().inboxStore; try { - const processedItem = await processInboxItem(itemId); - inboxStore.removeInboxItem(itemId); + const processedItem = await processInboxItem(itemUid); + inboxStore.removeInboxItemByUid(itemUid); return processedItem; } catch (error) { console.error('Failed to process inbox item:', error); @@ -255,13 +255,13 @@ export const processInboxItemWithStore = async ( }; export const deleteInboxItemWithStore = async ( - itemId: number + itemUid: string ): Promise => { const inboxStore = useStore.getState().inboxStore; try { - await deleteInboxItem(itemId); - inboxStore.removeInboxItem(itemId); + await deleteInboxItem(itemUid); + inboxStore.removeInboxItemByUid(itemUid); } catch (error) { console.error('Failed to delete inbox item:', error); throw error; diff --git a/frontend/utils/noteDeleteUtils.ts b/frontend/utils/noteDeleteUtils.ts index fcf8be9..4b5aa1e 100644 --- a/frontend/utils/noteDeleteUtils.ts +++ b/frontend/utils/noteDeleteUtils.ts @@ -4,24 +4,34 @@ import { Note } from '../entities/Note'; /** * Shared utility function to delete a note and update the global store - * @param noteId - The ID of the note to delete + * @param noteOrUid - The note object or UID of the note to delete * @param showSuccessToast - Function to show success toast * @param t - Translation function * @returns Promise */ export const deleteNoteWithStoreUpdate = async ( - noteId: number, + noteOrUid: Note | string, showSuccessToast: (message: string) => void, t: (key: string) => string ): Promise => { - await apiDeleteNote(noteId); + let noteUid: string; + + if (typeof noteOrUid === 'object') { + // It's a Note object + noteUid = noteOrUid.uid!; + } else { + // It's a UID string + noteUid = noteOrUid; + } + + await apiDeleteNote(noteUid); // Remove note from global store const currentNotes = useStore.getState().notesStore.notes; useStore .getState() .notesStore.setNotes( - currentNotes.filter((note: Note) => note.id !== noteId) + currentNotes.filter((note: Note) => note.uid !== noteUid) ); // Show success toast diff --git a/frontend/utils/notesService.ts b/frontend/utils/notesService.ts index 4211cb4..0a26655 100644 --- a/frontend/utils/notesService.ts +++ b/frontend/utils/notesService.ts @@ -28,22 +28,39 @@ export const createNote = async (noteData: Note): Promise => { }; export const updateNote = async ( - noteId: number, + noteUid: string, noteData: Note ): Promise => { - const response = await fetch(`/api/note/${noteId}`, { + // Transform project_id to project_uid if needed + const requestData = { ...noteData }; + if (noteData.project && noteData.project.uid) { + requestData.project_uid = noteData.project.uid; + } else if (noteData.project_uid) { + // project_uid is already set, use it as-is + } else if (noteData.project_id && !noteData.project_uid) { + // Legacy: if only project_id is provided, we can't convert it to uid here + // This should not happen with the new implementation, but keeping for safety + console.warn( + 'Note update with project_id but no project_uid - this may fail' + ); + } + + // Use the provided noteUid + const noteIdentifier = noteUid; + + const response = await fetch(`/api/note/${noteIdentifier}`, { method: 'PATCH', credentials: 'include', headers: getPostHeaders(), - body: JSON.stringify(noteData), + body: JSON.stringify(requestData), }); await handleAuthResponse(response, 'Failed to update note.'); return await response.json(); }; -export const deleteNote = async (noteId: number): Promise => { - const response = await fetch(`/api/note/${noteId}`, { +export const deleteNote = async (noteUid: string): Promise => { + const response = await fetch(`/api/note/${noteUid}`, { method: 'DELETE', credentials: 'include', headers: getDefaultHeaders(), diff --git a/frontend/utils/projectsService.ts b/frontend/utils/projectsService.ts index cdc87e2..69f327d 100644 --- a/frontend/utils/projectsService.ts +++ b/frontend/utils/projectsService.ts @@ -2,13 +2,13 @@ import { Project } from '../entities/Project'; import { handleAuthResponse } from './authUtils'; export const fetchProjects = async ( - activeFilter = 'all', + stateFilter = 'all', areaFilter = '' ): Promise => { let url = `/api/projects`; const params = new URLSearchParams(); - if (activeFilter !== 'all') params.append('active', activeFilter); + if (stateFilter !== 'all') params.append('state', stateFilter); if (areaFilter) params.append('area_id', areaFilter); if (params.toString()) url += `?${params.toString()}`; @@ -24,14 +24,14 @@ export const fetchProjects = async ( }; export const fetchGroupedProjects = async ( - activeFilter = 'all', + stateFilter = 'all', areaFilter = '' ): Promise> => { let url = `/api/projects`; const params = new URLSearchParams(); params.append('grouped', 'true'); - if (activeFilter !== 'all') params.append('active', activeFilter); + if (stateFilter !== 'all') params.append('state', stateFilter); if (areaFilter) params.append('area_id', areaFilter); if (params.toString()) url += `?${params.toString()}`; @@ -74,10 +74,10 @@ export const createProject = async ( }; export const updateProject = async ( - projectId: number, + projectUid: string, projectData: Partial ): Promise => { - const response = await fetch(`/api/project/${projectId}`, { + const response = await fetch(`/api/project/${projectUid}`, { method: 'PATCH', credentials: 'include', headers: { @@ -91,14 +91,14 @@ export const updateProject = async ( return await response.json(); }; -export const deleteProject = async (projectId: number): Promise => { - if (!projectId || projectId === null || projectId === undefined) { - throw new Error('Cannot delete project: Invalid project ID'); +export const deleteProject = async (projectUid: string): Promise => { + if (!projectUid || projectUid === null || projectUid === undefined) { + throw new Error('Cannot delete project: Invalid project UID'); } - console.log('Attempting to delete project with ID:', projectId); + console.log('Attempting to delete project with UID:', projectUid); - const response = await fetch(`/api/project/${projectId}`, { + const response = await fetch(`/api/project/${projectUid}`, { method: 'DELETE', credentials: 'include', headers: { diff --git a/frontend/utils/tagsService.ts b/frontend/utils/tagsService.ts index f59b094..4508bb1 100644 --- a/frontend/utils/tagsService.ts +++ b/frontend/utils/tagsService.ts @@ -35,8 +35,8 @@ export const createTag = async (tagData: Tag): Promise => { return await response.json(); }; -export const updateTag = async (tagId: number, tagData: Tag): Promise => { - const response = await fetch(`/api/tag/${tagId}`, { +export const updateTag = async (tagUid: string, tagData: Tag): Promise => { + const response = await fetch(`/api/tag/${tagUid}`, { method: 'PATCH', credentials: 'include', headers: { @@ -50,8 +50,8 @@ export const updateTag = async (tagId: number, tagData: Tag): Promise => { return await response.json(); }; -export const deleteTag = async (tagId: number): Promise => { - const response = await fetch(`/api/tag/${tagId}`, { +export const deleteTag = async (tagUid: string): Promise => { + const response = await fetch(`/api/tag/${tagUid}`, { method: 'DELETE', credentials: 'include', headers: { diff --git a/frontend/utils/taskEventService.ts b/frontend/utils/taskEventService.ts index ac036b6..c95de7b 100644 --- a/frontend/utils/taskEventService.ts +++ b/frontend/utils/taskEventService.ts @@ -11,8 +11,10 @@ const API_BASE = '/api'; /** * Get task timeline (all events for a specific task) */ -export const getTaskTimeline = async (taskId: number): Promise => { - const response = await fetch(`${API_BASE}/task/${taskId}/timeline`, { +export const getTaskTimeline = async ( + taskUid: string +): Promise => { + const response = await fetch(`${API_BASE}/task/${taskUid}/timeline`, { credentials: 'include', }); @@ -29,11 +31,14 @@ export const getTaskTimeline = async (taskId: number): Promise => { * Get task completion time analytics */ export const getTaskCompletionTime = async ( - taskId: number + taskUid: string ): Promise => { - const response = await fetch(`${API_BASE}/task/${taskId}/completion-time`, { - credentials: 'include', - }); + const response = await fetch( + `${API_BASE}/task/${taskUid}/completion-time`, + { + credentials: 'include', + } + ); if (response.status === 404) { return null; @@ -111,15 +116,14 @@ export const getCompletionAnalytics = async ( options: { limit?: number; offset?: number; - projectId?: number; + projectUid?: string; } = {} ): Promise => { const params = new URLSearchParams(); if (options.limit) params.append('limit', options.limit.toString()); if (options.offset) params.append('offset', options.offset.toString()); - if (options.projectId) - params.append('projectId', options.projectId.toString()); + if (options.projectUid) params.append('projectUid', options.projectUid); const response = await fetch( `${API_BASE}/tasks/completion-analytics?${params}`, diff --git a/package.json b/package.json index 25e1c28..640d36d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tududi", - "version": "v0.82", + "version": "v0.83-rc7", "description": "Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration.", "directories": { "test": "test" @@ -12,6 +12,7 @@ "pre-push": "npm run lint:fix && npm run format:fix", "pre-release": "npm run lint:fix && npm run format:fix && npm run test && npm run test:ui", "test": "npm run backend:test", + "test:backend": "npm run backend:test", "test:ui": "bash e2e/bin/run-e2e.sh && echo \"Success!\"", "test:ui:headed": "cross-env E2E_MODE=headed E2E_SLOWMO=500 bash e2e/bin/run-e2e.sh", "test:watch": "npm run frontend:test:watch", diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index a16287d..d7a19f5 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -655,6 +655,20 @@ "inactive": "غير نشط", "all": "الكل", "allAreas": "جميع المناطق" + }, + "selectState": "اختر الحالة", + "state": "حالة المشروع", + "states": { + "idea": "فكرة", + "planned": "مخطط", + "in_progress": "قيد التنفيذ", + "blocked": "محجوز", + "completed": "مكتمل", + "idea_desc": "تم التقاطها ولكن لم يتم التخطيط لها بعد", + "planned_desc": "تم تحديد نطاقها وجاهزة للبدء", + "in_progress_desc": "يتم العمل النشط", + "blocked_desc": "مؤقتًا متوقف أو عالق", + "completed_desc": "تم الانتهاء منها" } }, "projectItem": { diff --git a/public/locales/bg/translation.json b/public/locales/bg/translation.json index 153a012..de97f5b 100644 --- a/public/locales/bg/translation.json +++ b/public/locales/bg/translation.json @@ -655,6 +655,20 @@ "inactive": "Неактивен", "all": "Всички", "allAreas": "Всички области" + }, + "selectState": "Изберете състояние", + "state": "Състояние на проекта", + "states": { + "idea": "Идея", + "planned": "Планирано", + "in_progress": "В процес", + "blocked": "Блокирано", + "completed": "Завършено", + "idea_desc": "Засечено, но все още не е планирано", + "planned_desc": "Определено и готово за стартиране", + "in_progress_desc": "Активна работа в ход", + "blocked_desc": "Временно спряно или блокирано", + "completed_desc": "Завършено и готово" } }, "projectItem": { diff --git a/public/locales/da/translation.json b/public/locales/da/translation.json index b245192..90d9762 100644 --- a/public/locales/da/translation.json +++ b/public/locales/da/translation.json @@ -655,6 +655,20 @@ "inactive": "Inaktiv", "all": "Alle", "allAreas": "Alle områder" + }, + "selectState": "Vælg tilstand", + "state": "Projektstatus", + "states": { + "idea": "Idé", + "planned": "Planlagt", + "in_progress": "I gang", + "blocked": "Blokeret", + "completed": "Færdig", + "idea_desc": "Fanget, men ikke planlagt endnu", + "planned_desc": "Afgrænset og klar til at starte", + "in_progress_desc": "Aktivt arbejde i gang", + "blocked_desc": "Midlertidigt pauseret eller fastlåst", + "completed_desc": "Afsluttet og færdig" } }, "projectItem": { diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 7da299d..cd7f17e 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -745,6 +745,20 @@ "inactive": "Inaktiv", "all": "Alle", "allAreas": "Alle Bereiche" + }, + "selectState": "Zustand auswählen", + "state": "Projektzustand", + "states": { + "idea": "Idee", + "planned": "Geplant", + "in_progress": "In Arbeit", + "blocked": "Blockiert", + "completed": "Abgeschlossen", + "idea_desc": "Erfasst, aber noch nicht geplant", + "planned_desc": "Definiert und bereit zum Start", + "in_progress_desc": "Aktive Arbeit im Gange", + "blocked_desc": "Vorübergehend pausiert oder festgefahren", + "completed_desc": "Fertiggestellt und abgeschlossen" } }, "projectItem": { diff --git a/public/locales/el/translation.json b/public/locales/el/translation.json index b223632..b39c8b5 100644 --- a/public/locales/el/translation.json +++ b/public/locales/el/translation.json @@ -364,6 +364,20 @@ "inactive": "Ανενεργά", "all": "Όλα", "allAreas": "Όλες οι περιοχές" + }, + "selectState": "Επιλέξτε Κατάσταση", + "state": "Κατάσταση Έργου", + "states": { + "idea": "Ιδέα", + "planned": "Προγραμματισμένο", + "in_progress": "Σε Εξέλιξη", + "blocked": "Εμποδισμένο", + "completed": "Ολοκληρωμένο", + "idea_desc": "Καταγεγραμμένο αλλά όχι ακόμα προγραμματισμένο", + "planned_desc": "Καθορισμένο και έτοιμο να ξεκινήσει", + "in_progress_desc": "Ενεργή εργασία σε εξέλιξη", + "blocked_desc": "Προσωρινά παγωμένο ή κολλημένο", + "completed_desc": "Ολοκληρώθηκε και τελείωσε" } }, "notes": { diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 6b81eb1..6f1d2dc 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -650,11 +650,25 @@ "active": "Active", "inactive": "Inactive", "metrics": "Projects", + "selectState": "Select State", + "state": "Project State", "filters": { "active": "Active", "inactive": "Inactive", "all": "All", "allAreas": "All Areas" + }, + "states": { + "idea": "Idea", + "planned": "Planned", + "in_progress": "In Progress", + "blocked": "Blocked", + "completed": "Completed", + "idea_desc": "Captured but not planned yet", + "planned_desc": "Scoped and ready to start", + "in_progress_desc": "Active work happening", + "blocked_desc": "Temporarily paused or stuck", + "completed_desc": "Finished and done" } }, "projectItem": { diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 7496348..4af039a 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -364,6 +364,20 @@ "inactive": "Inactivos", "all": "Todos", "allAreas": "Todas las áreas" + }, + "selectState": "Seleccionar Estado", + "state": "Estado del Proyecto", + "states": { + "idea": "Idea", + "planned": "Planificado", + "in_progress": "En Progreso", + "blocked": "Bloqueado", + "completed": "Completado", + "idea_desc": "Capturado pero aún no planificado", + "planned_desc": "Definido y listo para comenzar", + "in_progress_desc": "Trabajo activo en curso", + "blocked_desc": "Pausado temporalmente o atascado", + "completed_desc": "Terminado y completado" } }, "projectItem": { diff --git a/public/locales/fi/translation.json b/public/locales/fi/translation.json index 8bb60d0..a2eebcd 100644 --- a/public/locales/fi/translation.json +++ b/public/locales/fi/translation.json @@ -655,6 +655,20 @@ "inactive": "Passiivinen", "all": "Kaikki", "allAreas": "Kaikki alueet" + }, + "selectState": "Valitse tila", + "state": "Projektin tila", + "states": { + "idea": "Idea", + "planned": "Suunniteltu", + "in_progress": "Käynnissä", + "blocked": "Estetty", + "completed": "Valmis", + "idea_desc": "Tallennettu mutta ei vielä suunniteltu", + "planned_desc": "Määritelty ja valmis aloittamaan", + "in_progress_desc": "Aktiivista työtä käynnissä", + "blocked_desc": "Tilapäisesti keskeytetty tai jumissa", + "completed_desc": "Valmistunut ja tehty" } }, "projectItem": { diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index fadd68e..06843e6 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -655,6 +655,20 @@ "inactive": "Inactif", "all": "Tous", "allAreas": "Toutes les zones" + }, + "selectState": "Sélectionner l'état", + "state": "État du projet", + "states": { + "idea": "Idée", + "planned": "Prévu", + "in_progress": "En cours", + "blocked": "Bloqué", + "completed": "Terminé", + "idea_desc": "Capturé mais pas encore planifié", + "planned_desc": "Défini et prêt à commencer", + "in_progress_desc": "Travail actif en cours", + "blocked_desc": "Temporairement mis en pause ou bloqué", + "completed_desc": "Fini et terminé" } }, "projectItem": { diff --git a/public/locales/id/translation.json b/public/locales/id/translation.json index 8950239..7bfb217 100644 --- a/public/locales/id/translation.json +++ b/public/locales/id/translation.json @@ -655,6 +655,20 @@ "inactive": "Tidak Aktif", "all": "Semua", "allAreas": "Semua Area" + }, + "selectState": "Pilih Status", + "state": "Status Proyek", + "states": { + "idea": "Ide", + "planned": "Direncanakan", + "in_progress": "Sedang Berlangsung", + "blocked": "Terblokir", + "completed": "Selesai", + "idea_desc": "Tercatat tetapi belum direncanakan", + "planned_desc": "Sudah ditentukan dan siap untuk dimulai", + "in_progress_desc": "Pekerjaan aktif sedang berlangsung", + "blocked_desc": "Sementara terhenti atau terjebak", + "completed_desc": "Selesai dan selesai" } }, "projectItem": { diff --git a/public/locales/it/translation.json b/public/locales/it/translation.json index a1e417c..e63d834 100644 --- a/public/locales/it/translation.json +++ b/public/locales/it/translation.json @@ -655,6 +655,20 @@ "inactive": "Inattivo", "all": "Tutti", "allAreas": "Tutte le Aree" + }, + "selectState": "Seleziona Stato", + "state": "Stato del Progetto", + "states": { + "idea": "Idea", + "planned": "Pianificato", + "in_progress": "In Corso", + "blocked": "Bloccato", + "completed": "Completato", + "idea_desc": "Catturato ma non ancora pianificato", + "planned_desc": "Definito e pronto per iniziare", + "in_progress_desc": "Lavoro attivo in corso", + "blocked_desc": "Pausa temporanea o bloccato", + "completed_desc": "Finito e completato" } }, "projectItem": { diff --git a/public/locales/jp/translation.json b/public/locales/jp/translation.json index d174153..4ef0d9a 100644 --- a/public/locales/jp/translation.json +++ b/public/locales/jp/translation.json @@ -522,7 +522,21 @@ }, "active": "アクティブ", "inactive": "非アクティブ", - "metrics": "プロジェクト" + "metrics": "プロジェクト", + "selectState": "状態を選択", + "state": "プロジェクトの状態", + "states": { + "idea": "アイデア", + "planned": "計画中", + "in_progress": "進行中", + "blocked": "ブロック中", + "completed": "完了", + "idea_desc": "キャプチャされたがまだ計画されていない", + "planned_desc": "スコープが決まり、開始準備が整った", + "in_progress_desc": "アクティブな作業が行われている", + "blocked_desc": "一時的に停止または行き詰まっている", + "completed_desc": "完了し、終了した" + } }, "projectItem": { "edit": "編集", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 0858f8c..74a549e 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -655,6 +655,20 @@ "inactive": "비활성", "all": "모두", "allAreas": "모든 영역" + }, + "selectState": "상태 선택", + "state": "프로젝트 상태", + "states": { + "idea": "아이디어", + "planned": "계획됨", + "in_progress": "진행 중", + "blocked": "차단됨", + "completed": "완료됨", + "idea_desc": "포착되었지만 아직 계획되지 않음", + "planned_desc": "범위가 정의되고 시작할 준비가 됨", + "in_progress_desc": "활동적인 작업 진행 중", + "blocked_desc": "일시적으로 중단되거나 막힘", + "completed_desc": "완료되고 끝남" } }, "projectItem": { diff --git a/public/locales/nl/translation.json b/public/locales/nl/translation.json index 279f017..6c65b52 100644 --- a/public/locales/nl/translation.json +++ b/public/locales/nl/translation.json @@ -655,6 +655,20 @@ "inactive": "Inactief", "all": "Alle", "allAreas": "Alle gebieden" + }, + "selectState": "Selecteer Staat", + "state": "Projectstaat", + "states": { + "idea": "Idee", + "planned": "Gepland", + "in_progress": "In Uitvoering", + "blocked": "Geblokkeerd", + "completed": "Voltooid", + "idea_desc": "Vastgelegd maar nog niet gepland", + "planned_desc": "Afgebakend en klaar om te starten", + "in_progress_desc": "Actief werk aan de gang", + "blocked_desc": "Tijdelijk gepauzeerd of vastgelopen", + "completed_desc": "Afgerond en gedaan" } }, "projectItem": { diff --git a/public/locales/no/translation.json b/public/locales/no/translation.json index 7c98bab..4aed660 100644 --- a/public/locales/no/translation.json +++ b/public/locales/no/translation.json @@ -655,6 +655,20 @@ "inactive": "Inaktiv", "all": "Alle", "allAreas": "Alle områder" + }, + "selectState": "Velg tilstand", + "state": "Prosjektstatus", + "states": { + "idea": "Ide", + "planned": "Planlagt", + "in_progress": "Under arbeid", + "blocked": "Blokkert", + "completed": "Fullført", + "idea_desc": "Fanget, men ikke planlagt ennå", + "planned_desc": "Avgrenset og klar til å starte", + "in_progress_desc": "Aktivt arbeid pågår", + "blocked_desc": "Midlertidig pauset eller fastlåst", + "completed_desc": "Ferdig og gjort" } }, "projectItem": { diff --git a/public/locales/pl/translation.json b/public/locales/pl/translation.json index 4f9e55c..af94a74 100644 --- a/public/locales/pl/translation.json +++ b/public/locales/pl/translation.json @@ -655,6 +655,20 @@ "inactive": "Nieaktywne", "all": "Wszystkie", "allAreas": "Wszystkie obszary" + }, + "selectState": "Wybierz stan", + "state": "Stan projektu", + "states": { + "idea": "Pomysł", + "planned": "Zaplanowane", + "in_progress": "W trakcie", + "blocked": "Zablokowane", + "completed": "Zakończone", + "idea_desc": "Zarejestrowane, ale jeszcze nie zaplanowane", + "planned_desc": "Określone i gotowe do rozpoczęcia", + "in_progress_desc": "Aktywna praca w toku", + "blocked_desc": "Tymczasowo wstrzymane lub utknęło", + "completed_desc": "Zakończone i gotowe" } }, "projectItem": { diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index e87583e..6d8151c 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -655,6 +655,20 @@ "inactive": "Inativo", "all": "Todos", "allAreas": "Todas as Áreas" + }, + "selectState": "Selecionar Estado", + "state": "Estado do Projeto", + "states": { + "idea": "Ideia", + "planned": "Planejado", + "in_progress": "Em Progresso", + "blocked": "Bloqueado", + "completed": "Concluído", + "idea_desc": "Capturado, mas ainda não planejado", + "planned_desc": "Escopado e pronto para começar", + "in_progress_desc": "Trabalho ativo em andamento", + "blocked_desc": "Pausado temporariamente ou preso", + "completed_desc": "Finalizado e concluído" } }, "projectItem": { diff --git a/public/locales/ro/translation.json b/public/locales/ro/translation.json index df6682c..9d1a2e4 100644 --- a/public/locales/ro/translation.json +++ b/public/locales/ro/translation.json @@ -655,6 +655,20 @@ "inactive": "Inactiv", "all": "Toate", "allAreas": "Toate zonele" + }, + "selectState": "Selectați Starea", + "state": "Starea Proiectului", + "states": { + "idea": "Idee", + "planned": "Planificat", + "in_progress": "În Progres", + "blocked": "Blocat", + "completed": "Finalizat", + "idea_desc": "Capturat, dar încă neplanificat", + "planned_desc": "Definit și gata de început", + "in_progress_desc": "Lucru activ în desfășurare", + "blocked_desc": "Pauză temporară sau blocat", + "completed_desc": "Finalizat și terminat" } }, "projectItem": { diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index de02af1..406ed65 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -655,6 +655,20 @@ "inactive": "Неактивные", "all": "Все", "allAreas": "Все области" + }, + "selectState": "Выберите состояние", + "state": "Состояние проекта", + "states": { + "idea": "Идея", + "planned": "Запланировано", + "in_progress": "В процессе", + "blocked": "Заблокировано", + "completed": "Завершено", + "idea_desc": "Зафиксировано, но еще не запланировано", + "planned_desc": "Определено и готово к началу", + "in_progress_desc": "Активная работа идет", + "blocked_desc": "Временно приостановлено или застряло", + "completed_desc": "Завершено и выполнено" } }, "projectItem": { diff --git a/public/locales/sl/translation.json b/public/locales/sl/translation.json index 819c51b..97af05d 100644 --- a/public/locales/sl/translation.json +++ b/public/locales/sl/translation.json @@ -655,6 +655,20 @@ "inactive": "Neaktivno", "all": "Vse", "allAreas": "Vse področja" + }, + "selectState": "Izberite stanje", + "state": "Stanje projekta", + "states": { + "idea": "Ideja", + "planned": "Načrtovano", + "in_progress": "V teku", + "blocked": "Zasblocked", + "completed": "Dokončano", + "idea_desc": "Zajeto, a še ni načrtovano", + "planned_desc": "Določeno in pripravljeno za začetek", + "in_progress_desc": "Aktivno delo poteka", + "blocked_desc": "Začasno ustavljeno ali zastojev", + "completed_desc": "Dokončano in opravljeno" } }, "projectItem": { diff --git a/public/locales/sv/translation.json b/public/locales/sv/translation.json index 2c15936..f4b0321 100644 --- a/public/locales/sv/translation.json +++ b/public/locales/sv/translation.json @@ -655,6 +655,20 @@ "inactive": "Inaktiva", "all": "Alla", "allAreas": "Alla områden" + }, + "selectState": "Välj status", + "state": "Projektstatus", + "states": { + "idea": "Idé", + "planned": "Planerad", + "in_progress": "Pågående", + "blocked": "Blockerad", + "completed": "Avslutad", + "idea_desc": "Fångad men inte planerad än", + "planned_desc": "Avgränsad och redo att starta", + "in_progress_desc": "Aktivt arbete pågår", + "blocked_desc": "Tillfälligt pausad eller fast", + "completed_desc": "Avslutad och klar" } }, "projectItem": { diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index 485e9f5..0915d12 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -655,6 +655,20 @@ "inactive": "Pasif", "all": "Hepsi", "allAreas": "Tüm Alanlar" + }, + "selectState": "Durum Seç", + "state": "Proje Durumu", + "states": { + "idea": "Fikir", + "planned": "Planlandı", + "in_progress": "Devam Ediyor", + "blocked": "Engellendi", + "completed": "Tamamlandı", + "idea_desc": "Kaydedildi ama henüz planlanmadı", + "planned_desc": "Kapsam belirlendi ve başlamaya hazır", + "in_progress_desc": "Aktif çalışma devam ediyor", + "blocked_desc": "Geçici olarak duraklatıldı veya takıldı", + "completed_desc": "Tamamlandı ve sona erdi" } }, "projectItem": { diff --git a/public/locales/ua/translation.json b/public/locales/ua/translation.json index e90024d..6e8bc2d 100644 --- a/public/locales/ua/translation.json +++ b/public/locales/ua/translation.json @@ -168,7 +168,21 @@ }, "active": "Активні", "inactive": "Неактивні", - "metrics": "Проекти" + "metrics": "Проекти", + "selectState": "Вибрати стан", + "state": "Стан проекту", + "states": { + "idea": "Ідея", + "planned": "Заплановано", + "in_progress": "В процесі", + "blocked": "Заблоковано", + "completed": "Завершено", + "idea_desc": "Захоплено, але ще не заплановано", + "planned_desc": "Визначено та готово до початку", + "in_progress_desc": "Активна робота триває", + "blocked_desc": "Тимчасово призупинено або застрягло", + "completed_desc": "Завершено та виконано" + } }, "projectItem": { "edit": "Редагувати", diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index 741737e..1b86d3f 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -655,6 +655,20 @@ "inactive": "Không hoạt động", "all": "Tất cả", "allAreas": "Tất cả các khu vực" + }, + "selectState": "Chọn Trạng Thái", + "state": "Trạng Thái Dự Án", + "states": { + "idea": "Ý Tưởng", + "planned": "Đã Lên Kế Hoạch", + "in_progress": "Đang Tiến Hành", + "blocked": "Bị Chặn", + "completed": "Đã Hoàn Thành", + "idea_desc": "Đã ghi lại nhưng chưa lên kế hoạch", + "planned_desc": "Đã xác định và sẵn sàng bắt đầu", + "in_progress_desc": "Công việc đang diễn ra", + "blocked_desc": "Tạm dừng hoặc bị mắc kẹt", + "completed_desc": "Đã hoàn tất và xong" } }, "projectItem": { diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 6f7d2cd..52afdd1 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -655,6 +655,20 @@ "inactive": "非活动", "all": "所有", "allAreas": "所有区域" + }, + "selectState": "选择状态", + "state": "项目状态", + "states": { + "idea": "想法", + "planned": "已规划", + "in_progress": "进行中", + "blocked": "被阻塞", + "completed": "已完成", + "idea_desc": "已捕获但尚未规划", + "planned_desc": "已确定范围并准备开始", + "in_progress_desc": "正在进行的工作", + "blocked_desc": "暂时暂停或卡住", + "completed_desc": "已完成并结束" } }, "projectItem": { diff --git a/scripts/create-version.sh b/scripts/create-version.sh new file mode 100755 index 0000000..93b1387 --- /dev/null +++ b/scripts/create-version.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +VERSION=$1 + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: This script must be run inside a git repository." >&2 + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) +cd "$REPO_ROOT" + +if [[ ! -f package.json ]]; then + echo "Error: package.json not found in repository root ($REPO_ROOT)." >&2 + exit 1 +fi + +if git show-ref --tags --verify --quiet "refs/tags/$VERSION"; then + echo "Error: Tag $VERSION already exists." >&2 + exit 1 +fi + +if [[ -n $(git status --porcelain) ]]; then + echo "Error: Working tree has uncommitted changes. Please commit or stash them before continuing." >&2 + exit 1 +fi + +CURRENT_VERSION=$(node -e "console.log(require('./package.json').version)") +if [[ "$CURRENT_VERSION" == "$VERSION" ]]; then + echo "Error: package.json is already set to version $VERSION." >&2 + exit 1 +fi + +node - "$VERSION" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const version = process.argv[2]; +if (!version) { + console.error('No version provided to the Node helper.'); + process.exit(1); +} + +const pkgPath = path.resolve('package.json'); +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); +pkg.version = version; +fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +NODE + +git add package.json + +if git diff --cached --quiet; then + echo "Error: No changes to commit after updating package.json." >&2 + exit 1 +fi + +COMMIT_MESSAGE="release: $VERSION" +git commit -m "$COMMIT_MESSAGE" + +git tag -a "$VERSION" -m "Release $VERSION" + +echo "Version updated to $VERSION." +echo "Created commit: $COMMIT_MESSAGE" +echo "Created annotated tag: $VERSION"