Tutorial: Feature Development Workflow

Build a complete feature using Bootspring's feature development workflow with orchestrated agent collaboration.

What You'll Build#

  • User notifications system with:
    • Database schema design
    • API endpoints
    • Real-time updates
    • Email notifications
    • UI components

Prerequisites#

  • Bootspring initialized project
  • PostgreSQL database configured
  • Basic understanding of agents and workflows

Step 1: Start the Feature Workflow#

Initialize the feature workflow with your requirements:

bootspring workflow start feature-development

When prompted, describe your feature:

Build a user notification system that: - Stores notifications in the database - Supports different notification types (info, warning, success, error) - Marks notifications as read/unread - Sends email for important notifications - Shows real-time updates in the UI

Step 2: Requirements Phase#

The workflow first engages the product-expert to clarify requirements.

Agent Conversation#

bootspring agent invoke product-expert "Define user stories for the notification system"

Expected Output:

1## User Stories 2 3### Core Functionality 4- As a user, I want to receive notifications about important events 5- As a user, I want to mark notifications as read 6- As a user, I want to see unread count in the header 7- As a user, I want to filter notifications by type 8 9### Email Integration 10- As a user, I want to receive email for critical notifications 11- As a user, I want to configure my notification preferences 12 13### Real-time 14- As a user, I want to see new notifications without refreshing

Step 3: Architecture Phase#

The architecture-expert designs the system.

bootspring agent invoke architecture-expert "Design notification system architecture"

Expected Output:

1## Architecture Design 2 3### Database Layer 4- Notification model with polymorphic types 5- NotificationPreference model for user settings 6- Indexed queries for performance 7 8### API Layer 9- REST endpoints for CRUD operations 10- Server-Sent Events for real-time updates 11- Background job for email dispatch 12 13### UI Layer 14- NotificationBell component with badge 15- NotificationList with infinite scroll 16- NotificationItem with actions 17 18### Integration Points 19- Email service (Resend) 20- WebSocket/SSE for real-time 21- Background queue (optional)

Step 4: Database Design Phase#

The database-expert creates the schema.

bootspring agent invoke database-expert "Create Prisma schema for notifications"

Generated Schema#

1// prisma/schema.prisma 2 3enum NotificationType { 4 INFO 5 SUCCESS 6 WARNING 7 ERROR 8} 9 10enum NotificationChannel { 11 IN_APP 12 EMAIL 13 BOTH 14} 15 16model Notification { 17 id String @id @default(cuid()) 18 userId String 19 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 20 21 type NotificationType @default(INFO) 22 title String 23 message String 24 link String? 25 26 read Boolean @default(false) 27 readAt DateTime? 28 29 emailSent Boolean @default(false) 30 31 createdAt DateTime @default(now()) 32 updatedAt DateTime @updatedAt 33 34 @@index([userId, read]) 35 @@index([userId, createdAt]) 36} 37 38model NotificationPreference { 39 id String @id @default(cuid()) 40 userId String @unique 41 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 42 43 channel NotificationChannel @default(IN_APP) 44 45 // Per-type preferences 46 infoEnabled Boolean @default(true) 47 successEnabled Boolean @default(true) 48 warningEnabled Boolean @default(true) 49 errorEnabled Boolean @default(true) 50 51 // Email preferences 52 emailDigest Boolean @default(false) 53 digestFrequency String @default("daily") // daily, weekly 54 55 createdAt DateTime @default(now()) 56 updatedAt DateTime @updatedAt 57}

Apply the schema:

npx prisma db push npx prisma generate

Step 5: API Development Phase#

The api-expert creates the endpoints.

bootspring agent invoke api-expert "Create notification API endpoints"

Notification Service#

