From f41e6724cdf8fcfa375ee7d01ae4e0a1db99a64a Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Wed, 4 Feb 2026 01:51:20 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=20all=20customer=20selection=20wh?= =?UTF-8?q?en=20adding=20to=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FSD.md | 105 ++++++++- docs/TSD.md | 170 +++++++++++++- src/app/releases/[id]/page.tsx | 60 +++-- .../releases/activate-release-dialog.tsx | 207 +++++++++++++++++ .../releases/add-customer-dialog.tsx | 208 ++++++++++++++++++ src/components/releases/release-actions.tsx | 128 +++++++++++ src/lib/actions/releases.ts | 75 ++++++- 7 files changed, 905 insertions(+), 48 deletions(-) create mode 100644 src/components/releases/activate-release-dialog.tsx create mode 100644 src/components/releases/add-customer-dialog.tsx create mode 100644 src/components/releases/release-actions.tsx diff --git a/docs/FSD.md b/docs/FSD.md index f1a2de7..a0606d9 100644 --- a/docs/FSD.md +++ b/docs/FSD.md @@ -191,11 +191,12 @@ Customer-specific step instance (actual execution unit). |----|---------|-------------| | FR-001 | Create Release | Create with type, name, description, version/date for release type | | FR-002 | Release Lifecycle | Draft → Active → Archived workflow | -| FR-003 | Activate Release | Copies template steps to all active customers | +| FR-003 | Activate Release | Copies template steps to selected customers (defaults to all, with option to exclude) | | FR-004 | Clone Release | Use existing release as template for new one | | FR-005 | Archive Release | Archive completed/abandoned releases | | FR-006 | View Release Dashboard | See progress across all customers and clusters | | FR-007 | Filter Dashboard | Filter by cluster to focus on specific infrastructure | +| FR-008 | Incremental Customer Add | Add customers to an active release after initial activation | ### 4.4 Step Management (Template Layer) @@ -489,6 +490,62 @@ The side panel opens when clicking a step cell in the matrix view, showing custo └─────────────────────────────────────────────────────────────────┘ ``` +### 5.9 Activate Release - Customer Selection Dialog + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Activate Release: v2.5.0 [X] │ +├─────────────────────────────────────────────────────────────────┤ +│ Select customers to include in this release: │ +│ │ +│ [Search customers...] [Select All] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ 📁 prod-us (3 customers) │ +│ ───────────────────────────────────────────────────────────── │ +│ ☑️ customer-a namespace: cust-a-prod │ +│ ☑️ customer-b namespace: cust-b-prod │ +│ ☐ customer-c namespace: cust-c-prod [excluded] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ 📁 prod-eu (2 customers) │ +│ ───────────────────────────────────────────────────────────── │ +│ ☑️ customer-d namespace: cust-d-prod │ +│ ☑️ customer-e namespace: cust-e-prod │ +│ │ +│ Selected: 4 of 5 customers │ +│ │ +│ [Cancel] [Activate] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.10 Release Matrix - Add Customer Button + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Release: v2.5.0 (Regular Release) │ +│ Type: release | Status: Active | Date: 2024-01-15 │ +│ [Deploy Tab] [Verify Tab] [Settings] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Filter: [All Clusters ▼] [All Status ▼] [+ Add Customer] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ 📁 prod-us │ +│ ───────────────────────────────────────────────────────────── │ +│ ┌──────────┬─────────────────┬─────────────────┬────────────┐ │ +│ │ Step │ customer-a │ customer-b │ customer-c │ │ +│ │ │ (cust-a-prod) │ (cust-b-prod) │ (ADD ➕) │ │ +│ ├──────────┼─────────────────┼─────────────────┼────────────┤ │ +│ │ 1. Deploy│ ✅ Done │ ✅ Done │ │ │ +│ │ 2. SQL │ ✅ Done │ ⚠️ Overridden │ │ │ +│ └──────────┴─────────────────┴─────────────────┴────────────┘ │ +│ │ +│ Legend: ✅ Done | 🔄 Pending | ⏸️ Skipped | ⚠️ Custom/Overridden │ +│ ➕ Click to add customer to release │ +└─────────────────────────────────────────────────────────────────┘ +``` + --- ## 6. Business Rules @@ -504,10 +561,13 @@ The side panel opens when clicking a step cell in the matrix view, showing custo - **BR-M03**: Soft delete only; maintain history for audit ### 6.3 Release Rules -- **BR-R01**: When release is activated, steps are created for ALL active customers across ALL clusters +- **BR-R01**: When release is activated, steps are created for SELECTED active customers (default: all, with ability to exclude) - **BR-R02**: Editing template step only affects customers where step is `pending` - **BR-R03**: Once step is marked `done`, content is locked (prevent accidental changes) - **BR-R04**: Reverting a step sets status to `reverted` but preserves history +- **BR-R05**: Additional customers can be added to an active release (incremental add) +- **BR-R06**: Only customers not already in the release can be added incrementally +- **BR-R07**: Incrementally added customers receive the current template steps (including any edits made after initial activation) ### 6.4 Step Rules - **BR-S01**: Custom steps (is_custom=true) don't affect other customers or template (unless "Add to template" is checked) @@ -584,6 +644,47 @@ The side panel opens when clicking a step cell in the matrix view, showing custo 4. Verify and archive ``` +### 7.4 Selective Activation & Incremental Add + +**Scenario 1: Staged Release Deployment** +``` +1. Create "release" type Release + └─→ Add deploy and verify steps + +2. Activate with Customer Selection + └─→ Dialog shows all customers (grouped by cluster) + └─→ All customers pre-selected by default + └─→ Uncheck customers to exclude (e.g., pilot group only) + └─→ Click Activate + +3. Monitor pilot group progress + +4. Add remaining customers incrementally + └─→ Go to Release Matrix view + └─→ Click "Add Customer" button + └─→ Select customers to add + └─→ New customers receive current template steps + +5. Continue until all customers complete +``` + +**Scenario 2: New Customer with Updated Onboarding** +``` +1. Original onboarding release (v1) applied to all existing customers + +2. System evolves, onboarding template changes + +3. Clone v1 → Create onboarding v2 with updated steps + +4. New customer joins + +5. Activate onboarding v2 + └─→ Exclude all existing customers (they have v1) + └─→ Include only the new customer + +6. New customer completes onboarding v2 +``` + --- ## 8. Data Display Requirements diff --git a/docs/TSD.md b/docs/TSD.md index 2165769..e2f052b 100644 --- a/docs/TSD.md +++ b/docs/TSD.md @@ -485,7 +485,10 @@ export async function updateRelease( return release; } -export async function activateRelease(id: number): Promise { +export async function activateRelease( + id: number, + customerIds?: number[] +): Promise { const release = await db.query.releases.findFirst({ where: eq(releases.id, id), with: { templates: true }, @@ -494,12 +497,20 @@ export async function activateRelease(id: number): Promise { if (!release) throw new Error('Release not found'); if (release.status !== 'draft') throw new Error('Release is not in draft status'); - const activeCustomers = await db.query.customers.findMany({ - where: eq(customers.isActive, true), - }); + // If customerIds not provided, use all active customers (backward compatible) + const targetCustomers = customerIds + ? await db.query.customers.findMany({ + where: and( + eq(customers.isActive, true), + inArray(customers.id, customerIds) + ), + }) + : await db.query.customers.findMany({ + where: eq(customers.isActive, true), + }); // Create customer steps from templates - const customerStepsToInsert = activeCustomers.flatMap(customer => + const customerStepsToInsert = targetCustomers.flatMap(customer => release.templates.map(template => ({ releaseId: id, customerId: customer.id, @@ -527,6 +538,64 @@ export async function activateRelease(id: number): Promise { revalidatePath(`/releases/${id}`); } +export async function addCustomersToRelease( + releaseId: number, + customerIds: number[] +): Promise { + const release = await db.query.releases.findFirst({ + where: eq(releases.id, releaseId), + with: { templates: true }, + }); + + if (!release) throw new Error('Release not found'); + if (release.status !== 'active') throw new Error('Release must be active to add customers'); + + // Get existing customer IDs in this release + const existingSteps = await db.query.customerSteps.findMany({ + where: eq(customerSteps.releaseId, releaseId), + columns: { customerId: true }, + }); + const existingCustomerIds = new Set(existingSteps.map(s => s.customerId)); + + // Filter out customers already in the release + const newCustomerIds = customerIds.filter(id => !existingCustomerIds.has(id)); + + if (newCustomerIds.length === 0) { + throw new Error('All selected customers are already in this release'); + } + + // Get the new customers + const newCustomers = await db.query.customers.findMany({ + where: and( + eq(customers.isActive, true), + inArray(customers.id, newCustomerIds) + ), + }); + + // Create customer steps from current templates + const customerStepsToInsert = newCustomers.flatMap(customer => + release.templates.map(template => ({ + releaseId: releaseId, + customerId: customer.id, + templateId: template.id, + name: template.name, + category: template.category, + type: template.type, + content: template.content, + orderIndex: template.orderIndex, + status: 'pending' as const, + isCustom: false, + isOverridden: false, + })) + ); + + if (customerStepsToInsert.length > 0) { + await db.insert(customerSteps).values(customerStepsToInsert); + } + + revalidatePath(`/releases/${releaseId}`); +} + export async function archiveRelease(id: number): Promise { await db.update(releases) .set({ status: 'archived', updatedAt: new Date() }) @@ -1110,9 +1179,78 @@ export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps } ``` +### 6.7 Activate Release Dialog (NEW) + +```typescript +// components/releases/activate-release-dialog.tsx + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Checkbox } from '@/components/ui/checkbox'; +import { useState } from 'react'; + +interface CustomerWithCluster { + id: number; + name: string; + namespace: string; + cluster: { + id: number; + name: string; + }; +} + +interface ActivateReleaseDialogProps { + releaseId: number; + releaseName: string; + customers: CustomerWithCluster[]; + isOpen: boolean; + onClose: () => void; + onActivate: (customerIds: number[]) => Promise; +} + +export function ActivateReleaseDialog({ + releaseId, + releaseName, + customers, + isOpen, + onClose, + onActivate +}: ActivateReleaseDialogProps) { + // Dialog for selecting customers before activation + // Groups customers by cluster + // Select All / Deselect All buttons + // Shows count: "Selected X of Y customers" + // Activate button (disabled if no customers selected) + // Cancel button +} +``` + +### 6.8 Add Customer to Release Dialog (NEW) + +```typescript +// components/releases/add-customer-dialog.tsx + +interface AddCustomerDialogProps { + releaseId: number; + releaseName: string; + // Customers not currently in the release + availableCustomers: CustomerWithCluster[]; + isOpen: boolean; + onClose: () => void; + onAdd: (customerIds: number[]) => Promise; +} + +export function AddCustomerDialog({ ...props }: AddCustomerDialogProps) { + // Dialog for adding customers to an active release + // Shows only customers NOT already in the release + // Multi-select with checkboxes + // Grouped by cluster + // Add button (disabled if none selected) +} +``` + --- -## 6.7 API Endpoints (NEW) +## 6.9 API Endpoints ### Reorder Steps Endpoint @@ -1147,6 +1285,26 @@ export async function GET( } ``` +### Add Customers to Release Endpoint + +```typescript +// app/api/releases/[id]/add-customers/route.ts + +import { NextRequest } from 'next/server'; +import { addCustomersToRelease } from '@/lib/actions/releases'; + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + const releaseId = parseInt(params.id); + const { customerIds } = await request.json(); + + await addCustomersToRelease(releaseId, customerIds); + return Response.json({ success: true }); +} +``` + --- ## 7. Configuration diff --git a/src/app/releases/[id]/page.tsx b/src/app/releases/[id]/page.tsx index 4018a41..a4aa9f1 100644 --- a/src/app/releases/[id]/page.tsx +++ b/src/app/releases/[id]/page.tsx @@ -1,15 +1,16 @@ import Link from 'next/link'; -import { notFound, redirect } from 'next/navigation'; -import { ArrowLeft, Edit, Play, Archive, Plus } from 'lucide-react'; +import { notFound } from 'next/navigation'; +import { ArrowLeft, Edit } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Separator } from '@/components/ui/separator'; -import { getReleaseById, activateRelease, archiveRelease } from '@/lib/actions/releases'; +import { getReleaseById } from '@/lib/actions/releases'; import { getReleaseStepsGroupedByCluster, getStepStats } from '@/lib/actions/customer-steps'; import { listCustomers } from '@/lib/actions/customers'; import { ReleaseMatrixClient } from '@/components/releases/release-matrix-client'; +import { ReleaseActions } from '@/components/releases/release-actions'; interface ReleaseDetailPageProps { params: Promise<{ @@ -57,19 +58,14 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro ? await getStepStats(releaseId) : { total: 0, done: 0, skipped: 0, pending: 0, reverted: 0, percentage: 0 }; - const customers = await listCustomers(); + const allCustomers = await listCustomers(); - async function handleActivate() { - 'use server'; - await activateRelease(releaseId); - redirect(`/releases/${releaseId}`); - } - - async function handleArchive() { - 'use server'; - await archiveRelease(releaseId); - redirect(`/releases/${releaseId}`); - } + // Get customer IDs already in this release + const existingCustomerIds = Object.values(stepsByCluster).flatMap((clusterData: any) => + Object.values(clusterData.customers as Record).map( + (c: { customer: { id: number } }) => c.customer.id + ) + ); return (
@@ -99,22 +95,13 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro Edit - {release.status === 'draft' && ( -
- -
- )} - {release.status === 'active' && ( -
- -
- )} +
@@ -132,7 +119,14 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
-

