Compare commits

...

3 Commits

Author SHA1 Message Date
tigerenwork f41e6724cd feat: all customer selection when adding to release 2026-02-04 01:51:20 +08:00
tigerenwork 3e10f1ee9f fix: resovle static page dynamic build 2026-02-03 13:43:32 +08:00
tigerenwork 445d7122aa fix: add type annotation 2026-02-03 11:42:38 +08:00
14 changed files with 922 additions and 50 deletions

View File

@ -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

View File

@ -485,7 +485,10 @@ export async function updateRelease(
return release;
}
export async function activateRelease(id: number): Promise<void> {
export async function activateRelease(
id: number,
customerIds?: number[]
): Promise<void> {
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<void> {
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<void> {
revalidatePath(`/releases/${id}`);
}
export async function addCustomersToRelease(
releaseId: number,
customerIds: number[]
): Promise<void> {
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<void> {
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<void>;
}
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<void>;
}
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

View File

@ -8,6 +8,8 @@ interface EditClusterPageProps {
}>;
}
export const dynamic = 'force-dynamic';
export default async function EditClusterPage({ params }: EditClusterPageProps) {
const { id } = await params;
const cluster = await getClusterById(parseInt(id));

View File

@ -11,6 +11,8 @@ interface ClusterDetailPageProps {
}>;
}
export const dynamic = 'force-dynamic';
export default async function ClusterDetailPage({ params }: ClusterDetailPageProps) {
const { id } = await params;
const cluster = await getClusterWithCustomers(parseInt(id));

View File

@ -9,6 +9,8 @@ interface EditCustomerPageProps {
}>;
}
export const dynamic = 'force-dynamic';
export default async function EditCustomerPage({ params }: EditCustomerPageProps) {
const { id } = await params;
const [customer, clusters] = await Promise.all([

View File

@ -14,6 +14,8 @@ interface CustomerDetailPageProps {
}>;
}
export const dynamic = 'force-dynamic';
export default async function CustomerDetailPage({ params }: CustomerDetailPageProps) {
const { id } = await params;
const customer = await getCustomerById(parseInt(id));

View File

@ -2,6 +2,9 @@ import { notFound } from 'next/navigation';
import { CustomerForm } from '@/components/customers/customer-form';
import { listClusters } from '@/lib/actions/clusters';
// Force dynamic rendering to ensure data is fetched at request time
export const dynamic = 'force-dynamic';
export default async function NewCustomerPage() {
const clusters = await listClusters();

View File

@ -8,6 +8,8 @@ interface EditReleasePageProps {
}>;
}
export const dynamic = 'force-dynamic';
export default async function EditReleasePage({ params }: EditReleasePageProps) {
const { id } = await params;
const release = await getReleaseById(parseInt(id));

View File

@ -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<{
@ -37,6 +38,8 @@ function getStatusColor(status: string) {
return statusColors[status] || 'bg-slate-100 text-slate-800';
}
export const dynamic = 'force-dynamic';
export default async function ReleaseDetailPage({ params }: ReleaseDetailPageProps) {
const { id } = await params;
const releaseId = parseInt(id);
@ -55,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<string, { customer: { id: number } }>).map(
(c: { customer: { id: number } }) => c.customer.id
)
);
return (
<div className="space-y-6">
@ -97,22 +95,13 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
Edit
</Button>
</Link>
{release.status === 'draft' && (
<form action={handleActivate}>
<Button size="sm" type="submit">
<Play className="w-4 h-4 mr-2" />
Activate
</Button>
</form>
)}
{release.status === 'active' && (
<form action={handleArchive}>
<Button variant="outline" size="sm" type="submit">
<Archive className="w-4 h-4 mr-2" />
Archive
</Button>
</form>
)}
<ReleaseActions
releaseId={releaseId}
releaseName={release.name}
releaseStatus={release.status}
allCustomers={allCustomers}
existingCustomerIds={existingCustomerIds}
/>
</div>
</div>
@ -130,7 +119,14 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
</div>
<div>
<label className="text-sm font-medium text-slate-500">Total Customers</label>
<p className="text-slate-900">{customers.length}</p>
<p className="text-slate-900">
{release.status === 'active' ? existingCustomerIds.length : allCustomers.length}
{release.status === 'active' && allCustomers.length > existingCustomerIds.length && (
<span className="text-slate-400 text-sm ml-1">
({allCustomers.length - existingCustomerIds.length} not in release)
</span>
)}
</p>
</div>
{release.status === 'active' && (
<div>
@ -171,7 +167,6 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
<h3 className="font-medium mb-3">Deploy Steps ({release.templates.filter(t => t.category === 'deploy').length})</h3>
<Link href={`/releases/${releaseId}/steps`}>
<Button variant="outline" size="sm">
<Plus className="w-4 h-4 mr-2" />
Manage Deploy Steps
</Button>
</Link>
@ -180,7 +175,6 @@ export default async function ReleaseDetailPage({ params }: ReleaseDetailPagePro
<h3 className="font-medium mb-3">Verify Steps ({release.templates.filter(t => t.category === 'verify').length})</h3>
<Link href={`/releases/${releaseId}/steps`}>
<Button variant="outline" size="sm">
<Plus className="w-4 h-4 mr-2" />
Manage Verify Steps
</Button>
</Link>

View File

@ -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<void>;
}
export function ActivateReleaseDialog({
releaseName,
customers,
isOpen,
onClose,
onActivate
}: ActivateReleaseDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(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<string, CustomerWithCluster[]>);
}, [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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Activate Release: {releaseName}</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2 text-sm text-slate-600">
<Users className="w-4 h-4" />
<span>Selected {selectedCount} of {totalCustomers} customers</span>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={selectAll}>
Select All
</Button>
<Button variant="outline" size="sm" onClick={deselectAll}>
Deselect All
</Button>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search customers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="flex-1 border rounded-lg p-4 my-2">
<div className="space-y-4">
{Object.entries(groupedByCluster).length === 0 ? (
<p className="text-slate-500 text-center py-4">No customers found</p>
) : (
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 (
<div key={clusterName}>
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
<Checkbox
checked={allSelected}
data-state={someSelected ? 'indeterminate' : allSelected ? 'checked' : 'unchecked'}
onCheckedChange={() => toggleCluster(clusterCustomers)}
/>
<span className="font-medium text-slate-700">
{clusterName} ({clusterCustomers.length})
</span>
</div>
<div className="ml-6 space-y-2">
{clusterCustomers.map((customer) => (
<div key={customer.id} className="flex items-center gap-3">
<Checkbox
checked={selectedIds.has(customer.id)}
onCheckedChange={() => toggleCustomer(customer.id)}
/>
<div className="flex-1">
<span className="font-medium text-sm">{customer.name}</span>
<span className="text-slate-500 text-sm ml-2">
namespace: {customer.namespace}
</span>
</div>
</div>
))}
</div>
</div>
);
})
)}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleActivate}
disabled={selectedCount === 0 || isLoading}
>
{isLoading ? 'Activating...' : `Activate (${selectedCount})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<void>;
}
export function AddCustomerDialog({
releaseName,
availableCustomers,
isOpen,
onClose,
onAdd
}: AddCustomerDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(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<string, CustomerWithCluster[]>);
}, [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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Customers to Release</DialogTitle>
</DialogHeader>
<div className="py-4 text-center">
<Users className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-600">
All available customers are already in this release.
</p>
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Add Customers to Release: {releaseName}</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2 text-sm text-slate-600">
<Users className="w-4 h-4" />
<span>Selected {selectedCount} customers</span>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search customers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="flex-1 border rounded-lg p-4 my-2">
<div className="space-y-4">
{Object.entries(groupedByCluster).length === 0 ? (
<p className="text-slate-500 text-center py-4">No customers found</p>
) : (
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 (
<div key={clusterName}>
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
<Checkbox
checked={allSelected}
data-state={someSelected ? 'indeterminate' : allSelected ? 'checked' : 'unchecked'}
onCheckedChange={() => toggleCluster(clusterCustomers)}
/>
<span className="font-medium text-slate-700">
{clusterName} ({clusterCustomers.length})
</span>
</div>
<div className="ml-6 space-y-2">
{clusterCustomers.map((customer) => (
<div key={customer.id} className="flex items-center gap-3">
<Checkbox
checked={selectedIds.has(customer.id)}
onCheckedChange={() => toggleCustomer(customer.id)}
/>
<div className="flex-1">
<span className="font-medium text-sm">{customer.name}</span>
<span className="text-slate-500 text-sm ml-2">
namespace: {customer.namespace}
</span>
</div>
</div>
))}
</div>
</div>
);
})
)}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={selectedCount === 0 || isLoading}
>
{isLoading ? 'Adding...' : `Add Customers (${selectedCount})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<>
<Button size="sm" onClick={() => setIsActivateDialogOpen(true)}>
<Play className="w-4 h-4 mr-2" />
Activate
</Button>
<ActivateReleaseDialog
releaseId={releaseId}
releaseName={releaseName}
customers={allCustomers}
isOpen={isActivateDialogOpen}
onClose={() => setIsActivateDialogOpen(false)}
onActivate={handleActivate}
/>
</>
);
}
if (releaseStatus === 'active') {
return (
<>
<Button
variant="outline"
size="sm"
onClick={handleArchive}
disabled={isArchiving}
>
<Archive className="w-4 h-4 mr-2" />
{isArchiving ? 'Archiving...' : 'Archive'}
</Button>
{availableCustomers.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setIsAddCustomerDialogOpen(true)}
>
+ Add Customer
</Button>
)}
<AddCustomerDialog
releaseId={releaseId}
releaseName={releaseName}
availableCustomers={availableCustomers}
isOpen={isAddCustomerDialogOpen}
onClose={() => setIsAddCustomerDialogOpen(false)}
onAdd={handleAddCustomers}
/>
</>
);
}
return null;
}

View File

@ -71,13 +71,13 @@ export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps
<div className="flex">
{/* Line numbers column */}
<div className="flex flex-col text-slate-500 text-right pr-4 select-none min-w-[3rem]">
{lines.map((_, i) => (
{lines.map((_: string, i: number) => (
<span key={i}>{i + 1}</span>
))}
</div>
{/* Code column */}
<div className="flex flex-col">
{highlightedLines.map((line, i) => (
{highlightedLines.map((line: string, i: number) => (
<span
key={i}
className="text-slate-100 whitespace-pre"

View File

@ -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<ReleaseInput>) {
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() })