Fix tags issue (#450)

* Fix uniqueness issue for tags

* fixup! Fix uniqueness issue for tags
This commit is contained in:
Chris 2025-10-25 05:07:43 +03:00 committed by GitHub
parent 3057051ecd
commit fe0266d70a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 287 additions and 33 deletions

View file

@ -1,36 +1,169 @@
'use strict';
const { safeAddIndex } = require('../utils/migration-utils');
module.exports = {
up: async (queryInterface, Sequelize) => {
// First, remove any duplicate tags per user
// This query keeps the oldest tag and removes duplicates
await queryInterface.sequelize.query(`
DELETE FROM tags
WHERE id NOT IN (
SELECT MIN(id)
FROM tags
GROUP BY user_id, name
)
`);
const transaction = await queryInterface.sequelize.transaction();
// Add unique constraint on user_id and name combination
await safeAddIndex(queryInterface, 'tags', ['user_id', 'name'], {
unique: true,
name: 'tags_user_id_name_unique',
});
try {
// Step 0: Count original rows for verification
const [originalCount] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM tags;',
{ transaction, type: Sequelize.QueryTypes.SELECT }
);
console.log(`📊 Original tags count: ${originalCount.count}`);
// Step 1: Remove duplicate tags per user (keep oldest)
const [duplicatesResult] = await queryInterface.sequelize.query(
`
SELECT COUNT(*) as count FROM tags
WHERE id NOT IN (
SELECT MIN(id)
FROM tags
GROUP BY user_id, name
);
`,
{ transaction, type: Sequelize.QueryTypes.SELECT }
);
console.log(
`🔍 Found ${duplicatesResult.count} duplicate tags to remove`
);
await queryInterface.sequelize.query(
`
DELETE FROM tags
WHERE id NOT IN (
SELECT MIN(id)
FROM tags
GROUP BY user_id, name
)
`,
{ transaction }
);
// Step 1.5: Verify count after deduplication
const [afterDedup] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM tags;',
{ transaction, type: Sequelize.QueryTypes.SELECT }
);
console.log(`📊 After deduplication: ${afterDedup.count} tags`);
// Step 2: Create new tags table with correct schema
await queryInterface.sequelize.query(
`
CREATE TABLE tags_new (
id INTEGER PRIMARY KEY,
uid VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL REFERENCES users (id),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
`,
{ transaction }
);
// Step 3: Copy existing data
await queryInterface.sequelize.query(
`
INSERT INTO tags_new (id, uid, name, user_id, created_at, updated_at)
SELECT id, uid, name, user_id, created_at, updated_at
FROM tags;
`,
{ transaction }
);
// Step 3.5: Verify data was copied correctly
const [newCount] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM tags_new;',
{ transaction, type: Sequelize.QueryTypes.SELECT }
);
console.log(`📊 Copied to new table: ${newCount.count} tags`);
if (newCount.count !== afterDedup.count) {
throw new Error(
`Data verification failed! Expected ${afterDedup.count} tags but found ${newCount.count} in new table`
);
}
// Step 3.6: Verify all UIDs were copied
const [uidCheck] = await queryInterface.sequelize.query(
`
SELECT COUNT(*) as count FROM tags t
WHERE NOT EXISTS (
SELECT 1 FROM tags_new tn WHERE tn.uid = t.uid
);
`,
{ transaction, type: Sequelize.QueryTypes.SELECT }
);
if (uidCheck.count > 0) {
throw new Error(
`Data verification failed! ${uidCheck.count} tags were not copied to new table`
);
}
console.log(
'✅ Data verification passed - all tags copied correctly'
);
// Step 4: Drop old table
await queryInterface.sequelize.query('DROP TABLE tags;', {
transaction,
});
// Step 5: Rename new table
await queryInterface.sequelize.query(
'ALTER TABLE tags_new RENAME TO tags;',
{ transaction }
);
// Step 6: Create composite unique index
await queryInterface.addIndex('tags', ['user_id', 'name'], {
unique: true,
name: 'tags_user_id_name_unique',
transaction,
});
// Step 7: Create index on user_id for performance
await queryInterface.addIndex('tags', ['user_id'], {
name: 'tags_user_id',
transaction,
});
// Step 8: Final verification
const [finalCount] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM tags;',
{ transaction, type: Sequelize.QueryTypes.SELECT }
);
console.log(`📊 Final tags count: ${finalCount.count}`);
if (finalCount.count !== afterDedup.count) {
throw new Error(
`Final verification failed! Expected ${afterDedup.count} tags but found ${finalCount.count}`
);
}
await transaction.commit();
console.log('✅ Successfully fixed tags table unique constraints');
console.log(
`✅ All ${finalCount.count} tags preserved (${duplicatesResult.count} duplicates removed)`
);
} catch (error) {
await transaction.rollback();
console.error('❌ Error fixing tags table:', error);
console.error(
'❌ Transaction rolled back - no changes were made to the database'
);
throw error;
}
},
down: async (queryInterface, Sequelize) => {
// Remove the unique index
try {
await queryInterface.removeIndex(
'tags',
'tags_user_id_name_unique'
);
} catch (error) {
console.log('Index may not exist:', error.message);
}
// Reverting this migration would restore the broken schema
// It's better to not support rollback for schema fixes
console.warn(
'⚠️ Cannot rollback this migration - it fixes a broken schema'
);
console.warn('⚠️ Please restore from backup if needed');
},
};

