Merge branch 'cleanups' into pro/user-perms

This commit is contained in:
antanst 2025-10-02 15:42:42 +03:00
commit 1a500663ed
107 changed files with 2752 additions and 1048 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
certs/
.DS_Store
.cursor
AGENTS.md
CLAUDE.local.md
.byebug_history

View file

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

View file

@ -13,7 +13,7 @@ module.exports = {
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
verbose: false,
forceExit: true,
clearMocks: true,
resetMocks: true,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
const logError = console.error;
const logInfo = console.log;
const logDebug = console.log;
module.exports = {
logError,
logInfo,
logDebug,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
const { validateTagName } = require('../../../utils/validation');
const { validateTagName } = require('../../../services/tagsService');
describe('validation utils', () => {
describe('validateTagName', () => {

View file

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

View file

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

113
e2e/bin/run-single-test.sh Executable file
View file

@ -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 <test-name-pattern> [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"

View file

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

View file

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

View file

@ -149,15 +149,15 @@ const Layout: React.FC<LayoutProps> = ({
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<LayoutProps> = ({
try {
const newProject = await createProject({
name,
active: true,
state: 'planned',
});
return newProject;
} catch (error) {
@ -234,8 +234,8 @@ const Layout: React.FC<LayoutProps> = ({
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<LayoutProps> = ({
const handleSaveArea = async (areaData: Partial<Area>) => {
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<LayoutProps> = ({
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<LayoutProps> = ({
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<LayoutProps> = ({
.getState()
.projectsStore.setProjects(
currentProjects.filter(
(p) => p.id !== projectId
(p) => p.uid !== projectUid
)
);

View file

@ -8,7 +8,7 @@ interface AreaModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (areaData: Partial<Area>) => Promise<void>;
onDelete?: (areaId: number) => Promise<void>;
onDelete?: (areaUid: string) => Promise<void>;
area?: Area | null;
}
@ -22,6 +22,7 @@ const AreaModal: React.FC<AreaModalProps> = ({
const { t } = useTranslation();
const [formData, setFormData] = useState<Area>({
id: area?.id || 0,
uid: area?.uid || '',
name: area?.name || '',
description: area?.description || '',
});
@ -38,6 +39,7 @@ const AreaModal: React.FC<AreaModalProps> = ({
if (isOpen) {
setFormData({
id: area?.id || 0,
uid: area?.uid || '',
name: area?.name || '',
description: area?.description || '',
});
@ -109,7 +111,7 @@ const AreaModal: React.FC<AreaModalProps> = ({
try {
await onSave(formData);
showSuccessToast(
formData.id
formData.uid
? t('success.areaUpdated')
: t('success.areaCreated')
);
@ -131,9 +133,9 @@ const AreaModal: React.FC<AreaModalProps> = ({
};
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<AreaModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{area &&
area.id &&
area.id !== 0 &&
onDelete && (
<button
type="button"
onClick={handleDeleteArea}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t(
'common.delete',
'Delete'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
{area && area.uid && onDelete && (
<button
type="button"
onClick={handleDeleteArea}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleClose}

View file

@ -29,7 +29,7 @@ const Areas: React.FC = () => {
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
const justOpenedRef = useRef<boolean>(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -78,8 +78,8 @@ const Areas: React.FC = () => {
try {
useStore.getState().areasStore.setLoading(true);
let result: Area;
if (areaData.id && areaData.id !== 0) {
result = await updateArea(areaData.id, {
if (areaData.uid) {
result = await updateArea(areaData.uid, {
name: areaData.name,
description: areaData.description,
});
@ -89,7 +89,7 @@ const Areas: React.FC = () => {
.getState()
.areasStore.setAreas(
currentAreas.map((area: any) =>
area.id === result.id ? result : area
area.uid === result.uid ? result : area
)
);
} else {
@ -131,14 +131,14 @@ const Areas: React.FC = () => {
useStore.getState().areasStore.setLoading(true);
try {
await deleteArea(areaToDelete.id!);
await deleteArea(areaToDelete.uid!);
// Remove the area from global state immediately
const currentAreas = useStore.getState().areasStore.areas;
useStore
.getState()
.areasStore.setAreas(
currentAreas.filter(
(area: any) => area.id !== areaToDelete.id
(area: any) => area.uid !== areaToDelete.uid
)
);
setIsConfirmDialogOpen(false);
@ -174,17 +174,17 @@ const Areas: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{areas.map((area: any) => (
<Link
key={area.id}
key={area.uid}
to={
area.uid
? `/projects?area=${area.uid}-${area.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`
: `/projects?area_id=${area.id}`
: `/projects?area_id=${area.uid}`
}
className={`bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col group hover:opacity-90 transition-opacity cursor-pointer ${
dropdownOpen === area.id ? 'z-50' : ''
dropdownOpen === area.uid ? 'z-50' : ''
}`}
style={{
minHeight: '120px',
@ -215,9 +215,9 @@ const Areas: React.FC = () => {
e.preventDefault();
e.stopPropagation();
const newDropdownState =
dropdownOpen === area.id
dropdownOpen === area.uid
? null
: area.id!;
: area.uid!;
if (newDropdownState !== null) {
justOpenedRef.current = true;
@ -228,12 +228,12 @@ const Areas: React.FC = () => {
aria-label={t(
'areas.toggleDropdownMenu'
)}
data-testid={`area-dropdown-${area.id}`}
data-testid={`area-dropdown-${area.uid}`}
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
{dropdownOpen === area.id && (
{dropdownOpen === area.uid && (
<div className="absolute right-0 top-full mt-1 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-[60]">
<button
onClick={(e) => {
@ -243,7 +243,7 @@ const Areas: React.FC = () => {
setDropdownOpen(null);
}}
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={`area-edit-${area.id}`}
data-testid={`area-edit-${area.uid}`}
>
{t('areas.edit', 'Edit')}
</button>
@ -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')}
</button>
@ -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()

View file

@ -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<void>;
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<void>;
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<InboxItemDetailProps> = ({
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [loading, setLoading] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
};
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<InboxItemDetailProps> = ({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
<div className="flex-1">
<div className="flex items-center px-4 py-2 gap-2">
<div className="flex-1 w-4/5">
<button
onClick={() => {
if (onUpdate && item.id !== undefined) {
onUpdate(item.id);
if (onUpdate && item.uid !== undefined) {
onUpdate(item.uid);
}
}}
className="text-base font-medium text-gray-900 dark:text-gray-300 break-words text-left cursor-pointer w-full"
@ -511,14 +558,15 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
)}
</div>
<div className="flex items-center justify-start space-x-1 shrink-0">
{/* Desktop view (md and larger) */}
<div className="hidden md:flex items-center justify-end w-1/5 space-x-1">
{loading && <div className="spinner" />}
{/* Edit Button */}
<button
onClick={() => {
if (onUpdate && item.id !== undefined) {
onUpdate(item.id);
if (onUpdate && item.uid !== undefined) {
onUpdate(item.uid);
}
}}
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
@ -563,6 +611,135 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
<TrashIcon className="h-4 w-4" />
</button>
</div>
{/* Mobile 3-dot dropdown menu */}
<div className="flex md:hidden items-center justify-end w-1/5 relative">
{loading && <div className="spinner mr-2" />}
<button
ref={buttonRef}
type="button"
data-dropdown-button
onClick={(e) => {
e.stopPropagation();
const newOpenState = !isDropdownOpen;
// Close other dropdowns when opening this one
if (newOpenState) {
document.dispatchEvent(
new CustomEvent('closeOtherDropdowns', {
detail: { dropdownId },
})
);
}
setIsDropdownOpen(newOpenState);
}}
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
{/* Dropdown Menu - Positioned Relatively */}
{isDropdownOpen && (
<div
data-dropdown-id={dropdownId}
className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] transform-gpu"
style={{
// Prevent dropdown from being cut off at the bottom of viewport
transform:
buttonRef.current &&
buttonRef.current.getBoundingClientRect()
.bottom +
240 >
window.innerHeight
? 'translateY(-100%) translateY(-8px)'
: 'none',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="py-1">
{/* Edit Button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (onUpdate) {
const identifier =
item.uid ??
(item.id !== undefined
? String(item.id)
: null);
if (identifier) {
onUpdate(identifier);
} else {
console.warn(
'Inbox item is missing an identifier for update.'
);
}
}
setIsDropdownOpen(false);
}}
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t('common.edit', 'Edit')}
</button>
{/* Convert to Task Button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleConvertToTask();
setIsDropdownOpen(false);
}}
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t('inbox.createTask', 'Create Task')}
</button>
{/* Convert to Project Button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleConvertToProject();
setIsDropdownOpen(false);
}}
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t('inbox.createProject', 'Create Project')}
</button>
{/* Convert to Note Button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleConvertToNote();
setIsDropdownOpen(false);
}}
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t('inbox.createNote', 'Create Note')}
</button>
{/* Delete Button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDelete();
setIsDropdownOpen(false);
}}
className="w-full px-4 py-2 text-sm text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
{t('common.delete', 'Delete')}
</button>
</div>
</div>
)}
</div>
</div>
{showConfirmDialog && (
<ConfirmDialog

View file

@ -60,13 +60,13 @@ const InboxItems: React.FC = () => {
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(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<number | null>(null);
const [itemToEdit, setItemToEdit] = useState<string | null>(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<void> => {
const handleUpdateItem = async (uid: string): Promise<void> => {
// 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<Project> => {
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 = () => {
<div className="space-y-2">
{inboxItems.map((item) => (
<InboxItemDetail
key={item.id}
key={item.uid || item.id}
item={item}
onProcess={handleProcessItem}
onDelete={handleDeleteItem}
@ -677,7 +680,7 @@ const InboxItems: React.FC = () => {
onSave={handleSaveTask}
onSaveNote={handleSaveNote}
initialText={
inboxItems.find((item) => item.id === itemToEdit)
inboxItems.find((item) => item.uid === itemToEdit)
?.content || ''
}
editMode={true}

View file

@ -926,7 +926,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
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<InboxModalProps> = ({
>
{filteredTags.map((tag, index) => (
<button
key={tag.id || index}
key={
tag.uid ||
tag.id ||
index
}
onClick={() =>
handleTagSelect(
tag.name

View file

@ -61,11 +61,7 @@ const NoteDetails: React.FC = () => {
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await deleteNoteWithStoreUpdate(
noteToDelete.id!,
showSuccessToast,
t
);
await deleteNoteWithStoreUpdate(noteToDelete, showSuccessToast, t);
navigate('/notes');
} catch (err) {
console.error('Error deleting note:', err);
@ -74,14 +70,18 @@ const NoteDetails: React.FC = () => {
const handleSaveNote = async (updatedNote: Note) => {
try {
if (updatedNote.id !== undefined) {
const noteIdentifier =
updatedNote.uid ??
(updatedNote.id !== undefined ? String(updatedNote.id) : null);
if (noteIdentifier) {
const savedNote = await apiUpdateNote(
updatedNote.id,
noteIdentifier,
updatedNote
);
setNote(savedNote);
} else {
console.error('Error: Note ID is undefined.');
console.error('Error: Note identifier is undefined.');
}
} catch (err) {
console.error('Error saving note:', err);
@ -237,10 +237,10 @@ const NoteDetails: React.FC = () => {
isOpen={isNoteModalOpen}
onClose={() => setIsNoteModalOpen(false)}
onSave={handleSaveNote}
onDelete={async (noteId) => {
onDelete={async (noteUid) => {
try {
await deleteNoteWithStoreUpdate(
noteId,
noteUid,
showSuccessToast,
t
);

View file

@ -27,7 +27,7 @@ interface NoteModalProps {
onClose: () => void;
note?: Note | null;
onSave: (noteData: Note) => Promise<void>;
onDelete?: (noteId: number) => Promise<void>;
onDelete?: (noteUid: string) => Promise<void>;
projects?: Project[];
onCreateProject?: (name: string) => Promise<Project>;
}
@ -119,9 +119,9 @@ const NoteModal: React.FC<NoteModalProps> = ({
setNewProjectName(currentProject ? currentProject.name : '');
// Auto-expand sections if they have content from existing note, editing existing note, or creating new note with pre-filled project
const shouldExpandTags = tagNames.length > 0 || !!note.id; // Expand if has tags OR editing existing note
const shouldExpandTags = tagNames.length > 0 || !!note.uid; // Expand if has tags OR editing existing note
const shouldExpandProject =
!!currentProject || !!note.id || !!projectIdToFind; // Expand if has project OR editing existing note OR has project_id
!!currentProject || !!note.uid || !!projectIdToFind; // Expand if has project OR editing existing note OR has project_id
setExpandedSections({
tags: shouldExpandTags,
@ -260,8 +260,8 @@ const NoteModal: React.FC<NoteModalProps> = ({
const handleProjectSelection = (project: Project) => {
setFormData((prev) => ({
...prev,
project: { id: project.id!, name: project.name },
project_id: project.id,
project: { id: project.id!, name: project.name, uid: project.uid },
project_uid: project.uid,
}));
setNewProjectName(project.name);
setDropdownOpen(false);
@ -274,8 +274,12 @@ const NoteModal: React.FC<NoteModalProps> = ({
const newProject = await onCreateProject(newProjectName.trim());
setFormData((prev) => ({
...prev,
project: { id: newProject.id!, name: newProject.name },
project_id: newProject.id,
project: {
id: newProject.id!,
name: newProject.name,
uid: newProject.uid,
},
project_uid: newProject.uid,
}));
setFilteredProjects([...filteredProjects, newProject]);
setNewProjectName(newProject.name);
@ -339,9 +343,9 @@ const NoteModal: React.FC<NoteModalProps> = ({
};
const handleDeleteNote = async () => {
if (formData.id && formData.id !== 0 && onDelete) {
if (formData.uid && onDelete) {
try {
await onDelete(formData.id);
await onDelete(formData.uid);
showSuccessToast(t('success.noteDeleted'));
handleClose();
} catch (err) {
@ -636,7 +640,7 @@ const NoteModal: React.FC<NoteModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{note && note.id && onDelete && (
{note && note.uid && onDelete && (
<button
type="button"
onClick={handleDeleteNote}

View file

@ -51,11 +51,7 @@ const Notes: React.FC = () => {
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await deleteNoteWithStoreUpdate(
noteToDelete.id!,
showSuccessToast,
t
);
await deleteNoteWithStoreUpdate(noteToDelete, showSuccessToast, t);
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
} catch (err) {
@ -70,10 +66,10 @@ const Notes: React.FC = () => {
const handleSaveNote = async (noteData: Note) => {
try {
if (noteData.id) {
const savedNote = await updateNote(noteData.id, noteData);
if (noteData.uid) {
const savedNote = await updateNote(noteData.uid, noteData);
const updatedNotes = notes.map((note) =>
note.id === noteData.id ? savedNote : note
note.uid === noteData.uid ? savedNote : note
);
setNotes(updatedNotes);
} else {
@ -221,7 +217,7 @@ const Notes: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedNotes.map((note) => (
<NoteCard
key={note.id}
key={note.uid}
note={note}
onEdit={handleEditNote}
onDelete={(note) => {
@ -243,10 +239,10 @@ const Notes: React.FC = () => {
setIsNoteModalOpen(false);
}}
onSave={handleSaveNote}
onDelete={async (noteId) => {
onDelete={async (noteUid) => {
try {
await deleteNoteWithStoreUpdate(
noteId,
noteUid,
showSuccessToast,
t
);

View file

@ -87,7 +87,8 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
// 1. Stalled Projects (no tasks/actions)
const stalledProjects = projects.filter(
(project) =>
project.active &&
(project.state === 'planned' ||
project.state === 'in_progress') &&
!activeTasks.some((task) => task.project_id === project.id)
);
@ -123,7 +124,12 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
(task.status === 'not_started' ||
task.status === 'in_progress')
);
return project.active && hasCompletedTasks && !hasNextAction;
return (
(project.state === 'planned' ||
project.state === 'in_progress') &&
hasCompletedTasks &&
!hasNextAction
);
});
if (projectsNeedingNextAction.length > 0) {
@ -221,7 +227,13 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
// 6. Stuck projects (not updated in a month)
const stuckProjects = projects.filter((project) => {
if (!project.active) return false;
if (
!(
project.state === 'planned' ||
project.state === 'in_progress'
)
)
return false;
// Projects don't have date fields in the interface, so we'll check if they have recent tasks
const projectTasks = activeTasks.filter(
@ -345,7 +357,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const project = await createProject({ name, active: true });
const project = await createProject({ name, state: 'planned' });
setAllProjects((prev) => [...prev, project]);
return project;
} catch (error) {

View file

@ -32,13 +32,13 @@ import FirstDayOfWeekDropdown from '../Shared/FirstDayOfWeekDropdown';
import { getLocaleFirstDayOfWeek } from '../../utils/profileService';
interface ProfileSettingsProps {
currentUser: { id: number; email: string };
currentUser: { uid: string; email: string };
isDarkMode?: boolean;
toggleDarkMode?: () => void;
}
interface Profile {
id: number;
uid: string;
email: string;
appearance: 'light' | 'dark';
language: string;

View file

@ -7,6 +7,12 @@ import {
TrashIcon,
TagIcon,
PlusCircleIcon,
Squares2X2Icon,
PlayIcon,
LightBulbIcon,
ClipboardDocumentListIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import TaskList from '../Task/TaskList';
import ProjectModal from '../Project/ProjectModal';
@ -39,6 +45,7 @@ import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService';
import AutoSuggestNextActionBox from './AutoSuggestNextActionBox';
import SortFilterButton, { SortOption } from '../Shared/SortFilterButton';
import LoadingSpinner from '../Shared/LoadingSpinner';
import { usePersistedModal } from '../../hooks/usePersistedModal';
const ProjectDetails: React.FC = () => {
const { uidSlug } = useParams<{ uidSlug: string }>();
@ -64,7 +71,14 @@ const ProjectDetails: React.FC = () => {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// Use persisted modal state that survives component remounts
const {
isOpen: isModalOpen,
openModal,
closeModal,
} = usePersistedModal(project?.id);
const editButtonRef = useRef<HTMLButtonElement>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false);
@ -376,22 +390,41 @@ const ProjectDetails: React.FC = () => {
}
};
const handleEditProject = () => {
setIsModalOpen(true);
};
// Setup native event listener for edit button to avoid React event system conflicts
useEffect(() => {
const button = editButtonRef.current;
if (button) {
const handleClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
openModal();
};
button.addEventListener('click', handleClick);
return () => {
button.removeEventListener('click', handleClick);
};
}
}, [openModal]);
const handleSaveProject = async (updatedProject: Project) => {
if (!updatedProject.id) {
if (!updatedProject.uid) {
return;
}
try {
const savedProject = await updateProject(
updatedProject.id,
updatedProject.uid,
updatedProject
);
setProject(savedProject);
setIsModalOpen(false);
// Merge the saved project with existing project to preserve area data
setProject((prevProject) => ({
...savedProject,
// Preserve area info if it's missing from the response
area: savedProject.area || prevProject?.area,
Area: (savedProject as any).Area || (prevProject as any)?.Area,
}));
closeModal();
} catch {
// Error saving project - silently handled
}
@ -477,12 +510,12 @@ const ProjectDetails: React.FC = () => {
};
const handleDeleteProject = async () => {
if (!project?.id) {
if (!project?.uid) {
return;
}
try {
await deleteProject(project.id);
await deleteProject(project.uid);
navigate('/projects');
} catch {
// Error deleting project - silently handled
@ -493,7 +526,7 @@ const ProjectDetails: React.FC = () => {
const handleEditNote = async (note: Note) => {
try {
// Fetch the complete note data including tags
const response = await fetch(`/api/note/${note.id}`, {
const response = await fetch(`/api/note/${note.uid}`, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
@ -513,10 +546,17 @@ const ProjectDetails: React.FC = () => {
setIsNoteModalOpen(true);
};
const handleDeleteNote = async (noteId: number) => {
const handleDeleteNote = async (noteIdentifier: string) => {
try {
await apiDeleteNote(noteId);
setNotes(notes.filter((n) => n.id !== noteId));
await apiDeleteNote(noteIdentifier);
setNotes(
notes.filter((n) => {
const currentIdentifier =
n.uid ??
(n.id !== undefined ? String(n.id) : undefined);
return currentIdentifier !== noteIdentifier;
})
);
setNoteToDelete(null);
setIsConfirmDialogOpen(false);
} catch {
@ -528,8 +568,15 @@ const ProjectDetails: React.FC = () => {
const handleSaveNote = async (noteData: Note) => {
try {
let savedNote: Note;
if (noteData.id) {
savedNote = await updateNote(noteData.id, noteData);
const noteIdentifier =
noteData.uid ??
(noteData.id !== undefined ? String(noteData.id) : null);
let isUpdate = false;
if (noteIdentifier) {
savedNote = await updateNote(noteIdentifier, noteData);
isUpdate = true;
} else {
savedNote = await createNote(noteData);
}
@ -542,9 +589,21 @@ const ProjectDetails: React.FC = () => {
// If updated note moved to another project, remove it from this list
if (savedNote.id && savedNote.project_id !== project?.id) {
setNotes(notes.filter((n) => n.id !== savedNote.id));
} else if (noteData.id) {
} else if (isUpdate) {
const savedIdentifier =
savedNote.uid ??
(savedNote.id !== undefined ? String(savedNote.id) : null);
setNotes(
notes.map((n) => (n.id === savedNote.id ? savedNote : n))
notes.map((n) => {
const currentIdentifier =
n.uid ??
(n.id !== undefined ? String(n.id) : undefined);
return currentIdentifier === savedIdentifier
? savedNote
: n;
})
);
} else {
setNotes([savedNote, ...notes]);
@ -635,6 +694,36 @@ const ProjectDetails: React.FC = () => {
return sortedTasks;
}, [tasks, showCompleted, orderBy]);
// Function to get the appropriate icon for project state
const getStateIcon = (state: string) => {
switch (state) {
case 'idea':
return (
<LightBulbIcon className="h-3 w-3 text-yellow-500 flex-shrink-0 mt-0.5" />
);
case 'planned':
return (
<ClipboardDocumentListIcon className="h-3 w-3 text-blue-500 flex-shrink-0 mt-0.5" />
);
case 'in_progress':
return (
<PlayIcon className="h-3 w-3 text-green-500 flex-shrink-0 mt-0.5" />
);
case 'blocked':
return (
<ExclamationTriangleIcon className="h-3 w-3 text-red-500 flex-shrink-0 mt-0.5" />
);
case 'completed':
return (
<CheckCircleIcon className="h-3 w-3 text-gray-500 flex-shrink-0 mt-0.5" />
);
default:
return (
<PlayIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
);
}
};
if (loading) {
return <LoadingSpinner message="Loading project details..." />;
}
@ -687,14 +776,31 @@ const ProjectDetails: React.FC = () => {
</div>
</div>
{/* Tags Display - Bottom Left */}
{project.tags && project.tags.length > 0 && (
<div className="absolute bottom-2 left-2 flex items-center space-x-1">
{/* State, Tags and Area Display - Bottom Left */}
<div className="absolute bottom-2 left-2 flex items-center space-x-2">
{/* Project State Display */}
{project.state && (
<div className="flex items-center space-x-2 bg-black bg-opacity-40 backdrop-blur-sm rounded px-2 py-1">
{getStateIcon(project.state)}
<div className="flex items-center space-x-1">
<span>
<span className="text-xs text-white/90 font-medium">
{t(
`projects.states.${project.state}`
)}
</span>
</span>
</div>
</div>
)}
{/* Tags Display */}
{project.tags && project.tags.length > 0 && (
<div className="flex items-center space-x-2 bg-black bg-opacity-40 backdrop-blur-sm rounded px-2 py-1">
<TagIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
<div className="flex items-center space-x-1">
{project.tags.map((tag, index) => (
<span key={tag.id || index}>
<span key={tag.uid || tag.id || index}>
<button
onClick={() => {
// Navigate to tag details page
@ -733,22 +839,75 @@ const ProjectDetails: React.FC = () => {
))}
</div>
</div>
</div>
)}
)}
{/* Area Display */}
{(project.area || (project as any).Area) && (
<div className="flex items-center space-x-2 bg-black bg-opacity-40 backdrop-blur-sm rounded px-2 py-1">
<Squares2X2Icon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
<div className="flex items-center space-x-1">
<span>
<button
onClick={() => {
// Use the correct area property (Area or area)
const projectArea =
project.area ||
(project as any).Area;
// Find the area in the areas store to get the uid
const area = areas.find(
(a) =>
a.id === projectArea.id
);
const areaUid = area?.uid;
if (!areaUid) {
console.warn(
'Area uid not found for area id:',
projectArea.id
);
return;
}
// Navigate to projects filtered by this area (same as Areas page)
const areaSlug =
projectArea.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(/^-|-$/g, '');
navigate(
`/projects?area=${areaUid}-${areaSlug}`
);
}}
className="text-xs text-white/90 hover:text-blue-200 transition-colors cursor-pointer font-medium"
>
{
(
project.area ||
(project as any).Area
)?.name
}
</button>
</span>
</div>
</div>
)}
</div>
{/* Edit/Delete Buttons - Bottom Right */}
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditProject();
}}
ref={editButtonRef}
type="button"
className="p-2 bg-black bg-opacity-50 text-blue-400 hover:text-blue-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -1010,7 +1169,7 @@ const ProjectDetails: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{notes.map((note) => (
<NoteCard
key={note.id}
key={note.uid}
note={note}
onEdit={handleEditNote}
onDelete={(note) => {
@ -1043,8 +1202,9 @@ const ProjectDetails: React.FC = () => {
project: {
id: project.id,
name: project.name,
uid: project.uid,
},
project_id: project.id,
project_uid: project.uid,
});
setIsNoteModalOpen(true);
}}
@ -1059,7 +1219,7 @@ const ProjectDetails: React.FC = () => {
<ProjectModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={closeModal}
onSave={handleSaveProject}
project={project}
areas={areas}
@ -1083,7 +1243,17 @@ const ProjectDetails: React.FC = () => {
<ConfirmDialog
title="Delete Note"
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
onConfirm={() => handleDeleteNote(noteToDelete.id!)}
onConfirm={() => {
const identifier =
noteToDelete?.uid ??
(noteToDelete?.id !== undefined
? String(noteToDelete.id)
: null);
if (identifier) {
handleDeleteNote(identifier);
}
}}
onCancel={() => {
setIsConfirmDialogOpen(false);
setNoteToDelete(null);

View file

@ -1,8 +1,16 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import { Project } from '../../entities/Project';
import {
PencilSquareIcon,
TrashIcon,
LightBulbIcon,
DocumentTextIcon,
PlayIcon,
StopIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import { Project, ProjectState } from '../../entities/Project';
import { useTranslation } from 'react-i18next';
import { useToast } from '../Shared/ToastContext';
@ -33,6 +41,40 @@ const getProjectInitials = (name: string, maxLetters?: number) => {
return maxLetters ? initials.substring(0, maxLetters) : initials;
};
const getStateIcon = (state: ProjectState | undefined) => {
switch (state) {
case 'idea':
return { icon: LightBulbIcon };
case 'planned':
return { icon: DocumentTextIcon };
case 'in_progress':
return { icon: PlayIcon };
case 'blocked':
return { icon: StopIcon };
case 'completed':
return { icon: CheckCircleIcon };
default:
return { icon: LightBulbIcon };
}
};
const getStateLabel = (state: ProjectState | undefined, t: any): string => {
switch (state) {
case 'idea':
return t('projects.states.idea', 'Idea');
case 'planned':
return t('projects.states.planned', 'Planned');
case 'in_progress':
return t('projects.states.in_progress', 'In Progress');
case 'blocked':
return t('projects.states.blocked', 'Blocked');
case 'completed':
return t('projects.states.completed', 'Completed');
default:
return t('projects.states.idea', 'Idea');
}
};
const ProjectItem: React.FC<ProjectItemProps> = ({
project,
viewMode,
@ -89,6 +131,21 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
{getProjectInitials(project.name)}
</span>
)}
{/* State icon in top right corner of image area */}
<div
className="absolute top-2 right-2 z-10"
title={getStateLabel(project.state, t)}
>
{(() => {
const { icon: StateIcon } = getStateIcon(
project.state
);
return (
<StateIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 opacity-70 drop-shadow-sm" />
);
})()}
</div>
</div>
</Link>
)}

View file

@ -8,8 +8,8 @@ import TagInput from '../Tag/TagInput';
import PriorityDropdown from '../Shared/PriorityDropdown';
import AreaDropdown from '../Shared/AreaDropdown';
import DatePicker from '../Shared/DatePicker';
import ProjectStateDropdown from '../Shared/ProjectStateDropdown';
import { PriorityType } from '../../entities/Task';
import Switch from '../Shared/Switch';
import { useStore } from '../../store/useStore';
import { useTranslation } from 'react-i18next';
import {
@ -19,14 +19,14 @@ import {
CameraIcon,
CalendarIcon,
ExclamationTriangleIcon,
PowerIcon,
PlayIcon,
} from '@heroicons/react/24/outline';
interface ProjectModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (project: Project) => void;
onDelete?: (projectId: number) => Promise<void>;
onDelete?: (projectUid: string) => Promise<void>;
project?: Project;
areas: Area[];
}
@ -45,7 +45,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
name: '',
description: '',
area_id: null,
active: true,
state: 'idea',
tags: [],
priority: 'low',
due_date_at: null,
@ -76,35 +76,32 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
// Collapsible sections state
const [expandedSections, setExpandedSections] = useState({
state: false,
tags: false,
area: false,
image: false,
priority: false,
dueDate: false,
active: false,
});
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation();
// Load tags when modal opens and auto-focus on the name input
// Auto-focus on the name input when modal opens
useEffect(() => {
if (isOpen) {
// Load tags with a delay to avoid conflicts with modal state
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
// Delay tag loading to avoid immediate state conflicts
setTimeout(() => {
if (isOpen) {
// Only load if modal is still open
tagsStore.loadTags();
}
}, 300);
}
setTimeout(() => {
nameInputRef.current?.focus();
}, 200);
}
}, [isOpen, tagsStore]);
}, [isOpen]);
// Load tags only when user actually interacts with tag input to prevent refresh
const handleTagInputFocus = () => {
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
tagsStore.loadTags();
}
};
// Manage body scroll when modal is open
useEffect(() => {
@ -139,7 +136,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
name: '',
description: '',
area_id: null,
active: true,
state: 'idea',
tags: [],
priority: 'low',
due_date_at: null,
@ -176,13 +173,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
handleClose();
};
if (isOpen) {
if (isOpen && !modalJustOpened) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
}, [isOpen, modalJustOpened]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -348,8 +345,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
tags: tags.map((name) => ({ name })),
};
// Save the project
onSave(projectData);
// Save the project and wait for it to complete
await onSave(projectData);
showSuccessToast(
project
@ -369,9 +366,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
};
const handleDeleteConfirm = async () => {
if (project && project.id && onDelete) {
if (project && project.uid && onDelete) {
try {
await onDelete(project.id);
await onDelete(project.uid);
showSuccessToast(t('success.projectDeleted'));
setShowConfirmDialog(false);
handleClose();
@ -390,13 +387,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}, 300);
};
const handleToggleActive = () => {
setFormData((prev) => ({
...prev,
active: !prev.active,
}));
};
const toggleSection = useCallback(
(section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
@ -445,7 +435,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
return createPortal(
<>
,
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
@ -529,34 +518,29 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</div>
{/* Expandable Sections - Only show when expanded */}
{/* Active Status Section - First */}
{expandedSections.active && (
{/* State Section - First */}
{expandedSections.state && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t(
'projects.active',
'Status'
'projects.state',
'Project State'
)}
</h3>
<div className="flex items-center">
<Switch
isChecked={
formData.active
}
onToggle={
handleToggleActive
}
/>
<label
htmlFor="active"
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
>
{t(
'projects.active',
'Active'
)}
</label>
</div>
<ProjectStateDropdown
value={
formData.state ||
'idea'
}
onChange={(state) =>
setFormData(
(prev) => ({
...prev,
state,
})
)
}
/>
</div>
)}
@ -576,6 +560,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
availableTags={
availableTags
}
onFocus={
handleTagInputFocus
}
/>
</div>
)}
@ -741,26 +728,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<div className="flex items-center justify-between">
{/* Left side: Section icons */}
<div className="flex items-center space-x-1">
{/* Active Status Toggle - First */}
{/* State Toggle - First */}
<button
type="button"
onClick={() =>
toggleSection('active')
toggleSection('state')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.active
expandedSections.state
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'projects.active',
'Status'
'projects.state',
'Project State'
)}
>
<PowerIcon className="h-5 w-5" />
{!formData.active && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full"></span>
)}
<PlayIcon className="h-5 w-5" />
{formData.state &&
formData.state !== 'idea' && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Tags Toggle */}

View file

@ -66,7 +66,7 @@ const Projects: React.FC = () => {
const [orderBy, setOrderBy] = useState<string>('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 = () => {
<div className="w-full md:w-auto mb-4 md:mb-0">
<FilterDropdown
options={statusOptions}
value={activeFilter}
onChange={handleActiveFilterChange}
value={stateFilter}
onChange={handleStateFilterChange}
size="desktop"
autoWidth={true}
/>
@ -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);

View file

@ -123,8 +123,8 @@ const AreaDropdown: React.FC<AreaDropdownProps> = ({
{/* Area options */}
{areas.map((area) => (
<button
key={area.id}
onClick={() => handleSelect(area.id!)}
key={area.uid}
onClick={() => handleSelect(null)} // Temporarily disabled
className="flex items-center px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full last:rounded-b-md"
>
<span className="flex items-center space-x-2">

View file

@ -7,20 +7,12 @@ import {
EllipsisVerticalIcon,
} from '@heroicons/react/24/outline';
import MarkdownRenderer from './MarkdownRenderer';
import { Note } from '../../entities/Note';
interface NoteCardProps {
note: {
id?: string | number;
uid?: string;
title: string;
content?: string;
tags?: { name: string; uid?: string }[];
Tags?: { name: string; uid?: string }[];
project?: { name: string; id?: number; uid?: string };
Project?: { name: string; id?: number; uid?: string };
};
onEdit?: (note: any) => void;
onDelete?: (note: any) => void;
note: Note;
onEdit?: (note: Note) => void;
onDelete?: (note: Note) => void;
showActions?: boolean;
showProject?: boolean;
}
@ -39,6 +31,8 @@ const NoteCard: React.FC<NoteCardProps> = ({
const tags = note.tags || note.Tags || [];
const project = note.project || note.Project;
const noteIdentifier =
note.uid ?? (note.id !== undefined ? String(note.id) : 'note');
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -62,14 +56,10 @@ const NoteCard: React.FC<NoteCardProps> = ({
return (
<div className="relative group">
<Link
to={
note.uid
? `/note/${note.uid}-${note.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`
: `/note/${note.id}`
}
to={`/note/${noteIdentifier}-${note.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')}`}
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer"
style={{
minHeight: '280px',
@ -211,7 +201,7 @@ const NoteCard: React.FC<NoteCardProps> = ({
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none transition-opacity duration-300 p-1"
aria-label={t('notes.toggleDropdownMenu')}
type="button"
data-testid={`note-dropdown-${note.id}`}
data-testid={`note-dropdown-${noteIdentifier}`}
>
<EllipsisVerticalIcon className="h-4 w-4" />
</button>
@ -227,7 +217,7 @@ const NoteCard: React.FC<NoteCardProps> = ({
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')}
</button>

View file

@ -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<ProjectStateDropdownProps> = ({
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: <LightBulbIcon className="w-5 h-5 text-yellow-500" />,
},
{
value: 'planned' as ProjectState,
label: t('projects.states.planned', 'Planned'),
description: t(
'projects.states.planned_desc',
'scoped and ready to start'
),
icon: (
<ClipboardDocumentListIcon className="w-5 h-5 text-blue-500" />
),
},
{
value: 'in_progress' as ProjectState,
label: t('projects.states.in_progress', 'In Progress'),
description: t(
'projects.states.in_progress_desc',
'active work happening'
),
icon: <PlayIcon className="w-5 h-5 text-green-500" />,
},
{
value: 'blocked' as ProjectState,
label: t('projects.states.blocked', 'Blocked'),
description: t(
'projects.states.blocked_desc',
'temporarily paused or stuck'
),
icon: <ExclamationTriangleIcon className="w-5 h-5 text-red-500" />,
},
{
value: 'completed' as ProjectState,
label: t('projects.states.completed', 'Completed'),
description: t(
'projects.states.completed_desc',
'finished and done'
),
icon: <CheckCircleIcon className="w-5 h-5 text-gray-500" />,
},
];
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button
type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{selectedState ? (
selectedState.icon
) : (
<LightBulbIcon className="w-5 h-5 text-gray-400" />
)}
<span>
{selectedState
? selectedState.label
: t('projects.selectState', 'Select State')}
</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
{isOpen && (
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600">
{states.map((state) => (
<button
key={state.value}
onClick={() => handleSelect(state.value)}
className="flex items-center justify-between w-full px-4 py-3 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600 first:rounded-t-md last:rounded-b-md transition duration-150 ease-in-out"
>
<div className="flex items-center space-x-3">
{state.icon}
<div className="text-left">
<div className="font-medium">
{state.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{state.description}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
);
};
export default ProjectStateDropdown;

View file

@ -31,7 +31,7 @@ const SidebarProjects: React.FC<SidebarProjectsProps> = ({
)}`}
onClick={() =>
handleNavClick(
'/projects?active=true',
'/projects',
'Projects',
<FolderIcon className="h-5 w-5 mr-2" />
)

View file

@ -39,7 +39,7 @@ const TagDetails: React.FC = () => {
// State for ProjectItem components
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null);
const [, setProjectToDelete] = useState<Project | null>(null);
const [, setIsConfirmDialogOpen] = useState<boolean>(false);
const navigate = useNavigate();
@ -260,10 +260,10 @@ const TagDetails: React.FC = () => {
<ul className="space-y-1">
{notes.map((note) => (
<li
key={note.id}
key={note.uid}
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
onMouseEnter={() =>
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}`}
>

View file

@ -6,12 +6,14 @@ interface TagInputProps {
initialTags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: Tag[];
onFocus?: () => void;
}
const TagInput: React.FC<TagInputProps> = ({
initialTags,
onTagsChange,
availableTags,
onFocus,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
@ -229,6 +231,7 @@ const TagInput: React.FC<TagInputProps> = ({
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<TagInputProps> = ({
>
{filteredTags.map((tag, index) => (
<button
key={tag.id}
key={tag.uid}
type="button"
onClick={() => selectTag(tag.name)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 ${

View file

@ -8,7 +8,7 @@ interface TagModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (tag: Tag) => void;
onDelete?: (tagId: number) => void;
onDelete?: (tagUid: string) => void;
tag?: Tag | null;
}
@ -127,9 +127,9 @@ const TagModal: React.FC<TagModalProps> = ({
};
const handleDeleteTag = async () => {
if (formData.id && onDelete) {
if (formData.uid && onDelete) {
try {
await onDelete(formData.id);
await onDelete(formData.uid);
showSuccessToast(
t('success.tagDeleted', 'Tag deleted successfully!')
);
@ -193,7 +193,7 @@ const TagModal: React.FC<TagModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{tag && tag.id && onDelete && (
{tag && tag.uid && onDelete && (
<button
type="button"
onClick={handleDeleteTag}

View file

@ -133,8 +133,8 @@ const Tags: React.FC = () => {
const handleDeleteTag = async () => {
if (!tagToDelete) return;
try {
await apiDeleteTag(tagToDelete.id!);
setTags(tags.filter((tag) => tag.id !== tagToDelete.id));
await apiDeleteTag(tagToDelete.uid!);
setTags(tags.filter((tag) => tag.uid !== tagToDelete.uid));
// Remove the deleted tag from metrics as well
setTagMetrics((prev) => {
const newMetrics = { ...prev };
@ -155,10 +155,10 @@ const Tags: React.FC = () => {
const handleSaveTag = async (tagData: Tag) => {
try {
if (tagData.id) {
await updateTag(tagData.id, tagData);
if (tagData.uid) {
await updateTag(tagData.uid, tagData);
setTags(
tags.map((tag) => (tag.id === tagData.id ? tagData : tag))
tags.map((tag) => (tag.uid === tagData.uid ? tagData : tag))
);
} else {
const newTag = await createTag(tagData);
@ -281,7 +281,7 @@ const Tags: React.FC = () => {
return (
<li
key={tag.id}
key={tag.uid || tag.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4"
onMouseEnter={() =>
setHoveredTagId(
@ -372,7 +372,7 @@ const Tags: React.FC = () => {
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Edit ${tag.name}`}
title={`Edit ${tag.name}`}
data-testid={`tag-edit-${tag.id}`}
data-testid={`tag-edit-${tag.uid || tag.id}`}
>
<PencilSquareIcon className="h-4 w-4" />
</button>
@ -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}`}
>
<TrashIcon className="h-4 w-4" />
</button>
@ -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];

View file

@ -484,6 +484,7 @@ const TaskDetails: React.FC = () => {
) => (
<React.Fragment
key={
tag.uid ||
tag.id ||
tag.name
}
@ -1014,7 +1015,7 @@ const TaskDetails: React.FC = () => {
</h4>
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
<TaskTimeline
taskId={task.id}
taskUid={task.uid}
refreshKey={timelineRefreshKey}
/>
</div>

View file

@ -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<TaskTagsProps> = ({
<div className={`flex flex-wrap gap-2 ${className}`}>
{tags.map((tag, index) => (
<div
key={tag.id || index}
key={tag.uid || tag.id || index}
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium px-2 py-1.5 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
>
<button
@ -54,7 +54,7 @@ const TaskTags: React.FC<TaskTagsProps> = ({
{onTagRemove && (
<button
type="button"
onClick={() => onTagRemove(tag.id)}
onClick={() => onTagRemove(tag.uid)}
className="ml-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 focus:outline-none"
aria-label={`Remove tag ${tag.name}`}
>

View file

@ -13,11 +13,11 @@ import {
} from '@heroicons/react/24/outline';
interface TaskTimelineProps {
taskId: number | undefined;
taskUid: string | undefined;
refreshKey?: number;
}
const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskUid, refreshKey }) => {
const { t } = useTranslation();
const [events, setEvents] = useState<TaskEvent[]>([]);
const [loading, setLoading] = useState(true);
@ -25,7 +25,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
useEffect(() => {
const fetchTimeline = async () => {
if (!taskId || taskId === undefined) {
if (!taskUid || taskUid === undefined) {
setLoading(false);
setEvents([]);
return;
@ -35,7 +35,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
setError(null);
try {
const timeline = await getTaskTimeline(taskId);
const timeline = await getTaskTimeline(taskUid);
// Sort events by created_at in descending order (most recent first)
const sortedTimeline = timeline.sort(
(a, b) =>
@ -53,7 +53,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
};
fetchTimeline();
}, [taskId, refreshKey]);
}, [taskUid, refreshKey]);
const getTranslatedStatusLabel = (status: number | string): string => {
// Handle both numeric and string status values
@ -196,7 +196,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
);
}
if (!taskId) {
if (!taskUid) {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<SparklesIcon className="h-6 w-6 mb-2" />

View file

@ -913,7 +913,13 @@ const TasksToday: React.FC = () => {
<p className="text-sm font-semibold">
{Array.isArray(localProjects)
? localProjects.filter(
(project) => project.active
(project) =>
project.state &&
[
'planned',
'in_progress',
'blocked',
].includes(project.state)
).length
: 0}
</p>

View file

@ -4,13 +4,13 @@ import TaskTimeline from './TaskTimeline';
import { ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface TimelinePanelProps {
taskId: number | undefined;
taskUid: string | undefined;
isExpanded: boolean;
onToggle: () => void;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
taskId,
taskUid,
isExpanded,
onToggle,
}) => {
@ -52,7 +52,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div>
</div>
<div className="p-3 lg:p-4 flex-1 overflow-hidden">
<TaskTimeline taskId={taskId} />
<TaskTimeline taskUid={taskUid} />
</div>
</>
)}

View file

@ -1,5 +1,6 @@
export interface InboxItem {
id?: number;
uid?: string;
content: string;
status?: string; // 'added' | 'processed' | 'deleted'
source?: string; // 'telegram'

View file

@ -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?: {

View file

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

View file

@ -1,5 +1,5 @@
export interface User {
id: number;
uid: string;
email: string;
language: string;
appearance: string;

View file

@ -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<NodeJS.Timeout>();
// 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,
};
};

View file

@ -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<StoreState>((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<StoreState>((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 },

View file

@ -28,10 +28,10 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
};
export const updateArea = async (
areaId: number,
areaUid: string,
areaData: Partial<Area>
): Promise<Area> => {
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<void> => {
const response = await fetch(`/api/areas/${areaId}`, {
export const deleteArea = async (areaUid: string): Promise<void> => {
const response = await fetch(`/api/areas/${areaUid}`, {
method: 'DELETE',
credentials: 'include',
headers: {

View file

@ -70,10 +70,10 @@ export const createInboxItem = async (
};
export const updateInboxItem = async (
itemId: number,
itemUid: string,
content: string
): Promise<InboxItem> => {
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<InboxItem> => {
const response = await fetch(`/api/inbox/${itemId}/process`, {
export const processInboxItem = async (itemUid: string): Promise<InboxItem> => {
const response = await fetch(`/api/inbox/${itemUid}/process`, {
method: 'PATCH',
credentials: 'include',
headers: {
@ -100,8 +100,8 @@ export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
return await response.json();
};
export const deleteInboxItem = async (itemId: number): Promise<void> => {
const response = await fetch(`/api/inbox/${itemId}`, {
export const deleteInboxItem = async (itemUid: string): Promise<void> => {
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<InboxItem> => {
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<InboxItem> => {
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<void> => {
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;

View file

@ -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<void>
*/
export const deleteNoteWithStoreUpdate = async (
noteId: number,
noteOrUid: Note | string,
showSuccessToast: (message: string) => void,
t: (key: string) => string
): Promise<void> => {
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

View file

@ -28,22 +28,39 @@ export const createNote = async (noteData: Note): Promise<Note> => {
};
export const updateNote = async (
noteId: number,
noteUid: string,
noteData: Note
): Promise<Note> => {
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<void> => {
const response = await fetch(`/api/note/${noteId}`, {
export const deleteNote = async (noteUid: string): Promise<void> => {
const response = await fetch(`/api/note/${noteUid}`, {
method: 'DELETE',
credentials: 'include',
headers: getDefaultHeaders(),

View file

@ -2,13 +2,13 @@ import { Project } from '../entities/Project';
import { handleAuthResponse } from './authUtils';
export const fetchProjects = async (
activeFilter = 'all',
stateFilter = 'all',
areaFilter = ''
): Promise<Project[]> => {
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<Record<string, Project[]>> => {
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<Project>
): Promise<Project> => {
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<void> => {
if (!projectId || projectId === null || projectId === undefined) {
throw new Error('Cannot delete project: Invalid project ID');
export const deleteProject = async (projectUid: string): Promise<void> => {
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: {

View file

@ -35,8 +35,8 @@ export const createTag = async (tagData: Tag): Promise<Tag> => {
return await response.json();
};
export const updateTag = async (tagId: number, tagData: Tag): Promise<Tag> => {
const response = await fetch(`/api/tag/${tagId}`, {
export const updateTag = async (tagUid: string, tagData: Tag): Promise<Tag> => {
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<Tag> => {
return await response.json();
};
export const deleteTag = async (tagId: number): Promise<void> => {
const response = await fetch(`/api/tag/${tagId}`, {
export const deleteTag = async (tagUid: string): Promise<void> => {
const response = await fetch(`/api/tag/${tagUid}`, {
method: 'DELETE',
credentials: 'include',
headers: {

View file

@ -11,8 +11,10 @@ const API_BASE = '/api';
/**
* Get task timeline (all events for a specific task)
*/
export const getTaskTimeline = async (taskId: number): Promise<TaskEvent[]> => {
const response = await fetch(`${API_BASE}/task/${taskId}/timeline`, {
export const getTaskTimeline = async (
taskUid: string
): Promise<TaskEvent[]> => {
const response = await fetch(`${API_BASE}/task/${taskUid}/timeline`, {
credentials: 'include',
});
@ -29,11 +31,14 @@ export const getTaskTimeline = async (taskId: number): Promise<TaskEvent[]> => {
* Get task completion time analytics
*/
export const getTaskCompletionTime = async (
taskId: number
taskUid: string
): Promise<TaskCompletionTime | null> => {
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<CompletionAnalyticsResponse> => {
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}`,

View file

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

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": "編集",

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

Some files were not shown because too many files have changed in this diff Show more