Phase 1 Batch 7: Cloudinary & Media type safety improvements
Fixed 11 any-type errors across 3 files:
1. src/lib/server/cloudinary.ts (2 errors)
- Fixed UploadResult.colors: Array<{hex, rgb, population}> (line 72)
- Fixed uploadFile customOptions: Record<string, unknown> (line 85)
2. src/lib/server/cloudinary-audit.ts (6 errors)
- Fixed gallery arrays: Record<string, unknown>[] (lines 100, 319)
- Fixed attachments arrays: Record<string, unknown>[] (lines 124, 364)
- Fixed updates objects: Record<string, unknown> (lines 299, 352)
3. src/lib/server/media-usage.ts (3 errors)
- Fixed extractMediaIds data parameter: unknown (line 188)
- Fixed extractMediaFromRichText content parameter: unknown (line 227)
- Fixed traverse node parameter: unknown (line 232)
Progress: 11 any-type errors remaining (down from 22)
393 lines
10 KiB
TypeScript
393 lines
10 KiB
TypeScript
import { v2 as cloudinary } from 'cloudinary'
|
|
import { prisma } from './database'
|
|
import { extractPublicId } from './cloudinary'
|
|
import { formatBytes } from '$lib/utils/format'
|
|
|
|
export { formatBytes }
|
|
|
|
export interface CloudinaryResource {
|
|
public_id: string
|
|
secure_url: string
|
|
resource_type: string
|
|
type: string
|
|
format: string
|
|
version: number
|
|
width?: number
|
|
height?: number
|
|
bytes: number
|
|
created_at: string
|
|
folder?: string
|
|
}
|
|
|
|
export interface AuditResult {
|
|
totalCloudinaryFiles: number
|
|
totalDatabaseReferences: number
|
|
orphanedFiles: CloudinaryResource[]
|
|
orphanedTotalBytes: number
|
|
missingFromCloudinary: string[]
|
|
}
|
|
|
|
/**
|
|
* Fetches all resources from Cloudinary with pagination
|
|
*/
|
|
export async function fetchAllCloudinaryResources(): Promise<CloudinaryResource[]> {
|
|
const resources: CloudinaryResource[] = []
|
|
let nextCursor: string | undefined
|
|
|
|
do {
|
|
try {
|
|
const result = await cloudinary.api.resources({
|
|
type: 'upload',
|
|
max_results: 500,
|
|
next_cursor: nextCursor
|
|
})
|
|
|
|
resources.push(...result.resources)
|
|
nextCursor = result.next_cursor
|
|
} catch (error) {
|
|
console.error('Error fetching Cloudinary resources:', error)
|
|
throw error
|
|
}
|
|
} while (nextCursor)
|
|
|
|
return resources
|
|
}
|
|
|
|
/**
|
|
* Gets all Cloudinary URLs/public IDs referenced in the database
|
|
*/
|
|
export async function fetchAllDatabaseCloudinaryReferences(): Promise<Set<string>> {
|
|
const publicIds = new Set<string>()
|
|
|
|
// Get all Media table URLs
|
|
const mediaRecords = await prisma.media.findMany({
|
|
select: {
|
|
url: true,
|
|
thumbnailUrl: true
|
|
}
|
|
})
|
|
|
|
for (const media of mediaRecords) {
|
|
if (media.url?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(media.url)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
if (media.thumbnailUrl?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(media.thumbnailUrl)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
}
|
|
|
|
// Get Project images
|
|
const projects = await prisma.project.findMany({
|
|
select: {
|
|
featuredImage: true,
|
|
logoUrl: true,
|
|
gallery: true
|
|
}
|
|
})
|
|
|
|
for (const project of projects) {
|
|
if (project.featuredImage?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(project.featuredImage)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
if (project.logoUrl?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(project.logoUrl)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
if (project.gallery && typeof project.gallery === 'object') {
|
|
const gallery = project.gallery as Record<string, unknown>[]
|
|
for (const item of gallery) {
|
|
if (item.url?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(item.url)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get Post images
|
|
const posts = await prisma.post.findMany({
|
|
select: {
|
|
featuredImage: true,
|
|
attachments: true
|
|
}
|
|
})
|
|
|
|
for (const post of posts) {
|
|
if (post.featuredImage?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(post.featuredImage)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
if (post.attachments && typeof post.attachments === 'object') {
|
|
const attachments = post.attachments as Record<string, unknown>[]
|
|
for (const attachment of attachments) {
|
|
if (attachment.url?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(attachment.url)
|
|
if (publicId) publicIds.add(publicId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return publicIds
|
|
}
|
|
|
|
/**
|
|
* Performs a comprehensive audit of Cloudinary resources vs database references
|
|
*/
|
|
export async function auditCloudinaryResources(): Promise<AuditResult> {
|
|
console.log('Starting Cloudinary audit...')
|
|
|
|
// Fetch all resources from Cloudinary
|
|
const cloudinaryResources = await fetchAllCloudinaryResources()
|
|
console.log(`Found ${cloudinaryResources.length} files in Cloudinary`)
|
|
|
|
// Fetch all database references
|
|
const databasePublicIds = await fetchAllDatabaseCloudinaryReferences()
|
|
console.log(`Found ${databasePublicIds.size} Cloudinary references in database`)
|
|
|
|
// Find orphaned files (in Cloudinary but not in database)
|
|
const orphanedFiles: CloudinaryResource[] = []
|
|
let orphanedTotalBytes = 0
|
|
|
|
for (const resource of cloudinaryResources) {
|
|
// Skip thumbnails generated by Cloudinary (they have specific naming patterns)
|
|
if (resource.public_id.includes('_thumbnail_')) {
|
|
continue
|
|
}
|
|
|
|
if (!databasePublicIds.has(resource.public_id)) {
|
|
orphanedFiles.push(resource)
|
|
orphanedTotalBytes += resource.bytes || 0
|
|
}
|
|
}
|
|
|
|
// Find missing files (in database but not in Cloudinary)
|
|
const cloudinaryPublicIds = new Set(cloudinaryResources.map((r) => r.public_id))
|
|
const missingFromCloudinary: string[] = []
|
|
|
|
for (const publicId of databasePublicIds) {
|
|
if (!cloudinaryPublicIds.has(publicId)) {
|
|
missingFromCloudinary.push(publicId)
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalCloudinaryFiles: cloudinaryResources.length,
|
|
totalDatabaseReferences: databasePublicIds.size,
|
|
orphanedFiles,
|
|
orphanedTotalBytes,
|
|
missingFromCloudinary
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes orphaned files from Cloudinary
|
|
*/
|
|
export async function deleteOrphanedFiles(
|
|
publicIds: string[],
|
|
dryRun = true
|
|
): Promise<{
|
|
attempted: number
|
|
succeeded: number
|
|
failed: string[]
|
|
}> {
|
|
const results = {
|
|
attempted: publicIds.length,
|
|
succeeded: 0,
|
|
failed: [] as string[]
|
|
}
|
|
|
|
if (dryRun) {
|
|
console.log(`DRY RUN: Would delete ${publicIds.length} files`)
|
|
return { ...results, succeeded: publicIds.length }
|
|
}
|
|
|
|
for (const publicId of publicIds) {
|
|
try {
|
|
await cloudinary.uploader.destroy(publicId)
|
|
results.succeeded++
|
|
console.log(`Deleted: ${publicId}`)
|
|
} catch (error) {
|
|
results.failed.push(publicId)
|
|
console.error(`Failed to delete ${publicId}:`, error)
|
|
}
|
|
}
|
|
|
|
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 shouldDelete = false
|
|
let updateThumbnail = false
|
|
|
|
// Check if the main URL is broken
|
|
if (media.url?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(media.url)
|
|
if (publicId && publicIds.includes(publicId)) {
|
|
// If the main URL is broken, we need to delete the entire record
|
|
// since url is a required field
|
|
shouldDelete = true
|
|
}
|
|
}
|
|
|
|
// Check if only the thumbnail is broken
|
|
if (!shouldDelete && media.thumbnailUrl?.includes('cloudinary.com')) {
|
|
const publicId = extractPublicId(media.thumbnailUrl)
|
|
if (publicId && publicIds.includes(publicId)) {
|
|
updateThumbnail = true
|
|
}
|
|
}
|
|
|
|
if (shouldDelete) {
|
|
// Delete the media record entirely since the main URL is broken
|
|
await prisma.media.delete({
|
|
where: { id: media.id }
|
|
})
|
|
results.cleanedMedia++
|
|
} else if (updateThumbnail) {
|
|
// Only update the thumbnail to null if it's broken
|
|
await prisma.media.update({
|
|
where: { id: media.id },
|
|
data: { thumbnailUrl: null }
|
|
})
|
|
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: Record<string, unknown> = {}
|
|
|
|
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 Record<string, unknown>[]
|
|
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: Record<string, unknown> = {}
|
|
|
|
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 Record<string, unknown>[]
|
|
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
|
|
}
|