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
+38 -1
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Gauge } from 'lucide-react' import { Download, Gauge } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell' import { AppShell } from '@/components/AppShell'
import { Button } from '@/components/ui/button'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -59,6 +60,7 @@ export function DeliveryPage() {
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product')) const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
const [teams, setTeams] = useState<TeamProgress[]>([]) const [teams, setTeams] = useState<TeamProgress[]>([])
const [analytics, setAnalytics] = useState<Analytics | null>(null) const [analytics, setAnalytics] = useState<Analytics | null>(null)
const [downloading, setDownloading] = useState(false)
useEffect(() => { useEffect(() => {
if (!organizationId) return if (!organizationId) return
@@ -122,6 +124,33 @@ export function DeliveryPage() {
const product = products.find((p) => p.id === productId) ?? null const product = products.find((p) => p.id === productId) ?? null
// Download the product as a zip of its delivered artifacts. The export endpoint streams a file, so we
// fetch it with the auth header (the api helper only does JSON), then trigger a browser download.
const downloadProject = useCallback(async () => {
if (!productId) return
setDownloading(true)
try {
const token = useAuth.getState().token
const res = await fetch(`/api/orgboard/products/${productId}/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Export failed (${res.status})`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(product?.name ?? 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (err) {
toast.error((err as Error).message)
} finally {
setDownloading(false)
}
}, [productId, product])
return ( return (
<AppShell> <AppShell>
<div className="mx-auto max-w-4xl p-6"> <div className="mx-auto max-w-4xl p-6">
@@ -129,6 +158,13 @@ export function DeliveryPage() {
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight"> <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Gauge className="size-6" /> Delivery <Gauge className="size-6" /> Delivery
</h1> </h1>
<div className="flex items-center gap-2">
{product && (
<Button variant="outline" size="sm" disabled={downloading} onClick={downloadProject}>
<Download data-icon="inline-start" />
{downloading ? 'Preparing…' : 'Download project'}
</Button>
)}
{products.length > 0 && ( {products.length > 0 && (
<Select value={productId ?? ''} onValueChange={setProductId}> <Select value={productId ?? ''} onValueChange={setProductId}>
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger> <SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
@@ -139,6 +175,7 @@ export function DeliveryPage() {
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
</div>
</header> </header>
{product && ( {product && (
Binary file not shown.
@@ -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.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
@@ -24,6 +28,7 @@ internal static class OrgBoardEndpoints
group.MapGet("/products", ListProducts).RequireAuthorization(); group.MapGet("/products", ListProducts).RequireAuthorization();
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization(); group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization(); group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
group.MapGet("/products/{id:guid}/export", ExportProduct).RequireAuthorization();
group.MapPost("/teams", CreateTeam).RequireAuthorization(); group.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization(); group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).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)); 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( private static async Task<IResult> ListTeams(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{ {