{customers.length}

+

+ {release.status === 'active' ? existingCustomerIds.length : allCustomers.length} + {release.status === 'active' && allCustomers.length > existingCustomerIds.length && ( + + ({allCustomers.length - existingCustomerIds.length} not in release) + + )} +

{release.status === 'active' && (
@@ -173,7 +167,6 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro

Deploy Steps ({release.templates.filter(t => t.category === 'deploy').length})

@@ -182,7 +175,6 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro

Verify Steps ({release.templates.filter(t => t.category === 'verify').length})

diff --git a/src/components/releases/activate-release-dialog.tsx b/src/components/releases/activate-release-dialog.tsx new file mode 100644 index 0000000..54e0af4 --- /dev/null +++ b/src/components/releases/activate-release-dialog.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Users } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; + +interface CustomerWithCluster { + id: number; + name: string; + namespace: string; + cluster: { + id: number; + name: string; + }; +} + +interface ActivateReleaseDialogProps { + releaseId: number; + releaseName: string; + customers: CustomerWithCluster[]; + isOpen: boolean; + onClose: () => void; + onActivate: (customerIds: number[]) => Promise; +} + +export function ActivateReleaseDialog({ + releaseName, + customers, + isOpen, + onClose, + onActivate +}: ActivateReleaseDialogProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // Initialize all customers as selected when dialog opens + useMemo(() => { + if (isOpen) { + setSelectedIds(new Set(customers.map(c => c.id))); + } + }, [isOpen, customers]); + + // Group customers by cluster + const groupedByCluster = useMemo(() => { + const filtered = customers.filter(c => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.namespace.toLowerCase().includes(searchQuery.toLowerCase()) || + c.cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return filtered.reduce((acc, customer) => { + const clusterName = customer.cluster.name; + if (!acc[clusterName]) { + acc[clusterName] = []; + } + acc[clusterName].push(customer); + return acc; + }, {} as Record); + }, [customers, searchQuery]); + + const toggleCustomer = (customerId: number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(customerId)) { + newSelected.delete(customerId); + } else { + newSelected.add(customerId); + } + setSelectedIds(newSelected); + }; + + const toggleCluster = (clusterCustomers: CustomerWithCluster[]) => { + const clusterIds = clusterCustomers.map(c => c.id); + const allSelected = clusterIds.every(id => selectedIds.has(id)); + + const newSelected = new Set(selectedIds); + if (allSelected) { + // Deselect all in cluster + clusterIds.forEach(id => newSelected.delete(id)); + } else { + // Select all in cluster + clusterIds.forEach(id => newSelected.add(id)); + } + setSelectedIds(newSelected); + }; + + const selectAll = () => { + setSelectedIds(new Set(customers.map(c => c.id))); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const handleActivate = async () => { + if (selectedIds.size === 0) return; + + setIsLoading(true); + try { + await onActivate(Array.from(selectedIds)); + onClose(); + } catch (error) { + console.error('Failed to activate release:', error); + } finally { + setIsLoading(false); + } + }; + + const totalCustomers = customers.length; + const selectedCount = selectedIds.size; + + return ( + + + + Activate Release: {releaseName} + + +
+
+ + Selected {selectedCount} of {totalCustomers} customers +
+
+ + +
+
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + +
+ {Object.entries(groupedByCluster).length === 0 ? ( +

No customers found

+ ) : ( + Object.entries(groupedByCluster).map(([clusterName, clusterCustomers]) => { + const allSelected = clusterCustomers.every(c => selectedIds.has(c.id)); + const someSelected = clusterCustomers.some(c => selectedIds.has(c.id)) && !allSelected; + + return ( +
+
+ toggleCluster(clusterCustomers)} + /> + + {clusterName} ({clusterCustomers.length}) + +
+
+ {clusterCustomers.map((customer) => ( +
+ toggleCustomer(customer.id)} + /> +
+ {customer.name} + + namespace: {customer.namespace} + +
+
+ ))} +
+
+ ); + }) + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/releases/add-customer-dialog.tsx b/src/components/releases/add-customer-dialog.tsx new file mode 100644 index 0000000..2abf066 --- /dev/null +++ b/src/components/releases/add-customer-dialog.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Users } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface CustomerWithCluster { + id: number; + name: string; + namespace: string; + cluster: { + id: number; + name: string; + }; +} + +interface AddCustomerDialogProps { + releaseId: number; + releaseName: string; + availableCustomers: CustomerWithCluster[]; + isOpen: boolean; + onClose: () => void; + onAdd: (customerIds: number[]) => Promise; +} + +export function AddCustomerDialog({ + releaseName, + availableCustomers, + isOpen, + onClose, + onAdd +}: AddCustomerDialogProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // Clear selection when dialog opens + useMemo(() => { + if (isOpen) { + setSelectedIds(new Set()); + } + }, [isOpen]); + + // Group customers by cluster + const groupedByCluster = useMemo(() => { + const filtered = availableCustomers.filter(c => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.namespace.toLowerCase().includes(searchQuery.toLowerCase()) || + c.cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return filtered.reduce((acc, customer) => { + const clusterName = customer.cluster.name; + if (!acc[clusterName]) { + acc[clusterName] = []; + } + acc[clusterName].push(customer); + return acc; + }, {} as Record); + }, [availableCustomers, searchQuery]); + + const toggleCustomer = (customerId: number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(customerId)) { + newSelected.delete(customerId); + } else { + newSelected.add(customerId); + } + setSelectedIds(newSelected); + }; + + const toggleCluster = (clusterCustomers: CustomerWithCluster[]) => { + const clusterIds = clusterCustomers.map(c => c.id); + const allSelected = clusterIds.every(id => selectedIds.has(id)); + + const newSelected = new Set(selectedIds); + if (allSelected) { + clusterIds.forEach(id => newSelected.delete(id)); + } else { + clusterIds.forEach(id => newSelected.add(id)); + } + setSelectedIds(newSelected); + }; + + const handleAdd = async () => { + if (selectedIds.size === 0) return; + + setIsLoading(true); + try { + await onAdd(Array.from(selectedIds)); + onClose(); + } catch (error) { + console.error('Failed to add customers:', error); + } finally { + setIsLoading(false); + } + }; + + const selectedCount = selectedIds.size; + + if (availableCustomers.length === 0) { + return ( + + + + Add Customers to Release + +
+ +

+ All available customers are already in this release. +

+
+ + + +
+
+ ); + } + + return ( + + + + Add Customers to Release: {releaseName} + + +
+
+ + Selected {selectedCount} customers +
+
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + +
+ {Object.entries(groupedByCluster).length === 0 ? ( +

No customers found

+ ) : ( + Object.entries(groupedByCluster).map(([clusterName, clusterCustomers]) => { + const allSelected = clusterCustomers.every(c => selectedIds.has(c.id)); + const someSelected = clusterCustomers.some(c => selectedIds.has(c.id)) && !allSelected; + + return ( +
+
+ toggleCluster(clusterCustomers)} + /> + + {clusterName} ({clusterCustomers.length}) + +
+
+ {clusterCustomers.map((customer) => ( +
+ toggleCustomer(customer.id)} + /> +
+ {customer.name} + + namespace: {customer.namespace} + +
+
+ ))} +
+
+ ); + }) + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/releases/release-actions.tsx b/src/components/releases/release-actions.tsx new file mode 100644 index 0000000..84da287 --- /dev/null +++ b/src/components/releases/release-actions.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState } from 'react'; +import { Play, Archive } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ActivateReleaseDialog } from './activate-release-dialog'; +import { AddCustomerDialog } from './add-customer-dialog'; +import { activateRelease, addCustomersToRelease } from '@/lib/actions/releases'; +import { useRouter } from 'next/navigation'; + +interface CustomerWithCluster { + id: number; + name: string; + namespace: string; + cluster: { + id: number; + name: string; + }; +} + +interface ReleaseActionsProps { + releaseId: number; + releaseName: string; + releaseStatus: string; + allCustomers: CustomerWithCluster[]; + // Customers already in the release (for active releases) + existingCustomerIds?: number[]; +} + +export function ReleaseActions({ + releaseId, + releaseName, + releaseStatus, + allCustomers, + existingCustomerIds = [] +}: ReleaseActionsProps) { + const router = useRouter(); + const [isActivateDialogOpen, setIsActivateDialogOpen] = useState(false); + const [isAddCustomerDialogOpen, setIsAddCustomerDialogOpen] = useState(false); + const [isArchiving, setIsArchiving] = useState(false); + + const handleActivate = async (customerIds: number[]) => { + await activateRelease(releaseId, customerIds); + router.refresh(); + }; + + const handleArchive = async () => { + if (!confirm('Are you sure you want to archive this release?')) return; + + setIsArchiving(true); + try { + const { archiveRelease } = await import('@/lib/actions/releases'); + await archiveRelease(releaseId); + router.refresh(); + } catch (error) { + console.error('Failed to archive release:', error); + } finally { + setIsArchiving(false); + } + }; + + const handleAddCustomers = async (customerIds: number[]) => { + await addCustomersToRelease(releaseId, customerIds); + router.refresh(); + }; + + // Calculate available customers (not already in release) + const availableCustomers = allCustomers.filter( + c => !existingCustomerIds.includes(c.id) + ); + + if (releaseStatus === 'draft') { + return ( + <> + + + setIsActivateDialogOpen(false)} + onActivate={handleActivate} + /> + + ); + } + + if (releaseStatus === 'active') { + return ( + <> + + + {availableCustomers.length > 0 && ( + + )} + + setIsAddCustomerDialogOpen(false)} + onAdd={handleAddCustomers} + /> + + ); + } + + return null; +} diff --git a/src/lib/actions/releases.ts b/src/lib/actions/releases.ts index dd70210..45c5be8 100644 --- a/src/lib/actions/releases.ts +++ b/src/lib/actions/releases.ts @@ -9,7 +9,7 @@ import { type ReleaseType, type ReleaseStatus, } from '@/lib/db/schema'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, inArray } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; export type ReleaseInput = { @@ -41,7 +41,7 @@ export async function updateRelease(id: number, data: Partial) { return release; } -export async function activateRelease(id: number) { +export async function activateRelease(id: number, customerIds?: number[]) { const release = await db.query.releases.findFirst({ where: eq(releases.id, id), with: { templates: true }, @@ -50,12 +50,20 @@ export async function activateRelease(id: number) { if (!release) throw new Error('Release not found'); if (release.status !== 'draft') throw new Error('Release is not in draft status'); - const activeCustomers = await db.query.customers.findMany({ - where: eq(customers.isActive, true), - }); + // If customerIds not provided, use all active customers (backward compatible) + const targetCustomers = customerIds + ? await db.query.customers.findMany({ + where: and( + eq(customers.isActive, true), + inArray(customers.id, customerIds) + ), + }) + : await db.query.customers.findMany({ + where: eq(customers.isActive, true), + }); // Create customer steps from templates - const customerStepsToInsert = activeCustomers.flatMap(customer => + const customerStepsToInsert = targetCustomers.flatMap(customer => release.templates.map(template => ({ releaseId: id, customerId: customer.id, @@ -83,6 +91,61 @@ export async function activateRelease(id: number) { revalidatePath(`/releases/${id}`); } +export async function addCustomersToRelease(releaseId: number, customerIds: number[]) { + const release = await db.query.releases.findFirst({ + where: eq(releases.id, releaseId), + with: { templates: true }, + }); + + if (!release) throw new Error('Release not found'); + if (release.status !== 'active') throw new Error('Release must be active to add customers'); + + // Get existing customer IDs in this release + const existingSteps = await db.query.customerSteps.findMany({ + where: eq(customerSteps.releaseId, releaseId), + columns: { customerId: true }, + }); + const existingCustomerIds = new Set(existingSteps.map((s: { customerId: number }) => s.customerId)); + + // Filter out customers already in the release + const newCustomerIds = customerIds.filter(id => !existingCustomerIds.has(id)); + + if (newCustomerIds.length === 0) { + throw new Error('All selected customers are already in this release'); + } + + // Get the new customers + const newCustomers = await db.query.customers.findMany({ + where: and( + eq(customers.isActive, true), + inArray(customers.id, newCustomerIds) + ), + }); + + // Create customer steps from current templates + const customerStepsToInsert = newCustomers.flatMap(customer => + release.templates.map(template => ({ + releaseId: releaseId, + customerId: customer.id, + templateId: template.id, + name: template.name, + category: template.category, + type: template.type, + content: template.content, + orderIndex: template.orderIndex, + status: 'pending' as const, + isCustom: false, + isOverridden: false, + })) + ); + + if (customerStepsToInsert.length > 0) { + await db.insert(customerSteps).values(customerStepsToInsert); + } + + revalidatePath(`/releases/${releaseId}`); +} + export async function archiveRelease(id: number) { await db.update(releases) .set({ status: 'archived', updatedAt: new Date() })