release-tracker/docs/TSD.md

38 KiB

Technical Specification Document

Release Orchestration & Tracking System

Version: 1.0

Date: 2026-02-01


1. Technology Stack

Layer Technology Version Rationale
Framework Next.js 14+ (App Router) Full-stack React, API routes, RSC support
Language TypeScript 5.x Type safety, better DX
Runtime Node.js 20.x LTS Stable, good performance
Database SQLite 3.x Zero-config, single-file, sufficient for single-user
ORM Drizzle ORM Latest Type-safe SQL, lightweight, migrations support
Styling Tailwind CSS 3.x Utility-first, rapid UI development
UI Components shadcn/ui Latest Accessible, customizable components
Icons Lucide React Latest Clean, consistent icons
Syntax Highlight PrismJS Latest Code display for bash/SQL
State Management React Server Components + Server Actions Built-in Simplified data flow, minimal client JS

Alternative Considerations

Alternative Why Not Chosen Migration Path
PostgreSQL Overkill for single-user tool Can migrate if multi-user needed later
Prisma Heavier, requires client generation Drizzle is lighter and faster
tRPC Server Actions sufficient Can add if API complexity grows
Redux/Zustand Server-state preferred Already using RSC pattern

2. Database Schema

2.1 Drizzle ORM Schema Definition

// lib/db/schema.ts

import { sqliteTable, integer, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';

// ==================== Clusters ====================
export const clusters = sqliteTable('clusters', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull().unique(),
  kubeconfigPath: text('kubeconfig_path'),
  description: text('description'),
  isActive: integer('is_active', { mode: 'boolean' }).default(true),
  metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});

export const clustersRelations = relations(clusters, ({ many }) => ({
  customers: many(customers),
}));

// ==================== Customers ====================
export const customers = sqliteTable('customers', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  clusterId: integer('cluster_id').notNull().references(() => clusters.id, { onDelete: 'restrict' }),
  namespace: text('namespace').notNull(),
  name: text('name').notNull(),
  description: text('description'),
  isActive: integer('is_active', { mode: 'boolean' }).default(true),
  metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table) => ({
  uniqueNamespacePerCluster: uniqueIndex('unique_namespace_per_cluster')
    .on(table.clusterId, table.namespace),
}));

export const customersRelations = relations(customers, ({ one, many }) => ({
  cluster: one(clusters, { fields: [customers.clusterId], references: [clusters.id] }),
  steps: many(customerSteps),
}));

// ==================== Releases ====================
export const releases = sqliteTable('releases', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  type: text('type', { enum: ['onboarding', 'release', 'hotfix'] }).notNull(),
  status: text('status', { enum: ['draft', 'active', 'archived'] }).default('draft'),
  versionNumber: text('version_number'),
  releaseDate: integer('release_date', { mode: 'timestamp' }),
  description: text('description'),
  metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});

export const releasesRelations = relations(releases, ({ many }) => ({
  templates: many(stepTemplates),
  customerSteps: many(customerSteps),
}));

// ==================== Step Templates ====================
export const stepTemplates = sqliteTable('step_templates', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  releaseId: integer('release_id').notNull().references(() => releases.id, { onDelete: 'cascade' }),
  name: text('name').notNull(),
  category: text('category', { enum: ['deploy', 'verify'] }).notNull(),
  type: text('type', { enum: ['bash', 'sql', 'text'] }).notNull(),
  content: text('content').notNull(),
  orderIndex: integer('order_index').notNull(),
  description: text('description'),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table) => ({
  uniqueOrderPerReleaseCategory: uniqueIndex('unique_order_per_release_category')
    .on(table.releaseId, table.category, table.orderIndex),
}));

export const stepTemplatesRelations = relations(stepTemplates, ({ one, many }) => ({
  release: one(releases, { fields: [stepTemplates.releaseId], references: [releases.id] }),
  customerSteps: many(customerSteps),
}));

