jedmund-svelte/src/routes/api/projects/[id]/+server.ts
Justin Edmund 73c2fae7b8 fix: complete API route type safety improvements
- add ProjectUpdateBody interface for partial updates
- use Prisma.ProjectUpdateInput for update operations
- replace all remaining any types in projects endpoints
- consistent use of proper types across all API routes
2025-11-23 05:16:55 -08:00

321 lines
9.7 KiB
TypeScript

import type { RequestHandler } from './$types'
import type { Prisma } from '@prisma/client'
import { prisma } from '$lib/server/database'
import {
jsonResponse,
errorResponse,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { ensureUniqueSlug } from '$lib/server/database'
import {
updateMediaUsage,
removeMediaUsage,
extractMediaIds,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// Type for project update request body (partial of ProjectCreateBody)
interface ProjectUpdateBody {
title?: string
subtitle?: string
description?: string
year?: number
client?: string
role?: string
featuredImage?: string
logoUrl?: string
gallery?: Prisma.JsonValue
externalUrl?: string
caseStudyContent?: Prisma.JsonValue
backgroundColor?: string
highlightColor?: string
projectType?: string
displayOrder?: number
status?: string
password?: string | null
slug?: string
}
// GET /api/projects/[id] - Get a single project
export const GET: RequestHandler = async (event) => {
const id = parseInt(event.params.id)
if (isNaN(id)) {
return errorResponse('Invalid project ID', 400)
}
try {
const project = await prisma.project.findUnique({
where: { id }
})
if (!project) {
return errorResponse('Project not found', 404)
}
return jsonResponse(project)
} catch (error) {
logger.error('Failed to retrieve project', error as Error)
return errorResponse('Failed to retrieve project', 500)
}
}
// PUT /api/projects/[id] - Update a project
export const PUT: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
const id = parseInt(event.params.id)
if (isNaN(id)) {
return errorResponse('Invalid project ID', 400)
}
try {
const body = await parseRequestBody<ProjectUpdateBody>(event.request)
if (!body) {
return errorResponse('Invalid request body', 400)
}
// Check if project exists
const existing = await prisma.project.findUnique({
where: { id }
})
if (!existing) {
return errorResponse('Project not found', 404)
}
// Handle slug update
let slug = existing.slug
if (body.slug && body.slug !== existing.slug) {
slug = await ensureUniqueSlug(body.slug, 'project', id)
}
// Concurrency control: if updatedAt provided, ensure it matches current
if (body.updatedAt) {
const incoming = new Date(body.updatedAt)
if (existing.updatedAt.getTime() !== incoming.getTime()) {
return errorResponse('Conflict: project has changed', 409)
}
}
// Update project
const project = await prisma.project.update({
where: { id },
data: {
slug,
title: body.title !== undefined ? body.title : existing.title,
subtitle: body.subtitle !== undefined ? body.subtitle : existing.subtitle,
description: body.description !== undefined ? body.description : existing.description,
year: body.year !== undefined ? body.year : existing.year,
client: body.client !== undefined ? body.client : existing.client,
role: body.role !== undefined ? body.role : existing.role,
featuredImage:
body.featuredImage !== undefined ? body.featuredImage : existing.featuredImage,
logoUrl: body.logoUrl !== undefined ? body.logoUrl : existing.logoUrl,
gallery: body.gallery !== undefined ? body.gallery : existing.gallery,
externalUrl: body.externalUrl !== undefined ? body.externalUrl : existing.externalUrl,
caseStudyContent:
body.caseStudyContent !== undefined ? body.caseStudyContent : existing.caseStudyContent,
backgroundColor:
body.backgroundColor !== undefined ? body.backgroundColor : existing.backgroundColor,
highlightColor:
body.highlightColor !== undefined ? body.highlightColor : existing.highlightColor,
projectType: body.projectType !== undefined ? body.projectType : existing.projectType,
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
status: body.status !== undefined ? body.status : existing.status,
password: body.password !== undefined ? body.password : existing.password,
publishedAt:
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
}
})
// Update media usage tracking
try {
// Remove all existing usage for this project first
await removeMediaUsage('project', id)
// Track all current media usage in the updated project
const usageReferences: MediaUsageReference[] = []
// Track featured image
const featuredImageIds = extractMediaIds(project, 'featuredImage')
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
contentId: id,
fieldName: 'featuredImage'
})
})
// Track logo
const logoIds = extractMediaIds(project, 'logoUrl')
logoIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
contentId: id,
fieldName: 'logoUrl'
})
})
// Track gallery images
const galleryIds = extractMediaIds(project, 'gallery')
galleryIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
contentId: id,
fieldName: 'gallery'
})
})
// Track media in case study content
const contentIds = extractMediaIds(project, 'caseStudyContent')
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
contentId: id,
fieldName: 'content'
})
})
if (usageReferences.length > 0) {
await updateMediaUsage(usageReferences)
}
} catch (error) {
logger.warn('Failed to update media usage tracking for project', { projectId: id, error })
}
logger.info('Project updated', { id: project.id, slug: project.slug })
return jsonResponse(project)
} catch (error) {
logger.error('Failed to update project', error as Error)
return errorResponse('Failed to update project', 500)
}
}
// PATCH /api/projects/[id] - Partially update a project
export const PATCH: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
const id = parseInt(event.params.id)
if (isNaN(id)) {
return errorResponse('Invalid project ID', 400)
}
try {
const body = await parseRequestBody<ProjectUpdateBody>(event.request)
if (!body) {
return errorResponse('Invalid request body', 400)
}
// Check if project exists
const existing = await prisma.project.findUnique({
where: { id }
})
if (!existing) {
return errorResponse('Project not found', 404)
}
// Concurrency control: if updatedAt provided, ensure it matches current
if (body.updatedAt) {
const incoming = new Date(body.updatedAt)
if (existing.updatedAt.getTime() !== incoming.getTime()) {
return errorResponse('Conflict: project has changed', 409)
}
}
// Build update data object with only provided fields
const updateData: Prisma.ProjectUpdateInput = {}
// Handle status update specially
if (body.status !== undefined) {
updateData.status = body.status
// Set publishedAt if changing to published for the first time
if (body.status === 'published' && !existing.publishedAt) {
updateData.publishedAt = new Date()
}
// Clear publishedAt if changing to draft
else if (body.status === 'draft') {
updateData.publishedAt = null
}
}
// Add other fields if provided
if (body.title !== undefined) updateData.title = body.title
if (body.subtitle !== undefined) updateData.subtitle = body.subtitle
if (body.description !== undefined) updateData.description = body.description
if (body.year !== undefined) updateData.year = body.year
if (body.client !== undefined) updateData.client = body.client
if (body.role !== undefined) updateData.role = body.role
if (body.featuredImage !== undefined) updateData.featuredImage = body.featuredImage
if (body.logoUrl !== undefined) updateData.logoUrl = body.logoUrl
if (body.gallery !== undefined) updateData.gallery = body.gallery
if (body.externalUrl !== undefined) updateData.externalUrl = body.externalUrl
if (body.caseStudyContent !== undefined) updateData.caseStudyContent = body.caseStudyContent
if (body.backgroundColor !== undefined) updateData.backgroundColor = body.backgroundColor
if (body.highlightColor !== undefined) updateData.highlightColor = body.highlightColor
if (body.projectType !== undefined) updateData.projectType = body.projectType
if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder
if (body.password !== undefined) updateData.password = body.password
// Handle slug update if provided
if (body.slug && body.slug !== existing.slug) {
updateData.slug = await ensureUniqueSlug(body.slug, 'project', id)
}
// Update project
const project = await prisma.project.update({
where: { id },
data: updateData
})
logger.info('Project partially updated', { id: project.id, fields: Object.keys(updateData) })
return jsonResponse(project)
} catch (error) {
logger.error('Failed to update project', error as Error)
return errorResponse('Failed to update project', 500)
}
}
// DELETE /api/projects/[id] - Delete a project
export const DELETE: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
const id = parseInt(event.params.id)
if (isNaN(id)) {
return errorResponse('Invalid project ID', 400)
}
try {
// Remove media usage tracking first
await removeMediaUsage('project', id)
// Delete the project
await prisma.project.delete({
where: { id }
})
logger.info('Project deleted', { id })
return new Response(null, { status: 204 })
} catch (error) {
logger.error('Failed to delete project', error as Error)
return errorResponse('Failed to delete project', 500)
}
}