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 { 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,16 +158,24 @@ 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>
|
||||||
{products.length > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<Select value={productId ?? ''} onValueChange={setProductId}>
|
{product && (
|
||||||
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
|
<Button variant="outline" size="sm" disabled={downloading} onClick={downloadProject}>
|
||||||
<SelectContent>
|
<Download data-icon="inline-start" />
|
||||||
<SelectGroup>
|
{downloading ? 'Preparing…' : 'Download project'}
|
||||||
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
</Button>
|
||||||
</SelectGroup>
|
)}
|
||||||
</SelectContent>
|
{products.length > 0 && (
|
||||||
</Select>
|
<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>
|
</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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user