- Add deleteGiteaRepo server action to remove repos via Gitea API - Add deleteProject function with full cascade: - Deletes Gitea repository if linked - Removes all agent_runs, messages, backlog items - Removes project phases, activities, recommendations - Finally removes the project itself - Add ProjectSettingsMenu with delete confirmation dialog - Add use-toast hook for notifications using sonner - Add shadcn alert-dialog component - Restore brand button variant after shadcn update 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import { createClient } from '@/lib/supabase/client'
|
|
import { deleteGiteaRepo } from '@/app/(dashboard)/projects/new/actions'
|
|
import type {
|
|
DesignPhase,
|
|
PhaseStatus,
|
|
BacklogItem,
|
|
ProjectHealth,
|
|
AIRecommendation
|
|
} from '@/types/design-thinking'
|
|
|
|
export interface Project {
|
|
id: string
|
|
user_id: string
|
|
name: string
|
|
description: string | null
|
|
current_phase: DesignPhase
|
|
health_score: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface ProjectPhase {
|
|
id: string
|
|
project_id: string
|
|
phase: DesignPhase
|
|
status: PhaseStatus
|
|
started_at: string | null
|
|
completed_at: string | null
|
|
notes: string | null
|
|
iteration: number
|
|
}
|
|
|
|
// Fetch all projects for current user
|
|
export async function getProjects(): Promise<Project[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('projects')
|
|
.select('*')
|
|
.order('updated_at', { ascending: false })
|
|
|
|
if (error) throw error
|
|
return (data || []) as unknown as Project[]
|
|
}
|
|
|
|
// Fetch single project with phases
|
|
export async function getProject(projectId: string): Promise<{ project: Project; phases: ProjectPhase[] }> {
|
|
const supabase = createClient()
|
|
|
|
const [projectResult, phasesResult] = await Promise.all([
|
|
supabase.from('projects').select('*').eq('id', projectId).single(),
|
|
supabase.from('project_phases').select('*').eq('project_id', projectId).order('iteration', { ascending: false })
|
|
])
|
|
|
|
if (projectResult.error) throw projectResult.error
|
|
if (phasesResult.error) throw phasesResult.error
|
|
|
|
return {
|
|
project: projectResult.data as unknown as Project,
|
|
phases: (phasesResult.data || []) as unknown as ProjectPhase[]
|
|
}
|
|
}
|
|
|
|
// Create new project
|
|
export async function createProject(name: string, description?: string): Promise<Project> {
|
|
const supabase = createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) throw new Error('Not authenticated')
|
|
|
|
// Use raw SQL-like insert with explicit types
|
|
const { data, error } = await supabase
|
|
.from('projects')
|
|
.insert([{
|
|
user_id: user.id,
|
|
name,
|
|
description: description || null,
|
|
current_phase: 'empathize' as DesignPhase,
|
|
health_score: 100
|
|
}] as never)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
const project = data as unknown as Project
|
|
|
|
// Initialize phase tracking
|
|
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
|
await supabase.from('project_phases').insert(
|
|
phases.map((phase, index) => ({
|
|
project_id: project.id,
|
|
phase,
|
|
status: index === 0 ? 'in_progress' : 'not_started',
|
|
iteration: 1,
|
|
started_at: index === 0 ? new Date().toISOString() : null
|
|
})) as never
|
|
)
|
|
|
|
return project
|
|
}
|
|
|
|
// Update project phase
|
|
export async function updateProjectPhase(projectId: string, phase: DesignPhase, status: PhaseStatus): Promise<void> {
|
|
const supabase = createClient()
|
|
|
|
// Update the phase record
|
|
const { error: phaseError } = await supabase
|
|
.from('project_phases')
|
|
.update({
|
|
status,
|
|
started_at: status === 'in_progress' ? new Date().toISOString() : undefined,
|
|
completed_at: status === 'completed' ? new Date().toISOString() : undefined
|
|
} as never)
|
|
.eq('project_id', projectId)
|
|
.eq('phase', phase)
|
|
|
|
if (phaseError) throw phaseError
|
|
|
|
// Update project's current phase if advancing
|
|
if (status === 'completed') {
|
|
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
|
const currentIndex = phases.indexOf(phase)
|
|
if (currentIndex < phases.length - 1) {
|
|
const nextPhase = phases[currentIndex + 1]
|
|
await supabase
|
|
.from('projects')
|
|
.update({ current_phase: nextPhase } as never)
|
|
.eq('id', projectId)
|
|
|
|
// Start the next phase
|
|
await supabase
|
|
.from('project_phases')
|
|
.update({ status: 'in_progress', started_at: new Date().toISOString() } as never)
|
|
.eq('project_id', projectId)
|
|
.eq('phase', nextPhase)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Loop back to a previous phase (start new iteration)
|
|
export async function loopBackToPhase(projectId: string, fromPhase: DesignPhase, toPhase: DesignPhase): Promise<void> {
|
|
const supabase = createClient()
|
|
|
|
// Get current max iteration
|
|
const { data: currentPhases } = await supabase
|
|
.from('project_phases')
|
|
.select('iteration')
|
|
.eq('project_id', projectId)
|
|
.order('iteration', { ascending: false })
|
|
.limit(1)
|
|
|
|
const phasesData = currentPhases as unknown as Array<{ iteration: number }> | null
|
|
const newIteration = (phasesData?.[0]?.iteration || 1) + 1
|
|
|
|
// Create new iteration phases from toPhase onwards
|
|
const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test']
|
|
const startIndex = phases.indexOf(toPhase)
|
|
|
|
await supabase.from('project_phases').insert(
|
|
phases.slice(startIndex).map((phase, index) => ({
|
|
project_id: projectId,
|
|
phase,
|
|
status: index === 0 ? 'in_progress' : 'not_started',
|
|
iteration: newIteration,
|
|
started_at: index === 0 ? new Date().toISOString() : null
|
|
})) as never
|
|
)
|
|
|
|
// Update project's current phase
|
|
await supabase
|
|
.from('projects')
|
|
.update({ current_phase: toPhase } as never)
|
|
.eq('id', projectId)
|
|
|
|
// Log activity
|
|
await supabase.from('project_activities').insert([{
|
|
project_id: projectId,
|
|
activity_type: 'loop_back',
|
|
message: `Looped back from ${fromPhase} to ${toPhase} (iteration ${newIteration})`,
|
|
metadata: { from_phase: fromPhase, to_phase: toPhase, iteration: newIteration }
|
|
}] as never)
|
|
}
|
|
|
|
// Fetch backlog items for a project
|
|
export async function getBacklogItems(projectId: string): Promise<BacklogItem[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('backlog_items')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.order('priority_score', { ascending: false })
|
|
|
|
if (error) throw error
|
|
return (data || []) as unknown as BacklogItem[]
|
|
}
|
|
|
|
// Create backlog item
|
|
export async function createBacklogItem(
|
|
projectId: string,
|
|
item: Omit<BacklogItem, 'id' | 'priority_score' | 'created_at' | 'updated_at'>
|
|
): Promise<BacklogItem> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('backlog_items')
|
|
.insert([{ ...item, project_id: projectId }] as never)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data as unknown as BacklogItem
|
|
}
|
|
|
|
// Update backlog item
|
|
export async function updateBacklogItem(itemId: string, updates: Partial<BacklogItem>): Promise<BacklogItem> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('backlog_items')
|
|
.update(updates as never)
|
|
.eq('id', itemId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data as unknown as BacklogItem
|
|
}
|
|
|
|
// Fetch project health
|
|
export async function getProjectHealth(projectId: string): Promise<ProjectHealth> {
|
|
const supabase = createClient()
|
|
|
|
// Get latest health snapshot
|
|
const { data: snapshot } = await supabase
|
|
.from('project_health_snapshots')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.order('snapshot_date', { ascending: false })
|
|
.limit(1)
|
|
.single()
|
|
|
|
const snapshotData = snapshot as unknown as {
|
|
overall_score: number
|
|
velocity: number
|
|
blockers: number
|
|
overdue: number
|
|
completion_rate: number
|
|
} | null
|
|
|
|
if (snapshotData) {
|
|
return {
|
|
overall: snapshotData.overall_score,
|
|
velocity: snapshotData.velocity,
|
|
blockers: snapshotData.blockers,
|
|
overdue: snapshotData.overdue,
|
|
completion_rate: snapshotData.completion_rate
|
|
}
|
|
}
|
|
|
|
// Calculate on the fly if no snapshot
|
|
const { data: items } = await supabase
|
|
.from('backlog_items')
|
|
.select('status, due_date')
|
|
.eq('project_id', projectId)
|
|
|
|
const itemsData = items as unknown as Array<{ status: string; due_date: string | null }> | null
|
|
const total = itemsData?.length || 0
|
|
const done = itemsData?.filter(i => i.status === 'done').length || 0
|
|
const blocked = itemsData?.filter(i => i.status === 'blocked').length || 0
|
|
const overdue = itemsData?.filter(i =>
|
|
i.due_date && new Date(i.due_date) < new Date() && i.status !== 'done'
|
|
).length || 0
|
|
|
|
return {
|
|
overall: Math.max(0, 100 - (blocked * 10) - (overdue * 5)),
|
|
velocity: done,
|
|
blockers: blocked,
|
|
overdue,
|
|
completion_rate: total > 0 ? Math.round((done / total) * 100) : 0
|
|
}
|
|
}
|
|
|
|
// Fetch AI recommendations
|
|
export async function getRecommendations(projectId: string): Promise<AIRecommendation[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('ai_recommendations')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('dismissed', false)
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (error) throw error
|
|
return (data || []) as unknown as AIRecommendation[]
|
|
}
|
|
|
|
// Dismiss recommendation
|
|
export async function dismissRecommendation(recommendationId: string): Promise<void> {
|
|
const supabase = createClient()
|
|
await supabase
|
|
.from('ai_recommendations')
|
|
.update({ dismissed: true, dismissed_at: new Date().toISOString() } as never)
|
|
.eq('id', recommendationId)
|
|
}
|
|
|
|
// Fetch project activities
|
|
export async function getProjectActivities(projectId: string, limit = 20) {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('project_activities')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(limit)
|
|
|
|
if (error) throw error
|
|
return (data || []) as unknown as Array<{
|
|
id: string
|
|
activity_type: string
|
|
message: string
|
|
created_at: string
|
|
}>
|
|
}
|
|
|
|
// Delete project with cascade (includes Gitea repo deletion)
|
|
export async function deleteProject(projectId: string): Promise<{ success: boolean; error?: string }> {
|
|
const supabase = createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return { success: false, error: 'Not authenticated' }
|
|
}
|
|
|
|
try {
|
|
// First, get the project to check ownership and get gitea_repo
|
|
const { data: projectData, error: fetchError } = await supabase
|
|
.from('projects')
|
|
.select('id, user_id, gitea_repo, name')
|
|
.eq('id', projectId)
|
|
.single()
|
|
|
|
if (fetchError || !projectData) {
|
|
return { success: false, error: 'Project not found' }
|
|
}
|
|
|
|
const project = projectData as unknown as {
|
|
id: string
|
|
user_id: string
|
|
gitea_repo: string | null
|
|
name: string
|
|
}
|
|
|
|
// Verify ownership
|
|
if (project.user_id !== user.id) {
|
|
return { success: false, error: 'Not authorized to delete this project' }
|
|
}
|
|
|
|
// Extract repo name from gitea_repo URL if it exists
|
|
// Format: https://gitea.mylder.io/admin/repo-name
|
|
if (project.gitea_repo) {
|
|
const repoMatch = project.gitea_repo.match(/\/([^/]+)$/)
|
|
const repoName = repoMatch ? repoMatch[1] : null
|
|
|
|
if (repoName) {
|
|
const giteaResult = await deleteGiteaRepo(repoName)
|
|
if (!giteaResult.success) {
|
|
console.error('Failed to delete Gitea repo:', giteaResult.error)
|
|
// Continue with database deletion even if Gitea fails
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete related data in order (due to foreign key constraints)
|
|
// Delete agent_runs
|
|
await supabase
|
|
.from('agent_runs')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Delete messages
|
|
await supabase
|
|
.from('messages')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Delete backlog items
|
|
await supabase
|
|
.from('backlog_items')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Delete project activities
|
|
await supabase
|
|
.from('project_activities')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Delete AI recommendations
|
|
await supabase
|
|
.from('ai_recommendations')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Delete project phases
|
|
await supabase
|
|
.from('project_phases')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Delete health snapshots
|
|
await supabase
|
|
.from('project_health_snapshots')
|
|
.delete()
|
|
.eq('project_id', projectId)
|
|
|
|
// Finally, delete the project itself
|
|
const { error: deleteError } = await supabase
|
|
.from('projects')
|
|
.delete()
|
|
.eq('id', projectId)
|
|
|
|
if (deleteError) {
|
|
return { success: false, error: deleteError.message }
|
|
}
|
|
|
|
return { success: true }
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to delete project'
|
|
}
|
|
}
|
|
}
|