// ==================== Customer Steps ====================
export const customerSteps = sqliteTable('customer_steps', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  releaseId: integer('release_id').notNull().references(() => releases.id, { onDelete: 'cascade' }),
  customerId: integer('customer_id').notNull().references(() => customers.id, { onDelete: 'cascade' }),
  templateId: integer('template_id').references(() => stepTemplates.id, { onDelete: 'set null' }),
  
  // Copied/Overridden fields
  name: text('name').notNull(),
  category: text('category', { enum: ['deploy', 'verify'] }).notNull(),
  type: text('type', { enum: ['bash', 'sql', 'text'] }).notNull(),
  content: text('content').notNull(),
  orderIndex: integer('order_index').notNull(),
  
  // Execution tracking
  status: text('status', { enum: ['pending', 'done', 'skipped', 'reverted'] }).default('pending'),
  executedAt: integer('executed_at', { mode: 'timestamp' }),
  executedBy: text('executed_by'),
  skipReason: text('skip_reason'),
  notes: text('notes'),
  
  // Flags
  isCustom: integer('is_custom', { mode: 'boolean' }).default(false),
  isOverridden: integer('is_overridden', { mode: 'boolean' }).default(false),
  
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
}, (table) => ({
  // Ensure unique steps per customer per release
  uniqueCustomerStep: uniqueIndex('unique_customer_step')
    .on(table.releaseId, table.customerId, table.templateId),
}));

export const customerStepsRelations = relations(customerSteps, ({ one }) => ({
  release: one(releases, { fields: [customerSteps.releaseId], references: [releases.id] }),
  customer: one(customers, { fields: [customerSteps.customerId], references: [customers.id] }),
  template: one(stepTemplates, { fields: [customerSteps.templateId], references: [stepTemplates.id] }),
}));

// ==================== Types ====================
export type Cluster = typeof clusters.$inferSelect;
export type NewCluster = typeof clusters.$inferInsert;

export type Customer = typeof customers.$inferSelect;
export type NewCustomer = typeof customers.$inferInsert;

export type Release = typeof releases.$inferSelect;
export type NewRelease = typeof releases.$inferInsert;

export type StepTemplate = typeof stepTemplates.$inferSelect;
export type NewStepTemplate = typeof stepTemplates.$inferInsert;

export type CustomerStep = typeof customerSteps.$inferSelect;
export type NewCustomerStep = typeof customerSteps.$inferInsert;

export type StepCategory = 'deploy' | 'verify';
export type StepType = 'bash' | 'sql' | 'text';
export type ReleaseType = 'onboarding' | 'release' | 'hotfix';
export type ReleaseStatus = 'draft' | 'active' | 'archived';
export type StepStatus = 'pending' | 'done' | 'skipped' | 'reverted';

2.2 Database Indexes

Index Purpose
unique_namespace_per_cluster Prevent duplicate namespaces within a cluster
unique_order_per_release_category Prevent duplicate ordering within a release category
unique_customer_step Prevent duplicate steps per customer per release
customer_steps.release_id Foreign key index for release lookups
customer_steps.customer_id Foreign key index for customer lookups
step_templates.release_id Foreign key index for template lookups
customers.cluster_id Foreign key index for cluster lookups

3. Project Structure

