Files
mylder-frontend/src/lib/supabase/projects.ts
christiankrag 8e1ec041de Add cascade delete for projects with Gitea repository cleanup
- 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>
2025-12-15 14:37:43 +01:00

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'
}
}
}