From ac0ecf2a92b7003c39db6a137e569e7972fa613d Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 16 Jun 2025 18:57:57 +0100 Subject: [PATCH] Add broken reference cleanup functionality to Cloudinary audit - Add cleanupBrokenReferences function to remove missing Cloudinary URLs from database - Add PATCH endpoint to API for cleaning broken references - Add UI section to show broken references with cleanup button - Add confirmation modal for cleanup action - Add console logging to debug delete button issue Co-Authored-By: Claude --- src/lib/server/cloudinary-audit.ts | 164 ++++++++++++++++++ src/routes/admin/media/audit/+page.svelte | 146 ++++++++++++++++ .../api/admin/cloudinary-audit/+server.ts | 27 ++- 3 files changed, 336 insertions(+), 1 deletion(-) diff --git a/src/lib/server/cloudinary-audit.ts b/src/lib/server/cloudinary-audit.ts index f2e869d..e1e76f5 100644 --- a/src/lib/server/cloudinary-audit.ts +++ b/src/lib/server/cloudinary-audit.ts @@ -218,3 +218,167 @@ export async function deleteOrphanedFiles( return results } + +/** + * Cleans up broken references from the database + */ +export async function cleanupBrokenReferences(publicIds: string[]): Promise<{ + cleanedMedia: number + cleanedProjects: number + cleanedPosts: number + errors: string[] +}> { + const results = { + cleanedMedia: 0, + cleanedProjects: 0, + cleanedPosts: 0, + errors: [] as string[] + } + + try { + // Clean up Media table + const mediaToClean = await prisma.media.findMany({ + where: { + OR: [ + { url: { contains: 'cloudinary.com' } }, + { thumbnailUrl: { contains: 'cloudinary.com' } } + ] + } + }) + + for (const media of mediaToClean) { + let updated = false + const updates: any = {} + + if (media.url?.includes('cloudinary.com')) { + const publicId = extractPublicId(media.url) + if (publicId && publicIds.includes(publicId)) { + updates.url = null + updated = true + } + } + + if (media.thumbnailUrl?.includes('cloudinary.com')) { + const publicId = extractPublicId(media.thumbnailUrl) + if (publicId && publicIds.includes(publicId)) { + updates.thumbnailUrl = null + updated = true + } + } + + if (updated) { + await prisma.media.update({ + where: { id: media.id }, + data: updates + }) + results.cleanedMedia++ + } + } + + // Clean up Project table + const projectsToClean = await prisma.project.findMany({ + where: { + OR: [ + { featuredImage: { contains: 'cloudinary.com' } }, + { logoUrl: { contains: 'cloudinary.com' } } + ] + } + }) + + for (const project of projectsToClean) { + let updated = false + const updates: any = {} + + if (project.featuredImage?.includes('cloudinary.com')) { + const publicId = extractPublicId(project.featuredImage) + if (publicId && publicIds.includes(publicId)) { + updates.featuredImage = null + updated = true + } + } + + if (project.logoUrl?.includes('cloudinary.com')) { + const publicId = extractPublicId(project.logoUrl) + if (publicId && publicIds.includes(publicId)) { + updates.logoUrl = null + updated = true + } + } + + // Handle gallery items + if (project.gallery && typeof project.gallery === 'object') { + const gallery = project.gallery as any[] + const cleanedGallery = gallery.filter(item => { + if (item.url?.includes('cloudinary.com')) { + const publicId = extractPublicId(item.url) + return !(publicId && publicIds.includes(publicId)) + } + return true + }) + + if (cleanedGallery.length !== gallery.length) { + updates.gallery = cleanedGallery + updated = true + } + } + + if (updated) { + await prisma.project.update({ + where: { id: project.id }, + data: updates + }) + results.cleanedProjects++ + } + } + + // Clean up Post table + const postsToClean = await prisma.post.findMany({ + where: { + featuredImage: { contains: 'cloudinary.com' } + } + }) + + for (const post of postsToClean) { + let updated = false + const updates: any = {} + + if (post.featuredImage?.includes('cloudinary.com')) { + const publicId = extractPublicId(post.featuredImage) + if (publicId && publicIds.includes(publicId)) { + updates.featuredImage = null + updated = true + } + } + + // Handle attachments + if (post.attachments && typeof post.attachments === 'object') { + const attachments = post.attachments as any[] + const cleanedAttachments = attachments.filter(attachment => { + if (attachment.url?.includes('cloudinary.com')) { + const publicId = extractPublicId(attachment.url) + return !(publicId && publicIds.includes(publicId)) + } + return true + }) + + if (cleanedAttachments.length !== attachments.length) { + updates.attachments = cleanedAttachments + updated = true + } + } + + if (updated) { + await prisma.post.update({ + where: { id: post.id }, + data: updates + }) + results.cleanedPosts++ + } + } + } catch (error) { + results.errors.push(error instanceof Error ? error.message : 'Unknown error') + console.error('Error cleaning up broken references:', error) + } + + return results +} diff --git a/src/routes/admin/media/audit/+page.svelte b/src/routes/admin/media/audit/+page.svelte index 190a995..027c180 100644 --- a/src/routes/admin/media/audit/+page.svelte +++ b/src/routes/admin/media/audit/+page.svelte @@ -40,6 +40,9 @@ let selectedFiles = new Set() let showDeleteModal = false let deleteResults: { succeeded: number; failed: string[] } | null = null + let cleanupResults: { cleanedMedia: number; cleanedProjects: number; cleanedPosts: number; errors: string[] } | null = null + let showCleanupModal = false + let cleaningUp = false $: allSelected = auditData && selectedFiles.size === auditData.orphanedFiles.length $: hasSelection = selectedFiles.size > 0 @@ -99,6 +102,7 @@ } async function deleteSelected(dryRun = true) { + console.log('deleteSelected called', { dryRun, hasSelection, deleting, selectedFiles: Array.from(selectedFiles) }) if (!hasSelection || deleting) return if (!dryRun) { @@ -156,6 +160,50 @@ const year = date.getFullYear().toString().slice(-2) return `${month}/${day}/${year}` } + + async function cleanupBrokenReferences() { + if (!auditData || auditData.missingReferences.length === 0 || cleaningUp) return + + showCleanupModal = false + cleaningUp = true + cleanupResults = null + + try { + const auth = localStorage.getItem('admin_auth') + if (!auth) { + error = 'Not authenticated' + cleaningUp = false + return + } + + const response = await fetch('/api/admin/cloudinary-audit', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${auth}` + }, + body: JSON.stringify({ + publicIds: auditData.missingReferences + }) + }) + + if (!response.ok) { + throw new Error('Failed to clean up broken references') + } + + const result = await response.json() + cleanupResults = result.results + + // Refresh audit after successful cleanup + setTimeout(() => { + runAudit() + }, 2000) + } catch (err) { + error = err instanceof Error ? err.message : 'An error occurred' + } finally { + cleaningUp = false + } + } @@ -318,6 +366,37 @@ {/if} {/if} + + {#if auditData.missingReferences.length > 0} +
+

Broken References

+

+ Found {auditData.missingReferences.length} files referenced in the database but missing from Cloudinary. +

+ + + {#if cleanupResults} +
+

Cleanup Complete

+

✓ Cleaned {cleanupResults.cleanedMedia} media records

+

✓ Cleaned {cleanupResults.cleanedProjects} project records

+

✓ Cleaned {cleanupResults.cleanedPosts} post records

+ {#if cleanupResults.errors.length > 0} +

✗ Errors: {cleanupResults.errors.join(', ')}

+ {/if} +
+ {/if} +
+ {/if} {/if}
@@ -336,6 +415,21 @@ + + +
+

Are you sure you want to clean up {auditData?.missingReferences.length || 0} broken references?

+

⚠️ This will remove Cloudinary URLs from database records where the files no longer exist.

+

This action cannot be undone.

+
+
+ + +
+
+