1// lib/services/notification-service.ts 2import { prisma } from '@/lib/prisma'; 3import { NotificationType } from '@prisma/client'; 4import { sendNotificationEmail } from '@/lib/email'; 5 6export interface CreateNotificationInput { 7 userId: string; 8 type: NotificationType; 9 title: string; 10 message: string; 11 link?: string; 12} 13 14export async function createNotification(input: CreateNotificationInput) { 15 const { userId, type, title, message, link } = input; 16 17 // Check user preferences 18 const preferences = await prisma.notificationPreference.findUnique({ 19 where: { userId }, 20 }); 21 22 // Check if this notification type is enabled 23 const typeEnabled = preferences?.[`${type.toLowerCase()}Enabled`] ?? true; 24 if (!typeEnabled) { 25 return null; 26 } 27 28 // Create notification 29 const notification = await prisma.notification.create({ 30 data: { 31 userId, 32 type, 33 title, 34 message, 35 link, 36 }, 37 include: { 38 user: { 39 select: { email: true, name: true }, 40 }, 41 }, 42 }); 43 44 // Send email if configured 45 const shouldEmail = 46 preferences?.channel === 'EMAIL' || preferences?.channel === 'BOTH'; 47 48 if (shouldEmail && (type === 'WARNING' || type === 'ERROR')) { 49 await sendNotificationEmail({ 50 to: notification.user.email, 51 name: notification.user.name, 52 notification: { 53 title, 54 message, 55 type, 56 link, 57 }, 58 }); 59 60 await prisma.notification.update({ 61 where: { id: notification.id }, 62 data: { emailSent: true }, 63 }); 64 } 65 66 return notification; 67} 68 69export async function getNotifications( 70 userId: string, 71 options: { 72 unreadOnly?: boolean; 73 type?: NotificationType; 74 limit?: number; 75 cursor?: string; 76 } = {} 77) { 78 const { unreadOnly, type, limit = 20, cursor } = options; 79 80 const notifications = await prisma.notification.findMany({ 81 where: { 82 userId, 83 ...(unreadOnly && { read: false }), 84 ...(type && { type }), 85 }, 86 orderBy: { createdAt: 'desc' }, 87 take: limit + 1, 88 ...(cursor && { 89 cursor: { id: cursor }, 90 skip: 1, 91 }), 92 }); 93 94 const hasMore = notifications.length > limit; 95 const items = hasMore ? notifications.slice(0, -1) : notifications; 96 97 return { 98 notifications: items, 99 nextCursor: hasMore ? items[items.length - 1].id : null, 100 }; 101} 102 103export async function getUnreadCount(userId: string) { 104 return prisma.notification.count({ 105 where: { userId, read: false }, 106 }); 107} 108 109export async function markAsRead(notificationId: string, userId: string) { 110 return prisma.notification.update({ 111 where: { id: notificationId, userId }, 112 data: { read: true, readAt: new Date() }, 113 }); 114} 115 116export async function markAllAsRead(userId: string) { 117 return prisma.notification.updateMany({ 118 where: { userId, read: false }, 119 data: { read: true, readAt: new Date() }, 120 }); 121}

API Routes#

