Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Bit.Admin.Billing.Models.OrganizationPlanMigrationCohorts;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
Expand All @@ -8,6 +11,8 @@
using Bit.Core.Billing.Organizations.PlanMigration.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe;
Expand All @@ -22,10 +27,16 @@ public class OrganizationPlanMigrationCohortsController(
IStripeAdapter stripeAdapter,
ILogger<OrganizationPlanMigrationCohortsController> logger,
IFeatureService featureService,
IGetCohortAssignmentStateQuery getCohortAssignmentStateQuery) : Controller
IGetCohortAssignmentStateQuery getCohortAssignmentStateQuery,
IExportCohortAssignmentsQuery exportCohortAssignmentsQuery) : Controller
{
private const int _defaultPageSize = 25;

// How many CSV rows to buffer before flushing to the response stream during an export. Keeps
// bytes flowing on large cohorts so the connection doesn't idle into a server write-timeout.
// Intentionally independent of the export query's DB page size -- this only bounds buffering.
private const int _exportFlushEveryRows = 1000;

private bool PlanMigrationCohortsFeatureEnabled() =>
featureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration);

Expand Down Expand Up @@ -114,6 +125,94 @@ public async Task<IActionResult> Edit(Guid id)
return View(EditCohortViewModel.From(cohort, CohortFormModel.From(cohort), assignmentState));
}

[HttpGet("{id:guid}/export")]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
public async Task<IActionResult> Export(Guid id)
{
if (!PlanMigrationCohortsFeatureEnabled()) return NotFound();

var cohort = await cohortRepository.GetByIdAsync(id);
if (cohort == null) return NotFound();

var fileName = BuildExportFileName(cohort.Name);

logger.LogInformation(
"Cohort CSV export started. Actor: {Actor}, CohortId: {CohortId}",
User?.Identity?.Name ?? "unknown",
cohort.Id);

Response.ContentType = "text/csv";
Response.Headers.ContentDisposition = $"attachment; filename=\"{fileName}\"";

var config = new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = false };
var writer = new StreamWriter(Response.Body, new UTF8Encoding(false));
Comment thread
cyprain-okeke marked this conversation as resolved.
Dismissed
var csv = new CsvWriter(writer, config);
Comment thread
cyprain-okeke marked this conversation as resolved.
Dismissed

foreach (var header in new[]
{
"OrganizationId", "OrganizationName", "AssignedAt", "ScheduledDate", "MigratedDate",
})
{
csv.WriteField(header);
}
await csv.NextRecordAsync();

var sinceFlush = 0;
var rowsWritten = 0;
var aborted = false;
try
{
await foreach (var row in exportCohortAssignmentsQuery.GetByCohortIdAsync(cohort.Id))
{
csv.WriteField(row.OrganizationId.ToString());
csv.WriteField(SanitizeCsvField(row.OrganizationName));
csv.WriteField(row.AssignedAt.ToString("o", CultureInfo.InvariantCulture));
csv.WriteField(row.ScheduledDate?.ToString("o", CultureInfo.InvariantCulture) ?? string.Empty);
csv.WriteField(row.MigratedDate?.ToString("o", CultureInfo.InvariantCulture) ?? string.Empty);
await csv.NextRecordAsync();
rowsWritten++;

if (++sinceFlush >= _exportFlushEveryRows)
{
await csv.FlushAsync();
sinceFlush = 0;
}
}

await csv.FlushAsync();
}
catch (Exception ex)
{
// Deliberately broad: once the 200 + headers are committed we cannot convert any failure
// (DB read, serialization, write) into an error response, so every failure mode must take
// the same path -- log it and abort the connection so the operator gets a visibly broken
// download instead of a silently truncated CSV that looks complete.
logger.LogError(ex,
"Cohort CSV export failed mid-stream after the response started. CohortId: {CohortId}, RowsWritten: {RowsWritten}",
cohort.Id,
rowsWritten);
aborted = true;
HttpContext.Abort();
}
Comment thread
cyprain-okeke marked this conversation as resolved.
Fixed
Comment thread
cyprain-okeke marked this conversation as resolved.
Dismissed
finally
{
try
{
await csv.DisposeAsync();
await writer.DisposeAsync();
}
catch (Exception ex) when (aborted)
{
// After an abort the underlying stream is dead, so a dispose-time flush throws. The
// original failure was already logged and surfaced via the abort; record this at debug
// for diagnosability without masking the real cause.
logger.LogDebug(ex, "Disposing the CSV writer after an aborted export threw; ignoring.");
}
}