my-app/
├── app/                           # Next.js App Router
│   ├── page.tsx                   # Dashboard (main entry)
│   ├── layout.tsx                 # Root layout
│   ├── globals.css                # Global styles
│   │
│   ├── clusters/                  # Cluster management
│   │   ├── page.tsx               # List all clusters
│   │   ├── new/
│   │   │   └── page.tsx           # Create cluster form
│   │   └── [id]/
│   │       ├── page.tsx           # Cluster detail
│   │       └── edit/
│   │           └── page.tsx       # Edit cluster form
│   │
│   ├── customers/                 # Customer management
│   │   ├── page.tsx               # List all customers (grouped by cluster)
│   │   ├── new/
│   │   │   └── page.tsx           # Create customer form
│   │   └── [id]/
│   │       ├── page.tsx           # Customer detail
│   │       └── edit/
│   │           └── page.tsx       # Edit customer form
│   │
│   └── releases/                  # Release management
│       ├── page.tsx               # List all releases
│       ├── new/
│       │   └── page.tsx           # Create release wizard
│       └── [id]/
│           ├── page.tsx           # Release dashboard (matrix view)
│           ├── steps/
│           │   └── page.tsx       # Manage template steps
│           └── customer/
│               └── [customerId]/
│                   └── page.tsx   # Customer-specific steps view
│
├── components/                    # React components
│   ├── ui/                        # shadcn/ui components (auto-generated)
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   ├── dialog.tsx
│   │   ├── input.tsx
│   │   ├── select.tsx
│   │   ├── table.tsx
│   │   ├── tabs.tsx
│   │   ├── badge.tsx
│   │   └── ...
│   │
│   ├── clusters/
│   │   ├── cluster-card.tsx
│   │   ├── cluster-form.tsx
│   │   └── cluster-list.tsx
│   │
│   ├── customers/
│   │   ├── customer-card.tsx
│   │   ├── customer-form.tsx
│   │   ├── customer-list.tsx
│   │   └── customer-select.tsx
│   │
│   ├── releases/
│   │   ├── release-card.tsx
│   │   ├── release-form.tsx
│   │   ├── release-matrix.tsx     # Main matrix view
│   │   ├── release-progress.tsx
│   │   └── release-type-badge.tsx
│   │
│   ├── steps/
│   │   ├── step-card.tsx
│   │   ├── step-editor.tsx        # Code editor with syntax highlight
│   │   ├── step-form.tsx
│   │   ├── step-list.tsx
│   │   ├── step-detail-modal.tsx
│   │   ├── step-status-badge.tsx
│   │   └── step-type-icon.tsx
│   │
│   └── layout/
│       ├── sidebar.tsx
│       ├── header.tsx
│       └── breadcrumb.tsx
│
├── lib/                           # Utilities and shared code
│   ├── db/
│   │   ├── index.ts               # Database connection
│   │   ├── schema.ts              # Drizzle schema
│   │   └── migrations/            # Migration files
│   │
│   ├── actions/                   # Server Actions
│   │   ├── clusters.ts
│   │   ├── customers.ts
│   │   ├── releases.ts
│   │   ├── step-templates.ts
│   │   └── customer-steps.ts
│   │
│   ├── utils/
│   │   ├── cn.ts                  # Tailwind merge utility
│   │   ├── formatting.ts          # Date/text formatting
│   │   └── validations.ts         # Input validations
│   │
│   └── types/
│       └── index.ts               # Shared TypeScript types
│
├── public/                        # Static assets
│
├── drizzle.config.ts              # Drizzle configuration
├── next.config.js                 # Next.js configuration
├── tailwind.config.ts             # Tailwind configuration
├── tsconfig.json                  # TypeScript configuration
└── package.json

4. Server Actions API

4.1 Cluster Actions

// lib/actions/clusters.ts
'use server';

import { db } from '@/lib/db';
import { clusters, type NewCluster, type Cluster } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';

export async function createCluster(data: NewCluster): Promise<Cluster> {
  const [cluster] = await db.insert(clusters).values(data).returning();
  revalidatePath('/clusters');
  return cluster;
}

export async function updateCluster(
  id: number, 
  data: Partial<NewCluster>
): Promise<Cluster> {
  const [cluster] = await db
    .update(clusters)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(clusters.id, id))
    .returning();
  revalidatePath('/clusters');
  revalidatePath(`/clusters/${id}`);
  return cluster;
}

export async function deleteCluster(id: number): Promise<void> {
  // Check if cluster has active customers
  const customerCount = await db.query.customers.count({
    where: and(
      eq(customers.clusterId, id),
      eq(customers.isActive, true)
    )
  });
  
  if (customerCount > 0) {
    throw new Error('Cannot delete cluster with active customers');
  }
  
  await db.update(clusters)
    .set({ isActive: false, updatedAt: new Date() })
    .where(eq(clusters.id, id));
  revalidatePath('/clusters');
}

export async function listClusters(): Promise<Cluster[]> {
  return db.query.clusters.findMany({
    where: eq(clusters.isActive, true),
    orderBy: clusters.name,
  });
}

export async function getClusterWithCustomers(id: number) {
  return db.query.clusters.findFirst({
    where: eq(clusters.id, id),
    with: {
      customers: {
        where: eq(customers.isActive, true),
        orderBy: customers.name,
      },
    },
  });
}

4.2 Customer Actions

// lib/actions/customers.ts
'use server';

import { db } from '@/lib/db';
import { customers, type NewCustomer, type Customer } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';