1// app/api/notifications/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { getNotifications, getUnreadCount, markAllAsRead } from '@/lib/services/notification-service'; 5import { NotificationType } from '@prisma/client'; 6 7export async function GET(request: NextRequest) { 8 const { userId } = await auth(); 9 if (!userId) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 } 12 13 const searchParams = request.nextUrl.searchParams; 14 const unreadOnly = searchParams.get('unreadOnly') === 'true'; 15 const type = searchParams.get('type') as NotificationType | null; 16 const cursor = searchParams.get('cursor') || undefined; 17 const limit = parseInt(searchParams.get('limit') || '20'); 18 19 const result = await getNotifications(userId, { 20 unreadOnly, 21 type: type || undefined, 22 cursor, 23 limit, 24 }); 25 26 const unreadCount = await getUnreadCount(userId); 27 28 return NextResponse.json({ 29 ...result, 30 unreadCount, 31 }); 32} 33 34export async function POST(request: NextRequest) { 35 const { userId } = await auth(); 36 if (!userId) { 37 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 38 } 39 40 const searchParams = request.nextUrl.searchParams; 41 const action = searchParams.get('action'); 42 43 if (action === 'markAllRead') { 44 await markAllAsRead(userId); 45 return NextResponse.json({ success: true }); 46 } 47 48 return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); 49}
1// app/api/notifications/[id]/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { markAsRead } from '@/lib/services/notification-service'; 5import { prisma } from '@/lib/prisma'; 6 7export async function PATCH( 8 request: NextRequest, 9 { params }: { params: { id: string } } 10) { 11 const { userId } = await auth(); 12 if (!userId) { 13 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 14 } 15 16 const body = await request.json(); 17 18 if (body.read === true) { 19 const notification = await markAsRead(params.id, userId); 20 return NextResponse.json(notification); 21 } 22 23 return NextResponse.json({ error: 'Invalid update' }, { status: 400 }); 24} 25 26export async function DELETE( 27 request: NextRequest, 28 { params }: { params: { id: string } } 29) { 30 const { userId } = await auth(); 31 if (!userId) { 32 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 33 } 34 35 await prisma.notification.delete({ 36 where: { id: params.id, userId }, 37 }); 38 39 return NextResponse.json({ success: true }); 40}

Step 6: Real-time Updates Phase#

The backend-expert implements Server-Sent Events.

bootspring agent invoke backend-expert "Implement real-time notification updates with SSE"

SSE Endpoint#

1// app/api/notifications/stream/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest } from 'next/server'; 4 5// Store active connections 6const connections = new Map<string, Set<ReadableStreamDefaultController>>(); 7 8export function notifyUser(userId: string, notification: any) { 9 const userConnections = connections.get(userId); 10 if (userConnections) { 11 const data = `data: ${JSON.stringify(notification)}\n\n`; 12 userConnections.forEach((controller) => { 13 try { 14 controller.enqueue(new TextEncoder().encode(data)); 15 } catch { 16 // Connection closed 17 } 18 }); 19 } 20} 21 22export async function GET(request: NextRequest) { 23 const { userId } = await auth(); 24 if (!userId) { 25 return new Response('Unauthorized', { status: 401 }); 26 } 27 28 const stream = new ReadableStream({ 29 start(controller) { 30 // Add to connections 31 if (!connections.has(userId)) { 32 connections.set(userId, new Set()); 33 } 34 connections.get(userId)!.add(controller); 35 36 // Send initial connection message 37 controller.enqueue( 38 new TextEncoder().encode('data: {"type":"connected"}\n\n') 39 ); 40 41 // Keep-alive ping every 30 seconds 42 const pingInterval = setInterval(() => { 43 try { 44 controller.enqueue( 45 new TextEncoder().encode('data: {"type":"ping"}\n\n') 46 ); 47 } catch { 48 clearInterval(pingInterval); 49 } 50 }, 30000); 51 52 // Cleanup on close 53 request.signal.addEventListener('abort', () => { 54 clearInterval(pingInterval); 55 connections.get(userId)?.delete(controller); 56 if (connections.get(userId)?.size === 0) { 57 connections.delete(userId); 58 } 59 }); 60 }, 61 }); 62 63 return new Response(stream, { 64 headers: { 65 'Content-Type': 'text/event-stream', 66 'Cache-Control': 'no-cache', 67 Connection: 'keep-alive', 68 }, 69 }); 70}

Update Notification Service#

1// lib/services/notification-service.ts 2import { notifyUser } from '@/app/api/notifications/stream/route'; 3 4export async function createNotification(input: CreateNotificationInput) { 5 // ... existing code ... 6 7 // Notify via SSE 8 notifyUser(userId, { 9 type: 'new_notification', 10 notification, 11 }); 12 13 return notification; 14}

Step 7: Frontend Development Phase#

The frontend-expert creates the UI components.

