Implement alpha foundation workflow and GitHub sync

This commit is contained in:
Meta Vens 2026-04-22 09:50:31 +01:00
commit 3fd655e1f2
37 changed files with 7352 additions and 365 deletions

View file

@ -4,15 +4,20 @@ Bifrost is the planned GameCult member, governance, and labor platform running u
## Status
This repository now includes the first implementation slice of the ASP.NET Core app:
This repository now includes a viable alpha foundation slice of the ASP.NET Core app:
- Razor Pages app scaffold under `src/Bifrost.Web`
- PostgreSQL-backed EF Core domain model for members, projects, work items, motions, and ledgers
- GitHub OAuth wiring plus invite/approval-based membership gating
- initial member console pages for projects, work items, motions, ledger activity, and member approvals
- Razor Pages app under `src/Bifrost.Web`
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
- GitHub OAuth sign-in plus invite/approval-based membership gating
- explicit member roles and admin-managed patron or contributor tier snapshots
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
- member-facing motions with category thresholds aligned to the labor-platform doc
- health and readiness endpoints plus startup config validation
- GitHub App webhook ingestion for issue, pull request, and review sync
- xUnit integration tests under `tests/Bifrost.Web.Tests`
- matching deploy artifacts and runbooks in `E:\Projects\gamecult-ops`
It is not yet ready for production deployment. The current state is suitable for continued development and, after deploy-foundation work, an internal staging rollout.
It is closer to deployable now, but it is still not a finished internet-facing member alpha. The current state is suitable for a private deploy candidate on Yggdrasil while we finish the remaining operational hardening and post-MVP economics work.
## Chosen Stack
@ -39,13 +44,13 @@ It is not yet ready for production deployment. The current state is suitable for
## Next Build Target
The next build target is to deepen the first implementation milestone by adding:
The next build target is to deepen the alpha candidate by adding:
- database migrations and bootstrap seed/run instructions
- GitHub issue and pull request sync
- richer membership/admin role management
- work item completion and ledger approval flow
- payout proposal batch generation and review
- GitHub App installation/admin guidance and live webhook verification against the real repo
- richer workflow polish around work review, blocking, and closure ergonomics
- payout proposal batch generation and tighter ledger approval workflow
- staged deploy validation on Yggdrasil, including backup/restore rehearsal
- post-MVP economics automation for point transactions, decay, and revenue-share calculation
For the full staged roadmap, including contribution points, revenue share, patron or contributor decay, and labor-platform alignment, see [docs/full-implementation-strategy.md](E:\Projects\Bifrost\docs\full-implementation-strategy.md).

View file

@ -12,23 +12,25 @@ It is currently a strong first application slice, not an internet-facing alpha.
### What Exists Already
- ASP.NET Core 8 Razor Pages app scaffold
- PostgreSQL-backed EF Core model
- GitHub OAuth wiring
- invite and approval gate
- ASP.NET Core 8 Razor Pages app
- PostgreSQL-backed EF Core model plus initial migration
- GitHub OAuth wiring and startup config validation
- invite and approval gate with explicit member roles
- member profile editing plus patron or contributor tier snapshots
- project, work item, motion, member, and ledger UI
- basic integration tests
- work logs, work reviews, and completion flow
- GitHub App webhook ingestion for issue, pull request, and review sync
- health and readiness endpoints plus request logging
- deployment artifacts and runbooks in `gamecult-ops`
- integration tests covering console access, health/readiness, and GitHub webhook sync
### What Is Still Missing Before Deployment
- initial EF Core migrations and a repeatable database bootstrap path
- production config validation, secrets handling guidance, and startup failure checks
- GitHub App integration, webhook ingestion, and issue or pull request sync
- health checks, structured logging, and deployment smoke tests
- nginx and systemd deployment artifacts or matching runbooks in `gamecult-ops`
- backup and restore instructions for PostgreSQL and app secrets
- role and permission hardening beyond the initial admin flags
- production-safe ledger workflow, payout proposal generation, and approval gates
- live GitHub App installation validation against the real `GameCult/Bifrost` repository
- deploy rehearsal on Yggdrasil with real secrets and a real PostgreSQL database
- backup and restore rehearsal for PostgreSQL and app secrets
- fuller workflow polish around blockers, archival, and review ergonomics
- production-safe payout proposal batching and finance review workflow
- contribution point rules, patron point rules, decay jobs, and revenue share calculation
## Product Alignment With Labor Platform

View file

@ -5,4 +5,6 @@ public sealed class BootstrapOptions
public const string SectionName = "Bootstrap";
public string[] AdminGitHubLogins { get; init; } = [];
public bool ApplyMigrationsOnStartup { get; init; }
}

View file

@ -0,0 +1,26 @@
namespace Bifrost.Web.Configuration;
public sealed class GitHubAppOptions
{
public const string SectionName = "GitHubApp";
public string AppId { get; init; } = string.Empty;
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
public string PrivateKeyPem { get; init; } = string.Empty;
public string WebhookSecret { get; init; } = string.Empty;
public bool EnableWebhookSync { get; init; } = true;
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(AppId) &&
!string.IsNullOrWhiteSpace(PrivateKeyPem) &&
!string.IsNullOrWhiteSpace(WebhookSecret);
public bool IsWebhookConfigured =>
!string.IsNullOrWhiteSpace(WebhookSecret);
}

View file

@ -0,0 +1,16 @@
namespace Bifrost.Web.Configuration;
public sealed class BifrostHostOptions
{
public const string SectionName = "Host";
public string PublicBaseUrl { get; init; } = string.Empty;
public string ExpectedHost { get; init; } = string.Empty;
public bool RequireStrictHostValidation { get; init; } = true;
public bool IsConfigured =>
Uri.TryCreate(PublicBaseUrl, UriKind.Absolute, out _) &&
!string.IsNullOrWhiteSpace(ExpectedHost);
}

View file