export async function createCustomer(data: NewCustomer): Promise<Customer> {
  const [customer] = await db.insert(customers).values(data).returning();
  revalidatePath('/customers');
  revalidatePath(`/clusters/${data.clusterId}`);
  return customer;
}

export async function updateCustomer(
  id: number, 
  data: Partial<NewCustomer>
): Promise<Customer> {
  const [customer] = await db
    .update(customers)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(customers.id, id))
    .returning();
  revalidatePath('/customers');
  revalidatePath(`/customers/${id}`);
  return customer;
}

export async function deleteCustomer(id: number): Promise<void> {
  await db.update(customers)
    .set({ isActive: false, updatedAt: new Date() })
    .where(eq(customers.id, id));
  revalidatePath('/customers');
}

export async function listCustomers(): Promise<Customer[]> {
  return db.query.customers.findMany({
    where: eq(customers.isActive, true),
    with: { cluster: true },
    orderBy: customers.name,
  });
}

export async function listCustomersByCluster(clusterId: number): Promise<Customer[]> {
  return db.query.customers.findMany({
    where: and(
      eq(customers.clusterId, clusterId),
      eq(customers.isActive, true)
    ),
    orderBy: customers.name,
  });
}

export async function getCustomerWithCluster(id: number) {
  return db.query.customers.findFirst({
    where: eq(customers.id, id),
    with: { cluster: true },
  });
}

4.3 Release Actions

// lib/actions/releases.ts
'use server';

import { db } from '@/lib/db';
import { 
  releases, 
  stepTemplates, 
  customerSteps, 
  customers,
  type NewRelease, 
  type Release 
} from '@/lib/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';

export async function createRelease(data: NewRelease): Promise<Release> {
  const [release] = await db.insert(releases).values(data).returning();
  revalidatePath('/releases');
  return release;
}

export async function updateRelease(
  id: number, 
  data: Partial<NewRelease>
): Promise<Release> {
  const [release] = await db
    .update(releases)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(releases.id, id))
    .returning();
  revalidatePath('/releases');
  revalidatePath(`/releases/${id}`);
  return release;
}