return new EmptyResult();
}

[HttpPost("{id:guid}")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Tools_ManagePlanMigrationCohorts)]
Expand Down Expand Up @@ -214,6 +313,32 @@ public async Task<IActionResult> Delete(Guid id)
private static string? NormalizeCouponCode(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();

private static string BuildExportFileName(string cohortName)
{
var slug = Regex.Replace(cohortName ?? string.Empty, "[^a-zA-Z0-9]+", "-")
.Trim('-')
.ToLowerInvariant();

if (string.IsNullOrEmpty(slug))
{
slug = "cohort";
}

return $"{slug}-{DateTime.UtcNow:yyyy-MM-dd}.csv";
}

private static string SanitizeCsvField(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}

return value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'
? "'" + value
: value;
}

// MVC skips IValidatableObject.Validate when any property-level attribute already failed, hiding cross-field
// rules until the operator resubmits. Run it explicitly so every error surfaces on a single submit.
// See https://github.com/dotnet/aspnetcore/issues/1899.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="cohort-form">Save</button>
<a asp-action="Index" class="btn btn-secondary ms-2">Cancel</a>
<a asp-action="Export" asp-route-id="@Model.FormModel.Id" class="btn btn-secondary ms-2">
<i class="fa fa-download"></i> Export CSV
</a>
<div class="ms-auto d-flex">
<form method="post" asp-action="Delete" asp-route-id="@Model.FormModel.Id"
onsubmit="return confirm('Are you sure you want to delete this cohort?')">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@
@foreach (var row in Model.Items)
{
<tr>
<td><a asp-action="Edit" asp-route-id="@row.Id">@row.Name</a></td>
<td>
<a asp-action="Edit" asp-route-id="@row.Id">@row.Name</a>
<a asp-action="Export" asp-route-id="@row.Id" class="ms-1" title="Export CSV">
<i class="fa fa-download"></i>
</a>
</td>
<td>
@if (row.IsChurnOnly)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddTransient<IGetOrganizationMetadataQuery, GetOrganizationMetadataQuery>();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IGetCohortAssignmentStateQuery, GetCohortAssignmentStateQuery>();
services.AddTransient<IExportCohortAssignmentsQuery, ExportCohortAssignmentsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
ο»Ώnamespace Bit.Core.Billing.Organizations.PlanMigration.Models;

/// <summary>
/// A single organization's row in a cohort CSV export. Pure data: the export query yields these,
/// and the Admin controller is responsible for CSV formatting and HTTP streaming. Contains no
/// Vault Data -- only org-level operational metadata.
/// </summary>
/// <param name="Id">
/// The assignment <c>Id</c>. Used only as the keyset-paging tiebreaker; it is NOT written to the CSV.
/// </param>
/// <param name="OrganizationName">The organization's display name (joined from the Organization table).</param>
/// <param name="AssignedAt">
/// The date the organization was assigned to the cohort (the assignment <c>CreationDate</c>).
/// </param>
public record CohortAssignmentExportRow(
Guid Id,
Guid OrganizationId,
string OrganizationName,
DateTime AssignedAt,
DateTime? ScheduledDate,
DateTime? MigratedDate);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
ο»Ώusing Bit.Core.Billing.Organizations.PlanMigration.Models;
using Bit.Core.Billing.Organizations.PlanMigration.Repositories;

namespace Bit.Core.Billing.Organizations.PlanMigration.Queries;

public interface IExportCohortAssignmentsQuery
{
/// <summary>
/// Streams every organization assigned to the cohort as <see cref="CohortAssignmentExportRow"/>
/// records, in a deterministic, cross-provider-stable order. Pure data: the caller is
/// responsible for any CSV formatting or HTTP streaming. The cohort's current state is read at
/// enumeration time (no caching).
/// </summary>
IAsyncEnumerable<CohortAssignmentExportRow> GetByCohortIdAsync(Guid cohortId);
}

public class ExportCohortAssignmentsQuery(
IOrganizationPlanMigrationCohortAssignmentRepository assignmentRepository)
: IExportCohortAssignmentsQuery
{
// Bounded page size keeps memory flat regardless of cohort size; each page is its own
// short-lived database read.
private const int _pageSize = 1000;

public async IAsyncEnumerable<CohortAssignmentExportRow> GetByCohortIdAsync(Guid cohortId)
{
DateTime? afterCreationDate = null;
Guid? afterId = null;

while (true)
{
var page = await assignmentRepository.GetExportRowsByCohortIdAsync(
cohortId, afterCreationDate, afterId, _pageSize);

foreach (var row in page)
{
yield return row;
}

// A page shorter than the requested size means there is nothing left to read.
if (page.Count < _pageSize)
{
yield break;
}

var last = page[^1];
afterCreationDate = last.AssignedAt;
afterId = last.Id;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,19 @@ public interface IOrganizationPlanMigrationCohortAssignmentRepository
/// single atomic statement and returns the Insert/Update/Unassign counts. MSSQL/Dapper only.
/// </summary>
Task<CohortBulkAssignmentSummary> SyncManyAsync(IEnumerable<ResolvedCohortBulkAssignmentRow> rows);

/// <summary>
/// Returns a single bounded keyset page of export rows for the given cohort, joined to the
/// organization to surface <see cref="CohortAssignmentExportRow.OrganizationName"/>. Rows are
/// ordered by <c>(CreationDate, Id)</c> using the provider's native ordering; the cursor is only
/// internally consistent (the seek matches the ORDER BY on a given provider). Row order is not
/// guaranteed identical across databases, which is acceptable because the export is consumed as
/// a download. A page shorter than <paramref name="take"/> signals the end.
/// </summary>
/// <param name="cohortId">The cohort whose assignments to export.</param>
/// <param name="afterCreationDate">Exclusive lower bound on <c>CreationDate</c>; null for the first page.</param>
/// <param name="afterId">Tiebreaker lower bound on <c>Id</c> when <c>CreationDate</c> ties; null for the first page.</param>
/// <param name="take">Maximum number of rows to return.</param>
Task<IReadOnlyList<CohortAssignmentExportRow>> GetExportRowsByCohortIdAsync(
Guid cohortId, DateTime? afterCreationDate, Guid? afterId, int take);
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ public async Task<int> GetCohortNonPendingAssignmentsCountAsync(Guid cohortId)
commandType: CommandType.StoredProcedure);
}

public async Task<IReadOnlyList<CohortAssignmentExportRow>> GetExportRowsByCohortIdAsync(
Guid cohortId, DateTime? afterCreationDate, Guid? afterId, int take)
{
await using var connection = new SqlConnection(ReadOnlyConnectionString);

var results = await connection.QueryAsync<CohortAssignmentExportRow>(
$"[{Schema}].[{Table}_ReadManyExportByCohortId]",
new
{
CohortId = cohortId,
AfterCreationDate = afterCreationDate,
AfterId = afterId,
Take = take,
},
commandType: CommandType.StoredProcedure);

return results.ToList();
}

public async Task<CohortBulkAssignmentSummary> SyncManyAsync(
IEnumerable<ResolvedCohortBulkAssignmentRow> rows)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public void Configure(EntityTypeBuilder<OrganizationPlanMigrationCohortAssignmen
.HasIndex(a => new { a.CohortId, a.ScheduledDate, a.MigratedDate })
.IsClustered(false);

// Composite index serves the CSV export cursor: cohort filter plus the
// (CreationDate, Id) keyset seek (PM-36965).
builder
.HasIndex(a => new { a.CohortId, a.CreationDate, a.Id })
.IsClustered(false);

builder
.HasOne(a => a.Organization)
.WithMany()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,37 @@ join c in dbContext.OrganizationPlanMigrationCohorts on a.CohortId equals c.Id
return await query.CountAsync();
}

public async Task<IReadOnlyList<CohortAssignmentExportRow>> GetExportRowsByCohortIdAsync(
Guid cohortId, DateTime? afterCreationDate, Guid? afterId, int take)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);

var assignments = dbContext.OrganizationPlanMigrationCohortAssignments
.Where(a => a.CohortId == cohortId);

if (afterCreationDate != null)
{
assignments = assignments.Where(a =>
a.CreationDate > afterCreationDate.Value
Comment thread
cyprain-okeke marked this conversation as resolved.
Dismissed
|| (a.CreationDate == afterCreationDate.Value
&& a.Id > afterId!.Value));
}

return await assignments
.OrderBy(a => a.CreationDate)
.ThenBy(a => a.Id)
.Take(take)
.Select(a => new CohortAssignmentExportRow(
a.Id,
a.OrganizationId,
a.Organization.Name,
a.CreationDate,
a.ScheduledDate,
a.MigratedDate))
.ToListAsync();
}

public Task<CohortBulkAssignmentSummary> SyncManyAsync(
IEnumerable<ResolvedCohortBulkAssignmentRow> rows) =>
throw new NotSupportedException(
Expand Down
Loading
Loading