@ -11,6 +11,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<Membership> Memberships => Set<Membership>();
public DbSet<RoleAssignment> RoleAssignments => Set<RoleAssignment>();
public DbSet<TierSnapshot> TierSnapshots => Set<TierSnapshot>();
public DbSet<MembershipInvitation> MembershipInvitations => Set<MembershipInvitation>();
public DbSet<Project> Projects => Set<Project>();
@ -21,6 +25,14 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<Assignment> Assignments => Set<Assignment>();
public DbSet<WorkLog> WorkLogs => Set<WorkLog>();
public DbSet<WorkReview> WorkReviews => Set<WorkReview>();
public DbSet<GitHubIssueLink> GitHubIssueLinks => Set<GitHubIssueLink>();
public DbSet<GitHubPullRequestLink> GitHubPullRequestLinks => Set<GitHubPullRequestLink>();
public DbSet<Motion> Motions => Set<Motion>();
public DbSet<Vote> Votes => Set<Vote>();
@ -29,6 +41,18 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
public DbSet<PointTransaction> PointTransactions => Set<PointTransaction>();
public DbSet<PatronSupportEvent> PatronSupportEvents => Set<PatronSupportEvent>();
public DbSet<DecayRun> DecayRuns => Set<DecayRun>();
public DbSet<RevenueEvent> RevenueEvents => Set<RevenueEvent>();
public DbSet<RevenueShareBatch> RevenueShareBatches => Set<RevenueShareBatch>();
public DbSet<RevenueShareLine> RevenueShareLines => Set<RevenueShareLine>();
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@ -48,6 +72,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
entity.HasOne(x => x.UserAccount)
.WithOne(x => x.MemberProfile)
.HasForeignKey<MemberProfile>(x => x.UserAccountId);
entity.Property(x => x.Nickname).HasMaxLength(120);
entity.Property(x => x.Headline).HasMaxLength(240);
entity.Property(x => x.PortfolioUrl).HasMaxLength(500);
});
modelBuilder.Entity<Membership>(entity =>
@ -57,8 +85,30 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.HasForeignKey<Membership>(x => x.UserAccountId);
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.PatronWeight).HasPrecision(18, 2);
entity.Property(x => x.ContributorWeight).HasPrecision(18, 2);
});
modelBuilder.Entity<RoleAssignment>(entity =>
{
entity.HasIndex(x => new { x.MembershipId, x.Role }).IsUnique();
entity.Property(x => x.Role).HasConversion<string>();
entity.HasOne(x => x.Membership)
.WithMany(x => x.RoleAssignments)
.HasForeignKey(x => x.MembershipId);
entity.HasOne(x => x.AssignedByUserAccount)
.WithMany()
.HasForeignKey(x => x.AssignedByUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<TierSnapshot>(entity =>
{
entity.Property(x => x.Kind).HasConversion<string>();
entity.Property(x => x.Label).HasMaxLength(120);
entity.Property(x => x.Weight).HasPrecision(18, 2);
entity.HasIndex(x => new { x.MembershipId, x.Kind, x.IsCurrent });
entity.HasOne(x => x.Membership)
.WithMany(x => x.TierSnapshots)
.HasForeignKey(x => x.MembershipId);
});
modelBuilder.Entity<MembershipInvitation>(entity =>
@ -79,8 +129,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
modelBuilder.Entity<Project>(entity =>
{
entity.HasIndex(x => x.Slug).IsUnique();
entity.HasIndex(x => x.GitHubRepository);
entity.Property(x => x.Name).HasMaxLength(180);
entity.Property(x => x.Slug).HasMaxLength(120);
entity.Property(x => x.GitHubRepository).HasMaxLength(240);
entity.Property(x => x.Status).HasConversion<string>();
entity.HasOne(x => x.OwnerUserAccount)
.WithMany(x => x.OwnedProjects)
@ -92,9 +144,13 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
{
entity.Property(x => x.SourceType).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ReviewStatus).HasConversion<string>();
entity.Property(x => x.SkillLevel).HasConversion<string>();
entity.Property(x => x.Title).HasMaxLength(180);
entity.Property(x => x.ExternalSourceId).HasMaxLength(120);
entity.Property(x => x.EffortPoints).HasPrecision(18, 2);
entity.Property(x => x.Category).HasMaxLength(120);
entity.Property(x => x.ExternalSourceId).HasMaxLength(240);
entity.Property(x => x.EstimatedHours).HasPrecision(18, 2);
entity.Property(x => x.ContributionPoints).HasPrecision(18, 2);
entity.HasOne(x => x.Project)
.WithMany(x => x.WorkItems)
.HasForeignKey(x => x.ProjectId);
@ -136,9 +192,66 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkLog>(entity =>
{
entity.Property(x => x.Hours).HasPrecision(18, 2);
entity.Property(x => x.ApprovalStatus).HasConversion<string>();
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.WorkLogs)
.HasForeignKey(x => x.WorkItemId);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.WorkLogs)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ReviewedByUserAccount)
.WithMany()
.HasForeignKey(x => x.ReviewedByUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<WorkReview>(entity =>
{
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ReviewerName).HasMaxLength(200);
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.WorkReviews)
.HasForeignKey(x => x.WorkItemId);
entity.HasOne(x => x.ReviewerUserAccount)
.WithMany(x => x.WorkReviews)
.HasForeignKey(x => x.ReviewerUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<GitHubIssueLink>(entity =>
{
entity.HasIndex(x => x.WorkItemId).IsUnique();
entity.HasIndex(x => new { x.RepositoryFullName, x.IssueNumber }).IsUnique();
entity.Property(x => x.RepositoryFullName).HasMaxLength(240);
entity.Property(x => x.State).HasConversion<string>();
entity.Property(x => x.IssueUrl).HasMaxLength(500);
entity.Property(x => x.TitleSnapshot).HasMaxLength(240);
entity.HasOne(x => x.WorkItem)
.WithOne(x => x.GitHubIssueLink)
.HasForeignKey<GitHubIssueLink>(x => x.WorkItemId);
});
modelBuilder.Entity<GitHubPullRequestLink>(entity =>
{
entity.HasIndex(x => new { x.RepositoryFullName, x.PullRequestNumber }).IsUnique();
entity.Property(x => x.RepositoryFullName).HasMaxLength(240);
entity.Property(x => x.State).HasConversion<string>();
entity.Property(x => x.ReviewDecision).HasConversion<string>();
entity.Property(x => x.PullRequestUrl).HasMaxLength(500);
entity.Property(x => x.TitleSnapshot).HasMaxLength(240);
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.GitHubPullRequestLinks)
.HasForeignKey(x => x.WorkItemId);
});
modelBuilder.Entity<Motion>(entity =>
{
entity.Property(x => x.Scope).HasConversion<string>();
entity.Property(x => x.Category).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3);
entity.HasOne(x => x.Project)
@ -198,6 +311,79 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<PointTransaction>(entity =>
{
entity.Property(x => x.Type).HasConversion<string>();
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.PointTransactions)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Project)
.WithMany(x => x.PointTransactions)
.HasForeignKey(x => x.ProjectId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.PointTransactions)
.HasForeignKey(x => x.WorkItemId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<PatronSupportEvent>(entity =>
{
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.Property(x => x.ExternalSupportId).HasMaxLength(240);
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.PatronSupportEvents)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<DecayRun>(entity =>
{
entity.Property(x => x.Rate).HasPrecision(8, 4);
});
modelBuilder.Entity<RevenueEvent>(entity =>
{
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
entity.HasOne(x => x.Project)
.WithMany(x => x.RevenueEvents)
.HasForeignKey(x => x.ProjectId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<RevenueShareBatch>(entity =>
{
entity.HasOne(x => x.RevenueEvent)
.WithMany(x => x.RevenueShareBatches)
.HasForeignKey(x => x.RevenueEventId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.CreatedByUserAccount)
.WithMany()
.HasForeignKey(x => x.CreatedByUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<RevenueShareLine>(entity =>
{
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.Property(x => x.Basis).HasMaxLength(240);
entity.HasOne(x => x.RevenueShareBatch)
.WithMany(x => x.Lines)
.HasForeignKey(x => x.RevenueShareBatchId);
entity.HasOne(x => x.UserAccount)
.WithMany()
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.Project)
.WithMany(x => x.RevenueShareLines)
.HasForeignKey(x => x.ProjectId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<AuditEvent>(entity =>
{
entity.Property(x => x.EntityType).HasMaxLength(120);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,21 @@ public enum MembershipStatus
Suspended
}
public enum MemberRole
{
StandardMember,
Producer,
Maintainer,
LedgerReviewer,
PlatformAdmin
}
public enum TierSnapshotKind
{
Patron,
Contributor
}
public enum ProjectStatus
{
Proposed,
@ -26,15 +41,27 @@ public enum WorkItemSourceType
public enum WorkItemStatus
{
Backlog,
Proposed,
Open,
Claimed,
Volunteered,
Assigned,
InProgress,
Done,
SubmittedForReview,
ChangesRequested,
Approved,
Completed,
Blocked,
Archived
}
public enum WorkItemSkillLevel
{
Routine,
Specialized,
Senior,
Lead
}
public enum VolunteerClaimStatus
{
Active,
@ -42,12 +69,36 @@ public enum VolunteerClaimStatus
Accepted
}
public enum WorkLogApprovalStatus
{
Submitted,
Approved,
Rejected
}
public enum WorkReviewStatus
{
Pending,
ChangesRequested,
Approved
}
public enum MotionScope
{
Management,
Project
}
public enum MotionCategory
{
Bugs,
Cosmetics,
BalanceChanges,
Features,
NewContent,
FundamentalDesignChanges
}
public enum MotionStatus
{
Open,
@ -85,6 +136,30 @@ public enum PayoutProposalBatchStatus
Closed
}
public enum GitHubEntityState
{
Open,
Closed,
Merged
}
public enum GitHubReviewDecision
{
None,
Commented,
ChangesRequested,
Approved
}
public enum PointTransactionType
{
ManualAdjustment,
ApprovedWork,
ContinuousRoleCredit,
PatronSupport,
Decay
}
public sealed class UserAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
@ -117,12 +192,20 @@ public sealed class UserAccount
public ICollection<Assignment> Assignments { get; set; } = [];
public ICollection<WorkLog> WorkLogs { get; set; } = [];
public ICollection<WorkReview> WorkReviews { get; set; } = [];
public ICollection<Motion> CreatedMotions { get; set; } = [];
public ICollection<Vote> Votes { get; set; } = [];
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
public ICollection<PatronSupportEvent> PatronSupportEvents { get; set; } = [];
public ICollection<AuditEvent> AuditEvents { get; set; } = [];
}
@ -132,8 +215,12 @@ public sealed class MemberProfile
public Guid UserAccountId { get; set; }
public string Nickname { get; set; } = string.Empty;
public string Headline { get; set; } = string.Empty;
public string PortfolioUrl { get; set; } = string.Empty;
public string Skills { get; set; } = string.Empty;
public string Availability { get; set; } = string.Empty;
@ -161,20 +248,65 @@ public sealed class Membership
public bool CanModerateMotions { get; set; }
public decimal PatronWeight { get; set; }
public decimal ContributorWeight { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? ApprovedAtUtc { get; set; }
public string Notes { get; set; } = string.Empty;
[NotMapped]
public decimal EffectiveVotingWeight => Math.Max(1m, PatronWeight + ContributorWeight);
public UserAccount UserAccount { get; set; } = null!;
public ICollection<RoleAssignment> RoleAssignments { get; set; } = [];
public ICollection<TierSnapshot> TierSnapshots { get; set; } = [];
[NotMapped]
public decimal EffectiveVotingWeight =>
Status != MembershipStatus.Active
? 0m
: Math.Max(1m, TierSnapshots.Where(x => x.IsCurrent).Sum(x => x.Weight));
}
public sealed class RoleAssignment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MembershipId { get; set; }
public MemberRole Role { get; set; } = MemberRole.StandardMember;
public Guid? AssignedByUserAccountId { get; set; }
public DateTimeOffset AssignedAtUtc { get; set; }
public string Notes { get; set; } = string.Empty;
public Membership Membership { get; set; } = null!;
public UserAccount? AssignedByUserAccount { get; set; }
}
public sealed class TierSnapshot
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MembershipId { get; set; }
public TierSnapshotKind Kind { get; set; }
public string Label { get; set; } = string.Empty;
public decimal Weight { get; set; }
public bool IsCurrent { get; set; } = true;
public DateTimeOffset EffectiveFromUtc { get; set; }
public DateTimeOffset CapturedAtUtc { get; set; }
public string Notes { get; set; } = string.Empty;
public Membership Membership { get; set; } = null!;
}
public sealed class MembershipInvitation
@ -214,6 +346,8 @@ public sealed class Project
public string Summary { get; set; } = string.Empty;
public string GitHubRepository { get; set; } = string.Empty;
public ProjectStatus Status { get; set; } = ProjectStatus.Active;
public DateTimeOffset CreatedAtUtc { get; set; }
@ -227,6 +361,12 @@ public sealed class Project
public ICollection<Motion> Motions { get; set; } = [];
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
public ICollection<RevenueEvent> RevenueEvents { get; set; } = [];
public ICollection<RevenueShareLine> RevenueShareLines { get; set; } = [];
}
public sealed class WorkItem
@ -247,9 +387,23 @@ public sealed class WorkItem
public string Summary { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public WorkItemSkillLevel SkillLevel { get; set; } = WorkItemSkillLevel.Routine;
public WorkItemStatus Status { get; set; } = WorkItemStatus.Open;
public decimal EffortPoints { get; set; }
public WorkReviewStatus ReviewStatus { get; set; } = WorkReviewStatus.Pending;
public decimal EstimatedHours { get; set; }
public decimal ContributionPoints { get; set; }
public DateTimeOffset? TargetDateUtc { get; set; }
public DateTimeOffset? SubmittedAtUtc { get; set; }
public DateTimeOffset? CompletedAtUtc { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
@ -265,7 +419,17 @@ public sealed class WorkItem
public ICollection<Assignment> Assignments { get; set; } = [];
public ICollection<WorkLog> WorkLogs { get; set; } = [];
public ICollection<WorkReview> WorkReviews { get; set; } = [];
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
public GitHubIssueLink? GitHubIssueLink { get; set; }
public ICollection<GitHubPullRequestLink> GitHubPullRequestLinks { get; set; } = [];
}
public sealed class VolunteerClaim
@ -308,6 +472,100 @@ public sealed class Assignment
public UserAccount AssignedByUserAccount { get; set; } = null!;
}
public sealed class WorkLog
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkItemId { get; set; }
public Guid UserAccountId { get; set; }
public decimal Hours { get; set; }
public string Note { get; set; } = string.Empty;
public WorkLogApprovalStatus ApprovalStatus { get; set; } = WorkLogApprovalStatus.Submitted;
public Guid? ReviewedByUserAccountId { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? ReviewedAtUtc { get; set; }
public WorkItem WorkItem { get; set; } = null!;
public UserAccount UserAccount { get; set; } = null!;
public UserAccount? ReviewedByUserAccount { get; set; }
}
public sealed class WorkReview
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkItemId { get; set; }
public Guid? ReviewerUserAccountId { get; set; }
public string ReviewerName { get; set; } = string.Empty;
public WorkReviewStatus Status { get; set; }
public string Note { get; set; } = string.Empty;
public DateTimeOffset ReviewedAtUtc { get; set; }
public WorkItem WorkItem { get; set; } = null!;
public UserAccount? ReviewerUserAccount { get; set; }
}
public sealed class GitHubIssueLink
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkItemId { get; set; }
public string RepositoryFullName { get; set; } = string.Empty;
public int IssueNumber { get; set; }
public GitHubEntityState State { get; set; } = GitHubEntityState.Open;
public string IssueUrl { get; set; } = string.Empty;
public string TitleSnapshot { get; set; } = string.Empty;
public DateTimeOffset LastSynchronizedAtUtc { get; set; }
public WorkItem WorkItem { get; set; } = null!;
}
public sealed class GitHubPullRequestLink
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkItemId { get; set; }
public string RepositoryFullName { get; set; } = string.Empty;
public int PullRequestNumber { get; set; }
public GitHubEntityState State { get; set; } = GitHubEntityState.Open;
public bool IsMerged { get; set; }
public string PullRequestUrl { get; set; } = string.Empty;
public string TitleSnapshot { get; set; } = string.Empty;
public GitHubReviewDecision ReviewDecision { get; set; } = GitHubReviewDecision.None;
public DateTimeOffset LastSynchronizedAtUtc { get; set; }
public WorkItem WorkItem { get; set; } = null!;
}
public sealed class Motion
{
public Guid Id { get; set; } = Guid.NewGuid();
@ -318,13 +576,15 @@ public sealed class Motion
public MotionScope Scope { get; set; } = MotionScope.Project;
public MotionCategory Category { get; set; } = MotionCategory.Features;
public MotionStatus Status { get; set; } = MotionStatus.Open;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public decimal ApprovalThreshold { get; set; } = 0.60m;
public decimal ApprovalThreshold { get; set; } = 0.50m;
public DateTimeOffset OpensAtUtc { get; set; }
@ -332,6 +592,10 @@ public sealed class Motion
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? ResolvedAtUtc { get; set; }
public string ResolutionNote { get; set; } = string.Empty;
public Project? Project { get; set; }
public UserAccount CreatedByUserAccount { get; set; } = null!;
@ -410,6 +674,126 @@ public sealed class PayoutProposalBatch
public UserAccount CreatedByUserAccount { get; set; } = null!;
}
public sealed class PointTransaction
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? WorkItemId { get; set; }
public PointTransactionType Type { get; set; } = PointTransactionType.ManualAdjustment;
public decimal Amount { get; set; }
public bool IsDecaying { get; set; } = true;
public string Note { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; }
public UserAccount UserAccount { get; set; } = null!;
public Project? Project { get; set; }
public WorkItem? WorkItem { get; set; }
}
public sealed class PatronSupportEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public string ExternalSupportId { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string CurrencyCode { get; set; } = "USD";
public DateTimeOffset SupportedAtUtc { get; set; }
public DateTimeOffset RecordedAtUtc { get; set; }
public string Notes { get; set; } = string.Empty;
public UserAccount UserAccount { get; set; } = null!;
}
public sealed class DecayRun
{
public Guid Id { get; set; } = Guid.NewGuid();
public decimal Rate { get; set; }
public string Notes { get; set; } = string.Empty;
public DateTimeOffset RanAtUtc { get; set; }
}
public sealed class RevenueEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? ProjectId { get; set; }
public decimal Amount { get; set; }
public string CurrencyCode { get; set; } = "USD";
public string Notes { get; set; } = string.Empty;
public DateTimeOffset ReceivedAtUtc { get; set; }
public Project? Project { get; set; }
public ICollection<RevenueShareBatch> RevenueShareBatches { get; set; } = [];
}
public sealed class RevenueShareBatch
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? RevenueEventId { get; set; }
public Guid CreatedByUserAccountId { get; set; }
public string Summary { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; }
public RevenueEvent? RevenueEvent { get; set; }
public UserAccount CreatedByUserAccount { get; set; } = null!;
public ICollection<RevenueShareLine> Lines { get; set; } = [];
}
public sealed class RevenueShareLine
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid RevenueShareBatchId { get; set; }
public Guid? UserAccountId { get; set; }
public Guid? ProjectId { get; set; }
public decimal Amount { get; set; }
public string Basis { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public RevenueShareBatch RevenueShareBatch { get; set; } = null!;
public UserAccount? UserAccount { get; set; }
public Project? Project { get; set; }
}
public sealed class AuditEvent
{
public Guid Id { get; set; } = Guid.NewGuid();

View file

@ -0,0 +1,461 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.GitHub;
public sealed class GitHubWebhookService(
BifrostDbContext dbContext,
IOptions<GitHubAppOptions> gitHubAppOptions,
TimeProvider timeProvider,
ILogger<GitHubWebhookService> logger)
{
private static readonly Regex IssueReferenceRegex = new("#(?<number>\\d+)", RegexOptions.Compiled);
public async Task<GitHubWebhookResult> ProcessAsync(
string eventName,
string deliveryId,
string signature,
string payload,
CancellationToken cancellationToken)
{
var options = gitHubAppOptions.Value;
if (!options.EnableWebhookSync)
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, "Webhook sync is disabled.");
}
if (!options.IsWebhookConfigured)
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.NotConfigured, "Webhook secret is not configured.");
}
if (!IsValidSignature(options.WebhookSecret, payload, signature))
{
logger.LogWarning("Rejected GitHub webhook delivery {DeliveryId} because the signature did not match.", deliveryId);
return new GitHubWebhookResult(GitHubWebhookProcessStatus.InvalidSignature, "Invalid signature.");
}
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var result = eventName switch
{
"issues" => await ProcessIssueAsync(root, cancellationToken),
"pull_request" => await ProcessPullRequestAsync(root, cancellationToken),
"pull_request_review" => await ProcessPullRequestReviewAsync(root, cancellationToken),
_ => new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring unsupported event '{eventName}'.")
};
return result;
}
private async Task<GitHubWebhookResult> ProcessIssueAsync(JsonElement root, CancellationToken cancellationToken)
{
var action = ReadString(root, "action");
if (action is not ("opened" or "edited" or "reopened" or "closed"))
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring issue action '{action}'.");
}
var issue = root.GetProperty("issue");
if (issue.TryGetProperty("pull_request", out _))
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, "Ignoring pull-request issue shadow payload.");
}
var repositoryFullName = NormalizeRepositoryName(ReadString(root.GetProperty("repository"), "full_name"));
var issueNumber = issue.GetProperty("number").GetInt32();
var issueState = ParseGitHubEntityState(ReadString(issue, "state"), false);
var issueUrl = ReadString(issue, "html_url");
var title = ReadString(issue, "title");
var body = ReadString(issue, "body");
var senderLogin = ReadString(root.GetProperty("sender"), "login");
var now = timeProvider.GetUtcNow();
var owner = await ResolveFallbackOwnerAsync(senderLogin, cancellationToken);
if (owner is null)
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, "No local member exists yet to own the synced project.");
}
var project = await ResolveProjectAsync(repositoryFullName, owner, now, cancellationToken);
var issueLink = await dbContext.GitHubIssueLinks
.Include(x => x.WorkItem)
.SingleOrDefaultAsync(
x => x.RepositoryFullName == repositoryFullName && x.IssueNumber == issueNumber,
cancellationToken);
var workItem = issueLink?.WorkItem;
if (workItem is null)
{
workItem = new WorkItem
{
ProjectId = project.Id,
RequestedByUserAccountId = owner.Id,
SourceType = WorkItemSourceType.GitHubIssue,
ExternalSourceId = $"{repositoryFullName}#{issueNumber}",
Title = title,
Summary = body,
Category = "GitHub issue",
SkillLevel = WorkItemSkillLevel.Specialized,
Status = issueState == GitHubEntityState.Closed ? WorkItemStatus.Completed : WorkItemStatus.Open,
ReviewStatus = WorkReviewStatus.Pending,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
dbContext.WorkItems.Add(workItem);
issueLink = new GitHubIssueLink
{
WorkItem = workItem,
RepositoryFullName = repositoryFullName,
IssueNumber = issueNumber,
State = issueState,
IssueUrl = issueUrl,
TitleSnapshot = title,
LastSynchronizedAtUtc = now
};
dbContext.GitHubIssueLinks.Add(issueLink);
}
else
{
workItem.ProjectId = project.Id;
workItem.SourceType = WorkItemSourceType.GitHubIssue;
workItem.ExternalSourceId = $"{repositoryFullName}#{issueNumber}";
workItem.Title = title;
workItem.Summary = body;
workItem.UpdatedAtUtc = now;
workItem.Status = issueState == GitHubEntityState.Closed
? WorkItemStatus.Completed
: workItem.Status == WorkItemStatus.Archived
? WorkItemStatus.Archived
: workItem.Status == WorkItemStatus.Completed
? WorkItemStatus.Open
: workItem.Status;
issueLink!.State = issueState;
issueLink.IssueUrl = issueUrl;
issueLink.TitleSnapshot = title;
issueLink.LastSynchronizedAtUtc = now;
}
workItem.CompletedAtUtc = issueState == GitHubEntityState.Closed ? now : null;
dbContext.AuditEvents.Add(CreateAuditEvent(
owner.Id,
nameof(WorkItem),
workItem.Id,
"github.issue.synced",
$"Synced issue {repositoryFullName}#{issueNumber} into work item '{workItem.Title}'."));
await dbContext.SaveChangesAsync(cancellationToken);
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Processed, $"Processed issue {repositoryFullName}#{issueNumber}.");
}
private async Task<GitHubWebhookResult> ProcessPullRequestAsync(JsonElement root, CancellationToken cancellationToken)
{
var action = ReadString(root, "action");
if (action is not ("opened" or "edited" or "reopened" or "synchronize" or "closed" or "ready_for_review"))
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring pull request action '{action}'.");
}
var repositoryFullName = NormalizeRepositoryName(ReadString(root.GetProperty("repository"), "full_name"));
var pullRequest = root.GetProperty("pull_request");
var pullRequestNumber = pullRequest.GetProperty("number").GetInt32();
var pullRequestState = ParseGitHubEntityState(ReadString(pullRequest, "state"), pullRequest.GetProperty("merged").GetBoolean());
var pullRequestUrl = ReadString(pullRequest, "html_url");
var title = ReadString(pullRequest, "title");
var body = ReadString(pullRequest, "body");
var now = timeProvider.GetUtcNow();
var pullRequestLink = await dbContext.GitHubPullRequestLinks
.Include(x => x.WorkItem)
.SingleOrDefaultAsync(
x => x.RepositoryFullName == repositoryFullName && x.PullRequestNumber == pullRequestNumber,
cancellationToken);
var workItem = pullRequestLink?.WorkItem
?? await ResolveWorkItemFromReferencesAsync(repositoryFullName, body, title, cancellationToken);
if (workItem is null)
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"No work item mapping found for pull request {repositoryFullName}#{pullRequestNumber}.");
}
if (pullRequestLink is null)
{
pullRequestLink = new GitHubPullRequestLink
{
WorkItemId = workItem.Id,
RepositoryFullName = repositoryFullName,
PullRequestNumber = pullRequestNumber,
LastSynchronizedAtUtc = now
};
dbContext.GitHubPullRequestLinks.Add(pullRequestLink);
}
pullRequestLink.State = pullRequestState;
pullRequestLink.IsMerged = pullRequestState == GitHubEntityState.Merged;
pullRequestLink.PullRequestUrl = pullRequestUrl;
pullRequestLink.TitleSnapshot = title;
pullRequestLink.LastSynchronizedAtUtc = now;
workItem.UpdatedAtUtc = now;
if (pullRequestState == GitHubEntityState.Merged)
{
workItem.Status = WorkItemStatus.Completed;
workItem.ReviewStatus = WorkReviewStatus.Approved;
workItem.CompletedAtUtc = now;
}
else if (pullRequestState == GitHubEntityState.Closed)
{
workItem.Status = WorkItemStatus.InProgress;
}
else
{
workItem.Status = WorkItemStatus.SubmittedForReview;
workItem.ReviewStatus = WorkReviewStatus.Pending;
workItem.SubmittedAtUtc ??= now;
}
dbContext.AuditEvents.Add(CreateAuditEvent(
workItem.AssignedToUserAccountId ?? workItem.RequestedByUserAccountId,
nameof(WorkItem),
workItem.Id,
"github.pull-request.synced",
$"Synced pull request {repositoryFullName}#{pullRequestNumber} for work item '{workItem.Title}'."));
await dbContext.SaveChangesAsync(cancellationToken);
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Processed, $"Processed pull request {repositoryFullName}#{pullRequestNumber}.");
}
private async Task<GitHubWebhookResult> ProcessPullRequestReviewAsync(JsonElement root, CancellationToken cancellationToken)
{
var action = ReadString(root, "action");
if (action is not ("submitted" or "edited" or "dismissed"))
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring pull request review action '{action}'.");
}
var repositoryFullName = NormalizeRepositoryName(ReadString(root.GetProperty("repository"), "full_name"));
var pullRequest = root.GetProperty("pull_request");
var review = root.GetProperty("review");
var pullRequestNumber = pullRequest.GetProperty("number").GetInt32();
var reviewState = ParseReviewDecision(ReadString(review, "state"));
var reviewerLogin = ReadString(root.GetProperty("sender"), "login");
var reviewBody = ReadString(review, "body");
var now = timeProvider.GetUtcNow();
var pullRequestLink = await dbContext.GitHubPullRequestLinks
.Include(x => x.WorkItem)
.SingleOrDefaultAsync(
x => x.RepositoryFullName == repositoryFullName && x.PullRequestNumber == pullRequestNumber,
cancellationToken);
if (pullRequestLink?.WorkItem is null)
{
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"No work item mapping found for review on {repositoryFullName}#{pullRequestNumber}.");
}
var reviewer = await dbContext.UserAccounts
.SingleOrDefaultAsync(
x => x.NormalizedGitHubLogin == reviewerLogin.Trim().ToUpperInvariant(),
cancellationToken);
pullRequestLink.ReviewDecision = reviewState;
pullRequestLink.LastSynchronizedAtUtc = now;
pullRequestLink.WorkItem.UpdatedAtUtc = now;
pullRequestLink.WorkItem.ReviewStatus = reviewState switch
{
GitHubReviewDecision.Approved => WorkReviewStatus.Approved,
GitHubReviewDecision.ChangesRequested => WorkReviewStatus.ChangesRequested,
_ => WorkReviewStatus.Pending
};
pullRequestLink.WorkItem.Status = reviewState switch
{
GitHubReviewDecision.Approved => WorkItemStatus.Approved,
GitHubReviewDecision.ChangesRequested => WorkItemStatus.ChangesRequested,
_ => WorkItemStatus.SubmittedForReview
};
dbContext.WorkReviews.Add(new WorkReview
{
WorkItemId = pullRequestLink.WorkItemId,
ReviewerUserAccountId = reviewer?.Id,
ReviewerName = reviewer?.DisplayName ?? reviewerLogin,
Status = pullRequestLink.WorkItem.ReviewStatus,
Note = reviewBody,
ReviewedAtUtc = now
});
dbContext.AuditEvents.Add(CreateAuditEvent(
reviewer?.Id,
nameof(WorkItem),
pullRequestLink.WorkItemId,
"github.pull-request.reviewed",
$"Applied GitHub review state {reviewState} to work item '{pullRequestLink.WorkItem.Title}'."));
await dbContext.SaveChangesAsync(cancellationToken);
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Processed, $"Processed review for {repositoryFullName}#{pullRequestNumber}.");
}
private async Task<Project> ResolveProjectAsync(
string repositoryFullName,
UserAccount owner,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var project = await dbContext.Projects
.SingleOrDefaultAsync(x => x.GitHubRepository == repositoryFullName, cancellationToken);
if (project is not null)
{
return project;
}
var repositoryName = repositoryFullName.Split('/').LastOrDefault() ?? repositoryFullName;
var slugBase = SlugGenerator.Create(repositoryName);
var slug = slugBase;
var suffix = 2;
while (await dbContext.Projects.AnyAsync(x => x.Slug == slug, cancellationToken))
{
slug = $"{slugBase}-{suffix++}";
}
project = new Project
{
OwnerUserAccountId = owner.Id,
Slug = slug,
Name = repositoryName,
Summary = $"GitHub-synced project container for {repositoryFullName}.",
GitHubRepository = repositoryFullName,
Status = ProjectStatus.Active,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
dbContext.Projects.Add(project);
return project;
}
private async Task<WorkItem?> ResolveWorkItemFromReferencesAsync(
string repositoryFullName,
string pullRequestBody,
string pullRequestTitle,
CancellationToken cancellationToken)
{
var referencedIssueNumbers = IssueReferenceRegex.Matches($"{pullRequestTitle} {pullRequestBody}")
.Select(match => int.TryParse(match.Groups["number"].Value, out var issueNumber) ? issueNumber : 0)
.Where(issueNumber => issueNumber > 0)
.Distinct()
.ToArray();
if (referencedIssueNumbers.Length == 0)
{
return null;
}
return await dbContext.GitHubIssueLinks
.Include(x => x.WorkItem)
.Where(x => x.RepositoryFullName == repositoryFullName && referencedIssueNumbers.Contains(x.IssueNumber))
.Select(x => x.WorkItem)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<UserAccount?> ResolveFallbackOwnerAsync(string senderLogin, CancellationToken cancellationToken)
{
var normalizedLogin = senderLogin.Trim().ToUpperInvariant();
var matchedUser = await dbContext.UserAccounts
.SingleOrDefaultAsync(x => x.NormalizedGitHubLogin == normalizedLogin, cancellationToken);
if (matchedUser is not null)
{
return matchedUser;
}
return await dbContext.UserAccounts
.Include(x => x.Membership)
.OrderByDescending(x => x.Membership!.IsPlatformAdmin)
.ThenBy(x => x.DisplayName)
.FirstOrDefaultAsync(
x => x.Membership != null && x.Membership.Status == MembershipStatus.Active,
cancellationToken);
}
private static bool IsValidSignature(string secret, string payload, string signature)
{
if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(signature))
{
return false;
}
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expected = $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature));
}
private static GitHubEntityState ParseGitHubEntityState(string state, bool merged) =>
merged
? GitHubEntityState.Merged
: string.Equals(state, "closed", StringComparison.OrdinalIgnoreCase)
? GitHubEntityState.Closed
: GitHubEntityState.Open;
private static GitHubReviewDecision ParseReviewDecision(string state) =>
state.ToLowerInvariant() switch
{
"approved" => GitHubReviewDecision.Approved,
"changes_requested" => GitHubReviewDecision.ChangesRequested,
"commented" => GitHubReviewDecision.Commented,
_ => GitHubReviewDecision.None
};
private static string ReadString(JsonElement element, string propertyName) =>
element.TryGetProperty(propertyName, out var property) && property.ValueKind != JsonValueKind.Null
? property.GetString() ?? string.Empty
: string.Empty;
private static string NormalizeRepositoryName(string repositoryFullName) =>
repositoryFullName.Trim().Trim('/').ToLowerInvariant();
private AuditEvent CreateAuditEvent(
Guid? actorUserAccountId,
string entityType,
Guid entityId,
string action,
string detail) =>
new()
{
ActorUserAccountId = actorUserAccountId,
EntityType = entityType,
EntityId = entityId.ToString(),
Action = action,
Detail = detail,
OccurredAtUtc = timeProvider.GetUtcNow()
};
}
public sealed record GitHubWebhookResult(GitHubWebhookProcessStatus Status, string Message);
public enum GitHubWebhookProcessStatus
{
Processed,
Ignored,
InvalidSignature,
NotConfigured
}

