feat(notifications): replace hardcoded inbox notifications with subscriber-driven model

Replace inbox_listeners.go with a subscriber-driven notification system:

- Add issue_subscriber table with auto-subscribe on create/assign/comment
- New subscriber_listeners.go: maintains subscriber data on domain events
- New notification_listeners.go: notifySubscribers (fanout to all subscribers
  minus actor) and notifyDirect (targeted, punches through unsubscribe)
- Subscriber API: list/subscribe/unsubscribe endpoints
- Frontend: subscribers section in issue detail sidebar with real-time sync
- Frontend: inbox notification grouping by (issue_id, type, actor_id)
- Remove createInboxForIssueCreator from task.go (unified through event bus)
- 21 new Go tests, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-28 19:33:20 +08:00
parent 5fc03c61fe
commit bfe9498def
26 changed files with 2144 additions and 457 deletions

View file

@ -15,6 +15,7 @@ import type {
DaemonPairingSession,
ApproveDaemonPairingSessionRequest,
InboxItem,
IssueSubscriber,
Comment,
Workspace,
WorkspaceRepo,
@ -200,6 +201,19 @@ export class ApiClient {
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
}
// Subscribers
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
return this.fetch(`/api/issues/${issueId}/subscribers`);
}
async subscribeToIssue(issueId: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/subscribe`, { method: "POST" });
}
async unsubscribeFromIssue(issueId: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/unsubscribe`, { method: "POST" });
}
// Agents
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
const search = new URLSearchParams();