export async function activateRelease(id: number): Promise<void> {
  const release = await db.query.releases.findFirst({
    where: eq(releases.id, id),
    with: { templates: true },
  });
  
  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),
  });
  
  // Create customer steps from templates
  const customerStepsToInsert = activeCustomers.flatMap(customer => 
    release.templates.map(template => ({
      releaseId: id,
      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);
  }
  
  await db.update(releases)
    .set({ status: 'active', updatedAt: new Date() })
    .where(eq(releases.id, id));
  
  revalidatePath('/releases');
  revalidatePath(`/releases/${id}`);
}

export async function archiveRelease(id: number): Promise<void> {
  await db.update(releases)
    .set({ status: 'archived', updatedAt: new Date() })
    .where(eq(releases.id, id));
  revalidatePath('/releases');
}

export async function listReleases(): Promise<Release[]> {
  return db.query.releases.findMany({
    orderBy: (releases, { desc }) => [desc(releases.createdAt)],
  });
}

export async function getReleaseWithProgress(id: number) {
  const release = await db.query.releases.findFirst({
    where: eq(releases.id, id),
    with: {
      templates: {
        orderBy: [stepTemplates.category, stepTemplates.orderIndex],
      },
    },
  });
  
  if (!release) return null;
  
  // Get customer steps with customer and cluster info
  const steps = await db.query.customerSteps.findMany({
    where: eq(customerSteps.releaseId, id),
    with: {
      customer: {
        with: { cluster: true },
      },
    },
  });
  
  // Group by cluster for display
  const groupedByCluster = steps.reduce((acc, step) => {
    const clusterName = step.customer.cluster.name;
    if (!acc[clusterName]) acc[clusterName] = [];
    acc[clusterName].push(step);
    return acc;
  }, {} as Record<string, typeof steps>);
  
  return { ...release, groupedByCluster, allSteps: steps };
}

4.4 Step Template Actions

// lib/actions/step-templates.ts
'use server';

import { db } from '@/lib/db';
import { stepTemplates, customerSteps, type NewStepTemplate, type StepTemplate } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';

export async function addTemplateStep(data: NewStepTemplate): Promise<StepTemplate> {
  const [template] = await db.insert(stepTemplates).values(data).returning();
  revalidatePath(`/releases/${data.releaseId}/steps`);
  return template;
}

export async function updateTemplateStep(
  id: number, 
  data: Partial<NewStepTemplate>
): Promise<StepTemplate> {
  const [template] = await db
    .update(stepTemplates)
    .set(data)
    .where(eq(stepTemplates.id, id))
    .returning();
  
  // Update pending customer steps
  if (data.content) {
    await db.update(customerSteps)
      .set({ content: data.content })
      .where(and(
        eq(customerSteps.templateId, id),
        eq(customerSteps.status, 'pending'),
        eq(customerSteps.isOverridden, false)
      ));
  }
  
  revalidatePath(`/releases/${template.releaseId}/steps`);
  return template;
}

export async function deleteTemplateStep(id: number): Promise<void> {
  const template = await db.query.stepTemplates.findFirst({
    where: eq(stepTemplates.id, id),
  });
  
  if (!template) return;
  
  // Delete associated customer steps that haven't been executed
  await db.delete(customerSteps)
    .where(and(
      eq(customerSteps.templateId, id),
      eq(customerSteps.status, 'pending')
    ));
  
  await db.delete(stepTemplates).where(eq(stepTemplates.id, id));
  revalidatePath(`/releases/${template.releaseId}/steps`);
}

export async function reorderSteps(
  releaseId: number, 
  category: 'deploy' | 'verify',
  orderedIds: number[]
): Promise<void> {
  for (let i = 0; i < orderedIds.length; i++) {
    await db.update(stepTemplates)
      .set({ orderIndex: i })
      .where(eq(stepTemplates.id, orderedIds[i]));
    
    // Also update customer steps
    await db.update(customerSteps)
      .set({ orderIndex: i })
      .where(and(
        eq(customerSteps.templateId, orderedIds[i]),
        eq(customerSteps.category, category)
      ));
  }
  revalidatePath(`/releases/${releaseId}/steps`);
}

4.5 Customer Step Actions

// lib/actions/customer-steps.ts
'use server';

import { db } from '@/lib/db';
import { customerSteps, type CustomerStep } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';

export async function getCustomerSteps(
  releaseId: number, 
  customerId: number
): Promise<CustomerStep[]> {
  return db.query.customerSteps.findMany({
    where: and(
      eq(customerSteps.releaseId, releaseId),
      eq(customerSteps.customerId, customerId)
    ),
    orderBy: [customerSteps.category, customerSteps.orderIndex],
  });
}

export async function overrideStepContent(
  stepId: number, 
  newContent: string
): Promise<CustomerStep> {
  const [step] = await db
    .update(customerSteps)
    .set({ 
      content: newContent, 
      isOverridden: true,
      updatedAt: new Date()
    })
    .where(eq(customerSteps.id, stepId))
    .returning();
  revalidatePath(`/releases/${step.releaseId}`);
  return step;
}

export async function addCustomStep(
  releaseId: number,
  customerId: number,
  data: {
    name: string;
    category: 'deploy' | 'verify';
    type: 'bash' | 'sql' | 'text';
    content: string;
    orderIndex: number;
    addToTemplate?: boolean;  // NEW: Option to add to template
  }
): Promise<CustomerStep> {
  const { addToTemplate, ...stepData } = data;
  
  // If addToTemplate is true, create template step first
  let templateId: number | null = null;
  
  if (addToTemplate) {
    const [template] = await db.insert(stepTemplates).values({
      ...stepData,
      releaseId,
    }).returning();
    templateId = template.id;
    
    // Add to ALL active customers (not just the current one)
    const allCustomers = await db.query.customers.findMany({
      where: eq(customers.isActive, true),
    });
    
    const stepsToInsert = allCustomers.map(customer => ({
      releaseId,
      customerId: customer.id,
      templateId: template.id,
      ...stepData,
      status: 'pending' as const,
      isCustom: false,  // It's now a template step
      isOverridden: false,
    }));
    
    if (stepsToInsert.length > 0) {
      await db.insert(customerSteps).values(stepsToInsert);
    }
    
    // Return the step for the requested customer
    const [step] = await db.query.customerSteps.findMany({
      where: and(
        eq(customerSteps.releaseId, releaseId),
        eq(customerSteps.customerId, customerId),
        eq(customerSteps.templateId, templateId)
      ),
    });
    
    revalidatePath(`/releases/${releaseId}`);
    return step;
  }
  
  // Original behavior: custom step for single customer
  const [step] = await db.insert(customerSteps).values({
    ...stepData,
    releaseId,
    customerId,
    templateId: null,
    status: 'pending',
    isCustom: true,
    isOverridden: false,
  }).returning();
  revalidatePath(`/releases/${releaseId}`);
  return step;
}

export async function markStepDone(
  stepId: number, 
  notes?: string
): Promise<CustomerStep> {
  const [step] = await db
    .update(customerSteps)
    .set({ 
      status: 'done',
      executedAt: new Date(),
      notes: notes || null,
      updatedAt: new Date()
    })
    .where(eq(customerSteps.id, stepId))
    .returning();
  revalidatePath(`/releases/${step.releaseId}`);
  return step;
}

export async function markStepReverted(
  stepId: number, 
  reason?: string
): Promise<CustomerStep> {
  const [step] = await db
    .update(customerSteps)
    .set({ 
      status: 'reverted',
      notes: reason || null,
      updatedAt: new Date()
    })
    .where(eq(customerSteps.id, stepId))
    .returning();
  revalidatePath(`/releases/${step.releaseId}`);
  return step;
}

export async function skipStep(
  stepId: number, 
  reason: string
): Promise<CustomerStep> {
  const [step] = await db
    .update(customerSteps)
    .set({ 
      status: 'skipped',
      skipReason: reason,
      updatedAt: new Date()
    })
    .where(eq(customerSteps.id, stepId))
    .returning();
  revalidatePath(`/releases/${step.releaseId}`);
  return step;
}

export async function bulkMarkDone(stepIds: number[]): Promise<void> {
  for (const id of stepIds) {
    await markStepDone(id);
  }
}

export async function resetToTemplate(stepId: number): Promise<CustomerStep> {
  const step = await db.query.customerSteps.findFirst({
    where: eq(customerSteps.id, stepId),
    with: { template: true },
  });
  
  if (!step?.template) throw new Error('No template found');
  
  const [updated] = await db
    .update(customerSteps)
    .set({ 
      content: step.template.content,
      name: step.template.name,
      isOverridden: false,
      updatedAt: new Date()
    })
    .where(eq(customerSteps.id, stepId))
    .returning();
  revalidatePath(`/releases/${step.releaseId}`);
  return updated;
}

5. Key Implementation Details

5.1 Release Activation Algorithm

async function activateRelease(releaseId: number) {
  // 1. Validate release is in draft status
  // 2. Fetch all templates for the release
  // 3. Fetch all active customers with their clusters
  // 4. For each customer, create CustomerStep for each template
  // 5. Copy template content to customer step (denormalization)
  // 6. Set status = 'pending' for all
  // 7. Update release status to 'active'
}

5.2 Matrix View Data Query

// Optimized query for the matrix view
async function getReleaseMatrix(releaseId: number, clusterId?: number) {
  const whereClause = eq(customerSteps.releaseId, releaseId);
  
  const steps = await db.query.customerSteps.findMany({
    where: clusterId 
      ? and(whereClause, eq(customers.clusterId, clusterId))
      : whereClause,
    with: {
      customer: {
        with: { cluster: true },
      },
      template: true,
    },
    orderBy: [customerSteps.category, customerSteps.orderIndex],
  });
  
  // Transform into matrix format
  // Rows: Steps (grouped by category)
  // Columns: Customers (grouped by cluster)
  const matrix = {
    deploy: { steps: [], customers: {} },
    verify: { steps: [], customers: {} },
  };
  
  // Grouping logic...
  return matrix;
}

5.3 Progress Calculation

function calculateProgress(steps: CustomerStep[]) {
  const total = steps.length;
  const done = steps.filter(s => s.status === 'done').length;
  const skipped = steps.filter(s => s.status === 'skipped').length;
  const pending = steps.filter(s => s.status === 'pending').length;
  const reverted = steps.filter(s => s.status === 'reverted').length;
  
  return {
    total,
    done,
    skipped,
    pending,
    reverted,
    percentage: Math.round(((done + skipped) / total) * 100),
  };
}

5.4 Syntax Highlighting

// components/steps/code-block.tsx
import Prism from 'prismjs';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-bash';

interface CodeBlockProps {
  code: string;
  type: 'bash' | 'sql' | 'text';
}

export function CodeBlock({ code, type }: CodeBlockProps) {
  const language = type === 'text' ? 'text' : type;
  const highlighted = Prism.highlight(
    code,
    Prism.languages[language] || Prism.languages.text,
    language
  );
  
  return (
    <pre className="rounded bg-gray-900 p-4 overflow-x-auto">
      <code 
        className={`language-${language} text-sm`}
        dangerouslySetInnerHTML={{ __html: highlighted }}
      />
    </pre>
  );
}

6. Component Specifications

6.1 Release Matrix Component

// components/releases/release-matrix.tsx

interface ReleaseMatrixProps {
  releaseId: number;
  clusterId?: number; // Optional filter
}

interface MatrixData {
  clusters: {
    id: number;
    name: string;
    customers: {
      id: number;
      name: string;
      namespace: string;
      steps: CustomerStep[];
    }[];
  }[];
  deploySteps: StepTemplate[];
  verifySteps: StepTemplate[];
}

export function ReleaseMatrix({ releaseId, clusterId }: ReleaseMatrixProps) {
  // Fetches data and renders cluster-grouped matrix
  // Each cluster is a collapsible section
  // Table: Steps as rows, Customers as columns
  // Cells: Status badge with quick actions
}

6.2 Step Card Component

// components/steps/step-card.tsx

interface StepCardProps {
  step: CustomerStep;
  onMarkDone: (id: number) => void;
  onMarkReverted: (id: number) => void;
  onSkip: (id: number, reason: string) => void;
  onEdit: (id: number, content: string) => void;
}

export function StepCard({ step, ...actions }: StepCardProps) {
  // Displays step name, type badge, status badge
  // Shows cluster/namespace context
  // Actions based on current status
  // Modal for viewing full content
}

6.3 Step Detail Side Panel (NEW)

// components/steps/step-detail-panel.tsx

import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { CodeBlock } from './code-block';

interface StepDetailPanelProps {
  step: CustomerStep & { template?: StepTemplate; customer: Customer };
  isOpen: boolean;
  onClose: () => void;
  onMarkDone: (id: number, notes?: string) => void;
  onSkip: (id: number, reason: string) => void;
  onRevert: (id: number, reason?: string) => void;
  onOverride: (id: number, content: string) => void;
  onResetToTemplate: (id: number) => void;
  onEditCustom: (id: number, data: Partial<CustomStepInput>) => void;
  onDeleteCustom: (id: number) => void;
}

export function StepDetailPanel({ step, isOpen, onClose, ...actions }: StepDetailPanelProps) {
  // Side panel with:
  // - Source info (template/custom/overridden)
  // - Syntax-highlighted content
  // - Action buttons based on source type
  // - Execution notes input
  // - History timeline
}

6.4 Add Custom Step Dialog (NEW)

// components/steps/add-custom-step-dialog.tsx

interface AddCustomStepDialogProps {
  releaseId: number;
  customerId: number;
  category: 'deploy' | 'verify';
  existingSteps: { id: number; name: string; orderIndex: number }[];
  isOpen: boolean;
  onClose: () => void;
  onAdd: (data: CustomStepInput & { addToTemplate: boolean; insertPosition: number }) => void;
}

export function AddCustomStepDialog({ ...props }: AddCustomStepDialogProps) {
  // Dialog with:
  // - Name, type, content inputs
  // - Insert position dropdown (before/after existing steps)
  // - "Add to template" checkbox (unchecked by default)
  // - Add/Cancel buttons
}

6.5 Draggable Step List (NEW)

// components/steps/draggable-step-list.tsx

import { DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

interface DraggableStepListProps {
  steps: (StepTemplate | CustomerStep)[];
  onReorder: (orderedIds: number[]) => void;
  renderStep: (step: StepTemplate | CustomerStep, index: number) => React.ReactNode;
}

export function DraggableStepList({ steps, onReorder, renderStep }: DraggableStepListProps) {
  // Drag-and-drop list using @dnd-kit
  // Visual drag handle on each item
  // Smooth animations
  // Calls onReorder when drop completes
}

6.6 Code Block with Syntax Highlight (NEW)

// components/steps/code-block.tsx

import Prism from 'prismjs';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-bash';
import 'prismjs/themes/prism-tomorrow.css';

interface CodeBlockProps {
  code: string;
  type: 'bash' | 'sql' | 'text';
  showLineNumbers?: boolean;
}

export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps) {
  // Server-side or client-side syntax highlighting
  // Copy to clipboard button
  // Line numbers optional
}

6.7 API Endpoints (NEW)

Reorder Steps Endpoint

// app/api/steps/reorder/route.ts

import { NextRequest } from 'next/server';
import { reorderSteps } from '@/lib/actions/step-templates';

export async function POST(request: NextRequest) {
  const { releaseId, category, orderedIds } = await request.json();
  await reorderSteps(releaseId, category, orderedIds);
  return Response.json({ success: true });
}

Get Step Detail Endpoint

// app/api/steps/[id]/detail/route.ts

import { NextRequest } from 'next/server';
import { getStepWithDetails } from '@/lib/actions/customer-steps';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const stepId = parseInt(params.id);
  const step = await getStepWithDetails(stepId);
  return Response.json(step);
}

