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
|
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 selectedFiles = new Set<string>()
|
||||||
let showDeleteModal = false
|
let showDeleteModal = false
|
||||||
let deleteResults: { succeeded: number; failed: string[] } | null = null
|
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
|
$: allSelected = auditData && selectedFiles.size === auditData.orphanedFiles.length
|
||||||
$: hasSelection = selectedFiles.size > 0
|
$: hasSelection = selectedFiles.size > 0
|
||||||
|
|
@ -99,6 +102,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelected(dryRun = true) {
|
async function deleteSelected(dryRun = true) {
|
||||||
|
console.log('deleteSelected called', { dryRun, hasSelection, deleting, selectedFiles: Array.from(selectedFiles) })
|
||||||
if (!hasSelection || deleting) return
|
if (!hasSelection || deleting) return
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
|
|
@ -156,6 +160,50 @@
|
||||||
const year = date.getFullYear().toString().slice(-2)
|
const year = date.getFullYear().toString().slice(-2)
|
||||||
return `${month}/${day}/${year}`
|
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>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
|
|
@ -318,6 +366,37 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
|
|
||||||
|
|
@ -336,6 +415,21 @@
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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">
|
<style lang="scss">
|
||||||
header {
|
header {
|
||||||
display: flex;
|
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 {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { json } from '@sveltejs/kit'
|
import { json } from '@sveltejs/kit'
|
||||||
import type { RequestHandler } from './$types'
|
import type { RequestHandler } from './$types'
|
||||||
import { checkAdminAuth } from '$lib/server/api-utils'
|
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 { formatBytes } from '$lib/utils/format'
|
||||||
import { isCloudinaryConfigured } from '$lib/server/cloudinary'
|
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 })
|
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