View file

@ -2,6 +2,7 @@ using System.Security.Claims;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -26,7 +27,10 @@ public sealed class MembershipSynchronizationService(
var userAccount = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
.Include(x => x.Membership)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
var invitation = await dbContext.MembershipInvitations
@ -47,6 +51,7 @@ public sealed class MembershipSynchronizationService(
LastSeenAtUtc = now,
MemberProfile = new MemberProfile
{
Nickname = displayName ?? gitHubLogin,
Headline = "New member record",
UpdatedAtUtc = now
},
@ -70,6 +75,7 @@ public sealed class MembershipSynchronizationService(
userAccount.MemberProfile ??= new MemberProfile
{
UserAccountId = userAccount.Id,
Nickname = userAccount.DisplayName,
Headline = "Imported from GitHub sign-in",
UpdatedAtUtc = now
};
@ -86,6 +92,11 @@ public sealed class MembershipSynchronizationService(
}
}
if (string.IsNullOrWhiteSpace(userAccount.MemberProfile!.Nickname))
{
userAccount.MemberProfile.Nickname = userAccount.DisplayName;
}
if (invitation is not null && invitation.AcceptedByUserAccountId is null)
{
invitation.AcceptedByUserAccountId = userAccount.Id;
@ -94,12 +105,16 @@ public sealed class MembershipSynchronizationService(
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{
userAccount.Membership!.Status = MembershipStatus.Active;
userAccount.Membership.IsPlatformAdmin = true;
userAccount.Membership.CanManageProjects = true;
userAccount.Membership.CanManageLedger = true;
userAccount.Membership.CanModerateMotions = true;
userAccount.Membership.ApprovedAtUtc ??= now;
PromoteToBootstrapAdmin(userAccount.Membership!, now);
}
else if (userAccount.Membership!.Status == MembershipStatus.Active)
{
ApplicationBootstrapper.EnsureRole(
userAccount.Membership,
MemberRole.StandardMember,
null,
now,
"Default active member role");
}
userAccount.MemberProfile!.UpdatedAtUtc = now;
@ -107,6 +122,19 @@ public sealed class MembershipSynchronizationService(
await dbContext.SaveChangesAsync(cancellationToken);
}
private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now)
{
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
}
private static bool TryReadGitHubIdentity(
ClaimsPrincipal principal,
out long gitHubUserId,

View file

@ -0,0 +1,30 @@
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Motions;
public static class MotionCategoryPolicy
{
public static decimal GetThreshold(MotionCategory category) =>
category switch
{
MotionCategory.Bugs => 0.15m,
MotionCategory.Cosmetics => 0.30m,
MotionCategory.BalanceChanges => 0.40m,
MotionCategory.Features => 0.50m,
MotionCategory.NewContent => 0.50m,
MotionCategory.FundamentalDesignChanges => 0.66m,
_ => 0.50m
};
public static string GetLabel(MotionCategory category) =>
category switch
{
MotionCategory.Bugs => "Bugs",
MotionCategory.Cosmetics => "Cosmetics",
MotionCategory.BalanceChanges => "Balance changes",
MotionCategory.Features => "Features",
MotionCategory.NewContent => "New content",
MotionCategory.FundamentalDesignChanges => "Fundamental design changes",
_ => category.ToString()
};
}

View file

@ -0,0 +1,81 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
public sealed class ApplicationBootstrapper(
BifrostDbContext dbContext,
IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider,
ILogger<ApplicationBootstrapper> logger)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational() && bootstrapOptions.Value.ApplyMigrationsOnStartup)
{
logger.LogInformation("Applying database migrations on startup.");
await dbContext.Database.MigrateAsync(cancellationToken);
}
var adminLogins = bootstrapOptions.Value.AdminGitHubLogins
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().ToUpperInvariant())
.Distinct()
.ToArray();
if (adminLogins.Length == 0)
{
return;
}
var now = timeProvider.GetUtcNow();
var adminUsers = await dbContext.UserAccounts
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Where(x => adminLogins.Contains(x.NormalizedGitHubLogin))
.ToListAsync(cancellationToken);
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
{
var membership = adminUser.Membership!;
membership.Status = MembershipStatus.Active;
membership.IsPlatformAdmin = true;
membership.CanManageProjects = true;
membership.CanManageLedger = true;
membership.CanModerateMotions = true;
membership.ApprovedAtUtc ??= now;
EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
}
if (dbContext.ChangeTracker.HasChanges())
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
public static void EnsureRole(
Bifrost.Web.Domain.Membership membership,
MemberRole role,
Guid? actorUserAccountId,
DateTimeOffset assignedAtUtc,
string note)
{
if (membership.RoleAssignments.Any(x => x.Role == role))
{
return;
}
membership.RoleAssignments.Add(new RoleAssignment
{
Role = role,
AssignedByUserAccountId = actorUserAccountId,
AssignedAtUtc = assignedAtUtc,
Notes = note
});
}
}

View file

@ -9,25 +9,50 @@ public sealed record CurrentBifrostActor(bool IsAuthenticated, UserAccount? User
public Bifrost.Web.Domain.Membership? Membership => UserAccount?.Membership;
public string DisplayName =>
UserAccount?.DisplayName
UserAccount?.MemberProfile?.Nickname
?? UserAccount?.DisplayName
?? UserAccount?.GitHubLogin
?? "Guest";
public bool IsActiveMember => Membership?.Status == MembershipStatus.Active;
public bool CanManageMembership => Membership?.IsPlatformAdmin == true;
public IReadOnlyCollection<MemberRole> Roles =>
Membership?.RoleAssignments
.Select(x => x.Role)
.Distinct()
.ToArray()
?? [];
public bool IsPlatformAdmin =>
Membership?.IsPlatformAdmin == true ||
Roles.Contains(MemberRole.PlatformAdmin);
public bool CanManageMembership => IsPlatformAdmin;
public bool CanManageProjects =>
Membership?.IsPlatformAdmin == true ||
Membership?.CanManageProjects == true;
IsPlatformAdmin ||
Membership?.CanManageProjects == true ||
Roles.Contains(MemberRole.Producer);
public bool CanAssignWork =>
CanManageProjects ||
Roles.Contains(MemberRole.Maintainer);
public bool CanReviewWork =>
IsPlatformAdmin ||
Roles.Contains(MemberRole.Producer) ||
Roles.Contains(MemberRole.Maintainer);
public bool CanManageLedger =>
Membership?.IsPlatformAdmin == true ||
Membership?.CanManageLedger == true;
IsPlatformAdmin ||
Membership?.CanManageLedger == true ||
Roles.Contains(MemberRole.LedgerReviewer);
public bool CanModerateMotions =>
Membership?.IsPlatformAdmin == true ||
Membership?.CanModerateMotions == true;
IsPlatformAdmin ||
Membership?.CanModerateMotions == true ||
Roles.Contains(MemberRole.Producer) ||
Roles.Contains(MemberRole.Maintainer);
public decimal EffectiveVotingWeight => Membership?.EffectiveVotingWeight ?? 0m;
}

View file

@ -38,7 +38,10 @@ public sealed class CurrentBifrostActorAccessor(
var userAccount = await dbContext.UserAccounts
.AsNoTracking()
.Include(x => x.MemberProfile)
.Include(x => x.Membership)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
return Cache(httpContext, new CurrentBifrostActor(true, userAccount));

View file

@ -8,16 +8,22 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
{
public async Task<DashboardSnapshot> GetAsync(Guid? currentUserAccountId, CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var recentWorkItems = await dbContext.WorkItems
.AsNoTracking()
.Include(x => x.Project)
.Include(x => x.AssignedToUserAccount)
.OrderByDescending(x => x.UpdatedAtUtc)
.Take(5)
.Take(6)
.Select(x => new DashboardWorkItem(
x.Id,
x.Title,
x.Project.Name,
x.AssignedToUserAccount != null ? x.AssignedToUserAccount.DisplayName : "Unassigned",
x.Status.ToString(),
x.Id))
x.EstimatedHours,
x.WorkLogs.Sum(log => log.Hours)))
.ToListAsync(cancellationToken);
var openMotions = await dbContext.Motions
@ -26,12 +32,14 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
.OrderBy(x => x.ClosesAtUtc)
.Take(5)
.Select(x => new DashboardMotion(
x.Id,
x.Title,
x.Scope.ToString(),
x.Category.ToString(),
x.Votes.Sum(v => v.Choice == VoteChoice.For ? v.Weight : 0),
x.Votes.Sum(v => v.Choice == VoteChoice.Against ? v.Weight : 0),
x.ClosesAtUtc,
x.Id))
x.ApprovalThreshold,
x.ClosesAtUtc))
.ToListAsync(cancellationToken);
var outstandingApprovals = await dbContext.Memberships
@ -39,14 +47,58 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
.CountAsync(x => x.Status == MembershipStatus.PendingApproval, cancellationToken);
var assignedToMe = currentUserAccountId is null
? 0
? []
: await dbContext.WorkItems
.AsNoTracking()
.CountAsync(
x => x.AssignedToUserAccountId == currentUserAccountId &&
x.Status != WorkItemStatus.Done &&
x.Status != WorkItemStatus.Archived,
cancellationToken);
.Include(x => x.Project)
.Where(x => x.AssignedToUserAccountId == currentUserAccountId &&
x.Status != WorkItemStatus.Completed &&
x.Status != WorkItemStatus.Archived)
.OrderByDescending(x => x.UpdatedAtUtc)
.Take(5)
.Select(x => new DashboardLaneItem(x.Id, x.Title, x.Project.Name, x.Status.ToString(), x.TargetDateUtc))
.ToListAsync(cancellationToken);
var volunteeredByMe = currentUserAccountId is null
? []
: await dbContext.VolunteerClaims
.AsNoTracking()
.Where(x => x.UserAccountId == currentUserAccountId && x.Status == VolunteerClaimStatus.Active)
.OrderByDescending(x => x.CreatedAtUtc)
.Take(5)
.Select(x => new DashboardLaneItem(
x.WorkItemId,
x.WorkItem.Title,
x.WorkItem.Project.Name,
x.WorkItem.Status.ToString(),
x.WorkItem.TargetDateUtc))
.ToListAsync(cancellationToken);
var submittedByMe = currentUserAccountId is null
? []
: await dbContext.WorkItems
.AsNoTracking()
.Include(x => x.Project)
.Where(x => x.AssignedToUserAccountId == currentUserAccountId &&
(x.Status == WorkItemStatus.SubmittedForReview ||
x.Status == WorkItemStatus.ChangesRequested ||
x.Status == WorkItemStatus.Approved))
.OrderByDescending(x => x.UpdatedAtUtc)
.Take(5)
.Select(x => new DashboardLaneItem(x.Id, x.Title, x.Project.Name, x.Status.ToString(), x.TargetDateUtc))
.ToListAsync(cancellationToken);
var recentActivity = await dbContext.AuditEvents
.AsNoTracking()
.Include(x => x.ActorUserAccount)
.OrderByDescending(x => x.OccurredAtUtc)
.Take(8)
.Select(x => new DashboardActivity(
x.Action,
x.Detail,
x.ActorUserAccount != null ? x.ActorUserAccount.DisplayName : "System",
x.OccurredAtUtc))
.ToListAsync(cancellationToken);
var payoutPreviewNominal = await dbContext.LedgerEntries
.AsNoTracking()
@ -56,13 +108,19 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
return new DashboardSnapshot(
ActiveMembers: await dbContext.Memberships.AsNoTracking().CountAsync(x => x.Status == MembershipStatus.Active, cancellationToken),
OpenProjects: await dbContext.Projects.AsNoTracking().CountAsync(x => x.Status == ProjectStatus.Active, cancellationToken),
OpenWorkItems: await dbContext.WorkItems.AsNoTracking().CountAsync(x => x.Status == WorkItemStatus.Open || x.Status == WorkItemStatus.Claimed || x.Status == WorkItemStatus.InProgress, cancellationToken),
OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= timeProvider.GetUtcNow(), cancellationToken),
OpenWorkItems: await dbContext.WorkItems.AsNoTracking().CountAsync(
x => x.Status != WorkItemStatus.Completed && x.Status != WorkItemStatus.Archived,
cancellationToken),
OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= now, cancellationToken),
PendingMembershipApprovals: outstandingApprovals,
AssignedToCurrentMember: assignedToMe,
AssignedToCurrentMember: assignedToMe.Count,
ApprovedNominalPayoutValue: payoutPreviewNominal,
RecentWorkItems: recentWorkItems,
OpenMotionHighlights: openMotions);
OpenMotionHighlights: openMotions,
MyAssignedWork: assignedToMe,
MyVolunteeredWork: volunteeredByMe,
MySubmittedWork: submittedByMe,
RecentActivity: recentActivity);
}
}
@ -75,18 +133,40 @@ public sealed record DashboardSnapshot(
int AssignedToCurrentMember,
decimal ApprovedNominalPayoutValue,
IReadOnlyList<DashboardWorkItem> RecentWorkItems,
IReadOnlyList<DashboardMotion> OpenMotionHighlights);
IReadOnlyList<DashboardMotion> OpenMotionHighlights,
IReadOnlyList<DashboardLaneItem> MyAssignedWork,
IReadOnlyList<DashboardLaneItem> MyVolunteeredWork,
IReadOnlyList<DashboardLaneItem> MySubmittedWork,
IReadOnlyList<DashboardActivity> RecentActivity);
public sealed record DashboardWorkItem(
Guid WorkItemId,
string Title,
string ProjectName,
string AssigneeName,
string Status,
decimal EstimatedHours,
decimal ActualHours);
public sealed record DashboardMotion(
Guid MotionId,
string Title,
string Scope,
string Category,
decimal VotesFor,
decimal VotesAgainst,
decimal Threshold,
DateTimeOffset ClosesAtUtc);
public sealed record DashboardLaneItem(
Guid WorkItemId,
string Title,
string ProjectName,
string Status,
Guid WorkItemId);
DateTimeOffset? TargetDateUtc);
public sealed record DashboardMotion(
string Title,
string Scope,
decimal VotesFor,
decimal VotesAgainst,
DateTimeOffset ClosesAtUtc,
Guid MotionId);
public sealed record DashboardActivity(
string Action,
string Detail,
string ActorName,
DateTimeOffset OccurredAtUtc);