7. Configuration

7.1 Environment Variables

# .env.local
# Database
DATABASE_URL="file:./data/app.db"

# Optional: For future integrations
JENKINS_URL=""
JENKINS_TOKEN=""
RANCHER_URL=""
RANCHER_TOKEN=""

7.2 Drizzle Configuration

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './lib/db/schema.ts',
  out: './lib/db/migrations',
  driver: 'better-sqlite3',
  dbCredentials: {
    url: process.env.DATABASE_URL || 'file:./data/app.db',
  },
});

7.3 Next.js Configuration

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: true,
  },
  // Ensure SQLite database is not bundled
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false,
        path: false,
      };
    }
    return config;
  },
};

module.exports = nextConfig;

8. Database Migration Strategy

Initial Migration

# 1. Generate migration
npx drizzle-kit generate:sqlite

# 2. Apply migration
npx drizzle-kit push:sqlite

Schema Updates

# After modifying schema.ts:
npx drizzle-kit generate:sqlite
npx drizzle-kit push:sqlite

9. Development Workflow

9.1 Setup

# 1. Initialize project
npx create-next-app@latest my-app --typescript --tailwind --app

# 2. Install dependencies
cd my-app
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3

# 3. Initialize shadcn/ui
npx shadcn-ui@latest init

# 4. Add components
npx shadcn-ui@latest add button card dialog input select table tabs badge