bootspring agent invoke frontend-expert "Create notification UI components"

Notification Hook#

1// hooks/use-notifications.ts 2'use client'; 3 4import { useEffect, useState, useCallback } from 'react'; 5import useSWR from 'swr'; 6 7interface Notification { 8 id: string; 9 type: 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR'; 10 title: string; 11 message: string; 12 link?: string; 13 read: boolean; 14 createdAt: string; 15} 16 17interface NotificationsResponse { 18 notifications: Notification[]; 19 unreadCount: number; 20 nextCursor: string | null; 21} 22 23const fetcher = (url: string) => fetch(url).then((res) => res.json()); 24 25export function useNotifications() { 26 const { data, error, mutate } = useSWR<NotificationsResponse>( 27 '/api/notifications', 28 fetcher 29 ); 30 31 // SSE connection for real-time updates 32 useEffect(() => { 33 const eventSource = new EventSource('/api/notifications/stream'); 34 35 eventSource.onmessage = (event) => { 36 const data = JSON.parse(event.data); 37 38 if (data.type === 'new_notification') { 39 mutate(); 40 } 41 }; 42 43 eventSource.onerror = () => { 44 eventSource.close(); 45 // Reconnect after 5 seconds 46 setTimeout(() => { 47 mutate(); 48 }, 5000); 49 }; 50 51 return () => { 52 eventSource.close(); 53 }; 54 }, [mutate]); 55 56 const markAsRead = useCallback(async (id: string) => { 57 await fetch(`/api/notifications/${id}`, { 58 method: 'PATCH', 59 headers: { 'Content-Type': 'application/json' }, 60 body: JSON.stringify({ read: true }), 61 }); 62 mutate(); 63 }, [mutate]); 64 65 const markAllAsRead = useCallback(async () => { 66 await fetch('/api/notifications?action=markAllRead', { 67 method: 'POST', 68 }); 69 mutate(); 70 }, [mutate]); 71 72 const deleteNotification = useCallback(async (id: string) => { 73 await fetch(`/api/notifications/${id}`, { 74 method: 'DELETE', 75 }); 76 mutate(); 77 }, [mutate]); 78 79 return { 80 notifications: data?.notifications ?? [], 81 unreadCount: data?.unreadCount ?? 0, 82 isLoading: !error && !data, 83 isError: error, 84 markAsRead, 85 markAllAsRead, 86 deleteNotification, 87 refresh: mutate, 88 }; 89}

Notification Bell Component#