View file

@ -0,0 +1,60 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
public sealed class ReadinessService(
BifrostDbContext dbContext,
IConfiguration configuration,
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<BifrostHostOptions> hostOptions)
{
public async Task<ReadinessReport> GetReportAsync(CancellationToken cancellationToken)
{
var failures = new List<string>();
if (!gitHubOAuthOptions.Value.IsConfigured)
{
failures.Add("GitHub OAuth is not configured.");
}
if (gitHubAppOptions.Value.EnableWebhookSync && !gitHubAppOptions.Value.IsWebhookConfigured)
{
failures.Add("GitHub App webhook sync is enabled but the webhook secret is missing.");
}
if (!hostOptions.Value.IsConfigured)
{
failures.Add("Host public base URL or expected host is missing.");
}
var connectionString = configuration.GetConnectionString("Bifrost");
if (string.IsNullOrWhiteSpace(connectionString))
{
failures.Add("Connection string 'Bifrost' is missing.");
}
try
{
if (dbContext.Database.IsRelational())
{
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
if (!canConnect)
{
failures.Add("Database connectivity check failed.");
}
}
}
catch (Exception exception)
{
failures.Add($"Database readiness check failed: {exception.Message}");
}
return new ReadinessReport(failures.Count == 0, failures);
}
}
public sealed record ReadinessReport(bool IsReady, IReadOnlyList<string> Failures);

View file