# 5. Setup database
mkdir -p data
touch data/.gitkeep

9.2 Development Server

npm run dev
# Runs on http://localhost:3000

9.3 Build

npm run build
npm start

10. Security Considerations

Concern Mitigation
SQL Injection Drizzle ORM parameterized queries
XSS React's built-in escaping, sanitize user input
CSRF Next.js Server Actions built-in CSRF protection
Path Traversal Validate file paths for kubeconfig
Data Integrity Foreign key constraints, transactions

11. Performance Considerations

Area Strategy
Database Proper indexes on foreign keys and common queries
Data Fetching React Server Components for initial data
Re-rendering Server Actions with revalidatePath
Large Lists Virtualization if customer count grows large
Bundle Size Tree-shaking, dynamic imports for heavy components

12. Testing Strategy

Type Approach
Unit Test utilities, validations
Integration Test Server Actions with test database
E2E Playwright for critical user flows

13. Deployment

13.1 Production Build

npm run build

13.2 Data Persistence

  • SQLite database stored in data/app.db
  • Mount data directory as volume in container
  • Backup strategy: Regular file backups

13.3 Docker (Optional)

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
VOLUME ["/app/data"]
EXPOSE 3000
CMD ["npm", "start"]

14. Future Extensibility

Feature Technical Preparation
Multi-user Add users table, auth middleware, row-level security
Auto-execution Background job queue, K8s client libraries
Webhooks API routes for Jenkins/Rancher callbacks
Notifications WebSocket or Server-Sent Events for real-time updates
Audit log Separate audit_logs table with triggers
Export/Import CSV/JSON export endpoints