1// components/notifications/NotificationBell.tsx 2'use client'; 3 4import { useState } from 'react'; 5import { Bell, Check, X, ExternalLink } from 'lucide-react'; 6import { useNotifications } from '@/hooks/use-notifications'; 7import { formatDistanceToNow } from 'date-fns'; 8import Link from 'next/link'; 9 10const typeStyles = { 11 INFO: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', 12 SUCCESS: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', 13 WARNING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', 14 ERROR: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', 15}; 16 17export function NotificationBell() { 18 const [isOpen, setIsOpen] = useState(false); 19 const { 20 notifications, 21 unreadCount, 22 markAsRead, 23 markAllAsRead, 24 deleteNotification, 25 } = useNotifications(); 26 27 return ( 28 <div className="relative"> 29 <button 30 onClick={() => setIsOpen(!isOpen)} 31 className="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" 32 aria-label="Notifications" 33 > 34 <Bell className="h-5 w-5 text-gray-600 dark:text-gray-400" /> 35 {unreadCount > 0 && ( 36 <span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white"> 37 {unreadCount > 9 ? '9+' : unreadCount} 38 </span> 39 )} 40 </button> 41 42 {isOpen && ( 43 <> 44 {/* Backdrop */} 45 <div 46 className="fixed inset-0 z-40" 47 onClick={() => setIsOpen(false)} 48 /> 49 50 {/* Dropdown */} 51 <div className="absolute right-0 z-50 mt-2 w-80 rounded-xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700"> 52 {/* Header */} 53 <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> 54 <h3 className="font-semibold text-gray-900 dark:text-white"> 55 Notifications 56 </h3> 57 {unreadCount > 0 && ( 58 <button 59 onClick={() => markAllAsRead()} 60 className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400" 61 > 62 Mark all read 63 </button> 64 )} 65 </div> 66 67 {/* Notifications list */} 68 <div className="max-h-96 overflow-y-auto"> 69 {notifications.length === 0 ? ( 70 <div className="p-8 text-center text-gray-500 dark:text-gray-400"> 71 No notifications yet 72 </div> 73 ) : ( 74 notifications.map((notification) => ( 75 <div 76 key={notification.id} 77 className={`p-4 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${ 78 !notification.read ? 'bg-brand-50 dark:bg-brand-900/10' : '' 79 }`} 80 > 81 <div className="flex items-start gap-3"> 82 <span 83 className={`px-2 py-0.5 rounded text-xs font-medium ${ 84 typeStyles[notification.type] 85 }`} 86 > 87 {notification.type} 88 </span> 89 <div className="flex-1 min-w-0"> 90 <p className="font-medium text-gray-900 dark:text-white"> 91 {notification.title} 92 </p> 93 <p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2"> 94 {notification.message} 95 </p> 96 <p className="mt-2 text-xs text-gray-500"> 97 {formatDistanceToNow(new Date(notification.createdAt), { 98 addSuffix: true, 99 })} 100 </p> 101 </div> 102 <div className="flex items-center gap-1"> 103 {notification.link && ( 104 <Link 105 href={notification.link} 106 className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" 107 > 108 <ExternalLink className="h-4 w-4 text-gray-400" /> 109 </Link> 110 )} 111 {!notification.read && ( 112 <button 113 onClick={() => markAsRead(notification.id)} 114 className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" 115 title="Mark as read" 116 > 117 <Check className="h-4 w-4 text-gray-400" /> 118 </button> 119 )} 120 <button 121 onClick={() => deleteNotification(notification.id)} 122 className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" 123 title="Delete" 124 > 125 <X className="h-4 w-4 text-gray-400" /> 126 </button> 127 </div> 128 </div> 129 </div> 130 )) 131 )} 132 </div> 133 134 {/* Footer */} 135 <div className="p-3 border-t border-gray-200 dark:border-gray-700"> 136 <Link 137 href="/dashboard/notifications" 138 className="block text-center text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400" 139 > 140 View all notifications 141 </Link> 142 </div> 143 </div> 144 </> 145 )} 146 </div> 147 ); 148}

Step 8: Testing Phase#

The testing-expert creates tests.

bootspring agent invoke testing-expert "Write tests for the notification system"

Service Tests#

