Nicholai b20db98051
Some checks failed
CI / build-and-test (pull_request) Failing after 1m19s
feat(ci,flags,ops): ship end-to-end CI, feature-flag framework, gated surfaces, and ops docs
CI (.gitea/workflows/ci.yaml): lint → typecheck → vitest w/ coverage → OpenNext build → preview smoke → bundle-size budgets; Node 20; npm ci; artifacts; safe env; D1 dry-run scaffold.

Budgets: add scripts/budgets.mjs; TOTAL_STATIC_MAX_BYTES and MAX_ASSET_BYTES thresholds; report top offenders; fail on breach; README CI section.

Flags: add lib/flags.ts with typed booleans and safe defaults (ADMIN_ENABLED, ARTISTS_MODULE_ENABLED, UPLOADS_ADMIN_ENABLED, BOOKING_ENABLED, PUBLIC_APPOINTMENT_REQUESTS_ENABLED, REFERENCE_UPLOADS_PUBLIC_ENABLED, DEPOSITS_ENABLED, PUBLIC_DB_ARTISTS_ENABLED, ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED, STRICT_CI_GATES_ENABLED, ISR_CACHE_R2_ENABLED); robust parsing; client provider; unit tests.

Wiring: gate Admin shell and admin write APIs (503 JSON on uploads and artists writes); disable booking submit and short-circuit booking mutations when off; render static Hero/Artists when advanced animations off; tests for UI and API guards.

Ops: expand docs/prd/rollback-strategy.md with “Feature Flags Operations,” Cloudflare Dashboard and wrangler.toml steps, preview simulation, incident playbook, and post-toggle smoke checklist.

Release: add docs/releases/2025-09-19-feature-flags-rollout.md with last-good commit, preview/production flag matrices, rollback notes, and smoke results; link from rollback doc.

Chore: fix TS issues (gift-cards boolean handling, Lenis options, tailwind darkMode), remove next-on-pages peer conflict, update package.json scripts, configure Gitea act_runner label, open draft PR to trigger CI.

Refs: CI-1, FF-1, FF-2, FF-3, OPS-1
Impact: defaults preserve current behavior; no runtime changes unless flags flipped
2025-09-19 21:33:09 -06:00

