From 3fd655e1f22479447935f307731885e1cd554bae Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Apr 2026 09:50:31 +0100 Subject: [PATCH] Implement alpha foundation workflow and GitHub sync --- README.md | 29 +- docs/full-implementation-strategy.md | 28 +- .../Configuration/BootstrapOptions.cs | 2 + .../Configuration/GitHubAppOptions.cs | 26 + src/Bifrost.Web/Configuration/HostOptions.cs | 16 + src/Bifrost.Web/Data/BifrostDbContext.cs | 194 +- ...2084653_InitialAlphaFoundation.Designer.cs | 1576 +++++++++++++++++ .../20260422084653_InitialAlphaFoundation.cs | 1073 +++++++++++ .../BifrostDbContextModelSnapshot.cs | 1573 ++++++++++++++++ src/Bifrost.Web/Domain/BifrostModels.cs | 408 ++++- .../Features/GitHub/GitHubWebhookService.cs | 461 +++++ .../MembershipSynchronizationService.cs | 42 +- .../Features/Motions/MotionCategoryPolicy.cs | 30 + .../Shared/ApplicationBootstrapper.cs | 81 + .../Features/Shared/CurrentBifrostActor.cs | 41 +- .../Shared/CurrentBifrostActorAccessor.cs | 5 +- .../Shared/DashboardSnapshotService.cs | 126 +- .../Features/Shared/ReadinessService.cs | 60 + .../Shared/StartupConfigurationValidator.cs | 56 + src/Bifrost.Web/Pages/App/Index.cshtml | 151 +- src/Bifrost.Web/Pages/App/Index.cshtml.cs | 2 +- src/Bifrost.Web/Pages/App/Ledger/Index.cshtml | 108 +- .../Pages/App/Ledger/Index.cshtml.cs | 6 +- .../Pages/App/Members/Index.cshtml | 236 ++- .../Pages/App/Members/Index.cshtml.cs | 288 ++- .../Pages/App/Motions/Index.cshtml | 132 +- .../Pages/App/Motions/Index.cshtml.cs | 78 +- .../Pages/App/Projects/Index.cshtml | 69 +- .../Pages/App/Projects/Index.cshtml.cs | 52 +- .../Pages/App/WorkItems/Index.cshtml | 301 +++- .../Pages/App/WorkItems/Index.cshtml.cs | 260 ++- src/Bifrost.Web/Program.cs | 75 + src/Bifrost.Web/appsettings.Development.json | 8 + src/Bifrost.Web/appsettings.json | 18 +- src/Bifrost.Web/wwwroot/css/site.css | 21 + .../PlatformEndpointsTests.cs | 87 + .../Support/TestWebApplicationFactory.cs | 14 +- 37 files changed, 7360 insertions(+), 373 deletions(-) create mode 100644 src/Bifrost.Web/Configuration/GitHubAppOptions.cs create mode 100644 src/Bifrost.Web/Configuration/HostOptions.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.Designer.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.cs create mode 100644 src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs create mode 100644 src/Bifrost.Web/Features/GitHub/GitHubWebhookService.cs create mode 100644 src/Bifrost.Web/Features/Motions/MotionCategoryPolicy.cs create mode 100644 src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs create mode 100644 src/Bifrost.Web/Features/Shared/ReadinessService.cs create mode 100644 src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs create mode 100644 tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs diff --git a/README.md b/README.md index b325e4a..57512cf 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/full-implementation-strategy.md b/docs/full-implementation-strategy.md index 30edc70..21463e6 100644 --- a/docs/full-implementation-strategy.md +++ b/docs/full-implementation-strategy.md @@ -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 diff --git a/src/Bifrost.Web/Configuration/BootstrapOptions.cs b/src/Bifrost.Web/Configuration/BootstrapOptions.cs index 77b51e2..eb17ee1 100644 --- a/src/Bifrost.Web/Configuration/BootstrapOptions.cs +++ b/src/Bifrost.Web/Configuration/BootstrapOptions.cs @@ -5,4 +5,6 @@ public sealed class BootstrapOptions public const string SectionName = "Bootstrap"; public string[] AdminGitHubLogins { get; init; } = []; + + public bool ApplyMigrationsOnStartup { get; init; } } diff --git a/src/Bifrost.Web/Configuration/GitHubAppOptions.cs b/src/Bifrost.Web/Configuration/GitHubAppOptions.cs new file mode 100644 index 0000000..649cdc3 --- /dev/null +++ b/src/Bifrost.Web/Configuration/GitHubAppOptions.cs @@ -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); +} diff --git a/src/Bifrost.Web/Configuration/HostOptions.cs b/src/Bifrost.Web/Configuration/HostOptions.cs new file mode 100644 index 0000000..756e64e --- /dev/null +++ b/src/Bifrost.Web/Configuration/HostOptions.cs @@ -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); +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 2614e9d..63f590b 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -11,6 +11,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet Memberships => Set(); + public DbSet RoleAssignments => Set(); + + public DbSet TierSnapshots => Set(); + public DbSet MembershipInvitations => Set(); public DbSet Projects => Set(); @@ -21,6 +25,14 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet Assignments => Set(); + public DbSet WorkLogs => Set(); + + public DbSet WorkReviews => Set(); + + public DbSet GitHubIssueLinks => Set(); + + public DbSet GitHubPullRequestLinks => Set(); + public DbSet Motions => Set(); public DbSet Votes => Set(); @@ -29,6 +41,18 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet PayoutProposalBatches => Set(); + public DbSet PointTransactions => Set(); + + public DbSet PatronSupportEvents => Set(); + + public DbSet DecayRuns => Set(); + + public DbSet RevenueEvents => Set(); + + public DbSet RevenueShareBatches => Set(); + + public DbSet RevenueShareLines => Set(); + public DbSet AuditEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -48,6 +72,10 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.HasOne(x => x.UserAccount) .WithOne(x => x.MemberProfile) .HasForeignKey(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(entity => @@ -57,8 +85,30 @@ public sealed class BifrostDbContext(DbContextOptions options) .HasForeignKey(x => x.UserAccountId); entity.Property(x => x.Status).HasConversion(); - entity.Property(x => x.PatronWeight).HasPrecision(18, 2); - entity.Property(x => x.ContributorWeight).HasPrecision(18, 2); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.MembershipId, x.Role }).IsUnique(); + entity.Property(x => x.Role).HasConversion(); + 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(entity => + { + entity.Property(x => x.Kind).HasConversion(); + 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(entity => @@ -79,8 +129,10 @@ public sealed class BifrostDbContext(DbContextOptions options) modelBuilder.Entity(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(); entity.HasOne(x => x.OwnerUserAccount) .WithMany(x => x.OwnedProjects) @@ -92,9 +144,13 @@ public sealed class BifrostDbContext(DbContextOptions options) { entity.Property(x => x.SourceType).HasConversion(); entity.Property(x => x.Status).HasConversion(); + entity.Property(x => x.ReviewStatus).HasConversion(); + entity.Property(x => x.SkillLevel).HasConversion(); 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 options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.Property(x => x.Hours).HasPrecision(18, 2); + entity.Property(x => x.ApprovalStatus).HasConversion(); + 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(entity => + { + entity.Property(x => x.Status).HasConversion(); + 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(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(); + entity.Property(x => x.IssueUrl).HasMaxLength(500); + entity.Property(x => x.TitleSnapshot).HasMaxLength(240); + entity.HasOne(x => x.WorkItem) + .WithOne(x => x.GitHubIssueLink) + .HasForeignKey(x => x.WorkItemId); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.RepositoryFullName, x.PullRequestNumber }).IsUnique(); + entity.Property(x => x.RepositoryFullName).HasMaxLength(240); + entity.Property(x => x.State).HasConversion(); + entity.Property(x => x.ReviewDecision).HasConversion(); + 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(entity => { entity.Property(x => x.Scope).HasConversion(); + entity.Property(x => x.Category).HasConversion(); entity.Property(x => x.Status).HasConversion(); entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3); entity.HasOne(x => x.Project) @@ -198,6 +311,79 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.Property(x => x.Type).HasConversion(); + 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(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(entity => + { + entity.Property(x => x.Rate).HasPrecision(8, 4); + }); + + modelBuilder.Entity(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(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(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(entity => { entity.Property(x => x.EntityType).HasMaxLength(120); diff --git a/src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.Designer.cs b/src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.Designer.cs new file mode 100644 index 0000000..9ab3d1c --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.Designer.cs @@ -0,0 +1,1576 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20260422084653_InitialAlphaFoundation")] + partial class InitialAlphaFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique(); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("CreatedMotions"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.cs b/src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.cs new file mode 100644 index 0000000..92adeb9 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.cs @@ -0,0 +1,1073 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class InitialAlphaFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DecayRuns", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Rate = table.Column(type: "numeric(8,4)", precision: 8, scale: 4, nullable: false), + Notes = table.Column(type: "text", nullable: false), + RanAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DecayRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserAccounts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GitHubUserId = table.Column(type: "bigint", nullable: false), + GitHubLogin = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + NormalizedGitHubLogin = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + AvatarUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + LastSeenAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserAccounts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AuditEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ActorUserAccountId = table.Column(type: "uuid", nullable: true), + EntityType = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + EntityId = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Action = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Detail = table.Column(type: "text", nullable: false), + OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEvents", x => x.Id); + table.ForeignKey( + name: "FK_AuditEvents_UserAccounts_ActorUserAccountId", + column: x => x.ActorUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "MemberProfiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Nickname = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Headline = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + PortfolioUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Skills = table.Column(type: "text", nullable: false), + Availability = table.Column(type: "text", nullable: false), + Notes = table.Column(type: "text", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MemberProfiles", x => x.Id); + table.ForeignKey( + name: "FK_MemberProfiles_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MembershipInvitations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GitHubLogin = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + NormalizedGitHubLogin = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + IssuedByUserAccountId = table.Column(type: "uuid", nullable: true), + AcceptedByUserAccountId = table.Column(type: "uuid", nullable: true), + IssuedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + AcceptedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RevokedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + Notes = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MembershipInvitations", x => x.Id); + table.ForeignKey( + name: "FK_MembershipInvitations_UserAccounts_AcceptedByUserAccountId", + column: x => x.AcceptedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_MembershipInvitations_UserAccounts_IssuedByUserAccountId", + column: x => x.IssuedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "Memberships", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + IsPlatformAdmin = table.Column(type: "boolean", nullable: false), + CanManageProjects = table.Column(type: "boolean", nullable: false), + CanManageLedger = table.Column(type: "boolean", nullable: false), + CanModerateMotions = table.Column(type: "boolean", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ApprovedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + Notes = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Memberships", x => x.Id); + table.ForeignKey( + name: "FK_Memberships_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PatronSupportEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + ExternalSupportId = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + CurrencyCode = table.Column(type: "character varying(12)", maxLength: 12, nullable: false), + SupportedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RecordedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Notes = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PatronSupportEvents", x => x.Id); + table.ForeignKey( + name: "FK_PatronSupportEvents_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "PayoutProposalBatches", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedByUserAccountId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + Summary = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PayoutProposalBatches", x => x.Id); + table.ForeignKey( + name: "FK_PayoutProposalBatches_UserAccounts_CreatedByUserAccountId", + column: x => x.CreatedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Projects", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserAccountId = table.Column(type: "uuid", nullable: false), + Slug = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Name = table.Column(type: "character varying(180)", maxLength: 180, nullable: false), + Summary = table.Column(type: "text", nullable: false), + GitHubRepository = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Status = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Projects", x => x.Id); + table.ForeignKey( + name: "FK_Projects_UserAccounts_OwnerUserAccountId", + column: x => x.OwnerUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RoleAssignments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MembershipId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "text", nullable: false), + AssignedByUserAccountId = table.Column(type: "uuid", nullable: true), + AssignedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Notes = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleAssignments", x => x.Id); + table.ForeignKey( + name: "FK_RoleAssignments_Memberships_MembershipId", + column: x => x.MembershipId, + principalTable: "Memberships", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_RoleAssignments_UserAccounts_AssignedByUserAccountId", + column: x => x.AssignedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "TierSnapshots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MembershipId = table.Column(type: "uuid", nullable: false), + Kind = table.Column(type: "text", nullable: false), + Label = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Weight = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + IsCurrent = table.Column(type: "boolean", nullable: false), + EffectiveFromUtc = table.Column(type: "timestamp with time zone", nullable: false), + CapturedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + Notes = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TierSnapshots", x => x.Id); + table.ForeignKey( + name: "FK_TierSnapshots_Memberships_MembershipId", + column: x => x.MembershipId, + principalTable: "Memberships", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Motions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: true), + CreatedByUserAccountId = table.Column(type: "uuid", nullable: false), + Scope = table.Column(type: "text", nullable: false), + Category = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: false), + Summary = table.Column(type: "text", nullable: false), + ApprovalThreshold = table.Column(type: "numeric(6,3)", precision: 6, scale: 3, nullable: false), + OpensAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ClosesAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ResolvedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + ResolutionNote = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Motions", x => x.Id); + table.ForeignKey( + name: "FK_Motions_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Motions_UserAccounts_CreatedByUserAccountId", + column: x => x.CreatedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RevenueEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: true), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + CurrencyCode = table.Column(type: "character varying(12)", maxLength: 12, nullable: false), + Notes = table.Column(type: "text", nullable: false), + ReceivedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RevenueEvents", x => x.Id); + table.ForeignKey( + name: "FK_RevenueEvents_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "WorkItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: false), + RequestedByUserAccountId = table.Column(type: "uuid", nullable: false), + AssignedToUserAccountId = table.Column(type: "uuid", nullable: true), + SourceType = table.Column(type: "text", nullable: false), + ExternalSourceId = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Title = table.Column(type: "character varying(180)", maxLength: 180, nullable: false), + Summary = table.Column(type: "text", nullable: false), + Category = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + SkillLevel = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + ReviewStatus = table.Column(type: "text", nullable: false), + EstimatedHours = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + ContributionPoints = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + TargetDateUtc = table.Column(type: "timestamp with time zone", nullable: true), + SubmittedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkItems", x => x.Id); + table.ForeignKey( + name: "FK_WorkItems_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_WorkItems_UserAccounts_AssignedToUserAccountId", + column: x => x.AssignedToUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_WorkItems_UserAccounts_RequestedByUserAccountId", + column: x => x.RequestedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Votes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MotionId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Choice = table.Column(type: "text", nullable: false), + Weight = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Comment = table.Column(type: "text", nullable: false), + CastAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Votes", x => x.Id); + table.ForeignKey( + name: "FK_Votes_Motions_MotionId", + column: x => x.MotionId, + principalTable: "Motions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Votes_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RevenueShareBatches", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RevenueEventId = table.Column(type: "uuid", nullable: true), + CreatedByUserAccountId = table.Column(type: "uuid", nullable: false), + Summary = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RevenueShareBatches", x => x.Id); + table.ForeignKey( + name: "FK_RevenueShareBatches_RevenueEvents_RevenueEventId", + column: x => x.RevenueEventId, + principalTable: "RevenueEvents", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_RevenueShareBatches_UserAccounts_CreatedByUserAccountId", + column: x => x.CreatedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Assignments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + AssignedByUserAccountId = table.Column(type: "uuid", nullable: false), + Note = table.Column(type: "text", nullable: false), + AssignedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Assignments", x => x.Id); + table.ForeignKey( + name: "FK_Assignments_UserAccounts_AssignedByUserAccountId", + column: x => x.AssignedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Assignments_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Assignments_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GitHubIssueLinks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: false), + RepositoryFullName = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + IssueNumber = table.Column(type: "integer", nullable: false), + State = table.Column(type: "text", nullable: false), + IssueUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + TitleSnapshot = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + LastSynchronizedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GitHubIssueLinks", x => x.Id); + table.ForeignKey( + name: "FK_GitHubIssueLinks_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GitHubPullRequestLinks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: false), + RepositoryFullName = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + PullRequestNumber = table.Column(type: "integer", nullable: false), + State = table.Column(type: "text", nullable: false), + IsMerged = table.Column(type: "boolean", nullable: false), + PullRequestUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + TitleSnapshot = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + ReviewDecision = table.Column(type: "text", nullable: false), + LastSynchronizedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GitHubPullRequestLinks", x => x.Id); + table.ForeignKey( + name: "FK_GitHubPullRequestLinks_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LedgerEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: true), + WorkItemId = table.Column(type: "uuid", nullable: true), + CreatedByUserAccountId = table.Column(type: "uuid", nullable: false), + EntryType = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + Points = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + NominalAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Note = table.Column(type: "text", nullable: false), + EffectiveAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LedgerEntries", x => x.Id); + table.ForeignKey( + name: "FK_LedgerEntries_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_LedgerEntries_UserAccounts_CreatedByUserAccountId", + column: x => x.CreatedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LedgerEntries_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LedgerEntries_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "PointTransactions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: true), + WorkItemId = table.Column(type: "uuid", nullable: true), + Type = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + IsDecaying = table.Column(type: "boolean", nullable: false), + Note = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PointTransactions", x => x.Id); + table.ForeignKey( + name: "FK_PointTransactions_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_PointTransactions_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PointTransactions_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "VolunteerClaims", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + Note = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VolunteerClaims", x => x.Id); + table.ForeignKey( + name: "FK_VolunteerClaims_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VolunteerClaims_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WorkLogs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Hours = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Note = table.Column(type: "text", nullable: false), + ApprovalStatus = table.Column(type: "text", nullable: false), + ReviewedByUserAccountId = table.Column(type: "uuid", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ReviewedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkLogs", x => x.Id); + table.ForeignKey( + name: "FK_WorkLogs_UserAccounts_ReviewedByUserAccountId", + column: x => x.ReviewedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_WorkLogs_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_WorkLogs_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WorkReviews", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: false), + ReviewerUserAccountId = table.Column(type: "uuid", nullable: true), + ReviewerName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "text", nullable: false), + Note = table.Column(type: "text", nullable: false), + ReviewedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkReviews", x => x.Id); + table.ForeignKey( + name: "FK_WorkReviews_UserAccounts_ReviewerUserAccountId", + column: x => x.ReviewerUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_WorkReviews_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RevenueShareLines", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RevenueShareBatchId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: true), + ProjectId = table.Column(type: "uuid", nullable: true), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + Basis = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Notes = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RevenueShareLines", x => x.Id); + table.ForeignKey( + name: "FK_RevenueShareLines_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_RevenueShareLines_RevenueShareBatches_RevenueShareBatchId", + column: x => x.RevenueShareBatchId, + principalTable: "RevenueShareBatches", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_RevenueShareLines_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_Assignments_AssignedByUserAccountId", + table: "Assignments", + column: "AssignedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Assignments_UserAccountId", + table: "Assignments", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Assignments_WorkItemId", + table: "Assignments", + column: "WorkItemId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_ActorUserAccountId", + table: "AuditEvents", + column: "ActorUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_GitHubIssueLinks_RepositoryFullName_IssueNumber", + table: "GitHubIssueLinks", + columns: new[] { "RepositoryFullName", "IssueNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GitHubIssueLinks_WorkItemId", + table: "GitHubIssueLinks", + column: "WorkItemId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GitHubPullRequestLinks_RepositoryFullName_PullRequestNumber", + table: "GitHubPullRequestLinks", + columns: new[] { "RepositoryFullName", "PullRequestNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GitHubPullRequestLinks_WorkItemId", + table: "GitHubPullRequestLinks", + column: "WorkItemId"); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_CreatedByUserAccountId", + table: "LedgerEntries", + column: "CreatedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_ProjectId", + table: "LedgerEntries", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_UserAccountId", + table: "LedgerEntries", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_WorkItemId", + table: "LedgerEntries", + column: "WorkItemId"); + + migrationBuilder.CreateIndex( + name: "IX_MemberProfiles_UserAccountId", + table: "MemberProfiles", + column: "UserAccountId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MembershipInvitations_AcceptedByUserAccountId", + table: "MembershipInvitations", + column: "AcceptedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_MembershipInvitations_IssuedByUserAccountId", + table: "MembershipInvitations", + column: "IssuedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_MembershipInvitations_NormalizedGitHubLogin", + table: "MembershipInvitations", + column: "NormalizedGitHubLogin", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Memberships_UserAccountId", + table: "Memberships", + column: "UserAccountId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Motions_CreatedByUserAccountId", + table: "Motions", + column: "CreatedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Motions_ProjectId", + table: "Motions", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_PatronSupportEvents_UserAccountId", + table: "PatronSupportEvents", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_PayoutProposalBatches_CreatedByUserAccountId", + table: "PayoutProposalBatches", + column: "CreatedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_PointTransactions_ProjectId", + table: "PointTransactions", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_PointTransactions_UserAccountId", + table: "PointTransactions", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_PointTransactions_WorkItemId", + table: "PointTransactions", + column: "WorkItemId"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_GitHubRepository", + table: "Projects", + column: "GitHubRepository"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_OwnerUserAccountId", + table: "Projects", + column: "OwnerUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_Slug", + table: "Projects", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RevenueEvents_ProjectId", + table: "RevenueEvents", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_RevenueShareBatches_CreatedByUserAccountId", + table: "RevenueShareBatches", + column: "CreatedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_RevenueShareBatches_RevenueEventId", + table: "RevenueShareBatches", + column: "RevenueEventId"); + + migrationBuilder.CreateIndex( + name: "IX_RevenueShareLines_ProjectId", + table: "RevenueShareLines", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_RevenueShareLines_RevenueShareBatchId", + table: "RevenueShareLines", + column: "RevenueShareBatchId"); + + migrationBuilder.CreateIndex( + name: "IX_RevenueShareLines_UserAccountId", + table: "RevenueShareLines", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_RoleAssignments_AssignedByUserAccountId", + table: "RoleAssignments", + column: "AssignedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_RoleAssignments_MembershipId_Role", + table: "RoleAssignments", + columns: new[] { "MembershipId", "Role" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TierSnapshots_MembershipId_Kind_IsCurrent", + table: "TierSnapshots", + columns: new[] { "MembershipId", "Kind", "IsCurrent" }); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_GitHubUserId", + table: "UserAccounts", + column: "GitHubUserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_NormalizedGitHubLogin", + table: "UserAccounts", + column: "NormalizedGitHubLogin", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VolunteerClaims_UserAccountId", + table: "VolunteerClaims", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_VolunteerClaims_WorkItemId_UserAccountId", + table: "VolunteerClaims", + columns: new[] { "WorkItemId", "UserAccountId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Votes_MotionId_UserAccountId", + table: "Votes", + columns: new[] { "MotionId", "UserAccountId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Votes_UserAccountId", + table: "Votes", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkItems_AssignedToUserAccountId", + table: "WorkItems", + column: "AssignedToUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkItems_ProjectId", + table: "WorkItems", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkItems_RequestedByUserAccountId", + table: "WorkItems", + column: "RequestedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkLogs_ReviewedByUserAccountId", + table: "WorkLogs", + column: "ReviewedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkLogs_UserAccountId", + table: "WorkLogs", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkLogs_WorkItemId", + table: "WorkLogs", + column: "WorkItemId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkReviews_ReviewerUserAccountId", + table: "WorkReviews", + column: "ReviewerUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkReviews_WorkItemId", + table: "WorkReviews", + column: "WorkItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Assignments"); + + migrationBuilder.DropTable( + name: "AuditEvents"); + + migrationBuilder.DropTable( + name: "DecayRuns"); + + migrationBuilder.DropTable( + name: "GitHubIssueLinks"); + + migrationBuilder.DropTable( + name: "GitHubPullRequestLinks"); + + migrationBuilder.DropTable( + name: "LedgerEntries"); + + migrationBuilder.DropTable( + name: "MemberProfiles"); + + migrationBuilder.DropTable( + name: "MembershipInvitations"); + + migrationBuilder.DropTable( + name: "PatronSupportEvents"); + + migrationBuilder.DropTable( + name: "PayoutProposalBatches"); + + migrationBuilder.DropTable( + name: "PointTransactions"); + + migrationBuilder.DropTable( + name: "RevenueShareLines"); + + migrationBuilder.DropTable( + name: "RoleAssignments"); + + migrationBuilder.DropTable( + name: "TierSnapshots"); + + migrationBuilder.DropTable( + name: "VolunteerClaims"); + + migrationBuilder.DropTable( + name: "Votes"); + + migrationBuilder.DropTable( + name: "WorkLogs"); + + migrationBuilder.DropTable( + name: "WorkReviews"); + + migrationBuilder.DropTable( + name: "RevenueShareBatches"); + + migrationBuilder.DropTable( + name: "Memberships"); + + migrationBuilder.DropTable( + name: "Motions"); + + migrationBuilder.DropTable( + name: "WorkItems"); + + migrationBuilder.DropTable( + name: "RevenueEvents"); + + migrationBuilder.DropTable( + name: "Projects"); + + migrationBuilder.DropTable( + name: "UserAccounts"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs new file mode 100644 index 0000000..ea2e155 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -0,0 +1,1573 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + partial class BifrostDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique(); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("CreatedMotions"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 4eada37..e523ef0 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -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 Assignments { get; set; } = []; + public ICollection WorkLogs { get; set; } = []; + + public ICollection WorkReviews { get; set; } = []; + public ICollection CreatedMotions { get; set; } = []; public ICollection Votes { get; set; } = []; public ICollection LedgerEntries { get; set; } = []; + public ICollection PointTransactions { get; set; } = []; + + public ICollection PatronSupportEvents { get; set; } = []; + public ICollection 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 RoleAssignments { get; set; } = []; + + public ICollection 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 Motions { get; set; } = []; public ICollection LedgerEntries { get; set; } = []; + + public ICollection PointTransactions { get; set; } = []; + + public ICollection RevenueEvents { get; set; } = []; + + public ICollection 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 Assignments { get; set; } = []; + public ICollection WorkLogs { get; set; } = []; + + public ICollection WorkReviews { get; set; } = []; + public ICollection LedgerEntries { get; set; } = []; + + public ICollection PointTransactions { get; set; } = []; + + public GitHubIssueLink? GitHubIssueLink { get; set; } + + public ICollection 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 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 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(); diff --git a/src/Bifrost.Web/Features/GitHub/GitHubWebhookService.cs b/src/Bifrost.Web/Features/GitHub/GitHubWebhookService.cs new file mode 100644 index 0000000..2b1b9f3 --- /dev/null +++ b/src/Bifrost.Web/Features/GitHub/GitHubWebhookService.cs @@ -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, + TimeProvider timeProvider, + ILogger logger) +{ + private static readonly Regex IssueReferenceRegex = new("#(?\\d+)", RegexOptions.Compiled); + + public async Task 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 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 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 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 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 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 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 +} diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs index c255941..89e95b2 100644 --- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs +++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs @@ -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, diff --git a/src/Bifrost.Web/Features/Motions/MotionCategoryPolicy.cs b/src/Bifrost.Web/Features/Motions/MotionCategoryPolicy.cs new file mode 100644 index 0000000..8054440 --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/MotionCategoryPolicy.cs @@ -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() + }; +} diff --git a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs new file mode 100644 index 0000000..1a3cd0f --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs @@ -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, + TimeProvider timeProvider, + ILogger 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 + }); + } +} diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs index 9903eeb..648c662 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs @@ -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 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; } diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs index 03f05d2..49700f7 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs @@ -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)); diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs index 091d275..851add6 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -8,16 +8,22 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro { public async Task 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 RecentWorkItems, - IReadOnlyList OpenMotionHighlights); + IReadOnlyList OpenMotionHighlights, + IReadOnlyList MyAssignedWork, + IReadOnlyList MyVolunteeredWork, + IReadOnlyList MySubmittedWork, + IReadOnlyList 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); diff --git a/src/Bifrost.Web/Features/Shared/ReadinessService.cs b/src/Bifrost.Web/Features/Shared/ReadinessService.cs new file mode 100644 index 0000000..d9eb445 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/ReadinessService.cs @@ -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, + IOptions gitHubAppOptions, + IOptions hostOptions) +{ + public async Task GetReportAsync(CancellationToken cancellationToken) + { + var failures = new List(); + + 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 Failures); diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs new file mode 100644 index 0000000..49061f3 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -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, + IOptions gitHubAppOptions, + IOptions hostOptions) +{ + public void Validate() + { + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + { + return; + } + + var failures = new List(); + + 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)); + } + } +} diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml b/src/Bifrost.Web/Pages/App/Index.cshtml index 7815ee0..2db057c 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Index.cshtml @@ -10,8 +10,8 @@ Member console

Operational picture for @Model.Actor.DisplayName

- 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.

+ + } + else + { +
+ Ledger review actions are limited to finance reviewers and platform admins, but the ledger remains visible to all active members.
-
- - - -
-
- -
- + }
diff --git a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs index d1ef2d5..54f1d94 100644 --- a/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Ledger/Index.cshtml.cs @@ -38,6 +38,10 @@ public sealed class IndexModel( public async Task 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, diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml index 5b04c95..773577d 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml @@ -1,4 +1,5 @@ @page +@using Bifrost.Web.Domain @model Bifrost.Web.Pages.App.Members.IndexModel @{ ViewData["Title"] = "Members"; @@ -37,73 +38,196 @@ else {
- Only platform admins can issue invites or approve members. + Only platform admins can issue invites, approve members, or manage tier snapshots.
}
-
-
-
-

Current member records

-

@Model.Users.Count authenticated GitHub identities tracked.

-
-
+
+ My profile +

Work allocation context

+

+ Keep your profile current so producers and maintainers can see your skills, availability, and links at a glance. +

- @if (Model.Users.Count == 0) - { -
No member records yet.
- } - else - { -
- - + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + + + +
+
+
+

Current member records

+

@Model.Users.Count authenticated GitHub identities tracked.

+
+
+ + @if (Model.Users.Count == 0) + { +
No member records yet.
+ } + else + { +
+
+ + + + + + + + + + + @foreach (var user in Model.Users) + { - - - - - - - - @foreach (var user in Model.Users) - { - - - - - + + + - - } - -
AccountStatusProfileRoles and tiersActions
AccountStatusPermissionsActions
- @user.DisplayName
- @user.GitHubLogin -
- @user.Membership!.Status - - @(user.Membership.IsPlatformAdmin ? "Platform admin" : "Member") -
- Projects @(user.Membership.CanManageProjects ? "on" : "off"), - ledger @(user.Membership.CanManageLedger ? "on" : "off") -
-
- @if (Model.Actor.CanManageMembership && user.Membership.Status != MembershipStatus.Active) + + @user.DisplayName
+ @user.GitHubLogin +
+ @user.Status + + @if (!string.IsNullOrWhiteSpace(user.Nickname)) + { +
@user.Nickname
+ } + @if (!string.IsNullOrWhiteSpace(user.Headline)) + { +
@user.Headline
+ } + @if (!string.IsNullOrWhiteSpace(user.Skills)) + { +
@user.Skills
+ } + @if (!string.IsNullOrWhiteSpace(user.Availability)) + { +
Availability: @user.Availability
+ } + @if (!string.IsNullOrWhiteSpace(user.PortfolioUrl)) + { + + } +
+
+ @if (user.Roles.Count == 0) { -
- - -
+ No roles yet } else { - No action + @foreach (var role in user.Roles) + { + @role + } } -
-
- } -
+ +
+ Patron: @(string.IsNullOrWhiteSpace(user.PatronTierLabel) ? "none" : $"{user.PatronTierLabel} ({user.PatronTierWeight:0.##})") +
+
+ Contributor: @(string.IsNullOrWhiteSpace(user.ContributorTierLabel) ? "none" : $"{user.ContributorTierLabel} ({user.ContributorTierWeight:0.##})") +
+ + + @if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active) + { +
+ + +
+ } + + @if (Model.Actor.CanManageMembership) + { +
+ @foreach (var role in Enum.GetValues()) + { +
+ + + +
+ } +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ } + else if (user.Status == MembershipStatus.Active) + { + Visible to all active members. + } + + + } + + + + }
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs index b069823..2f385b7 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -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 Users { get; private set; } = []; + public IReadOnlyList Users { get; private set; } = []; public IReadOnlyList Invitations { get; private set; } = []; @@ -31,7 +34,7 @@ public sealed class IndexModel( public async Task 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 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 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 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 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 Roles, + string PatronTierLabel, + decimal PatronTierWeight, + string ContributorTierLabel, + decimal ContributorTierWeight); } diff --git a/src/Bifrost.Web/Pages/App/Motions/Index.cshtml b/src/Bifrost.Web/Pages/App/Motions/Index.cshtml index 7a95c2d..4c154d6 100644 --- a/src/Bifrost.Web/Pages/App/Motions/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Motions/Index.cshtml @@ -1,4 +1,5 @@ @page +@using Bifrost.Web.Domain @model Bifrost.Web.Pages.App.Motions.IndexModel @{ ViewData["Title"] = "Motions"; @@ -9,8 +10,8 @@ Governance

Open motions and voting

- 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.

@@ -28,6 +29,11 @@ +
+ + + +
@@ -54,54 +60,94 @@

Vote ledger

-

Your current effective weight is @Model.Actor.EffectiveVotingWeight.

+

Your current effective vote weight is @Model.Actor.EffectiveVotingWeight.ToString("0.##").

- @if (Model.Motions.Count == 0) - { -
No motions yet.
- } - else - { -
- @foreach (var motion in Model.Motions) - { -
-
- @motion.Scope - @motion.StatusLabel -
-

@motion.Title

-

@motion.Summary

-

- @motion.ProjectName - · closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC -

-
- For @motion.VotesFor - Against @motion.VotesAgainst - Abstain @motion.VotesAbstain -
-
- @foreach (var choice in Enum.GetValues()) - { - - - - - - } -
-
- } +
+
+ Bugs + 15% threshold
- } +
+ Cosmetics + 30% threshold +
+
+ Balance changes + 40% threshold +
+
+ Features and new content + 50% threshold +
+
+ Fundamental design changes + 66% threshold +
+
+
+ @if (Model.Motions.Count == 0) + { +
No motions yet.
+ } + else + { +
+ @foreach (var motion in Model.Motions) + { +
+
+ @motion.Scope + @motion.Category + @motion.StatusLabel +
+

@motion.Title

+

@motion.Summary

+

+ @motion.ProjectName + · threshold @motion.Threshold.ToString("P0") + · closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC +

+
+ For @motion.VotesFor + Against @motion.VotesAgainst + Abstain @motion.VotesAbstain +
+ @if (!string.IsNullOrWhiteSpace(motion.ResolutionNote)) + { +

@motion.ResolutionNote

+ } +
+ @foreach (var choice in Enum.GetValues()) + { +
+ + + + +
+ } + + @if (Model.Actor.CanModerateMotions || motion.StatusLabel == "Expired") + { +
+ + +
+ } +
+
+ } +
+ } +
+ @section Scripts { } diff --git a/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs index b3fa5bb..89433e9 100644 --- a/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Motions/Index.cshtml.cs @@ -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 OnPostVoteAsync(Guid motionId, VoteChoice choice, CancellationToken cancellationToken) + public async Task 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 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); } diff --git a/src/Bifrost.Web/Pages/App/Projects/Index.cshtml b/src/Bifrost.Web/Pages/App/Projects/Index.cshtml index 82f25af..ee1cea3 100644 --- a/src/Bifrost.Web/Pages/App/Projects/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Projects/Index.cshtml @@ -13,30 +13,45 @@ Keeping them explicit is what lets the rest of the system stay legible.

-
-
-
-
- - - + @if (Model.Actor.CanManageProjects) + { + +
+
+
+ + + +
+
+ + + Leave blank to derive from the name. + +
+
+ + + Use owner/repo when the project should sync GitHub issues and PRs. + +
- - - Leave blank to derive from the name. - + + +
+
+ +
+ + } + else + { +
+ Producers and platform admins can create or map project containers.
-
- - - -
-
- -
- + }
@@ -60,6 +75,7 @@ Name Status Owner + Repository Updated @@ -71,9 +87,20 @@ @project.Name
@project.Slug

@project.Summary

+
@project.OpenWorkItems open work items
@project.Status - @project.OwnerUserAccount.DisplayName + @project.OwnerName + + @if (string.IsNullOrWhiteSpace(project.GitHubRepository)) + { + Internal only + } + else + { + @project.GitHubRepository + } + @project.UpdatedAtUtc.ToString("dd MMM yyyy") } diff --git a/src/Bifrost.Web/Pages/App/Projects/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Projects/Index.cshtml.cs index d6bb42b..e6103ec 100644 --- a/src/Bifrost.Web/Pages/App/Projects/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Projects/Index.cshtml.cs @@ -19,7 +19,7 @@ public sealed class IndexModel( public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; - public IReadOnlyList Projects { get; private set; } = []; + public IReadOnlyList Projects { get; private set; } = []; public async Task OnGetAsync(CancellationToken cancellationToken) { @@ -29,6 +29,10 @@ public sealed class IndexModel( public async Task 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); } diff --git a/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml b/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml index 7465839..7f7b2be 100644 --- a/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml @@ -1,4 +1,5 @@ @page +@using Bifrost.Web.Domain @model Bifrost.Web.Pages.App.WorkItems.IndexModel @{ ViewData["Title"] = "Work items"; @@ -9,8 +10,8 @@ Work items

Internal and GitHub-backed work

- 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.

@@ -27,9 +28,29 @@
- - - + + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
@@ -43,96 +64,232 @@ -
-
-
-

Queue

-

@Model.WorkItems.Count tracked work items.

+
+ Workflow +

How this MVP flows

+
+
+ States + Proposed, open, volunteered, assigned, in progress, submitted for review, changes requested, approved, completed, blocked, archived. +
+
+ Time tracking + Members log actual hours per item. Producers and maintainers approve or reject those logs. +
+
+ GitHub sync + Issues and pull requests show up here when the GitHub App webhook is configured.
+
+ + +
+
+
+

Shared work board

+

@Model.WorkItems.Count tracked work items.

+
+
+ + @if (Model.WorkItems.Count == 0) + { +
No work items yet.
+ } + else + { +
+ + + + + + + + + + + + @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) - { -
No work items yet.
- } - else - { -
-
WorkWorkflowTime and pointsGitHubActions
- - - - - - - - - @foreach (var workItem in Model.WorkItems) - { - var hasClaim = workItem.VolunteerClaims.Any(x => x.UserAccountId == Model.Actor.UserAccount?.Id && x.Status == VolunteerClaimStatus.Active); - - - - + + + + - - - } - -
WorkStatusClaimsAssignment
- @workItem.Title
- @workItem.Project.Name -

@workItem.Summary

-
- @workItem.Status
- @workItem.EffortPoints.ToString("0.##") pts -
- @if (workItem.VolunteerClaims.Count == 0) - { - No claims yet. - } - else - { - @foreach (var claim in workItem.VolunteerClaims) + + @workItem.Title
+ @workItem.Project.Name +
+ @workItem.SourceType + @workItem.Category + @workItem.SkillLevel +
+

@workItem.Summary

+
+ @workItem.Status +
Review: @workItem.ReviewStatus
+
+ Assignee: + @(workItem.AssignedToUserAccount is null ? "Unassigned" : workItem.AssignedToUserAccount.DisplayName) +
+
+ Target: + @(workItem.TargetDateUtc is null ? "None" : workItem.TargetDateUtc.Value.ToString("dd MMM yyyy HH:mm") + " UTC") +
+ @if (workItem.VolunteerClaims.Count > 0) + { +
+ Volunteers: + @string.Join(", ", workItem.VolunteerClaims.Select(x => x.UserAccount.DisplayName)) +
+ } +
+
@workItem.EstimatedHours.ToString("0.##")h estimated
+
@workItem.WorkLogs.Sum(x => x.Hours).ToString("0.##")h logged
+
@workItem.ContributionPoints.ToString("0.##") contribution points
+ @if (workItem.WorkLogs.Count > 0) + { +
+ @foreach (var log in workItem.WorkLogs.OrderByDescending(x => x.CreatedAtUtc).Take(3)) { -
@claim.UserAccount.DisplayName (@claim.Status)
+
@log.UserAccount.DisplayName: @log.Hours.ToString("0.##")h (@log.ApprovalStatus)
} +
+ } +
+ @if (workItem.GitHubIssueLink is null && workItem.GitHubPullRequestLinks.Count == 0) + { + Internal-only + } + else + { + @if (workItem.GitHubIssueLink is not null) + { +
+ + Issue #@workItem.GitHubIssueLink.IssueNumber + + (@workItem.GitHubIssueLink.State) +
} - - @if (!hasClaim) + @foreach (var pr in workItem.GitHubPullRequestLinks.OrderByDescending(x => x.LastSynchronizedAtUtc)) + { +
+ + PR #@pr.PullRequestNumber + + (@pr.State, @pr.ReviewDecision) +
+ } + } +
+
+ @if (!hasClaim && workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived) {
} -
- @if (workItem.AssignedToUserAccount is not null) - { - @workItem.AssignedToUserAccount.DisplayName - } - else - { - Unassigned - } - @if (Model.Actor.CanManageProjects && workItem.VolunteerClaims.Any(x => x.Status == VolunteerClaimStatus.Active)) + @if (Model.Actor.CanAssignWork) { -
+
} -
-
- } -
+ + @if (canWorkOnItem && workItem.Status is WorkItemStatus.Assigned or WorkItemStatus.Volunteered or WorkItemStatus.Proposed or WorkItemStatus.Open or WorkItemStatus.ChangesRequested) + { +
+ + +
+ } + + @if (canWorkOnItem && workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived) + { +
+ + + + +
+ } + + @if (canWorkOnItem && workItem.Status is WorkItemStatus.InProgress or WorkItemStatus.ChangesRequested or WorkItemStatus.Approved) + { +
+ + +
+ } + + @if (Model.Actor.CanReviewWork && workItem.Status is WorkItemStatus.SubmittedForReview or WorkItemStatus.ChangesRequested or WorkItemStatus.Approved) + { +
+ + + + +
+ } + + @if (Model.Actor.CanReviewWork && workItem.Status == WorkItemStatus.Approved) + { +
+ + +
+ } + + @if (Model.Actor.CanReviewWork && pendingLogs.Count > 0) + { + @foreach (var workLog in pendingLogs) + { +
+ + + +
+ } + } +
+ + + } + + + + } @section Scripts { diff --git a/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml.cs index fa8c849..47b04b8 100644 --- a/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/WorkItems/Index.cshtml.cs @@ -22,6 +22,8 @@ public sealed class IndexModel( public IReadOnlyList ProjectOptions { get; private set; } = []; + public IReadOnlyList MemberOptions { get; private set; } = []; + public IReadOnlyList 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 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 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 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 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 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 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; } } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 504b8d9..691269a 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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() .BindConfiguration(GitHubOAuthOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(GitHubAppOptions.SectionName); + builder.Services .AddOptions() .BindConfiguration(BootstrapOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(BifrostHostOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -36,12 +46,25 @@ builder.Services.AddDbContext(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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services @@ -107,12 +130,21 @@ builder.Services.AddAuthorization(options => var app = builder.Build(); +app.Services.GetRequiredService().Validate(); +await using (var scope = app.Services.CreateAsyncScope()) +{ + await scope.ServiceProvider + .GetRequiredService() + .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(); 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 gitHubOptions) => { if (!gitHubOptions.Value.IsConfigured) diff --git a/src/Bifrost.Web/appsettings.Development.json b/src/Bifrost.Web/appsettings.Development.json index 33b8836..73c4591 100644 --- a/src/Bifrost.Web/appsettings.Development.json +++ b/src/Bifrost.Web/appsettings.Development.json @@ -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", diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index cc99b61..2380f07 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -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" } diff --git a/src/Bifrost.Web/wwwroot/css/site.css b/src/Bifrost.Web/wwwroot/css/site.css index f0731b4..f1e8ba7 100644 --- a/src/Bifrost.Web/wwwroot/css/site.css +++ b/src/Bifrost.Web/wwwroot/css/site.css @@ -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); diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs new file mode 100644 index 0000000..009dfc5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -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 +{ + 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(); + 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()}"; + } +} diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index efead8f..571c90f 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -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 { + 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 { ["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 services.AddDbContext(options => { - options.UseInMemoryDatabase($"bifrost-tests-{Guid.NewGuid():N}"); + options.UseInMemoryDatabase(_databaseName, _databaseRoot); }); services.AddAuthentication(options =>