nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
Raw
History Blame Contribute Delete
11.5 kB
import { NextRequest, NextResponse } from 'next/server'
import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth'
import { mutationLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
import {
ensureTenantWorkspaceAccess,
ForbiddenError
} from '@/lib/workspaces'
function normalizePrefix(input: string): string {
const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '')
return normalized.slice(0, 12)
}
function toProjectId(raw: string): number {
const id = Number.parseInt(raw, 10)
return Number.isFinite(id) ? id : NaN
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const tenantId = auth.user.tenant_id ?? 1
const forwardedFor = (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() || null
ensureTenantWorkspaceAccess(db, tenantId, workspaceId, {
actor: auth.user.username,
actorId: auth.user.id,
route: '/api/projects/[id]',
ipAddress: forwardedFor,
userAgent: request.headers.get('user-agent'),
})
const { id } = await params
const projectId = toProjectId(id)
if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
const projectScope = db.prepare(`
SELECT p.id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ? AND p.workspace_id = ? AND w.tenant_id = ?
LIMIT 1
`).get(projectId, workspaceId, tenantId)
if (!projectScope) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
const row = db.prepare(`
SELECT p.id, p.workspace_id, p.name, p.slug, p.description, p.ticket_prefix, p.ticket_counter, p.status,
p.github_repo, p.deadline, p.color, p.github_sync_enabled, p.github_labels_initialized, p.github_default_branch, p.created_at, p.updated_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) as task_count,
(SELECT GROUP_CONCAT(paa.agent_name) FROM project_agent_assignments paa WHERE paa.project_id = p.id) as assigned_agents_csv
FROM projects p
WHERE p.id = ? AND p.workspace_id = ?
`).get(projectId, workspaceId) as Record<string, unknown> | undefined
if (!row) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
const project = {
...row,
assigned_agents: row.assigned_agents_csv ? String(row.assigned_agents_csv).split(',') : [],
assigned_agents_csv: undefined,
}
return NextResponse.json({ project })
} catch (error) {
if (error instanceof ForbiddenError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error({ err: error }, 'GET /api/projects/[id] error')
return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const tenantId = auth.user.tenant_id ?? 1
const forwardedFor = (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() || null
ensureTenantWorkspaceAccess(db, tenantId, workspaceId, {
actor: auth.user.username,
actorId: auth.user.id,
route: '/api/projects/[id]',
ipAddress: forwardedFor,
userAgent: request.headers.get('user-agent'),
})
const { id } = await params
const projectId = toProjectId(id)
if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
const projectScope = db.prepare(`
SELECT p.id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ? AND p.workspace_id = ? AND w.tenant_id = ?
LIMIT 1
`).get(projectId, workspaceId, tenantId)
if (!projectScope) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any
if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
if (current.slug === 'general' && current.workspace_id === workspaceId && current.id === projectId) {
const body = await request.json()
if (body?.status === 'archived') {
return NextResponse.json({ error: 'Default project cannot be archived' }, { status: 400 })
}
}
const body = await request.json()
const updates: string[] = []
const paramsList: Array<string | number | null> = []
if (typeof body?.name === 'string') {
const name = body.name.trim()
if (!name) return NextResponse.json({ error: 'Project name cannot be empty' }, { status: 400 })
updates.push('name = ?')
paramsList.push(name)
}
if (typeof body?.description === 'string') {
updates.push('description = ?')
paramsList.push(body.description.trim() || null)
}
if (typeof body?.ticket_prefix === 'string' || typeof body?.ticketPrefix === 'string') {
const raw = String(body.ticket_prefix ?? body.ticketPrefix)
const prefix = normalizePrefix(raw)
if (!prefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 })
const conflict = db.prepare(`
SELECT id FROM projects
WHERE workspace_id = ? AND ticket_prefix = ? AND id != ?
`).get(workspaceId, prefix, projectId)
if (conflict) return NextResponse.json({ error: 'Ticket prefix already in use' }, { status: 409 })
updates.push('ticket_prefix = ?')
paramsList.push(prefix)
}
if (typeof body?.status === 'string') {
const status = body.status === 'archived' ? 'archived' : 'active'
updates.push('status = ?')
paramsList.push(status)
}
if (body?.github_repo !== undefined) {
updates.push('github_repo = ?')
paramsList.push(typeof body.github_repo === 'string' ? body.github_repo.trim() || null : null)
}
if (body?.deadline !== undefined) {
updates.push('deadline = ?')
paramsList.push(typeof body.deadline === 'number' ? body.deadline : null)
}
if (body?.color !== undefined) {
updates.push('color = ?')
paramsList.push(typeof body.color === 'string' ? body.color.trim() || null : null)
}
if (body?.github_sync_enabled !== undefined) {
updates.push('github_sync_enabled = ?')
paramsList.push(body.github_sync_enabled ? 1 : 0)
}
if (body?.github_default_branch !== undefined) {
updates.push('github_default_branch = ?')
paramsList.push(typeof body.github_default_branch === 'string' ? body.github_default_branch.trim() || 'main' : 'main')
}
if (body?.github_labels_initialized !== undefined) {
updates.push('github_labels_initialized = ?')
paramsList.push(body.github_labels_initialized ? 1 : 0)
}
if (updates.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 })
updates.push('updated_at = unixepoch()')
db.prepare(`
UPDATE projects
SET ${updates.join(', ')}
WHERE id = ? AND workspace_id = ?
`).run(...paramsList, projectId, workspaceId)
const project = db.prepare(`
SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status,
github_repo, deadline, color, github_sync_enabled, github_labels_initialized, github_default_branch, created_at, updated_at
FROM projects
WHERE id = ? AND workspace_id = ?
`).get(projectId, workspaceId)
return NextResponse.json({ project })
} catch (error) {
if (error instanceof ForbiddenError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error({ err: error }, 'PATCH /api/projects/[id] error')
return NextResponse.json({ error: 'Failed to update project' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const tenantId = auth.user.tenant_id ?? 1
const forwardedFor = (request.headers.get('x-forwarded-for') || '').split(',')[0]?.trim() || null
ensureTenantWorkspaceAccess(db, tenantId, workspaceId, {
actor: auth.user.username,
actorId: auth.user.id,
route: '/api/projects/[id]',
ipAddress: forwardedFor,
userAgent: request.headers.get('user-agent'),
})
const { id } = await params
const projectId = toProjectId(id)
if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
const projectScope = db.prepare(`
SELECT p.id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ? AND p.workspace_id = ? AND w.tenant_id = ?
LIMIT 1
`).get(projectId, workspaceId, tenantId)
if (!projectScope) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any
if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
if (current.slug === 'general') {
return NextResponse.json({ error: 'Default project cannot be deleted' }, { status: 400 })
}
const mode = new URL(request.url).searchParams.get('mode') || 'archive'
if (mode !== 'delete') {
db.prepare(`UPDATE projects SET status = 'archived', updated_at = unixepoch() WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId)
return NextResponse.json({ success: true, mode: 'archive' })
}
const fallback = db.prepare(`
SELECT id FROM projects
WHERE workspace_id = ? AND slug = 'general'
LIMIT 1
`).get(workspaceId) as { id: number } | undefined
if (!fallback) return NextResponse.json({ error: 'Default project missing' }, { status: 500 })
const tx = db.transaction(() => {
db.prepare(`
UPDATE tasks
SET project_id = ?
WHERE workspace_id = ? AND project_id = ?
`).run(fallback.id, workspaceId, projectId)
db.prepare(`DELETE FROM projects WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId)
})
tx()
return NextResponse.json({ success: true, mode: 'delete' })
} catch (error) {
if (error instanceof ForbiddenError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
logger.error({ err: error }, 'DELETE /api/projects/[id] error')
return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 })
}
}