@ -0,0 +1,56 @@
using Bifrost.Web.Configuration;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
public sealed class StartupConfigurationValidator(
IConfiguration configuration,
IHostEnvironment hostEnvironment,
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<BifrostHostOptions> hostOptions)
{
public void Validate()
{
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{
return;
}
var failures = new List<string>();
if (!gitHubOAuthOptions.Value.IsConfigured)
{
failures.Add("GitHub OAuth client configuration is required outside development.");
}
if (gitHubAppOptions.Value.EnableWebhookSync && !gitHubAppOptions.Value.IsConfigured)
{
failures.Add("GitHub App configuration is required when webhook sync is enabled.");
}
if (!hostOptions.Value.IsConfigured)
{
failures.Add("Host:PublicBaseUrl and Host:ExpectedHost must be configured.");
}
var allowedHosts = configuration["AllowedHosts"] ?? string.Empty;
if (hostOptions.Value.RequireStrictHostValidation &&
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*')))
{
failures.Add("AllowedHosts must be explicitly configured for production.");
}
var connectionString = configuration.GetConnectionString("Bifrost");
if (string.IsNullOrWhiteSpace(connectionString))
{
failures.Add("Connection string 'Bifrost' must be configured.");
}
if (failures.Count > 0)
{
throw new InvalidOperationException(
"Bifrost startup configuration is incomplete: " + string.Join(" ", failures));
}
}
}

View file

@ -10,8 +10,8 @@
<span class="eyebrow">Member console</span>
<h1>Operational picture for @Model.Actor.DisplayName</h1>
<p class="panel-intro">
This view ties together the slices we care about first: who can participate,
what work is moving, what motions are open, and what ledger value is accruing.
This is the alpha control room: personal lanes for your work, a shared operational picture,
governance pressure, and recent team activity in one place.
</p>
</div>
<button class="button secondary"
@ -27,24 +27,88 @@
</div>
</section>
<section class="panel-grid">
<section class="panel-grid" style="margin-top: 1.5rem;">
<article class="panel">
<div class="panel-header">
<div>
<h2>Recent work</h2>
<p class="muted">Latest work item movement across projects.</p>
<h2>My assigned work</h2>
<p class="muted">What is currently on your plate.</p>
</div>
<a class="button ghost" asp-page="/App/WorkItems/Index">Manage work items</a>
<a class="button ghost" asp-page="/App/WorkItems/Index">Open work board</a>
</div>
@if (Model.Snapshot.RecentWorkItems.Count == 0)
@if (Model.Snapshot.MyAssignedWork.Count == 0)
{
<div class="empty-state">No work items exist yet.</div>
<div class="empty-state">Nothing is currently assigned to you.</div>
}
else
{
<div class="list-grid">
@foreach (var item in Model.Snapshot.RecentWorkItems)
@foreach (var item in Model.Snapshot.MyAssignedWork)
{
<div class="status-banner">
<strong>@item.Title</strong>
<span class="muted">@item.ProjectName</span>
<div class="inline-actions">
<span class="pill">@item.Status</span>
@if (item.TargetDateUtc is not null)
{
<span class="pill warning">Due @item.TargetDateUtc.Value.ToString("dd MMM")</span>
}
</div>
</div>
}
</div>
}
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>My submitted work</h2>
<p class="muted">Items waiting on review or closure.</p>
</div>
</div>
@if (Model.Snapshot.MySubmittedWork.Count == 0)
{
<div class="empty-state">Nothing of yours is waiting on review.</div>
}
else
{
<div class="list-grid">
@foreach (var item in Model.Snapshot.MySubmittedWork)
{
<div class="status-banner">
<strong>@item.Title</strong>
<span class="muted">@item.ProjectName</span>
<div class="inline-actions">
<span class="pill">@item.Status</span>
</div>
</div>
}
</div>
}
</article>
</section>
<section class="panel-grid" style="margin-top: 1.5rem;">
<article class="panel">
<div class="panel-header">
<div>
<h2>My volunteered work</h2>
<p class="muted">Things you raised your hand for but are not fully assigned yet.</p>
</div>
</div>
@if (Model.Snapshot.MyVolunteeredWork.Count == 0)
{
<div class="empty-state">No active volunteer claims right now.</div>
}
else
{
<div class="list-grid">
@foreach (var item in Model.Snapshot.MyVolunteeredWork)
{
<div class="status-banner">
<strong>@item.Title</strong>
@ -62,7 +126,7 @@
<div class="panel-header">
<div>
<h2>Open motions</h2>
<p class="muted">A quick read on governance traffic and current vote pressure.</p>
<p class="muted">A quick read on governance traffic and threshold pressure.</p>
</div>
<a class="button ghost" asp-page="/App/Motions/Index">Review motions</a>
</div>
@ -80,10 +144,75 @@
<h3>@motion.Title</h3>
<div class="inline-actions">
<span class="pill">@motion.Scope</span>
<span class="pill">@motion.Category</span>
<span class="pill success">For @motion.VotesFor</span>
<span class="pill danger">Against @motion.VotesAgainst</span>
</div>
<p class="muted">Closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC</p>
<p class="muted">
Threshold @motion.Threshold.ToString("P0")
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
</p>
</div>
}
</div>
}
</article>
</section>
<section class="panel-grid" style="margin-top: 1.5rem;">
<article class="panel">
<div class="panel-header">
<div>
<h2>Recent work</h2>
<p class="muted">Latest movement across the shared work board.</p>
</div>
</div>
@if (Model.Snapshot.RecentWorkItems.Count == 0)
{
<div class="empty-state">No work items exist yet.</div>
}
else
{
<div class="list-grid">
@foreach (var item in Model.Snapshot.RecentWorkItems)
{
<div class="status-banner">
<strong>@item.Title</strong>
<span class="muted">@item.ProjectName</span>
<div class="inline-actions">
<span class="pill">@item.Status</span>
<span class="pill">@item.AssigneeName</span>
<span class="pill">@item.ActualHours.ToString("0.##") / @item.EstimatedHours.ToString("0.##")h</span>
</div>
</div>
}
</div>
}
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>Recent activity</h2>
<p class="muted">Audit trail excerpts across membership, work, motions, and ledger changes.</p>
</div>
</div>
@if (Model.Snapshot.RecentActivity.Count == 0)
{
<div class="empty-state">No team activity yet.</div>
}
else
{
<div class="list-grid">
@foreach (var activity in Model.Snapshot.RecentActivity)
{
<div class="status-banner">
<strong>@activity.ActorName</strong>
<span class="muted">@activity.Action</span>
<div class="muted">@activity.Detail</div>
<div class="muted">@activity.OccurredAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
</div>
}
</div>

View file

@ -11,7 +11,7 @@ public sealed class IndexModel(
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public DashboardSnapshot Snapshot { get; private set; } =
new(0, 0, 0, 0, 0, 0, 0m, [], []);
new(0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], []);
public async Task OnGetAsync(CancellationToken cancellationToken)
{

View file

@ -13,58 +13,72 @@
payout review possible later, with explicit status and enough context to audit the trail.
</p>
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.UserAccountId"></label>
<select asp-for="Input.UserAccountId" asp-items="Model.MemberOptions"></select>
<span asp-validation-for="Input.UserAccountId"></span>
<div class="callout">
Post-alpha economics automation is planned separately. The schema now leaves room for point transactions,
patron support events, weekly decay runs, revenue events, and revenue-share batches without making them launch blockers.
</div>
@if (Model.Actor.CanManageLedger)
{
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.UserAccountId"></label>
<select asp-for="Input.UserAccountId" asp-items="Model.MemberOptions"></select>
<span asp-validation-for="Input.UserAccountId"></span>
</div>
<div class="field">
<label asp-for="Input.EntryType"></label>
<select asp-for="Input.EntryType" asp-items="Html.GetEnumSelectList<LedgerEntryType>()"></select>
<span asp-validation-for="Input.EntryType"></span>
</div>
<div class="field">
<label asp-for="Input.Status"></label>
<select asp-for="Input.Status" asp-items="Html.GetEnumSelectList<LedgerEntryStatus>()"></select>
<span asp-validation-for="Input.Status"></span>
</div>
<div class="field">
<label asp-for="Input.ProjectId"></label>
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions">
<option value="">No project</option>
</select>
<span asp-validation-for="Input.ProjectId"></span>
</div>
<div class="field">
<label asp-for="Input.WorkItemId"></label>
<select asp-for="Input.WorkItemId" asp-items="Model.WorkItemOptions">
<option value="">No work item</option>
</select>
<span asp-validation-for="Input.WorkItemId"></span>
</div>
<div class="field">
<label asp-for="Input.Points"></label>
<input asp-for="Input.Points" />
<span asp-validation-for="Input.Points"></span>
</div>
<div class="field">
<label asp-for="Input.NominalAmount"></label>
<input asp-for="Input.NominalAmount" />
<span asp-validation-for="Input.NominalAmount"></span>
</div>
</div>
<div class="field">
<label asp-for="Input.EntryType"></label>
<select asp-for="Input.EntryType" asp-items="Html.GetEnumSelectList<LedgerEntryType>()"></select>
<span asp-validation-for="Input.EntryType"></span>
<label asp-for="Input.Note"></label>
<textarea asp-for="Input.Note"></textarea>
<span asp-validation-for="Input.Note"></span>
</div>
<div class="field">
<label asp-for="Input.Status"></label>
<select asp-for="Input.Status" asp-items="Html.GetEnumSelectList<LedgerEntryStatus>()"></select>
<span asp-validation-for="Input.Status"></span>
</div>
<div class="field">
<label asp-for="Input.ProjectId"></label>
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions">
<option value="">No project</option>
</select>
<span asp-validation-for="Input.ProjectId"></span>
</div>
<div class="field">
<label asp-for="Input.WorkItemId"></label>
<select asp-for="Input.WorkItemId" asp-items="Model.WorkItemOptions">
<option value="">No work item</option>
</select>
<span asp-validation-for="Input.WorkItemId"></span>
</div>
<div class="field">
<label asp-for="Input.Points"></label>
<input asp-for="Input.Points" />
<span asp-validation-for="Input.Points"></span>
</div>
<div class="field">
<label asp-for="Input.NominalAmount"></label>
<input asp-for="Input.NominalAmount" />
<span asp-validation-for="Input.NominalAmount"></span>
<div class="panel-actions">
<button class="button" type="submit">Record entry</button>
</div>
</form>
}
else
{
<div class="callout warning">
Ledger review actions are limited to finance reviewers and platform admins, but the ledger remains visible to all active members.
</div>
<div class="field">
<label asp-for="Input.Note"></label>
<textarea asp-for="Input.Note"></textarea>
<span asp-validation-for="Input.Note"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Record entry</button>
</div>
</form>
}
</article>
<article class="panel">

View file

@ -38,6 +38,10 @@ public sealed class IndexModel(
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageLedger || Actor.UserAccount is null)
{
return Forbid();
}
if (!ModelState.IsValid)
{
@ -51,7 +55,7 @@ public sealed class IndexModel(
UserAccountId = Input.UserAccountId,
ProjectId = Input.ProjectId,
WorkItemId = Input.WorkItemId,
CreatedByUserAccountId = Actor.UserAccount!.Id,
CreatedByUserAccountId = Actor.UserAccount.Id,
EntryType = Input.EntryType,
Status = Input.Status,
Points = Input.Points,

View file

@ -1,4 +1,5 @@
@page
@using Bifrost.Web.Domain
@model Bifrost.Web.Pages.App.Members.IndexModel
@{
ViewData["Title"] = "Members";
@ -37,73 +38,196 @@
else
{
<div class="callout warning">
Only platform admins can issue invites or approve members.
Only platform admins can issue invites, approve members, or manage tier snapshots.
</div>
}
</article>
<article class="table-panel">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Current member records</h2>
<p class="table-meta">@Model.Users.Count authenticated GitHub identities tracked.</p>
</div>
</div>
<article class="panel">
<span class="eyebrow">My profile</span>
<h2>Work allocation context</h2>
<p class="panel-intro">
Keep your profile current so producers and maintainers can see your skills, availability, and links at a glance.
</p>
@if (Model.Users.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No member records yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<form method="post" asp-page-handler="UpdateProfile">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Profile.DisplayName"></label>
<input asp-for="Profile.DisplayName" />
<span asp-validation-for="Profile.DisplayName"></span>
</div>
<div class="field">
<label asp-for="Profile.Nickname"></label>
<input asp-for="Profile.Nickname" />
<span asp-validation-for="Profile.Nickname"></span>
</div>
<div class="field">
<label asp-for="Profile.Headline"></label>
<input asp-for="Profile.Headline" />
<span asp-validation-for="Profile.Headline"></span>
</div>
<div class="field">
<label asp-for="Profile.PortfolioUrl"></label>
<input asp-for="Profile.PortfolioUrl" />
<span asp-validation-for="Profile.PortfolioUrl"></span>
</div>
</div>
<div class="field">
<label asp-for="Profile.Skills"></label>
<textarea asp-for="Profile.Skills"></textarea>
<span asp-validation-for="Profile.Skills"></span>
</div>
<div class="field">
<label asp-for="Profile.Availability"></label>
<input asp-for="Profile.Availability" />
<span asp-validation-for="Profile.Availability"></span>
</div>
<div class="field">
<label asp-for="Profile.Notes"></label>
<textarea asp-for="Profile.Notes"></textarea>
<span asp-validation-for="Profile.Notes"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Update profile</button>
</div>
</form>
</article>
</section>
<section class="table-panel" style="margin-top: 1.5rem;">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Current member records</h2>
<p class="table-meta">@Model.Users.Count authenticated GitHub identities tracked.</p>
</div>
</div>
@if (Model.Users.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No member records yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Account</th>
<th>Status</th>
<th>Profile</th>
<th>Roles and tiers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.Users)
{
<tr>
<th>Account</th>
<th>Status</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.Users)
{
<tr>
<td>
<strong>@user.DisplayName</strong><br />
<span class="muted">@user.GitHubLogin</span>
</td>
<td>
<span class="pill">@user.Membership!.Status</span>
</td>
<td>
@(user.Membership.IsPlatformAdmin ? "Platform admin" : "Member")
<div class="muted">
Projects @(user.Membership.CanManageProjects ? "on" : "off"),
ledger @(user.Membership.CanManageLedger ? "on" : "off")
</div>
</td>
<td>
@if (Model.Actor.CanManageMembership && user.Membership.Status != MembershipStatus.Active)
<td>
<strong>@user.DisplayName</strong><br />
<span class="muted">@user.GitHubLogin</span>
</td>
<td>
<span class="pill">@user.Status</span>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(user.Nickname))
{
<div><strong>@user.Nickname</strong></div>
}
@if (!string.IsNullOrWhiteSpace(user.Headline))
{
<div class="muted">@user.Headline</div>
}
@if (!string.IsNullOrWhiteSpace(user.Skills))
{
<div class="muted">@user.Skills</div>
}
@if (!string.IsNullOrWhiteSpace(user.Availability))
{
<div class="muted">Availability: @user.Availability</div>
}
@if (!string.IsNullOrWhiteSpace(user.PortfolioUrl))
{
<div><a href="@user.PortfolioUrl" target="_blank" rel="noreferrer">Portfolio</a></div>
}
</td>
<td>
<div class="inline-actions">
@if (user.Roles.Count == 0)
{
<form method="post" asp-page-handler="Approve">
<input type="hidden" name="userAccountId" value="@user.Id" />
<button class="button secondary" type="submit">Approve</button>
</form>
<span class="pill">No roles yet</span>
}
else
{
<span class="muted">No action</span>
@foreach (var role in user.Roles)
{
<span class="pill">@role</span>
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</article>
</div>
<div class="muted" style="margin-top: 0.6rem;">
Patron: @(string.IsNullOrWhiteSpace(user.PatronTierLabel) ? "none" : $"{user.PatronTierLabel} ({user.PatronTierWeight:0.##})")
</div>
<div class="muted">
Contributor: @(string.IsNullOrWhiteSpace(user.ContributorTierLabel) ? "none" : $"{user.ContributorTierLabel} ({user.ContributorTierWeight:0.##})")
</div>
</td>
<td>
@if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active)
{
<form method="post" asp-page-handler="Approve">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<button class="button secondary" type="submit">Approve</button>
</form>
}
@if (Model.Actor.CanManageMembership)
{
<div class="stack-form" style="margin-top: 0.8rem;">
@foreach (var role in Enum.GetValues<MemberRole>())
{
<form method="post" asp-page-handler="ToggleRole">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<input type="hidden" name="role" value="@role" />
<button class="button @(user.Roles.Contains(role) ? "warm" : "ghost")" type="submit">
@role
</button>
</form>
}
</div>
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.8rem;">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<input type="hidden" name="kind" value="@TierSnapshotKind.Patron" />
<input name="label" value="@user.PatronTierLabel" placeholder="Patron tier label" />
<input name="weight" value="@user.PatronTierWeight.ToString("0.##")" placeholder="Weight" />
<input name="notes" placeholder="Notes" />
<button class="button ghost" type="submit">Save patron tier</button>
</form>
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.6rem;">
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
<input type="hidden" name="kind" value="@TierSnapshotKind.Contributor" />
<input name="label" value="@user.ContributorTierLabel" placeholder="Contributor tier label" />
<input name="weight" value="@user.ContributorTierWeight.ToString("0.##")" placeholder="Weight" />
<input name="notes" placeholder="Notes" />
<button class="button ghost" type="submit">Save contributor tier</button>
</form>
}
else if (user.Status == MembershipStatus.Active)
{
<span class="muted">Visible to all active members.</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="table-panel" style="margin-top: 1.5rem;">

View file

@ -17,9 +17,12 @@ public sealed class IndexModel(
[BindProperty]
public InviteMemberInput Input { get; set; } = new();
[BindProperty]
public UpdateProfileInput Profile { get; set; } = new();
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<UserAccount> Users { get; private set; } = [];
public IReadOnlyList<MemberDirectoryItem> Users { get; private set; } = [];
public IReadOnlyList<MembershipInvitation> Invitations { get; private set; } = [];
@ -31,7 +34,7 @@ public sealed class IndexModel(
public async Task<IActionResult> OnPostInviteAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageMembership)
if (!Actor.CanManageMembership || Actor.UserAccount is null)
{
return Forbid();
}
@ -52,7 +55,7 @@ public sealed class IndexModel(
{
GitHubLogin = Input.GitHubLogin.Trim(),
NormalizedGitHubLogin = normalizedLogin,
IssuedByUserAccountId = Actor.UserAccount!.Id,
IssuedByUserAccountId = Actor.UserAccount.Id,
IssuedAtUtc = timeProvider.GetUtcNow(),
Notes = Input.Notes.Trim()
};
@ -76,7 +79,7 @@ public sealed class IndexModel(
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount!.Id,
Actor.UserAccount.Id,
nameof(MembershipInvitation),
invitation.Id,
"membership.invited",
@ -89,21 +92,29 @@ public sealed class IndexModel(
public async Task<IActionResult> OnPostApproveAsync(Guid userAccountId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageMembership)
if (!Actor.CanManageMembership || Actor.UserAccount is null)
{
return Forbid();
}
var user = await dbContext.UserAccounts
.Include(x => x.Membership)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
user.Membership!.Status = MembershipStatus.Active;
user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow();
ApplicationBootstrapper.EnsureRole(
user.Membership,
MemberRole.StandardMember,
Actor.UserAccount.Id,
timeProvider.GetUtcNow(),
"Approved into the member alpha.");
ApplyMembershipFlags(user.Membership);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount!.Id,
Actor.UserAccount.Id,
nameof(Membership),
user.Membership.Id,
"membership.approved",
@ -113,21 +124,238 @@ public sealed class IndexModel(
return RedirectToPage();
}
public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageMembership || Actor.UserAccount is null)
{
return Forbid();
}
var user = await dbContext.UserAccounts
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
if (user.Membership is null)
{
return RedirectToPage();
}
var existingAssignment = user.Membership.RoleAssignments.SingleOrDefault(x => x.Role == role);
if (existingAssignment is null)
{
user.Membership.RoleAssignments.Add(new RoleAssignment
{
Role = role,
AssignedByUserAccountId = Actor.UserAccount.Id,
AssignedAtUtc = timeProvider.GetUtcNow(),
Notes = "Assigned from the member admin page."
});
}
else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin))
{
dbContext.RoleAssignments.Remove(existingAssignment);
}
if (user.Membership.Status == MembershipStatus.Active)
{
ApplicationBootstrapper.EnsureRole(
user.Membership,
MemberRole.StandardMember,
Actor.UserAccount.Id,
timeProvider.GetUtcNow(),
"Default active member role.");
}
ApplyMembershipFlags(user.Membership);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(Membership),
user.Membership.Id,
"membership.role-toggled",
$"Toggled role {role} for {user.DisplayName}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostSetTierAsync(
Guid userAccountId,
TierSnapshotKind kind,
string label,
decimal weight,
string notes,
CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageMembership || Actor.UserAccount is null)
{
return Forbid();
}
var user = await dbContext.UserAccounts
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
if (user.Membership is null)
{
return RedirectToPage();
}
var now = timeProvider.GetUtcNow();
foreach (var snapshot in user.Membership.TierSnapshots.Where(x => x.Kind == kind && x.IsCurrent))
{
snapshot.IsCurrent = false;
}
if (!string.IsNullOrWhiteSpace(label) || weight > 0)
{
user.Membership.TierSnapshots.Add(new TierSnapshot
{
Kind = kind,
Label = string.IsNullOrWhiteSpace(label) ? "Tier snapshot" : label.Trim(),
Weight = weight,
IsCurrent = true,
EffectiveFromUtc = now,
CapturedAtUtc = now,
Notes = notes.Trim()
});
}
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(TierSnapshot),
user.Membership.Id,
"membership.tier-updated",
$"Updated {kind} tier snapshot for {user.DisplayName}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostUpdateProfileAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (Actor.UserAccount is null)
{
return Forbid();
}
if (!ModelState.IsValid)
{
await LoadAsync(cancellationToken);
return Page();
}
var user = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
.SingleAsync(x => x.Id == Actor.UserAccount.Id, cancellationToken);
user.DisplayName = Profile.DisplayName.Trim();
user.MemberProfile ??= new MemberProfile
{
UserAccountId = user.Id,
UpdatedAtUtc = timeProvider.GetUtcNow()
};
user.MemberProfile.Nickname = Profile.Nickname.Trim();
user.MemberProfile.Headline = Profile.Headline.Trim();
user.MemberProfile.PortfolioUrl = Profile.PortfolioUrl.Trim();
user.MemberProfile.Skills = Profile.Skills.Trim();
user.MemberProfile.Availability = Profile.Availability.Trim();
user.MemberProfile.Notes = Profile.Notes.Trim();
user.MemberProfile.UpdatedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(MemberProfile),
user.MemberProfile.Id,
"member-profile.updated",
$"Updated member profile for {user.DisplayName}.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
Users = await dbContext.UserAccounts
var users = await dbContext.UserAccounts
.AsNoTracking()
.Include(x => x.Membership)
.Include(x => x.MemberProfile)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.OrderBy(x => x.DisplayName)
.ToListAsync(cancellationToken);
Users = users.Select(user =>
{
var patronTier = user.Membership?.TierSnapshots
.Where(x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent)
.OrderByDescending(x => x.CapturedAtUtc)
.FirstOrDefault();
var contributorTier = user.Membership?.TierSnapshots
.Where(x => x.Kind == TierSnapshotKind.Contributor && x.IsCurrent)
.OrderByDescending(x => x.CapturedAtUtc)
.FirstOrDefault();
return new MemberDirectoryItem(
user.Id,
user.DisplayName,
user.GitHubLogin,
user.MemberProfile?.Nickname ?? string.Empty,
user.MemberProfile?.Headline ?? string.Empty,
user.MemberProfile?.PortfolioUrl ?? string.Empty,
user.MemberProfile?.Skills ?? string.Empty,
user.MemberProfile?.Availability ?? string.Empty,
user.Membership?.Status ?? MembershipStatus.Authenticated,
user.Membership?.RoleAssignments.Select(x => x.Role).OrderBy(x => x.ToString()).ToArray() ?? [],
patronTier?.Label ?? string.Empty,
patronTier?.Weight ?? 0m,
contributorTier?.Label ?? string.Empty,
contributorTier?.Weight ?? 0m);
}).ToList();
Invitations = await dbContext.MembershipInvitations
.AsNoTracking()
.Where(x => x.RevokedAtUtc == null)
.OrderByDescending(x => x.IssuedAtUtc)
.ToListAsync(cancellationToken);
if (Actor.UserAccount is not null)
{
var currentUser = users.SingleOrDefault(x => x.Id == Actor.UserAccount.Id);
if (currentUser is not null)
{
Profile = new UpdateProfileInput
{
DisplayName = currentUser.DisplayName,
Nickname = currentUser.MemberProfile?.Nickname ?? string.Empty,
Headline = currentUser.MemberProfile?.Headline ?? string.Empty,
PortfolioUrl = currentUser.MemberProfile?.PortfolioUrl ?? string.Empty,
Skills = currentUser.MemberProfile?.Skills ?? string.Empty,
Availability = currentUser.MemberProfile?.Availability ?? string.Empty,
Notes = currentUser.MemberProfile?.Notes ?? string.Empty
};
}
}
}
private static void ApplyMembershipFlags(Bifrost.Web.Domain.Membership membership)
{
var roles = membership.RoleAssignments.Select(x => x.Role).ToHashSet();
membership.IsPlatformAdmin = roles.Contains(MemberRole.PlatformAdmin);
membership.CanManageProjects = membership.IsPlatformAdmin || roles.Contains(MemberRole.Producer);
membership.CanManageLedger = membership.IsPlatformAdmin || roles.Contains(MemberRole.LedgerReviewer);
membership.CanModerateMotions = membership.IsPlatformAdmin || roles.Contains(MemberRole.Producer) || roles.Contains(MemberRole.Maintainer);
}
public sealed class InviteMemberInput
@ -139,4 +367,46 @@ public sealed class IndexModel(
[StringLength(500)]
public string Notes { get; set; } = string.Empty;
}
public sealed class UpdateProfileInput
{
[Required, StringLength(200)]
[Display(Name = "Display name")]
public string DisplayName { get; set; } = string.Empty;
[StringLength(120)]
public string Nickname { get; set; } = string.Empty;
[StringLength(240)]
public string Headline { get; set; } = string.Empty;
[Display(Name = "Portfolio URL")]
[StringLength(500)]
public string PortfolioUrl { get; set; } = string.Empty;
[StringLength(2000)]
public string Skills { get; set; } = string.Empty;
[StringLength(500)]
public string Availability { get; set; } = string.Empty;
[StringLength(2000)]
public string Notes { get; set; } = string.Empty;
}
public sealed record MemberDirectoryItem(
Guid UserAccountId,
string DisplayName,
string GitHubLogin,
string Nickname,
string Headline,
string PortfolioUrl,
string Skills,
string Availability,
MembershipStatus Status,
IReadOnlyList<MemberRole> Roles,
string PatronTierLabel,
decimal PatronTierWeight,
string ContributorTierLabel,
decimal ContributorTierWeight);
}

View file

@ -1,4 +1,5 @@
@page
@using Bifrost.Web.Domain
@model Bifrost.Web.Pages.App.Motions.IndexModel
@{
ViewData["Title"] = "Motions";
@ -9,8 +10,8 @@
<span class="eyebrow">Governance</span>
<h1>Open motions and voting</h1>
<p class="panel-intro">
Motions stay app-native here: no token theater, just explicit thresholds,
weighted votes, and an auditable trail of who changed what.
Motions stay app-native here: no token theater, just explicit thresholds from the labor platform,
weighted votes from current tier snapshots, and an auditable trail of who changed what.
</p>
<form method="post" asp-page-handler="Create">
@ -28,6 +29,11 @@
</select>
<span asp-validation-for="Input.ProjectId"></span>
</div>
<div class="field">
<label asp-for="Input.Category"></label>
<select asp-for="Input.Category" asp-items="Html.GetEnumSelectList<MotionCategory>()"></select>
<span asp-validation-for="Input.Category"></span>
</div>
<div class="field">
<label asp-for="Input.ClosesAtUtc"></label>
<input asp-for="Input.ClosesAtUtc" />
@ -54,54 +60,94 @@
<div class="panel-header">
<div>
<h2>Vote ledger</h2>
<p class="muted">Your current effective weight is @Model.Actor.EffectiveVotingWeight.</p>
<p class="muted">Your current effective vote weight is @Model.Actor.EffectiveVotingWeight.ToString("0.##").</p>
</div>
</div>
@if (Model.Motions.Count == 0)
{
<div class="empty-state">No motions yet.</div>
}
else
{
<div class="motion-grid">
@foreach (var motion in Model.Motions)
{
<article class="motion-card">
<div class="inline-actions">
<span class="pill">@motion.Scope</span>
<span class="pill @motion.StatusTone">@motion.StatusLabel</span>
</div>
<h3>@motion.Title</h3>
<p class="muted">@motion.Summary</p>
<p class="muted">
@motion.ProjectName
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
</p>
<div class="inline-actions">
<span class="pill success">For @motion.VotesFor</span>
<span class="pill danger">Against @motion.VotesAgainst</span>
<span class="pill">Abstain @motion.VotesAbstain</span>
</div>
<div class="vote-strip">
@foreach (var choice in Enum.GetValues<VoteChoice>())
{
<form method="post" asp-page-handler="Vote">
<input type="hidden" name="motionId" value="@motion.Id" />
<input type="hidden" name="choice" value="@choice" />
<button class="button @(motion.CurrentUserVote == choice ? "warm" : "secondary")" type="submit">
Vote @choice
</button>
</form>
}
</div>
</article>
}
<div class="list-grid">
<div class="status-banner">
<strong>Bugs</strong>
<span class="muted">15% threshold</span>
</div>
}
<div class="status-banner">
<strong>Cosmetics</strong>
<span class="muted">30% threshold</span>
</div>
<div class="status-banner">
<strong>Balance changes</strong>
<span class="muted">40% threshold</span>
</div>
<div class="status-banner">
<strong>Features and new content</strong>
<span class="muted">50% threshold</span>
</div>
<div class="status-banner">
<strong>Fundamental design changes</strong>
<span class="muted">66% threshold</span>
</div>
</div>
</article>
</section>
<section class="panel" style="margin-top: 1.5rem;">
@if (Model.Motions.Count == 0)
{
<div class="empty-state">No motions yet.</div>
}
else
{
<div class="motion-grid">
@foreach (var motion in Model.Motions)
{
<article class="motion-card">
<div class="inline-actions">
<span class="pill">@motion.Scope</span>
<span class="pill">@motion.Category</span>
<span class="pill @motion.StatusTone">@motion.StatusLabel</span>
</div>
<h3>@motion.Title</h3>
<p class="muted">@motion.Summary</p>
<p class="muted">
@motion.ProjectName
· threshold @motion.Threshold.ToString("P0")
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
</p>
<div class="inline-actions">
<span class="pill success">For @motion.VotesFor</span>
<span class="pill danger">Against @motion.VotesAgainst</span>
<span class="pill">Abstain @motion.VotesAbstain</span>
</div>
@if (!string.IsNullOrWhiteSpace(motion.ResolutionNote))
{
<p class="muted" style="margin-top: 0.8rem;">@motion.ResolutionNote</p>
}
<div class="vote-strip">
@foreach (var choice in Enum.GetValues<VoteChoice>())
{
<form method="post" asp-page-handler="Vote">
<input type="hidden" name="motionId" value="@motion.Id" />
<input type="hidden" name="choice" value="@choice" />
<input type="hidden" name="comment" value="" />
<button class="button @(motion.CurrentUserVote == choice ? "warm" : "secondary")" type="submit">
Vote @choice
</button>
</form>
}
@if (Model.Actor.CanModerateMotions || motion.StatusLabel == "Expired")
{
<form method="post" asp-page-handler="Close">
<input type="hidden" name="motionId" value="@motion.Id" />
<button class="button ghost" type="submit">Close motion</button>
</form>
}
</div>
</article>
}
</div>
}
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Motions;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
@ -51,11 +52,13 @@ public sealed class IndexModel(
var now = timeProvider.GetUtcNow();
var motion = new Motion
{
ProjectId = Input.ProjectId,
ProjectId = Input.Scope == MotionScope.Project ? Input.ProjectId : null,
CreatedByUserAccountId = actorUser.Id,
Scope = Input.Scope,
Category = Input.Category,
Title = Input.Title.Trim(),
Summary = Input.Summary.Trim(),
ApprovalThreshold = MotionCategoryPolicy.GetThreshold(Input.Category),
OpensAtUtc = now,
ClosesAtUtc = Input.ClosesAtUtc.ToUniversalTime(),
CreatedAtUtc = now
@ -74,7 +77,7 @@ public sealed class IndexModel(
return RedirectToPage();
}
public async Task<IActionResult> OnPostVoteAsync(Guid motionId, VoteChoice choice, CancellationToken cancellationToken)
public async Task<IActionResult> OnPostVoteAsync(Guid motionId, VoteChoice choice, string comment, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
@ -102,6 +105,7 @@ public sealed class IndexModel(
UserAccountId = actorUser.Id,
Choice = choice,
Weight = Actor.EffectiveVotingWeight,
Comment = comment?.Trim() ?? string.Empty,
CastAtUtc = timeProvider.GetUtcNow()
});
}
@ -109,6 +113,7 @@ public sealed class IndexModel(
{
vote.Choice = choice;
vote.Weight = Actor.EffectiveVotingWeight;
vote.Comment = comment?.Trim() ?? string.Empty;
vote.CastAtUtc = timeProvider.GetUtcNow();
}
@ -124,6 +129,50 @@ public sealed class IndexModel(
return RedirectToPage();
}
public async Task<IActionResult> OnPostCloseAsync(Guid motionId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
}
var motion = await dbContext.Motions
.Include(x => x.Votes)
.SingleAsync(x => x.Id == motionId, cancellationToken);
if (!Actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id)
{
return Forbid();
}
var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight);
var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight);
var decisiveVotes = votesFor + votesAgainst;
motion.Status = decisiveVotes > 0 && votesFor / decisiveVotes >= motion.ApprovalThreshold
? MotionStatus.Passed
: decisiveVotes > 0
? MotionStatus.Failed
: MotionStatus.Closed;
motion.ResolvedAtUtc = timeProvider.GetUtcNow();
motion.ResolutionNote = decisiveVotes > 0
? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold."
: "Closed without decisive votes.";
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(Motion),
motion.Id,
"motion.closed",
$"Closed motion {motion.Title} with status {motion.Status}.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
@ -147,13 +196,16 @@ public sealed class IndexModel(
x.Summary,
x.Project?.Name ?? "Management-wide",
x.Scope.ToString(),
MotionCategoryPolicy.GetLabel(x.Category),
x.ApprovalThreshold,
ResolveStatusLabel(x, timeProvider.GetUtcNow()),
ResolveStatusTone(x, timeProvider.GetUtcNow()),
x.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight),
x.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight),
x.Votes.Where(v => v.Choice == VoteChoice.Abstain).Sum(v => v.Weight),
x.Votes.Where(v => v.UserAccountId == Actor.UserAccount?.Id).Select(v => (VoteChoice?)v.Choice).FirstOrDefault(),
x.ClosesAtUtc)).ToList();
x.ClosesAtUtc,
x.ResolutionNote)).ToList();
}
private static string ResolveStatusLabel(Motion motion, DateTimeOffset now)
@ -170,13 +222,13 @@ public sealed class IndexModel(
var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight);
var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight);
var total = votesFor + votesAgainst;
if (total <= 0)
var decisiveVotes = votesFor + votesAgainst;
if (decisiveVotes <= 0)
{
return "Closed";
return "Expired";
}
return votesFor / total >= motion.ApprovalThreshold ? "Passed" : "Failed";
return votesFor / decisiveVotes >= motion.ApprovalThreshold ? "Passed" : "Failed";
}
private static string ResolveStatusTone(Motion motion, DateTimeOffset now) =>
@ -184,8 +236,8 @@ public sealed class IndexModel(
{
"Passed" => "success",
"Failed" => "danger",
"Open" => string.Empty,
_ => "warning"
"Expired" => "warning",
_ => string.Empty
};
public sealed class CreateMotionInput
@ -196,6 +248,9 @@ public sealed class IndexModel(
[Display(Name = "Project")]
public Guid? ProjectId { get; set; }
[Display(Name = "Category")]
public MotionCategory Category { get; set; } = MotionCategory.Features;
[Required, StringLength(180)]
public string Title { get; set; } = string.Empty;
@ -212,11 +267,14 @@ public sealed class IndexModel(
string Summary,
string ProjectName,
string Scope,
string Category,
decimal Threshold,
string StatusLabel,
string StatusTone,
decimal VotesFor,
decimal VotesAgainst,
decimal VotesAbstain,
VoteChoice? CurrentUserVote,
DateTimeOffset ClosesAtUtc);
DateTimeOffset ClosesAtUtc,
string ResolutionNote);
}

