Theme 3: download a product as a project (zip export)

New GET /api/orgboard/products/{id}/export streams a zip of the product's
delivered work: PRODUCT.md (identity), each team's artifacts written as real
source files when the artifact is a single fenced code block (App.tsx,
schema.sql, …) or markdown otherwise, plus a README manifest. Gated on
board-view permission. The Delivery dashboard gets a Download project button
that fetches the file with the auth header and saves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 07:30:37 +03:30
parent 1e33d57b4e
commit 0658061580
3 changed files with 201 additions and 11 deletions
@@ -1,3 +1,7 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -24,6 +28,7 @@ internal static class OrgBoardEndpoints
group.MapGet("/products", ListProducts).RequireAuthorization();
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
group.MapGet("/products/{id:guid}/export", ExportProduct).RequireAuthorization();
group.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).RequireAuthorization();
@@ -230,6 +235,154 @@ internal static class OrgBoardEndpoints
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
}
// Matches a fenced code block, capturing its language hint and body, so we can write a delivered
// artifact out as a real source file (App.tsx, schema.sql, …) instead of a wall of markdown.
private static readonly Regex FenceRx = new(
@"```(?<lang>[a-zA-Z0-9+#.]*)\s*\n(?<body>.*?)```",
RegexOptions.Singleline | RegexOptions.Compiled);
// Download the product as a project: PRODUCT.md + every team's delivered artifacts as files,
// plus a README manifest. This is the "an agent did the work, now I download the result" payoff —
// a portable bundle of what the team (human + AI) produced, gated on board-view permission.
private static async Task<IResult> ExportProduct(
Guid id, IPermissionService permissions, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(product.OrganizationId)))
{
return Results.Forbid();
}
var teams = await db.Teams
.Where(t => t.ProductId == id)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(ct);
var teamIds = teams.Select(t => t.Id).ToList();
var teamsById = teams.ToDictionary(t => t.Id);
// Only items that actually carry a delivered artifact are worth exporting.
var items = await db.WorkItems
.Where(w => teamIds.Contains(w.TeamId) && w.Description != null && w.Description != "")
.OrderBy(w => w.CreatedAtUtc)
.ToListAsync(ct);
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var manifest = new StringBuilder();
manifest.Append("# ").Append(product.Name).Append("\n\n");
manifest.Append("_Exported from TeamUp on ")
.Append(clock.GetUtcNow().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)).Append("._\n\n");
manifest.Append(items.Count).Append(" delivered artifact(s) across ")
.Append(teams.Count).Append(" team(s).\n\n");
if (!string.IsNullOrWhiteSpace(product.Identity))
{
WriteEntry(zip, "PRODUCT.md", product.Identity!);
manifest.Append("- `PRODUCT.md` — product identity\n");
}
var counters = new Dictionary<Guid, int>();
foreach (var item in items)
{
var team = teamsById[item.TeamId];
var folder = Slug(team.Name);
var n = counters.TryGetValue(item.TeamId, out var c) ? c + 1 : 1;
counters[item.TeamId] = n;
var (ext, content) = RenderArtifact(item.Description!);
var path = $"{folder}/{n:D2}-{Slug(item.Title)}{ext}";
WriteEntry(zip, path, content);
manifest.Append("- `").Append(path).Append("` — ").Append(item.Title).Append('\n');
}
WriteEntry(zip, "README.md", manifest.ToString());
}
return Results.File(buffer.ToArray(), "application/zip", $"{Slug(product.Name)}.zip");
}
private static void WriteEntry(ZipArchive zip, string path, string content)
{
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
using var stream = entry.Open();
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
writer.Write(content);
}
// If a delivered artifact is essentially one fenced code block, write it out as that source file;
// otherwise keep it as markdown.
private static (string Ext, string Content) RenderArtifact(string artifact)
{
var matches = FenceRx.Matches(artifact);
if (matches.Count > 0)
{
var largest = matches
.OrderByDescending(m => m.Groups["body"].Value.Length)
.First();
var body = largest.Groups["body"].Value;
if (body.Length >= artifact.Trim().Length * 0.5)
{
return (ExtensionFor(largest.Groups["lang"].Value), body.TrimEnd() + "\n");
}
}
return (".md", artifact);
}
private static string ExtensionFor(string lang) => lang.ToLowerInvariant() switch
{
"tsx" => ".tsx",
"ts" or "typescript" => ".ts",
"jsx" => ".jsx",
"js" or "javascript" => ".js",
"cs" or "csharp" => ".cs",
"py" or "python" => ".py",
"go" or "golang" => ".go",
"java" => ".java",
"rb" or "ruby" => ".rb",
"php" => ".php",
"rs" or "rust" => ".rs",
"sql" => ".sql",
"html" => ".html",
"css" => ".css",
"scss" => ".scss",
"json" => ".json",
"yaml" or "yml" => ".yml",
"sh" or "bash" or "shell" => ".sh",
"md" or "markdown" => ".md",
_ => ".txt",
};
// Filesystem-safe lower-kebab slug for folder/file names.
private static string Slug(string value)
{
var sb = new StringBuilder(value.Length);
foreach (var ch in value.Trim().ToLowerInvariant())
{
sb.Append(char.IsLetterOrDigit(ch) ? ch : '-');
}
var slug = sb.ToString();
while (slug.Contains("--", StringComparison.Ordinal))
{
slug = slug.Replace("--", "-", StringComparison.Ordinal);
}
slug = slug.Trim('-');
if (slug.Length == 0)
{
return "item";
}
return slug.Length > 50 ? slug[..50].Trim('-') : slug;
}
private static async Task<IResult> ListTeams(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{