Skip to main content

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 Signal model with unified status system
  • Added isPublic flag to replace SignalPoolEntry
  • Updated SignalAssignment with assignmentType
  • Updated ContactRequest with requestSource and connectionId
  • Updated SignalPoolView and SignalPoolRequest to reference Signal directly
  • Removed SignalPoolEntry model
  • 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 SignalStatusBadge component for admin (apps/admin/components/SignalStatusBadge.tsx)
  • Created SignalStatusBadge component 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

  1. Admin assigns signal directly to startup

    • Signal: unassigned → assigned
    • SignalAssignment created with type="direct"
    • Startup sees in inbox with "New" status
  2. Admin publishes signal to pool

    • Signal: unassigned → assigned, isPublic=true
    • Signal appears in public pool
    • All startups can view it
  3. Startup accepts from pool

    • SignalAssignment created with type="pool"
    • Signal: assigned → accepted
    • Startup can request contact
  4. Startup accepts direct assignment

    • Signal: assigned → accepted
    • Startup can request contact
  5. Startup requests contact (both flows)

    • Signal: accepted → contact_request_pending
    • ContactRequest created with appropriate requestSource
    • Admin can approve/reject
  6. Admin approves contact request

    • Signal: contact_request_pending → contact_request_approved
    • Connection created
    • ContactRequest.connectionId linked
  7. Admin rejects contact request

    • Signal: contact_request_pending → contact_request_rejected
    • Startup can see rejection
  8. 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 directly
  • pool: Startup accepted from public pool

This helps with analytics and reporting.


Support

For questions or issues during implementation:

  1. Review the existing service code in signal-status.service.ts
  2. Check the status transition map in signal-status.types.ts
  3. Refer to the Prisma schema for data structure
  4. Test status transitions in development before deploying

Notes

  • Legacy approvalStatus fields 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