View file

@ -13,30 +13,45 @@
Keeping them explicit is what lets the rest of the system stay legible.
</p>
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.Name"></label>
<input asp-for="Input.Name" />
<span asp-validation-for="Input.Name"></span>
@if (Model.Actor.CanManageProjects)
{
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.Name"></label>
<input asp-for="Input.Name" />
<span asp-validation-for="Input.Name"></span>
</div>
<div class="field">
<label asp-for="Input.Slug"></label>
<input asp-for="Input.Slug" />
<span class="hint">Leave blank to derive from the name.</span>
<span asp-validation-for="Input.Slug"></span>
</div>
<div class="field">
<label asp-for="Input.GitHubRepository"></label>
<input asp-for="Input.GitHubRepository" />
<span class="hint">Use <code>owner/repo</code> when the project should sync GitHub issues and PRs.</span>
<span asp-validation-for="Input.GitHubRepository"></span>
</div>
</div>
<div class="field">
<label asp-for="Input.Slug"></label>
<input asp-for="Input.Slug" />
<span class="hint">Leave blank to derive from the name.</span>
<span asp-validation-for="Input.Slug"></span>
<label asp-for="Input.Summary"></label>
<textarea asp-for="Input.Summary"></textarea>
<span asp-validation-for="Input.Summary"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Create project</button>
</div>
</form>
}
else
{
<div class="callout warning">
Producers and platform admins can create or map project containers.
</div>
<div class="field">
<label asp-for="Input.Summary"></label>
<textarea asp-for="Input.Summary"></textarea>
<span asp-validation-for="Input.Summary"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Create project</button>
</div>
</form>
}
</article>
<article class="table-panel">
@ -60,6 +75,7 @@
<th>Name</th>
<th>Status</th>
<th>Owner</th>
<th>Repository</th>
<th>Updated</th>
</tr>
</thead>
@ -71,9 +87,20 @@
<strong>@project.Name</strong><br />
<span class="muted">@project.Slug</span>
<p class="muted">@project.Summary</p>
<div class="muted">@project.OpenWorkItems open work items</div>
</td>
<td><span class="pill">@project.Status</span></td>
<td>@project.OwnerUserAccount.DisplayName</td>
<td>@project.OwnerName</td>
<td>
@if (string.IsNullOrWhiteSpace(project.GitHubRepository))
{
<span class="muted">Internal only</span>
}
else
{
<code>@project.GitHubRepository</code>
}
</td>
<td>@project.UpdatedAtUtc.ToString("dd MMM yyyy")</td>
</tr>
}

