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 <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-16 18:57:57 +01:00
parent 4aaf33f19e
commit ac0ecf2a92
3 changed files with 336 additions and 1 deletions

View file

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

View file

@ -40,6 +40,9 @@
let selectedFiles = new Set<string>()
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
}
}
</script>
<AdminPage>
@ -318,6 +366,37 @@
{/if}
</div>
{/if}
{#if auditData.missingReferences.length > 0}
<div class="broken-references-section">
<h2>Broken References</h2>
<p class="broken-references-info">
Found {auditData.missingReferences.length} files referenced in the database but missing from Cloudinary.
</p>
<Button
variant="secondary"
size="small"
onclick={() => (showCleanupModal = true)}
disabled={cleaningUp}
icon={AlertCircle}
iconPosition="left"
>
Clean Up Broken References
</Button>
{#if cleanupResults}
<div class="cleanup-results">
<h3>Cleanup Complete</h3>
<p>✓ Cleaned {cleanupResults.cleanedMedia} media records</p>
<p>✓ Cleaned {cleanupResults.cleanedProjects} project records</p>
<p>✓ Cleaned {cleanupResults.cleanedPosts} post records</p>
{#if cleanupResults.errors.length > 0}
<p>✗ Errors: {cleanupResults.errors.join(', ')}</p>
{/if}
</div>
{/if}
</div>
{/if}
{/if}
</AdminPage>
@ -336,6 +415,21 @@
</div>
</Modal>
<!-- Cleanup Confirmation Modal -->
<Modal bind:open={showCleanupModal} title="Clean Up Broken References">
<div class="cleanup-confirmation">
<p>Are you sure you want to clean up {auditData?.missingReferences.length || 0} broken references?</p>
<p class="warning">⚠️ This will remove Cloudinary URLs from database records where the files no longer exist.</p>
<p>This action cannot be undone.</p>
</div>
<div slot="actions">
<Button variant="secondary" onclick={() => (showCleanupModal = false)}>Cancel</Button>
<Button variant="danger" onclick={cleanupBrokenReferences} disabled={cleaningUp}>
{cleaningUp ? 'Cleaning Up...' : 'Clean Up References'}
</Button>
</div>
</Modal>
<style lang="scss">
header {
display: flex;
@ -665,6 +759,58 @@
}
}
.broken-references-section {
margin-top: 2rem;
padding: 1.5rem;
background: $grey-95;
border-radius: 8px;
border: 1px solid rgba($yellow-60, 0.2);
h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: $grey-10;
}
.broken-references-info {
margin: 0 0 1rem;
color: $grey-30;
}
}
.cleanup-results {
margin-top: 1rem;
padding: 1rem;
background: rgba($blue-60, 0.1);
border-radius: 8px;
h3 {
margin: 0 0 0.5rem;
color: $blue-60;
font-size: 1rem;
}
p {
margin: 0.25rem 0;
color: $grey-30;
font-size: 0.875rem;
}
}
.cleanup-confirmation {
padding: 1rem 0;
p {
margin: 0.5rem 0;
}
.warning {
color: $yellow-60;
font-weight: 500;
margin: 1rem 0;
}
}
@keyframes spin {
to {
transform: rotate(360deg);

View file

@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { checkAdminAuth } from '$lib/server/api-utils'
import { auditCloudinaryResources, deleteOrphanedFiles } from '$lib/server/cloudinary-audit'
import { auditCloudinaryResources, deleteOrphanedFiles, cleanupBrokenReferences } from '$lib/server/cloudinary-audit'
import { formatBytes } from '$lib/utils/format'
import { isCloudinaryConfigured } from '$lib/server/cloudinary'
@ -86,3 +86,28 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Failed to delete Cloudinary resources' }, { status: 500 })
}
}
export const PATCH: RequestHandler = async (event) => {
try {
if (!checkAdminAuth(event)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await event.request.json()
const { publicIds } = body
if (!Array.isArray(publicIds) || publicIds.length === 0) {
return json({ error: 'No public IDs provided' }, { status: 400 })
}
const results = await cleanupBrokenReferences(publicIds)
return json({
message: 'Broken references cleaned up',
results
})
} catch (error) {
console.error('Cleanup error:', error)
return json({ error: 'Failed to clean up broken references' }, { status: 500 })
}
}