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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user