Signal Status Refactor Implementation Guide
Overview
This guide provides step-by-step instructions for completing the unified signal status system refactor across the entire CivStart application.
Completed Work
✅ Database Schema
- Updated
Signalmodel with unified status system - Added
isPublicflag to replaceSignalPoolEntry - Updated
SignalAssignmentwithassignmentType - Updated
ContactRequestwithrequestSourceandconnectionId - Updated
SignalPoolViewandSignalPoolRequestto reference Signal directly - Removed
SignalPoolEntrymodel - Applied migration to development database
- Generated Prisma client
✅ Backend Types & Services
- Created signal status types and enums (
apps/backend/src/govintel/types/signal-status.types.ts) - Created signal status service (
apps/backend/src/govintel/services/signals/signal-status.service.ts) - Created signal status DTOs (
apps/backend/src/govintel/dto/signal-status.dto.ts)
✅ Frontend Components
- Created
SignalStatusBadgecomponent for admin (apps/admin/components/SignalStatusBadge.tsx) - Created
SignalStatusBadgecomponent for frontend (apps/frontend/components/SignalStatusBadge.tsx)
Remaining Work
1. Backend Controllers
1.1 Update GovIntel Controller (apps/backend/src/govintel/controllers/govintel.controller.ts)
Add new endpoints:
import { SignalStatusService } from '../services/signals/signal-status.service';
import {
AssignSignalToStartupsDto,
PublishToPoolDto,
ChangeSignalStatusDto,
ArchiveSignalDto,
UnarchiveSignalDto,
QuerySignalsDto,
} from '../dto/signal-status.dto';
// Inject SignalStatusService in constructor
/**
* POST /signals/:id/assign
* Assign signal to startups (direct assignment)
*/
@Post(':id/assign')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async assignSignalToStartups(
@Param('id') signalId: string,
@Body() dto: AssignSignalToStartupsDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.assignToStartups({
signalId,
...dto,
assignedBy: user.id,
});
}
/**
* POST /signals/:id/publish
* Publish signal to public pool
*/
@Post(':id/publish')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async publishToPool(
@Param('id') signalId: string,
@Body() dto: PublishToPoolDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.publishToPool({
signalId,
...dto,
publishedBy: user.id,
});
}
/**
* POST /signals/:id/unpublish
* Remove signal from public pool
*/
@Post(':id/unpublish')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async unpublishFromPool(
@Param('id') signalId: string,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.unpublishFromPool(signalId, user.id);
}
/**
* POST /signals/:id/archive
* Archive a signal
*/
@Post(':id/archive')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async archiveSignal(
@Param('id') signalId: string,
@Body() dto: ArchiveSignalDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.archiveSignal(signalId, user.id, dto.notes);
}
/**
* POST /signals/:id/unarchive
* Unarchive a signal
*/
@Post(':id/unarchive')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async unarchiveSignal(
@Param('id') signalId: string,
@Body() dto: UnarchiveSignalDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.unarchiveSignal(signalId, user.id, dto.notes);
}
/**
* GET /signals/:id/status-history
* Get status change history
*/
@Get(':id/status-history')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async getStatusHistory(@Param('id') signalId: string) {
return this.signalStatusService.getStatusHistory(signalId);
}
1.2 Update Signal Pool Controller (apps/backend/src/govintel/controllers/signal-pool.controller.ts)
Update existing methods to use the new system:
/**
* GET /signal-pool
* Get public pool signals (use isPublic flag instead of SignalPoolEntry)
*/
@Get()
@UseGuards(ClerkAuthGuard)
async getPublicPoolSignals(
@Query() query: QueryPoolSignalsDto,
@GetUser() user: ClerkUser,
) {
const { page = 1, limit = 20, visibilityLevel, solutionCategory, search } = query;
const where: any = {
isPublic: true,
isActive: true,
isArchived: false,
};
if (visibilityLevel) {
where.visibilityLevel = visibilityLevel;
}
if (solutionCategory) {
where.solutionCategory = solutionCategory;
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [signals, total] = await Promise.all([
this.prisma.signal.findMany({
where,
take: limit,
skip: (page - 1) * limit,
orderBy: { publishedAt: 'desc' },
include: {
government: true,
governmentContact: true,
},
}),
this.prisma.signal.count({ where }),
]);
return {
signals,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* POST /signal-pool/:signalId/accept
* Startup accepts signal from pool and creates assignment
*/
@Post(':signalId/accept')
@UseGuards(ClerkAuthGuard)
async acceptPoolSignal(
@Param('signalId') signalId: string,
@Body() dto: AcceptPoolSignalDto,
@GetUser() user: ClerkUser,
) {
// Create assignment with type="pool"
const assignment = await this.signalStatusService.assignToStartups({
signalId,
startupIds: [user.startupId], // Get from user context
assignedBy: user.id,
assignmentType: AssignmentType.POOL,
});
// Accept the signal
await this.signalStatusService.acceptSignal(signalId, user.startupId, user.id);
return { success: true, assignment };
}
/**
* POST /signal-pool/:signalId/view
* Track that a startup viewed a pool signal
*/
@Post(':signalId/view')
@UseGuards(ClerkAuthGuard)
async trackPoolView(
@Param('signalId') signalId: string,
@GetUser() user: ClerkUser,
) {
// Create or update view record
await this.prisma.signalPoolView.upsert({
where: {
signalId_startupId: {
signalId,
startupId: user.startupId,
},
},
create: {
signalId,
startupId: user.startupId,
viewedAt: new Date(),
},
update: {
viewedAt: new Date(),
},
});
// Increment view count
await this.prisma.signal.update({
where: { id: signalId },
data: { viewCount: { increment: 1 } },
});
return { success: true };
}
1.3 Create Startup Controller Endpoints (apps/backend/src/govintel/controllers/startup.controller.ts)
/**
* POST /startup/signals/:signalId/accept
* Accept assigned signal
*/
@Post('signals/:signalId/accept')
@UseGuards(ClerkAuthGuard)
async acceptSignal(
@Param('signalId') signalId: string,
@Body() dto: AcceptSignalDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.acceptSignal(signalId, user.startupId, user.id);
}
/**
* POST /startup/signals/:signalId/decline
* Decline assigned signal
*/
@Post('signals/:signalId/decline')
@UseGuards(ClerkAuthGuard)
async declineSignal(
@Param('signalId') signalId: string,
@Body() dto: DeclineSignalDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.declineSignal(signalId, user.startupId, user.id, dto.reason);
}
/**
* POST /startup/signals/:signalId/request-contact
* Request contact information (unified for direct and pool)
*/
@Post('signals/:signalId/request-contact')
@UseGuards(ClerkAuthGuard)
async requestContact(
@Param('signalId') signalId: string,
@Body() dto: CreateContactRequestDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.createContactRequest({
signalId,
startupId: user.startupId,
...dto,
requestedBy: user.id,
});
}
1.4 Update Contact Request Controller
/**
* POST /contact-requests/:id/approve
* Approve contact request
*/
@Post(':id/approve')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async approveContactRequest(
@Param('id') requestId: string,
@Body() dto: ReviewContactRequestDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.approveContactRequest(
requestId,
user.id,
dto.reviewNotes
);
}
/**
* POST /contact-requests/:id/reject
* Reject contact request
*/
@Post(':id/reject')
@UseGuards(ClerkAuthGuard, RolesGuard)
@Roles('admin')
async rejectContactRequest(
@Param('id') requestId: string,
@Body() dto: ReviewContactRequestDto,
@GetUser() user: ClerkUser,
) {
return this.signalStatusService.rejectContactRequest(
requestId,
user.id,
dto.reviewNotes
);
}
2. Frontend API Clients
2.1 Update Admin API Client (apps/admin/lib/api-client.ts)
Add new methods:
export class AdminApiClient {
// ... existing methods ...
// Signal status management
async assignSignalToStartups(
signalId: string,
dto: AssignSignalToStartupsDto,
) {
return this.post(`/govintel/signals/${signalId}/assign`, dto);
}
async publishToPool(signalId: string, dto: PublishToPoolDto) {
return this.post(`/govintel/signals/${signalId}/publish`, dto);
}
async unpublishFromPool(signalId: string) {
return this.post(`/govintel/signals/${signalId}/unpublish`, {});
}
async archiveSignal(signalId: string, notes?: string) {
return this.post(`/govintel/signals/${signalId}/archive`, { notes });
}
async unarchiveSignal(signalId: string, notes?: string) {
return this.post(`/govintel/signals/${signalId}/unarchive`, { notes });
}
async getStatusHistory(signalId: string) {
return this.get(`/govintel/signals/${signalId}/status-history`);
}
// Contact request management
async approveContactRequest(requestId: string, reviewNotes?: string) {
return this.post(`/contact-requests/${requestId}/approve`, {
decision: "approved",
reviewNotes,
});
}
async rejectContactRequest(requestId: string, reviewNotes?: string) {
return this.post(`/contact-requests/${requestId}/reject`, {
decision: "rejected",
reviewNotes,
});
}
}
2.2 Update Startup API Client (apps/frontend/lib/api-client.ts)
export class StartupApiClient {
// ... existing methods ...
// Signal actions
async acceptSignal(signalId: string, notes?: string) {
return this.post(`/startup/signals/${signalId}/accept`, { notes });
}
async declineSignal(signalId: string, reason?: string) {
return this.post(`/startup/signals/${signalId}/decline`, { reason });
}
async requestContact(signalId: string, requestSource: "direct" | "pool") {
return this.post(`/startup/signals/${signalId}/request-contact`, {
requestSource,
});
}
// Pool actions
async getPoolSignals(params?: {
page?: number;
limit?: number;
solutionCategory?: string;
search?: string;
}) {
return this.get("/signal-pool", { params });
}
async acceptPoolSignal(signalId: string, justification?: string) {
return this.post(`/signal-pool/${signalId}/accept`, { justification });
}
async trackPoolView(signalId: string) {
return this.post(`/signal-pool/${signalId}/view`, {});
}
}
3. Admin Pages
3.1 Update Signals List Page (apps/admin/app/signals/page.tsx)
import { SignalStatusBadge, STATUS_FILTER_OPTIONS } from '@/components/SignalStatusBadge';
// Add status filter
const [statusFilter, setStatusFilter] = useState('');
// Add filter UI
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
// Display status in table
<TableCell>
<SignalStatusBadge
status={signal.status}
isPublic={signal.isPublic}
showPublicIndicator={true}
/>
</TableCell>
3.2 Update Signal Detail Page (apps/admin/app/signals/[id]/page.tsx)
Add action buttons based on status:
import { SignalStatusBadge } from '@/components/SignalStatusBadge';
// Status display
<SignalStatusBadge
status={signal.status}
isPublic={signal.isPublic}
showPublicIndicator={true}
/>
// Action buttons
{signal.status === 'signal_unassigned' && (
<>
<Button onClick={handleAssignToStartups}>Assign to Startups</Button>
<Button onClick={handlePublishToPool}>Publish to Pool</Button>
</>
)}
{signal.isPublic && signal.status === 'signal_assigned' && (
<Button onClick={handleUnpublishFromPool}>Remove from Pool</Button>
)}
{!signal.isArchived && (
<Button variant="destructive" onClick={handleArchive}>Archive</Button>
)}
3.3 Update Contact Requests Page (apps/admin/app/contact-requests/page.tsx)
// Add approval/rejection actions
async function handleApprove(requestId: string) {
const notes = window.prompt('Review notes (optional):');
await apiClient.approveContactRequest(requestId, notes);
toast.success('Contact request approved');
refetch();
}
async function handleReject(requestId: string) {
const notes = window.prompt('Rejection reason:');
if (!notes) return;
await apiClient.rejectContactRequest(requestId, notes);
toast.success('Contact request rejected');
refetch();
}
// Display in table
<TableCell>
{request.status === 'pending' && (
<>
<Button size="sm" onClick={() => handleApprove(request.id)}>Approve</Button>
<Button size="sm" variant="destructive" onClick={() => handleReject(request.id)}>Reject</Button>
</>
)}
{request.status === 'approved' && <Badge variant="default">Approved</Badge>}
{request.status === 'rejected' && <Badge variant="destructive">Rejected</Badge>}
</TableCell>
4. Startup (Frontend) Pages
4.1 Update Inbox Page (apps/frontend/app/inbox/page.tsx)
import { SignalStatusBadge, canTakeAction, getAvailableActions } from '@/components/SignalStatusBadge';
// Display status
<SignalStatusBadge
status={signal.status}
showDescription={true}
/>
// Action buttons
{canTakeAction(signal.status) && (
<div className="flex gap-2">
{getAvailableActions(signal.status).includes('accept') && (
<Button onClick={() => handleAccept(signal.id)}>Accept</Button>
)}
{getAvailableActions(signal.status).includes('decline') && (
<Button variant="outline" onClick={() => handleDecline(signal.id)}>Decline</Button>
)}
{getAvailableActions(signal.status).includes('request_contact') && (
<Button onClick={() => handleRequestContact(signal.id)}>Request Contact Info</Button>
)}
</div>
)}
4.2 Update Pool Page (apps/frontend/app/pool/page.tsx)
// Fetch pool signals
const { data, isLoading } = useQuery({
queryKey: ["pool-signals", filters],
queryFn: () => apiClient.getPoolSignals(filters),
});
// Accept pool signal
async function handleAcceptPoolSignal(signalId: string) {
const justification = window.prompt("Why is this a good fit? (optional):");
await apiClient.acceptPoolSignal(signalId, justification);
toast.success("Signal accepted! Check your inbox.");
router.push("/inbox");
}
// Track views
useEffect(() => {
if (viewedSignalId) {
apiClient.trackPoolView(viewedSignalId);
}
}, [viewedSignalId]);
5. Testing
5.1 Test Scenarios
-
Admin assigns signal directly to startup
- Signal: unassigned → assigned
- SignalAssignment created with type="direct"
- Startup sees in inbox with "New" status
-
Admin publishes signal to pool
- Signal: unassigned → assigned, isPublic=true
- Signal appears in public pool
- All startups can view it
-
Startup accepts from pool
- SignalAssignment created with type="pool"
- Signal: assigned → accepted
- Startup can request contact
-
Startup accepts direct assignment
- Signal: assigned → accepted
- Startup can request contact
-
Startup requests contact (both flows)
- Signal: accepted → contact_request_pending
- ContactRequest created with appropriate requestSource
- Admin can approve/reject
-
Admin approves contact request
- Signal: contact_request_pending → contact_request_approved
- Connection created
- ContactRequest.connectionId linked
-
Admin rejects contact request
- Signal: contact_request_pending → contact_request_rejected
- Startup can see rejection
-
Admin archives signal
- Signal: any → archived
- No longer appears in active views
6. Migration Checklist
- Update backend controllers with new endpoints
- Update frontend API clients
- Update admin signals list page
- Update admin signal detail page
- Update admin contact requests page
- Update startup inbox page
- Update startup pool page
- Add tests for new status transitions
- Update API documentation
- Deploy to staging environment
- Test all workflows end-to-end
- Deploy to production
- Remove legacy approval status fields (future PR)
Key Concepts
Status Machine
The status transitions are strictly enforced by SIGNAL_STATUS_TRANSITIONS in the service. Invalid transitions throw errors.
Unified Contact Request Flow
Both direct assignments and pool signals use the same contact request process after acceptance. The only difference is the requestSource field.
Public Pool
The isPublic flag replaces the entire SignalPoolEntry table. Much simpler to manage and query.
Assignment Types
SignalAssignment.assignmentType tracks whether a startup got the signal via:
direct: Admin assigned directlypool: Startup accepted from public pool
This helps with analytics and reporting.
Support
For questions or issues during implementation:
- Review the existing service code in
signal-status.service.ts - Check the status transition map in
signal-status.types.ts - Refer to the Prisma schema for data structure
- Test status transitions in development before deploying
Notes
- Legacy
approvalStatusfields are kept temporarily for backward compatibility - They can be removed in a future PR after confirming all code uses the new system
- The migration SQL is designed to be safe and reversible if needed