feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)

- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests
- developer board: pool-scoped visibility, customer/search/minBounty/sort
  filters, stale-task age badges, competing-claims visibility setting,
  saved filters in profile extras
- claims: request/withdraw (developer), approve/decline (consultant) with
  notifications to winners and losers; unassign/abandon back to board
- work tracking: start, sanitized comments with @mention notifications,
  time logging, submit for review
- review queue + review with per-AC checklist stored on the timeline;
  approve writes the immutable bountyAwards row (human assignees only)
- assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint,
  idempotent by jobId, artifacts downloaded into GridFS, failure path
  keeps the task assigned with timeline + notification
- notifications API + bell with unread badge, dropdown, page, WS toasts
- pages: bounty board, my-tasks kanban, task detail (role-driven actions,
  review dialog, AI dialog), review queue, developer pool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:11:05 +02:00
parent f87b954f27
commit 70a813edfa
28 changed files with 2764 additions and 14 deletions
+31
View File
@@ -0,0 +1,31 @@
package store
import (
"context"
"fmt"
"time"
"bountyboard/internal/ulid"
)
// BountyAward is the immutable ledger row (§4.5) — never updated, never
// recomputed from mutable tasks.
type BountyAward struct {
ID string `bson:"_id" json:"id"`
TaskID string `bson:"taskId" json:"taskId"`
DeveloperID string `bson:"developerId" json:"developerId"`
ConsultantID string `bson:"consultantId" json:"consultantId"`
CustomerID string `bson:"customerId" json:"customerId"`
Amount float64 `bson:"amount" json:"amount"`
Coefficient float64 `bson:"coefficient" json:"coefficient"`
AwardedAt time.Time `bson:"awardedAt" json:"awardedAt"`
}
func (s *Store) InsertAward(ctx context.Context, a *BountyAward) error {
a.ID = ulid.New()
a.AwardedAt = time.Now().UTC()
if _, err := s.DB.Collection("bountyAwards").InsertOne(ctx, a); err != nil {
return fmt.Errorf("insert award: %w", err)
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/ulid"
)
// PoolEntry is a developer-pool membership row (§4.3).
type PoolEntry struct {
ID string `bson:"_id" json:"id"`
ConsultantID string `bson:"consultantId" json:"consultantId"`
DeveloperID string `bson:"developerId" json:"developerId"`
AddedAt time.Time `bson:"addedAt" json:"addedAt"`
}
func (s *Store) AddToPool(ctx context.Context, consultantID, developerID string) error {
_, err := s.DB.Collection("pools").InsertOne(ctx, PoolEntry{
ID: ulid.New(), ConsultantID: consultantID, DeveloperID: developerID,
AddedAt: time.Now().UTC(),
})
if err != nil {
if mongo.IsDuplicateKeyError(err) {
return ErrDuplicate
}
return fmt.Errorf("add to pool: %w", err)
}
return nil
}
func (s *Store) RemoveFromPool(ctx context.Context, consultantID, developerID string) error {
res, err := s.DB.Collection("pools").DeleteOne(ctx,
bson.M{"consultantId": consultantID, "developerId": developerID})
if err != nil {
return fmt.Errorf("remove from pool: %w", err)
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
// PoolDeveloperIDs lists developers in one consultant's pool.
func (s *Store) PoolDeveloperIDs(ctx context.Context, consultantID string) ([]string, error) {
return s.poolSide(ctx, bson.M{"consultantId": consultantID}, "developerId")
}
// PoolConsultantIDs lists consultants who selected the developer — drives
// the developer's bounty-board visibility (§3).
func (s *Store) PoolConsultantIDs(ctx context.Context, developerID string) ([]string, error) {
return s.poolSide(ctx, bson.M{"developerId": developerID}, "consultantId")
}
func (s *Store) poolSide(ctx context.Context, filter bson.M, field string) ([]string, error) {
cur, err := s.DB.Collection("pools").Find(ctx, filter)
if err != nil {
return nil, fmt.Errorf("list pool: %w", err)
}
var rows []PoolEntry
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode pool: %w", err)
}
out := make([]string, 0, len(rows))
for _, r := range rows {
if field == "developerId" {
out = append(out, r.DeveloperID)
} else {
out = append(out, r.ConsultantID)
}
}
return out, nil
}