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-developmentWhen 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 refreshingStep 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 generateStep 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-deployStep 10: Complete the Workflow#
Mark the workflow as complete:
bootspring workflow complete feature-developmentVerification 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