346 lines
9.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getDB } from '@/lib/db'
import { Flags } from '@/lib/flags'
import { z } from 'zod'
export const dynamic = "force-dynamic";
const createAppointmentSchema = z.object({
artistId: z.string().min(1),
clientId: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
startTime: z.string().datetime(),
endTime: z.string().datetime(),
depositAmount: z.number().optional(),
totalAmount: z.number().optional(),
notes: z.string().optional(),
})
const updateAppointmentSchema = createAppointmentSchema.partial().extend({
id: z.string().min(1),
status: z.enum(['PENDING', 'CONFIRMED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']).optional(),
})
function bookingDisabledResponse() {
return NextResponse.json({ error: 'Booking disabled' }, { status: 503 })
}
export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const start = searchParams.get('start')
const end = searchParams.get('end')
const artistId = searchParams.get('artistId')
const status = searchParams.get('status')
const db = getDB(context?.env)
let query = `
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON a.client_id = u.id
WHERE 1=1
`
const params: any[] = []
if (start) {
query += ` AND a.start_time >= ?`
params.push(start)
}
if (end) {
query += ` AND a.end_time <= ?`
params.push(end)
}
if (artistId) {
query += ` AND a.artist_id = ?`
params.push(artistId)
}
if (status) {
query += ` AND a.status = ?`
params.push(status)
}
query += ` ORDER BY a.start_time ASC`
const stmt = db.prepare(query)
const result = await stmt.bind(...params).all()
return NextResponse.json({ appointments: result.results })
} catch (error) {
console.error('Error fetching appointments:', error)
return NextResponse.json(
{ error: 'Failed to fetch appointments' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
try {
if (!Flags.BOOKING_ENABLED) {
return bookingDisabledResponse()
}
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = createAppointmentSchema.parse(body)
// Check for scheduling conflicts
const db = getDB(context?.env)
const conflictCheck = db.prepare(`
SELECT id FROM appointments
WHERE artist_id = ?
AND status NOT IN ('CANCELLED', 'COMPLETED')
AND (
(start_time <= ? AND end_time > ?) OR
(start_time < ? AND end_time >= ?) OR
(start_time >= ? AND end_time <= ?)
)
`)
const conflictResult = await conflictCheck.bind(
validatedData.artistId,
validatedData.startTime, validatedData.startTime,
validatedData.endTime, validatedData.endTime,
validatedData.startTime, validatedData.endTime
).all()
if (conflictResult.results.length > 0) {
return NextResponse.json(
{ error: 'Time slot conflicts with existing appointment' },
{ status: 409 }
)
}
const appointmentId = crypto.randomUUID()
const insertStmt = db.prepare(`
INSERT INTO appointments (
id, artist_id, client_id, title, description, start_time, end_time,
status, deposit_amount, total_amount, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`)
await insertStmt.bind(
appointmentId,
validatedData.artistId,
validatedData.clientId,
validatedData.title,
validatedData.description || null,
validatedData.startTime,
validatedData.endTime,
validatedData.depositAmount || null,
validatedData.totalAmount || null,
validatedData.notes || null
).run()
// Fetch the created appointment with related data
const selectStmt = db.prepare(`
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON a.client_id = u.id
WHERE a.id = ?
`)
const appointment = await selectStmt.bind(appointmentId).first()
return NextResponse.json({ appointment }, { status: 201 })
} catch (error) {
console.error('Error creating appointment:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid appointment data', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Failed to create appointment' },
{ status: 500 }
)
}
}
export async function PUT(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
try {
if (!Flags.BOOKING_ENABLED) {
return bookingDisabledResponse()
}
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = updateAppointmentSchema.parse(body)
const db = getDB(context?.env)
// Check if appointment exists
const existingStmt = db.prepare('SELECT * FROM appointments WHERE id = ?')
const existing = await existingStmt.bind(validatedData.id).first()
if (!existing) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
// Check for conflicts if time is being changed
if (validatedData.startTime || validatedData.endTime) {
const startTime = validatedData.startTime || existing.start_time
const endTime = validatedData.endTime || existing.end_time
const artistId = validatedData.artistId || existing.artist_id
const conflictCheck = db.prepare(`
SELECT id FROM appointments
WHERE artist_id = ?
AND id != ?
AND status NOT IN ('CANCELLED', 'COMPLETED')
AND (
(start_time <= ? AND end_time > ?) OR
(start_time < ? AND end_time >= ?) OR
(start_time >= ? AND end_time <= ?)
)
`)
const conflictResult = await conflictCheck.bind(
artistId, validatedData.id,
startTime, startTime,
endTime, endTime,
startTime, endTime
).all()
if (conflictResult.results.length > 0) {
return NextResponse.json(
{ error: 'Time slot conflicts with existing appointment' },
{ status: 409 }
)
}
}
// Build update query dynamically
const updateFields = []
const updateValues = []
Object.entries(validatedData).forEach(([key, value]) => {
if (key !== 'id' && value !== undefined) {
const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase()
updateFields.push(`${dbKey} = ?`)
updateValues.push(value)
}
})
if (updateFields.length === 0) {
return NextResponse.json(
{ error: 'No fields to update' },
{ status: 400 }
)
}
updateFields.push('updated_at = CURRENT_TIMESTAMP')
updateValues.push(validatedData.id)
const updateStmt = db.prepare(`
UPDATE appointments
SET ${updateFields.join(', ')}
WHERE id = ?
`)
await updateStmt.bind(...updateValues).run()
// Fetch updated appointment
const selectStmt = db.prepare(`
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON a.client_id = u.id
WHERE a.id = ?
`)
const appointment = await selectStmt.bind(validatedData.id).first()
return NextResponse.json({ appointment })
} catch (error) {
console.error('Error updating appointment:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid appointment data', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Failed to update appointment' },
{ status: 500 }
)
}
}
export async function DELETE(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
try {
if (!Flags.BOOKING_ENABLED) {
return bookingDisabledResponse()
}
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: 'Appointment ID is required' },
{ status: 400 }
)
}
const db = getDB(context?.env)
const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?')
const result = await deleteStmt.bind(id).run()
const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0
if (written === 0) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting appointment:', error)
return NextResponse.json(
{ error: 'Failed to delete appointment' },
{ status: 500 }
)
}
}