View file

@ -19,7 +19,7 @@ public sealed class IndexModel(
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<Project> Projects { get; private set; } = [];
public IReadOnlyList<ProjectListItem> Projects { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
@ -29,6 +29,10 @@ public sealed class IndexModel(
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageProjects || Actor.UserAccount is null)
{
return Forbid();
}
if (!ModelState.IsValid)
{
@ -45,13 +49,15 @@ public sealed class IndexModel(
return Page();
}
var repository = NormalizeRepository(Input.GitHubRepository);
var now = timeProvider.GetUtcNow();
var project = new Project
{
OwnerUserAccountId = Actor.UserAccount!.Id,
OwnerUserAccountId = Actor.UserAccount.Id,
Slug = slug,
Name = Input.Name.Trim(),
Summary = Input.Summary.Trim(),
GitHubRepository = repository,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
@ -77,9 +83,36 @@ public sealed class IndexModel(
.AsNoTracking()
.Include(x => x.OwnerUserAccount)
.OrderBy(x => x.Name)
.Select(x => new ProjectListItem(
x.Id,
x.Name,
x.Slug,
x.Summary,
x.Status.ToString(),
x.OwnerUserAccount.DisplayName,
x.GitHubRepository,
x.WorkItems.Count(workItem => workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived),
x.UpdatedAtUtc))
.ToListAsync(cancellationToken);
}
private static string NormalizeRepository(string input)
{
var trimmed = input.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return string.Empty;
}
const string prefix = "https://github.com/";
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[prefix.Length..];
}
return trimmed.Trim('/').ToLowerInvariant();
}
public sealed class CreateProjectInput
{
[Required, StringLength(180)]
@ -88,7 +121,22 @@ public sealed class IndexModel(
[StringLength(120)]
public string Slug { get; set; } = string.Empty;
[StringLength(240)]
[Display(Name = "GitHub repository")]
public string GitHubRepository { get; set; } = string.Empty;
[Required, StringLength(2000)]
public string Summary { get; set; } = string.Empty;
}
public sealed record ProjectListItem(
Guid Id,
string Name,
string Slug,
string Summary,
string Status,
string OwnerName,
string GitHubRepository,
int OpenWorkItems,
DateTimeOffset UpdatedAtUtc);
}

View file

@ -1,4 +1,5 @@
@page
@using Bifrost.Web.Domain
@model Bifrost.Web.Pages.App.WorkItems.IndexModel
@{
ViewData["Title"] = "Work items";
@ -9,8 +10,8 @@
<span class="eyebrow">Work items</span>
<h1>Internal and GitHub-backed work</h1>
<p class="panel-intro">
The goal here is one shared list with enough structure to support volunteering,
assignment, and later ledger attribution without turning task intake into ceremony.
This is the shared queue for internal operations, GitHub issues, assignment, actual hours, and review.
Everyone can see the board; mutation stays role-gated where it needs to.
</p>
<form method="post" asp-page-handler="Create">
@ -27,9 +28,29 @@
<span asp-validation-for="Input.Title"></span>
</div>
<div class="field">
<label asp-for="Input.EffortPoints"></label>
<input asp-for="Input.EffortPoints" />
<span asp-validation-for="Input.EffortPoints"></span>
<label asp-for="Input.Category"></label>
<input asp-for="Input.Category" />
<span asp-validation-for="Input.Category"></span>
</div>
<div class="field">
<label asp-for="Input.SkillLevel"></label>
<select asp-for="Input.SkillLevel" asp-items="Html.GetEnumSelectList<WorkItemSkillLevel>()"></select>
<span asp-validation-for="Input.SkillLevel"></span>
</div>
<div class="field">
<label asp-for="Input.EstimatedHours"></label>
<input asp-for="Input.EstimatedHours" />
<span asp-validation-for="Input.EstimatedHours"></span>
</div>
<div class="field">
<label asp-for="Input.ContributionPoints"></label>
<input asp-for="Input.ContributionPoints" />
<span asp-validation-for="Input.ContributionPoints"></span>
</div>
<div class="field">
<label asp-for="Input.TargetDateUtc"></label>
<input asp-for="Input.TargetDateUtc" />
<span asp-validation-for="Input.TargetDateUtc"></span>
</div>
</div>
<div class="field">
@ -43,96 +64,232 @@
</form>
</article>
<article class="table-panel">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Queue</h2>
<p class="table-meta">@Model.WorkItems.Count tracked work items.</p>
<article class="panel">
<span class="eyebrow">Workflow</span>
<h2>How this MVP flows</h2>
<div class="list-grid">
<div class="status-banner">
<strong>States</strong>
<span class="muted">Proposed, open, volunteered, assigned, in progress, submitted for review, changes requested, approved, completed, blocked, archived.</span>
</div>
<div class="status-banner">
<strong>Time tracking</strong>
<span class="muted">Members log actual hours per item. Producers and maintainers approve or reject those logs.</span>
</div>
<div class="status-banner">
<strong>GitHub sync</strong>
<span class="muted">Issues and pull requests show up here when the GitHub App webhook is configured.</span>
</div>
</div>
</article>
</section>
<section class="table-panel" style="margin-top: 1.5rem;">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Shared work board</h2>
<p class="table-meta">@Model.WorkItems.Count tracked work items.</p>
</div>
</div>
@if (Model.WorkItems.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No work items yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Work</th>
<th>Workflow</th>
<th>Time and points</th>
<th>GitHub</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var workItem in Model.WorkItems)
{
var hasClaim = workItem.VolunteerClaims.Any(x => x.UserAccountId == Model.Actor.UserAccount?.Id && x.Status == VolunteerClaimStatus.Active);
var canWorkOnItem =
workItem.AssignedToUserAccountId == Model.Actor.UserAccount?.Id ||
workItem.RequestedByUserAccountId == Model.Actor.UserAccount?.Id ||
hasClaim;
var pendingLogs = workItem.WorkLogs.Where(x => x.ApprovalStatus == WorkLogApprovalStatus.Submitted).ToList();
@if (Model.WorkItems.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No work items yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Work</th>
<th>Status</th>
<th>Claims</th>
<th>Assignment</th>
</tr>
</thead>
<tbody>
@foreach (var workItem in Model.WorkItems)
{
var hasClaim = workItem.VolunteerClaims.Any(x => x.UserAccountId == Model.Actor.UserAccount?.Id && x.Status == VolunteerClaimStatus.Active);
<tr>
<td>
<strong>@workItem.Title</strong><br />
<span class="muted">@workItem.Project.Name</span>
<p class="muted">@workItem.Summary</p>
</td>
<td>
<span class="pill">@workItem.Status</span><br />
<span class="muted">@workItem.EffortPoints.ToString("0.##") pts</span>
</td>
<td>
@if (workItem.VolunteerClaims.Count == 0)
{
<span class="muted">No claims yet.</span>
}
else
{
@foreach (var claim in workItem.VolunteerClaims)
<td>
<strong>@workItem.Title</strong><br />
<span class="muted">@workItem.Project.Name</span>
<div class="inline-actions" style="margin-top: 0.4rem;">
<span class="pill">@workItem.SourceType</span>
<span class="pill">@workItem.Category</span>
<span class="pill">@workItem.SkillLevel</span>
</div>
<p class="muted">@workItem.Summary</p>
</td>
<td>
<span class="pill">@workItem.Status</span>
<div class="muted" style="margin-top: 0.5rem;">Review: @workItem.ReviewStatus</div>
<div class="muted">
Assignee:
@(workItem.AssignedToUserAccount is null ? "Unassigned" : workItem.AssignedToUserAccount.DisplayName)
</div>
<div class="muted">
Target:
@(workItem.TargetDateUtc is null ? "None" : workItem.TargetDateUtc.Value.ToString("dd MMM yyyy HH:mm") + " UTC")
</div>
@if (workItem.VolunteerClaims.Count > 0)
{
<div class="muted" style="margin-top: 0.4rem;">
Volunteers:
@string.Join(", ", workItem.VolunteerClaims.Select(x => x.UserAccount.DisplayName))
</div>
}
</td>
<td>
<div>@workItem.EstimatedHours.ToString("0.##")h estimated</div>
<div>@workItem.WorkLogs.Sum(x => x.Hours).ToString("0.##")h logged</div>
<div>@workItem.ContributionPoints.ToString("0.##") contribution points</div>
@if (workItem.WorkLogs.Count > 0)
{
<div class="muted" style="margin-top: 0.6rem;">
@foreach (var log in workItem.WorkLogs.OrderByDescending(x => x.CreatedAtUtc).Take(3))
{
<div>@claim.UserAccount.DisplayName <span class="muted">(@claim.Status)</span></div>
<div>@log.UserAccount.DisplayName: @log.Hours.ToString("0.##")h (@log.ApprovalStatus)</div>
}
</div>
}
</td>
<td>
@if (workItem.GitHubIssueLink is null && workItem.GitHubPullRequestLinks.Count == 0)
{
<span class="muted">Internal-only</span>
}
else
{
@if (workItem.GitHubIssueLink is not null)
{
<div>
<a href="@workItem.GitHubIssueLink.IssueUrl" target="_blank" rel="noreferrer">
Issue #@workItem.GitHubIssueLink.IssueNumber
</a>
<span class="muted">(@workItem.GitHubIssueLink.State)</span>
</div>
}
@if (!hasClaim)
@foreach (var pr in workItem.GitHubPullRequestLinks.OrderByDescending(x => x.LastSynchronizedAtUtc))
{
<div style="margin-top: 0.35rem;">
<a href="@pr.PullRequestUrl" target="_blank" rel="noreferrer">
PR #@pr.PullRequestNumber
</a>
<span class="muted">(@pr.State, @pr.ReviewDecision)</span>
</div>
}
}
</td>
<td>
<div class="stack-form">
@if (!hasClaim && workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived)
{
<form method="post" asp-page-handler="Volunteer">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<button class="button ghost" type="submit">Volunteer</button>
</form>
}
</td>
<td>
@if (workItem.AssignedToUserAccount is not null)
{
<strong>@workItem.AssignedToUserAccount.DisplayName</strong>
}
else
{
<span class="muted">Unassigned</span>
}
@if (Model.Actor.CanManageProjects && workItem.VolunteerClaims.Any(x => x.Status == VolunteerClaimStatus.Active))
@if (Model.Actor.CanAssignWork)
{
<form method="post" asp-page-handler="Assign" class="inline-actions">
<form method="post" asp-page-handler="Assign">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<select name="userAccountId">
@foreach (var claim in workItem.VolunteerClaims.Where(x => x.Status == VolunteerClaimStatus.Active))
@foreach (var member in Model.MemberOptions)
{
<option value="@claim.UserAccountId">@claim.UserAccount.DisplayName</option>
if (member.Value == workItem.AssignedToUserAccountId?.ToString())
{
<option value="@member.Value" selected="selected">@member.Text</option>
}
else
{
<option value="@member.Value">@member.Text</option>
}
}
</select>
<button class="button secondary" type="submit">Assign</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</article>
@if (canWorkOnItem && workItem.Status is WorkItemStatus.Assigned or WorkItemStatus.Volunteered or WorkItemStatus.Proposed or WorkItemStatus.Open or WorkItemStatus.ChangesRequested)
{
<form method="post" asp-page-handler="Start">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<button class="button secondary" type="submit">Start</button>
</form>
}
@if (canWorkOnItem && workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived)
{
<form method="post" asp-page-handler="LogTime">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<input name="hours" placeholder="Hours" />
<input name="note" placeholder="What moved?" />
<button class="button ghost" type="submit">Log time</button>
</form>
}
@if (canWorkOnItem && workItem.Status is WorkItemStatus.InProgress or WorkItemStatus.ChangesRequested or WorkItemStatus.Approved)
{
<form method="post" asp-page-handler="Submit">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<button class="button" type="submit">Submit for review</button>
</form>
}
@if (Model.Actor.CanReviewWork && workItem.Status is WorkItemStatus.SubmittedForReview or WorkItemStatus.ChangesRequested or WorkItemStatus.Approved)
{
<form method="post" asp-page-handler="Review">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<select name="status">
<option value="@WorkReviewStatus.Approved">Approve</option>
<option value="@WorkReviewStatus.ChangesRequested">Request changes</option>
</select>
<input name="note" placeholder="Review note" />
<button class="button warm" type="submit">Record review</button>
</form>
}
@if (Model.Actor.CanReviewWork && workItem.Status == WorkItemStatus.Approved)
{
<form method="post" asp-page-handler="Complete">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<button class="button" type="submit">Mark completed</button>
</form>
}
@if (Model.Actor.CanReviewWork && pendingLogs.Count > 0)
{
@foreach (var workLog in pendingLogs)
{
<form method="post" asp-page-handler="ModerateLog">
<input type="hidden" name="workLogId" value="@workLog.Id" />
<select name="approvalStatus">
<option value="@WorkLogApprovalStatus.Approved">Approve @workLog.Hours.ToString("0.##")h</option>
<option value="@WorkLogApprovalStatus.Rejected">Reject @workLog.Hours.ToString("0.##")h</option>
</select>
<button class="button ghost" type="submit">@workLog.UserAccount.DisplayName log</button>
</form>
}
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@section Scripts {

View file

@ -22,6 +22,8 @@ public sealed class IndexModel(
public IReadOnlyList<SelectListItem> ProjectOptions { get; private set; } = [];
public IReadOnlyList<SelectListItem> MemberOptions { get; private set; } = [];
public IReadOnlyList<WorkItem> WorkItems { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
@ -52,9 +54,14 @@ public sealed class IndexModel(
RequestedByUserAccountId = actorUser.Id,
Title = Input.Title.Trim(),
Summary = Input.Summary.Trim(),
EffortPoints = Input.EffortPoints,
Category = Input.Category.Trim(),
SkillLevel = Input.SkillLevel,
EstimatedHours = Input.EstimatedHours,
ContributionPoints = Input.ContributionPoints,
TargetDateUtc = Input.TargetDateUtc?.ToUniversalTime(),
SourceType = WorkItemSourceType.Internal,
Status = WorkItemStatus.Open,
Status = Actor.CanManageProjects ? WorkItemStatus.Open : WorkItemStatus.Proposed,
ReviewStatus = WorkReviewStatus.Pending,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
@ -76,7 +83,6 @@ public sealed class IndexModel(
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
@ -101,9 +107,9 @@ public sealed class IndexModel(
existingClaim.Status = VolunteerClaimStatus.Active;
}
if (workItem.Status == WorkItemStatus.Open)
if (workItem.Status is WorkItemStatus.Proposed or WorkItemStatus.Open)
{
workItem.Status = WorkItemStatus.Claimed;
workItem.Status = WorkItemStatus.Volunteered;
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
}
@ -123,12 +129,7 @@ public sealed class IndexModel(
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (!Actor.CanManageProjects)
{
return Forbid();
}
if (actorUser is null)
if (!Actor.CanAssignWork || actorUser is null)
{
return Forbid();
}
@ -138,7 +139,7 @@ public sealed class IndexModel(
.SingleAsync(x => x.Id == workItemId, cancellationToken);
workItem.AssignedToUserAccountId = userAccountId;
workItem.Status = WorkItemStatus.InProgress;
workItem.Status = WorkItemStatus.Assigned;
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
var claim = workItem.VolunteerClaims.SingleOrDefault(x => x.UserAccountId == userAccountId);
@ -167,26 +168,240 @@ public sealed class IndexModel(
return RedirectToPage();
}
public async Task<IActionResult> OnPostStartAsync(Guid workItemId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
if (!CanWorkOnItem(Actor, workItem))
{
return Forbid();
}
workItem.Status = WorkItemStatus.InProgress;
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount!.Id,
nameof(WorkItem),
workItem.Id,
"work-item.started",
$"Started work on {workItem.Title}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostLogTimeAsync(Guid workItemId, decimal hours, string note, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
}
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
if (!CanWorkOnItem(Actor, workItem))
{
return Forbid();
}
dbContext.WorkLogs.Add(new WorkLog
{
WorkItemId = workItemId,
UserAccountId = actorUser.Id,
Hours = hours,
Note = note?.Trim() ?? string.Empty,
ApprovalStatus = WorkLogApprovalStatus.Submitted,
CreatedAtUtc = timeProvider.GetUtcNow()
});
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(WorkItem),
workItem.Id,
"work-log.created",
$"Logged {hours:0.##}h on {workItem.Title}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostModerateLogAsync(
Guid workLogId,
WorkLogApprovalStatus approvalStatus,
CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanReviewWork || Actor.UserAccount is null)
{
return Forbid();
}
var workLog = await dbContext.WorkLogs
.Include(x => x.WorkItem)
.SingleAsync(x => x.Id == workLogId, cancellationToken);
workLog.ApprovalStatus = approvalStatus;
workLog.ReviewedByUserAccountId = Actor.UserAccount.Id;
workLog.ReviewedAtUtc = timeProvider.GetUtcNow();
workLog.WorkItem.UpdatedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(WorkLog),
workLog.Id,
"work-log.moderated",
$"{approvalStatus} work log for {workLog.WorkItem.Title}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostSubmitAsync(Guid workItemId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
if (!CanWorkOnItem(Actor, workItem))
{
return Forbid();
}
workItem.Status = WorkItemStatus.SubmittedForReview;
workItem.ReviewStatus = WorkReviewStatus.Pending;
workItem.SubmittedAtUtc = timeProvider.GetUtcNow();
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount!.Id,
nameof(WorkItem),
workItem.Id,
"work-item.submitted",
$"Submitted {workItem.Title} for review.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostReviewAsync(
Guid workItemId,
WorkReviewStatus status,
string note,
CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanReviewWork || Actor.UserAccount is null)
{
return Forbid();
}
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
workItem.ReviewStatus = status;
workItem.Status = status == WorkReviewStatus.Approved
? WorkItemStatus.Approved
: WorkItemStatus.ChangesRequested;
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
dbContext.WorkReviews.Add(new WorkReview
{
WorkItemId = workItemId,
ReviewerUserAccountId = Actor.UserAccount.Id,
ReviewerName = Actor.DisplayName,
Status = status,
Note = note?.Trim() ?? string.Empty,
ReviewedAtUtc = timeProvider.GetUtcNow()
});
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(WorkItem),
workItem.Id,
"work-item.reviewed",
$"Reviewed {workItem.Title} as {status}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostCompleteAsync(Guid workItemId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanReviewWork || Actor.UserAccount is null)
{
return Forbid();
}
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
workItem.Status = WorkItemStatus.Completed;
workItem.CompletedAtUtc = timeProvider.GetUtcNow();
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(WorkItem),
workItem.Id,
"work-item.completed",
$"Completed {workItem.Title}.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
ProjectOptions = await dbContext.Projects
.AsNoTracking()
.Where(x => x.Status == ProjectStatus.Active)
.OrderBy(x => x.Name)
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToListAsync(cancellationToken);
MemberOptions = await dbContext.UserAccounts
.AsNoTracking()
.Include(x => x.Membership)
.Where(x => x.Membership != null && x.Membership.Status == MembershipStatus.Active)
.OrderBy(x => x.DisplayName)
.Select(x => new SelectListItem(x.DisplayName, x.Id.ToString()))
.ToListAsync(cancellationToken);
WorkItems = await dbContext.WorkItems
.AsNoTracking()
.Include(x => x.Project)
.Include(x => x.AssignedToUserAccount)
.Include(x => x.VolunteerClaims)
.ThenInclude(x => x.UserAccount)
.Include(x => x.WorkLogs)
.ThenInclude(x => x.UserAccount)
.Include(x => x.WorkReviews)
.Include(x => x.GitHubIssueLink)
.Include(x => x.GitHubPullRequestLinks)
.OrderByDescending(x => x.UpdatedAtUtc)
.ToListAsync(cancellationToken);
}
private static bool CanWorkOnItem(CurrentBifrostActor actor, WorkItem workItem)
{
var actorId = actor.UserAccount?.Id;
if (actorId is null)
{
return false;
}
return workItem.AssignedToUserAccountId == actorId ||
workItem.RequestedByUserAccountId == actorId ||
workItem.VolunteerClaims.Any(x => x.UserAccountId == actorId && x.Status == VolunteerClaimStatus.Active);
}
public sealed class CreateWorkItemInput
{
[Required]
@ -196,11 +411,24 @@ public sealed class IndexModel(
[Required, StringLength(180)]
public string Title { get; set; } = string.Empty;
[Required, StringLength(2000)]
public string Summary { get; set; } = string.Empty;
[Required, StringLength(120)]
public string Category { get; set; } = string.Empty;
[Display(Name = "Skill level")]
public WorkItemSkillLevel SkillLevel { get; set; } = WorkItemSkillLevel.Routine;
[Range(0, 1000)]
[Display(Name = "Effort points")]
public decimal EffortPoints { get; set; } = 1m;
[Display(Name = "Estimated hours")]
public decimal EstimatedHours { get; set; } = 1m;
[Range(0, 1000)]
[Display(Name = "Contribution points")]
public decimal ContributionPoints { get; set; } = 1m;
[Display(Name = "Target date (UTC)")]
public DateTimeOffset? TargetDateUtc { get; set; }
[Required, StringLength(2000)]
public string Summary { get; set; } = string.Empty;
}
}

View file

@ -3,12 +3,14 @@ using System.Security.Claims;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -18,10 +20,18 @@ builder.Services
.AddOptions<GitHubOAuthOptions>()
.BindConfiguration(GitHubOAuthOptions.SectionName);
builder.Services
.AddOptions<GitHubAppOptions>()
.BindConfiguration(GitHubAppOptions.SectionName);
builder.Services
.AddOptions<BootstrapOptions>()
.BindConfiguration(BootstrapOptions.SectionName);
builder.Services
.AddOptions<BifrostHostOptions>()
.BindConfiguration(BifrostHostOptions.SectionName);
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
@ -36,12 +46,25 @@ builder.Services.AddDbContext<BifrostDbContext>(options =>
options.UseNpgsql(connectionString);
});
builder.Services.AddHttpLogging(options =>
{
options.LoggingFields =
HttpLoggingFields.RequestMethod |
HttpLoggingFields.RequestPath |
HttpLoggingFields.ResponseStatusCode |
HttpLoggingFields.Duration;
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
builder.Services.AddScoped<AuditTrailService>();
builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services
@ -107,12 +130,21 @@ builder.Services.AddAuthorization(options =>
var app = builder.Build();
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
await using (var scope = app.Services.CreateAsyncScope())
{
await scope.ServiceProvider
.GetRequiredService<ApplicationBootstrapper>()
.RunAsync(CancellationToken.None);
}
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpLogging();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
@ -120,6 +152,49 @@ app.UseAuthentication();
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
app.UseAuthorization();
app.MapGet("/healthz", () => Results.Text("ok")).AllowAnonymous();
app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToken cancellationToken) =>
{
var report = await readinessService.GetReportAsync(cancellationToken);
return report.IsReady
? Results.Text("ready")
: Results.Text(
string.Join(Environment.NewLine, report.Failures),
statusCode: StatusCodes.Status503ServiceUnavailable);
}).AllowAnonymous();
app.MapPost("/github/webhooks", async (
HttpRequest request,
GitHubWebhookService gitHubWebhookService,
CancellationToken cancellationToken) =>
{
using var reader = new StreamReader(request.Body);
var payload = await reader.ReadToEndAsync(cancellationToken);
var eventName = request.Headers["X-GitHub-Event"].ToString();
var deliveryId = request.Headers["X-GitHub-Delivery"].ToString();
var signature = request.Headers["X-Hub-Signature-256"].ToString();
var result = await gitHubWebhookService.ProcessAsync(
eventName,
deliveryId,
signature,
payload,
cancellationToken);
return result.Status switch
{
GitHubWebhookProcessStatus.Processed => Results.Text(result.Message, statusCode: StatusCodes.Status202Accepted),
GitHubWebhookProcessStatus.Ignored => Results.Text(result.Message),
GitHubWebhookProcessStatus.NotConfigured => Results.Text(
result.Message,
statusCode: StatusCodes.Status503ServiceUnavailable),
_ => Results.Text(
result.Message,
statusCode: StatusCodes.Status401Unauthorized)
};
}).AllowAnonymous();
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
{
if (!gitHubOptions.Value.IsConfigured)

View file

@ -3,6 +3,14 @@
"ConnectionStrings": {
"Bifrost": "Host=127.0.0.1;Database=bifrost_dev;Username=postgres;Password=postgres"
},
"Bootstrap": {
"ApplyMigrationsOnStartup": true
},
"Host": {
"PublicBaseUrl": "https://localhost:5001",
"ExpectedHost": "localhost",
"RequireStrictHostValidation": false
},
"Logging": {
"LogLevel": {
"Default": "Information",

View file

@ -7,8 +7,22 @@
"ClientSecret": "",
"CallbackPath": "/signin-github"
},
"GitHubApp": {
"AppId": "",
"ClientId": "",
"ClientSecret": "",
"PrivateKeyPem": "",
"WebhookSecret": "",
"EnableWebhookSync": true
},
"Bootstrap": {
"AdminGitHubLogins": []
"AdminGitHubLogins": [],
"ApplyMigrationsOnStartup": false
},
"Host": {
"PublicBaseUrl": "https://bifrost.gamecult.org",
"ExpectedHost": "bifrost.gamecult.org",
"RequireStrictHostValidation": true
},
"Logging": {
"LogLevel": {
@ -16,5 +30,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "localhost"
}

View file

@ -191,6 +191,16 @@ code {
flex-wrap: wrap;
}
.stack-form {
display: grid;
gap: 0.55rem;
}
.stack-form form {
display: grid;
gap: 0.45rem;
}
.button {
display: inline-flex;
align-items: center;
@ -393,6 +403,17 @@ tbody tr:hover {
resize: vertical;
}
.stack-form input,
.stack-form select {
width: 100%;
border: 1px solid rgba(38, 34, 27, 0.14);
border-radius: 14px;
padding: 0.75rem 0.85rem;
background: #fffdf8;
color: var(--ink);
font: inherit;
}
.validation-summary-errors,
.field-validation-error {
color: var(--danger);

View file

@ -0,0 +1,87 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public PlatformEndpointsTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Health_endpoint_returns_ok()
{
using var client = _factory.CreateClient();
using var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Ready_endpoint_returns_ok_for_test_configuration()
{
using var client = _factory.CreateClient();
using var response = await client.GetAsync("/readyz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GitHub_issue_webhook_creates_work_item()
{
using var client = _factory.CreateClient();
_ = await client.GetAsync("/App");
const string payload = """
{
"action": "opened",
"repository": { "full_name": "GameCult/Bifrost" },
"issue": {
"number": 42,
"state": "open",
"html_url": "https://github.com/GameCult/Bifrost/issues/42",
"title": "Sync issue into Bifrost",
"body": "Track this from GitHub"
},
"sender": { "login": "test-admin" }
}
""";
using var request = new HttpRequestMessage(HttpMethod.Post, "/github/webhooks");
request.Headers.Add("X-GitHub-Event", "issues");
request.Headers.Add("X-GitHub-Delivery", Guid.NewGuid().ToString("N"));
request.Headers.Add("X-Hub-Signature-256", ComputeSignature("test-webhook-secret", payload));
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var workItem = dbContext.WorkItems.Single();
var issueLink = dbContext.GitHubIssueLinks.Single();
var project = dbContext.Projects.Single(x => x.Id == workItem.ProjectId);
Assert.Equal(WorkItemSourceType.GitHubIssue, workItem.SourceType);
Assert.Equal(42, issueLink.IssueNumber);
Assert.Equal("bifrost", project.Slug);
Assert.Equal("GameCult/Bifrost".ToLowerInvariant(), project.GitHubRepository);
}
private static string ComputeSignature(string secret, string payload)
{
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload));
return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@ -12,6 +13,9 @@ namespace Bifrost.Web.Tests.Support;
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly InMemoryDatabaseRoot _databaseRoot = new();
private readonly string _databaseName = $"bifrost-tests-{Guid.NewGuid():N}";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
@ -22,7 +26,13 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
["Bootstrap:AdminGitHubLogins:0"] = "test-admin",
["GitHubOAuth:ClientId"] = "configured-for-tests",
["GitHubOAuth:ClientSecret"] = "configured-for-tests"
["GitHubOAuth:ClientSecret"] = "configured-for-tests",
["GitHubApp:WebhookSecret"] = "test-webhook-secret",
["GitHubApp:PrivateKeyPem"] = "test-private-key",
["GitHubApp:AppId"] = "1",
["Host:PublicBaseUrl"] = "https://localhost",
["Host:ExpectedHost"] = "localhost",
["Host:RequireStrictHostValidation"] = "false"
});
});
@ -33,7 +43,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
services.AddDbContext<BifrostDbContext>(options =>
{
options.UseInMemoryDatabase($"bifrost-tests-{Guid.NewGuid():N}");
options.UseInMemoryDatabase(_databaseName, _databaseRoot);
});
services.AddAuthentication(options =>