Fix tags issue (#450)
* Fix uniqueness issue for tags * fixup! Fix uniqueness issue for tags
This commit is contained in:
parent
3057051ecd
commit
fe0266d70a
6 changed files with 287 additions and 33 deletions
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -330,7 +330,8 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
closeTagModal();
|
||||
// Re-throw error so TagModal can handle it
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue