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:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Gauge } from 'lucide-react'
|
||||
import { Download, Gauge } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -59,6 +60,7 @@ export function DeliveryPage() {
|
||||
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
|
||||
const [teams, setTeams] = useState<TeamProgress[]>([])
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) return
|
||||
@@ -122,6 +124,33 @@ export function DeliveryPage() {
|
||||
|
||||
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 (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
@@ -129,16 +158,24 @@ export function DeliveryPage() {
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Gauge className="size-6" /> Delivery
|
||||
</h1>
|
||||
{products.length > 0 && (
|
||||
<Select value={productId ?? ''} onValueChange={setProductId}>
|
||||
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<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 && (
|
||||
<Select value={productId ?? ''} onValueChange={setProductId}>
|
||||
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{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.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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user