fix colliding drawing IDs
This commit is contained in:
@@ -334,7 +334,20 @@ export const registerDashboardRoutes = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDrawing = await prisma.drawing.update({ where: { id }, data });
|
const updateResult = await prisma.drawing.updateMany({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDrawing = await prisma.drawing.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!updatedDrawing) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
invalidateDrawingsCache();
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -352,7 +365,12 @@ export const registerDashboardRoutes = (
|
|||||||
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
||||||
if (!drawing) return res.status(404).json({ error: "Drawing not found" });
|
if (!drawing) return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
|
||||||
await prisma.drawing.delete({ where: { id } });
|
const deleteResult = await prisma.drawing.deleteMany({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (deleteResult.count === 0) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
invalidateDrawingsCache();
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
if (config.enableAuditLogging) {
|
if (config.enableAuditLogging) {
|
||||||
@@ -375,6 +393,9 @@ export const registerDashboardRoutes = (
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
||||||
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
||||||
|
if (original.collectionId === "trash") {
|
||||||
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const newDrawing = await prisma.drawing.create({
|
const newDrawing = await prisma.drawing.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -443,10 +464,19 @@ export const registerDashboardRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedName = sanitizeText(parsed.data, 100);
|
const sanitizedName = sanitizeText(parsed.data, 100);
|
||||||
const updatedCollection = await prisma.collection.update({
|
const updateResult = await prisma.collection.updateMany({
|
||||||
where: { id },
|
where: { id, userId: req.user.id },
|
||||||
data: { name: sanitizedName },
|
data: { name: sanitizedName },
|
||||||
});
|
});
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
return res.status(404).json({ error: "Collection not found" });
|
||||||
|
}
|
||||||
|
const updatedCollection = await prisma.collection.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!updatedCollection) {
|
||||||
|
return res.status(404).json({ error: "Collection not found" });
|
||||||
|
}
|
||||||
return res.json(updatedCollection);
|
return res.json(updatedCollection);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -464,7 +494,7 @@ export const registerDashboardRoutes = (
|
|||||||
where: { collectionId: id, userId: req.user.id },
|
where: { collectionId: id, userId: req.user.id },
|
||||||
data: { collectionId: null },
|
data: { collectionId: null },
|
||||||
}),
|
}),
|
||||||
prisma.collection.delete({ where: { id } }),
|
prisma.collection.deleteMany({ where: { id, userId: req.user.id } }),
|
||||||
]);
|
]);
|
||||||
invalidateDrawingsCache();
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,21 @@ const makeUniqueName = (base: string, used: Set<string>): string => {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findFirstDuplicate = (values: string[]): string | null => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
if (seen.has(value)) return value;
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNonEmptyId = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
|
const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
|
||||||
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
|
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -439,6 +454,28 @@ Drawings: ${drawings.length}
|
|||||||
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
|
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id));
|
||||||
|
if (duplicateCollectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate collection id in manifest: ${duplicateCollectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id));
|
||||||
|
if (duplicateDrawingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath));
|
||||||
|
if (duplicateDrawingPath) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const drawing of manifest.drawings) {
|
for (const drawing of manifest.drawings) {
|
||||||
if (!getSafeZipEntry(zip, drawing.filePath)) {
|
if (!getSafeZipEntry(zip, drawing.filePath)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -532,6 +569,28 @@ Drawings: ${drawings.length}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id));
|
||||||
|
if (duplicateCollectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate collection id in manifest: ${duplicateCollectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id));
|
||||||
|
if (duplicateDrawingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath));
|
||||||
|
if (duplicateDrawingPath) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type PreparedImportDrawing = {
|
type PreparedImportDrawing = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -772,6 +831,31 @@ Drawings: ${drawings.length}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateDrawingIdRow = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM "${drawingTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicateDrawingIdRow?.id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate drawing id in legacy DB: ${String(duplicateDrawingIdRow.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (collectionTable) {
|
||||||
|
const duplicateCollectionIdRow = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM "${collectionTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicateCollectionIdRow?.id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate collection id in legacy DB: ${String(duplicateCollectionIdRow.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let latestMigration: string | null = null;
|
let latestMigration: string | null = null;
|
||||||
const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]);
|
const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]);
|
||||||
if (migrationsTable) {
|
if (migrationsTable) {
|
||||||
@@ -862,6 +946,28 @@ Drawings: ${drawings.length}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importedCollectionIds = importedCollections
|
||||||
|
.map((c) => normalizeNonEmptyId(c?.id))
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
const duplicateCollectionId = findFirstDuplicate(importedCollectionIds);
|
||||||
|
if (duplicateCollectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate collection id in legacy DB: ${duplicateCollectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedDrawingIds = importedDrawings
|
||||||
|
.map((d) => normalizeNonEmptyId(d?.id))
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
const duplicateDrawingId = findFirstDuplicate(importedDrawingIds);
|
||||||
|
if (duplicateDrawingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate drawing id in legacy DB: ${duplicateDrawingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type PreparedLegacyDrawing = {
|
type PreparedLegacyDrawing = {
|
||||||
importedId: string | null;
|
importedId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
const startXRef = useRef(0);
|
const startXRef = useRef(0);
|
||||||
const startWidthRef = useRef(0);
|
const startWidthRef = useRef(0);
|
||||||
|
const resizeMouseMoveHandlerRef = useRef<((e: MouseEvent) => void) | null>(null);
|
||||||
|
const resizeMouseUpHandlerRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Handle mouse down on resize handle
|
// Handle mouse down on resize handle
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
@@ -44,6 +46,13 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
startXRef.current = e.clientX;
|
startXRef.current = e.clientX;
|
||||||
startWidthRef.current = sidebarWidth;
|
startWidthRef.current = sidebarWidth;
|
||||||
|
|
||||||
|
if (resizeMouseMoveHandlerRef.current) {
|
||||||
|
document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current);
|
||||||
|
}
|
||||||
|
if (resizeMouseUpHandlerRef.current) {
|
||||||
|
document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
const diff = e.clientX - startXRef.current;
|
const diff = e.clientX - startXRef.current;
|
||||||
const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff));
|
const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff));
|
||||||
@@ -54,8 +63,12 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
resizeMouseMoveHandlerRef.current = null;
|
||||||
|
resizeMouseUpHandlerRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
resizeMouseMoveHandlerRef.current = handleMouseMove;
|
||||||
|
resizeMouseUpHandlerRef.current = handleMouseUp;
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
@@ -63,8 +76,14 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
// Cleanup event listeners on unmount
|
// Cleanup event listeners on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', () => {});
|
if (resizeMouseMoveHandlerRef.current) {
|
||||||
document.removeEventListener('mouseup', () => {});
|
document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current);
|
||||||
|
resizeMouseMoveHandlerRef.current = null;
|
||||||
|
}
|
||||||
|
if (resizeMouseUpHandlerRef.current) {
|
||||||
|
document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current);
|
||||||
|
resizeMouseUpHandlerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If status fails (backend down / schema mismatch), avoid locking the UI
|
// If status fails, default to auth-enabled mode to avoid exposing
|
||||||
// behind login. Backend still enforces auth when enabled.
|
// single-user UI paths accidentally. Backend remains the source of truth.
|
||||||
setAuthEnabled(false);
|
setAuthEnabled(true);
|
||||||
setBootstrapRequired(false);
|
setBootstrapRequired(false);
|
||||||
setUser(null);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedUser = localStorage.getItem(USER_KEY);
|
const storedUser = localStorage.getItem(USER_KEY);
|
||||||
|
|||||||
@@ -73,12 +73,14 @@ export const Dashboard: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const listRequestVersionRef = useRef(0);
|
||||||
|
|
||||||
const { uploadFiles } = useUpload();
|
const { uploadFiles } = useUpload();
|
||||||
|
|
||||||
const hasMore = drawings.length < totalCount;
|
const hasMore = drawings.length < totalCount;
|
||||||
|
|
||||||
const refreshData = useCallback(async () => {
|
const refreshData = useCallback(async () => {
|
||||||
|
const requestVersion = ++listRequestVersionRef.current;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [drawingsRes, collectionsData] = await Promise.all([
|
const [drawingsRes, collectionsData] = await Promise.all([
|
||||||
@@ -90,6 +92,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
api.getCollections()
|
api.getCollections()
|
||||||
]);
|
]);
|
||||||
|
if (requestVersion !== listRequestVersionRef.current) return;
|
||||||
setDrawings(drawingsRes.drawings);
|
setDrawings(drawingsRes.drawings);
|
||||||
setTotalCount(drawingsRes.totalCount);
|
setTotalCount(drawingsRes.totalCount);
|
||||||
setCollections(collectionsData);
|
setCollections(collectionsData);
|
||||||
@@ -97,12 +100,15 @@ export const Dashboard: React.FC = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch data:', err);
|
console.error('Failed to fetch data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (requestVersion === listRequestVersionRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
|
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
|
||||||
|
|
||||||
const fetchMore = useCallback(async () => {
|
const fetchMore = useCallback(async () => {
|
||||||
if (isFetchingMore || !hasMore || isLoading) return;
|
if (isFetchingMore || !hasMore || isLoading) return;
|
||||||
|
const requestVersion = listRequestVersionRef.current;
|
||||||
setIsFetchingMore(true);
|
setIsFetchingMore(true);
|
||||||
try {
|
try {
|
||||||
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
||||||
@@ -111,7 +117,12 @@ export const Dashboard: React.FC = () => {
|
|||||||
sortField: sortConfig.field,
|
sortField: sortConfig.field,
|
||||||
sortDirection: sortConfig.direction,
|
sortDirection: sortConfig.direction,
|
||||||
});
|
});
|
||||||
setDrawings(prev => [...prev, ...drawingsRes.drawings]);
|
if (requestVersion !== listRequestVersionRef.current) return;
|
||||||
|
setDrawings(prev => {
|
||||||
|
const seen = new Set(prev.map((d) => d.id));
|
||||||
|
const nextPage = drawingsRes.drawings.filter((d) => !seen.has(d.id));
|
||||||
|
return [...prev, ...nextPage];
|
||||||
|
});
|
||||||
setTotalCount(drawingsRes.totalCount);
|
setTotalCount(drawingsRes.totalCount);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch more data:', err);
|
console.error('Failed to fetch more data:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user