View file

@ -330,7 +330,8 @@ const Layout: React.FC<LayoutProps> = ({
if (isAuthError(error)) {
return;
}
closeTagModal();
// Re-throw error so TagModal can handle it
throw error;
}
};

View file

@ -14,9 +14,12 @@ import { Note } from '../../entities/Note';
import { Project } from '../../entities/Project';
import TaskList from '../Task/TaskList';
import ProjectItem from '../Project/ProjectItem';
import TagModal from './TagModal';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { Tag } from '../../entities/Tag';
import { useStore } from '../../store/useStore';
import { updateTag, deleteTag } from '../../utils/tagsService';
const TagDetails: React.FC = () => {
const { t } = useTranslation();
@ -41,7 +44,11 @@ const TagDetails: React.FC = () => {
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null);
const [, setProjectToDelete] = useState<Project | null>(null);
const [, setIsConfirmDialogOpen] = useState<boolean>(false);
// State for tag edit/delete
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const navigate = useNavigate();
useEffect(() => {
@ -157,6 +164,35 @@ const TagDetails: React.FC = () => {
}
};
// Tag handlers
const handleEditTag = () => {
setIsTagModalOpen(true);
};
const handleSaveTag = async (tagData: Tag) => {
try {
if (tag && tag.uid) {
const updatedTag = await updateTag(tag.uid, tagData);
setTag(updatedTag);
}
setIsTagModalOpen(false);
} catch (error) {
console.error('Error updating tag:', error);
throw error;
}
};
const handleDeleteTag = async () => {
try {
if (tag && tag.uid) {
await deleteTag(tag.uid);
navigate('/tags');
}
} catch (error) {
console.error('Error deleting tag:', error);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
@ -183,10 +219,28 @@ const TagDetails: React.FC = () => {
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Tag Header */}
<div className="flex items-center mb-8">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
Tag: {tag.name}
</h2>
<div className="flex items-center">
<button
onClick={handleEditTag}
className="px-1 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
aria-label="Edit tag"
title="Edit tag"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="px-1 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label="Delete tag"
title="Delete tag"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
{/* Summary Stats */}
@ -398,6 +452,29 @@ const TagDetails: React.FC = () => {
</div>
)}
</div>
{/* Tag Modal */}
{isTagModalOpen && tag && (
<TagModal
isOpen={isTagModalOpen}
onClose={() => setIsTagModalOpen(false)}
onSave={handleSaveTag}
tag={tag}
/>
)}
{/* Confirm Dialog */}
{isConfirmDialogOpen && tag && (
<ConfirmDialog
title={t('tags.deleteTag', 'Delete Tag')}
message={t(
'tags.deleteTagConfirm',
`Are you sure you want to delete the tag "${tag.name}"?`
)}
onConfirm={handleDeleteTag}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
);
};

View file

@ -111,8 +111,16 @@ const TagModal: React.FC<TagModalProps> = ({
);
}
handleClose();
} catch {
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
} catch (error: any) {
// Extract error message from the API response if available
let errorMessage = t(
'errors.failedToSaveTag',
'Failed to save tag.'
);
if (error?.message) {
errorMessage = error.message;
}
showErrorToast(errorMessage);
} finally {
setIsSubmitting(false);
}

View file

@ -69,6 +69,8 @@ const Tags: React.FC = () => {
setSelectedTag(null);
} catch (error) {
console.error('Error saving tag:', error);
// Re-throw the error so TagModal knows the operation failed
throw error;
}
};

View file

@ -31,7 +31,24 @@ export const createTag = async (tagData: Tag): Promise<Tag> => {
body: JSON.stringify(tagData),
});
await handleAuthResponse(response, 'Failed to create tag.');
if (!response.ok) {
// Handle authentication errors first
if (response.status === 401) {
await handleAuthResponse(response, 'Failed to create tag.');
return Promise.reject(new Error('Authentication required'));
}
// Try to get the specific error message from the response
let errorMessage = 'Failed to create tag.';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
// If parsing fails, use default message
}
throw new Error(errorMessage);
}
return await response.json();
};
@ -46,7 +63,23 @@ export const updateTag = async (tagUid: string, tagData: Tag): Promise<Tag> => {
body: JSON.stringify(tagData),
});
await handleAuthResponse(response, 'Failed to update tag.');
if (!response.ok) {
// Handle authentication errors first
if (response.status === 401) {
await handleAuthResponse(response, 'Failed to update tag.');
}
// Try to get the specific error message from the response
let errorMessage = 'Failed to update tag.';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
// If parsing fails, use default message
}
throw new Error(errorMessage);
}
return await response.json();
};