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:
parent
4aaf33f19e
commit
ac0ecf2a92
3 changed files with 336 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue