feat: admin area with customers, users, settings, audit log, service status (phase 5)

- customer CRUD wizard backend: per-system credential shapes validated via
  connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
  delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
  late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:57:10 +02:00
parent 34bf5b5ac2
commit 458ba6a7ee
22 changed files with 2609 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/ulid"
)
// AuditEntry records one privileged mutation (§4.8).
type AuditEntry struct {
ID string `bson:"_id" json:"id"`
At time.Time `bson:"at" json:"at"`
ActorID string `bson:"actorId" json:"actorId"`
ActorIP string `bson:"actorIp" json:"actorIp"`
Action string `bson:"action" json:"action"`
Entity string `bson:"entity" json:"entity"`
EntityID string `bson:"entityId" json:"entityId"`
Diff map[string]any `bson:"diff,omitempty" json:"diff,omitempty"`
}
// Audit appends to the audit log; failures are returned for logging but must
// never abort the mutation they describe.
func (s *Store) Audit(ctx context.Context, e AuditEntry) error {
e.ID = ulid.New()
e.At = time.Now().UTC()
if _, err := s.DB.Collection("auditLog").InsertOne(ctx, e); err != nil {
return fmt.Errorf("insert audit entry: %w", err)
}
return nil
}
// ListAudit returns newest-first entries with optional filters and ULID
// cursor pagination.
func (s *Store) ListAudit(ctx context.Context, actorID, entityID, cursor string, limit int) ([]AuditEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
filter := bson.M{}
if actorID != "" {
filter["actorId"] = actorID
}
if entityID != "" {
filter["entityId"] = entityID
}
if cursor != "" {
filter["_id"] = bson.M{"$lt": cursor}
}
cur, err := s.DB.Collection("auditLog").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
if err != nil {
return nil, fmt.Errorf("list audit: %w", err)
}
out := []AuditEntry{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode audit: %w", err)
}
return out, nil
}
+106
View File
@@ -0,0 +1,106 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
"bountyboard/internal/ulid"
)
func (s *Store) customers() *mongo.Collection { return s.DB.Collection("customers") }
func (s *Store) CreateCustomer(ctx context.Context, c *domain.Customer) error {
now := time.Now().UTC()
if c.ID == "" {
c.ID = ulid.New()
}
c.CreatedAt, c.UpdatedAt, c.Version = now, now, 1
if c.ConsultantIDs == nil {
c.ConsultantIDs = []string{}
}
if _, err := s.customers().InsertOne(ctx, c); err != nil {
if mongo.IsDuplicateKeyError(err) {
return fmt.Errorf("customer %q: %w", c.Name, ErrDuplicate)
}
return fmt.Errorf("insert customer: %w", err)
}
return nil
}
func (s *Store) CustomerByID(ctx context.Context, id string) (*domain.Customer, error) {
var c domain.Customer
err := s.customers().FindOne(ctx, bson.M{"_id": id}).Decode(&c)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("find customer: %w", err)
}
return &c, nil
}
// ListCustomers returns customers, optionally restricted to a consultant and
// optionally including archived ones, sorted by name.
func (s *Store) ListCustomers(ctx context.Context, consultantID string, includeArchived bool) ([]domain.Customer, error) {
filter := bson.M{}
if !includeArchived {
filter["archived"] = false
}
if consultantID != "" {
filter["consultantIds"] = consultantID
}
cur, err := s.customers().Find(ctx, filter)
if err != nil {
return nil, fmt.Errorf("list customers: %w", err)
}
out := []domain.Customer{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode customers: %w", err)
}
return out, nil
}
// UpdateCustomer applies a version-checked partial update.
func (s *Store) UpdateCustomer(ctx context.Context, id string, version int64, set bson.M) error {
return UpdateVersioned(ctx, s.customers(), id, version, bson.M{"$set": set})
}
func (s *Store) DeleteCustomer(ctx context.Context, id string) error {
res, err := s.customers().DeleteOne(ctx, bson.M{"_id": id})
if err != nil {
return fmt.Errorf("delete customer: %w", err)
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
// SetSyncStatus records the outcome of a sync run (not version-checked:
// background bookkeeping must not race with admin edits).
func (s *Store) SetSyncStatus(ctx context.Context, id string, at time.Time, status, errMsg string) error {
_, err := s.customers().UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{
"ticketing.lastSyncAt": at,
"ticketing.lastSyncStatus": status,
"ticketing.lastSyncError": errMsg,
}})
if err != nil {
return fmt.Errorf("set sync status: %w", err)
}
return nil
}
// CountTasksForCustomer guards customer deletion.
func (s *Store) CountTasksForCustomer(ctx context.Context, customerID string) (int64, error) {
n, err := s.DB.Collection("tasks").CountDocuments(ctx, bson.M{"customerId": customerID})
if err != nil {
return 0, fmt.Errorf("count tasks: %w", err)
}
return n, nil
}
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
)
// GetSettings returns the app settings document, creating defaults on first
// access.
func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) {
var st domain.AppSettings
err := s.DB.Collection("settings").FindOne(ctx, bson.M{"_id": "app"}).Decode(&st)
if errors.Is(err, mongo.ErrNoDocuments) {
st = domain.AppSettings{
ID: "app",
BrandingName: "Bounty Board",
DefaultPollIntervalSec: 60,
DefaultCustomerBudget: 1000,
UpdatedAt: time.Now().UTC(),
Version: 1,
}
if _, err := s.DB.Collection("settings").InsertOne(ctx, st); err != nil &&
!mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("init settings: %w", err)
}
return &st, nil
}
if err != nil {
return nil, fmt.Errorf("get settings: %w", err)
}
return &st, nil
}
// PatchSettings applies a partial settings update (not version-raced in the
// UI; last write wins is acceptable for a single admin page).
func (s *Store) PatchSettings(ctx context.Context, set bson.M) (*domain.AppSettings, error) {
if _, err := s.GetSettings(ctx); err != nil { // ensure the doc exists
return nil, err
}
set["updatedAt"] = time.Now().UTC()
_, err := s.DB.Collection("settings").UpdateOne(ctx, bson.M{"_id": "app"},
bson.M{"$set": set, "$inc": bson.M{"version": 1}})
if err != nil {
return nil, fmt.Errorf("patch settings: %w", err)
}
return s.GetSettings(ctx)
}