1// __tests__/services/notification-service.test.ts 2import { describe, it, expect, beforeEach } from 'vitest'; 3import { prisma } from '@/lib/prisma'; 4import { 5 createNotification, 6 getNotifications, 7 getUnreadCount, 8 markAsRead, 9 markAllAsRead, 10} from '@/lib/services/notification-service'; 11 12describe('NotificationService', () => { 13 let testUserId: string; 14 15 beforeEach(async () => { 16 // Create test user 17 const user = await prisma.user.create({ 18 data: { 19 email: 'test@example.com', 20 clerkId: 'test_clerk_id', 21 }, 22 }); 23 testUserId = user.id; 24 }); 25 26 describe('createNotification', () => { 27 it('creates a notification successfully', async () => { 28 const notification = await createNotification({ 29 userId: testUserId, 30 type: 'INFO', 31 title: 'Test Notification', 32 message: 'This is a test', 33 }); 34 35 expect(notification).toBeDefined(); 36 expect(notification?.title).toBe('Test Notification'); 37 expect(notification?.read).toBe(false); 38 }); 39 40 it('respects user preferences', async () => { 41 // Disable INFO notifications 42 await prisma.notificationPreference.create({ 43 data: { 44 userId: testUserId, 45 infoEnabled: false, 46 }, 47 }); 48 49 const notification = await createNotification({ 50 userId: testUserId, 51 type: 'INFO', 52 title: 'Should not create', 53 message: 'This should be skipped', 54 }); 55 56 expect(notification).toBeNull(); 57 }); 58 }); 59 60 describe('getNotifications', () => { 61 beforeEach(async () => { 62 // Create test notifications 63 await prisma.notification.createMany({ 64 data: [ 65 { userId: testUserId, type: 'INFO', title: 'Info 1', message: 'Msg' }, 66 { userId: testUserId, type: 'WARNING', title: 'Warning', message: 'Msg', read: true }, 67 { userId: testUserId, type: 'ERROR', title: 'Error', message: 'Msg' }, 68 ], 69 }); 70 }); 71 72 it('returns all notifications', async () => { 73 const result = await getNotifications(testUserId); 74 expect(result.notifications).toHaveLength(3); 75 }); 76 77 it('filters unread only', async () => { 78 const result = await getNotifications(testUserId, { unreadOnly: true }); 79 expect(result.notifications).toHaveLength(2); 80 }); 81 82 it('filters by type', async () => { 83 const result = await getNotifications(testUserId, { type: 'ERROR' }); 84 expect(result.notifications).toHaveLength(1); 85 expect(result.notifications[0].type).toBe('ERROR'); 86 }); 87 }); 88 89 describe('getUnreadCount', () => { 90 it('returns correct unread count', async () => { 91 await prisma.notification.createMany({ 92 data: [ 93 { userId: testUserId, type: 'INFO', title: 'Unread', message: 'Msg' }, 94 { userId: testUserId, type: 'INFO', title: 'Read', message: 'Msg', read: true }, 95 { userId: testUserId, type: 'INFO', title: 'Unread 2', message: 'Msg' }, 96 ], 97 }); 98 99 const count = await getUnreadCount(testUserId); 100 expect(count).toBe(2); 101 }); 102 }); 103 104 describe('markAsRead', () => { 105 it('marks notification as read', async () => { 106 const notification = await prisma.notification.create({ 107 data: { 108 userId: testUserId, 109 type: 'INFO', 110 title: 'Test', 111 message: 'Msg', 112 }, 113 }); 114 115 const updated = await markAsRead(notification.id, testUserId); 116 expect(updated.read).toBe(true); 117 expect(updated.readAt).toBeDefined(); 118 }); 119 }); 120 121 describe('markAllAsRead', () => { 122 it('marks all notifications as read', async () => { 123 await prisma.notification.createMany({ 124 data: [ 125 { userId: testUserId, type: 'INFO', title: 'N1', message: 'Msg' }, 126 { userId: testUserId, type: 'INFO', title: 'N2', message: 'Msg' }, 127 ], 128 }); 129 130 await markAllAsRead(testUserId); 131 132 const count = await getUnreadCount(testUserId); 133 expect(count).toBe(0); 134 }); 135 }); 136});

Step 9: Quality Gates#

Run quality checks before deployment:

1# Pre-commit checks 2bootspring quality pre-commit 3 4# Full test suite 5npm run test 6 7# Pre-deploy validation 8bootspring quality pre-deploy

Step 10: Complete the Workflow#

Mark the workflow as complete:

bootspring workflow complete feature-development

Verification Checklist#

  • Database schema created and migrated
  • API endpoints working correctly
  • Real-time updates functioning
  • Email notifications sending
  • UI components rendering correctly
  • Tests passing
  • Quality gates passed

What You Learned#

  • Using the feature development workflow
  • Coordinating multiple expert agents
  • Building full-stack features with Bootspring
  • Real-time updates with SSE
  • Testing multi-layer applications

Next Steps#