From 0658061580c4e3640527b3c878ec3f30c420eafe Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 17 Jun 2026 07:30:37 +0330 Subject: [PATCH] Theme 3: download a product as a project (zip export) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/pages/DeliveryPage.tsx | 59 +++++-- docs/TeamUp_Solution_Spec.docx | Bin 0 -> 20669 bytes .../Endpoints/OrgBoardEndpoints.cs | 153 ++++++++++++++++++ 3 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 docs/TeamUp_Solution_Spec.docx diff --git a/client/src/pages/DeliveryPage.tsx b/client/src/pages/DeliveryPage.tsx index 4ef3589..a12c454 100644 --- a/client/src/pages/DeliveryPage.tsx +++ b/client/src/pages/DeliveryPage.tsx @@ -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(() => localStorage.getItem('teamup.delivery.product')) const [teams, setTeams] = useState([]) const [analytics, setAnalytics] = useState(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 (
@@ -129,16 +158,24 @@ export function DeliveryPage() {

Delivery

- {products.length > 0 && ( - - )} +
+ {product && ( + + )} + {products.length > 0 && ( + + )} +
{product && ( diff --git a/docs/TeamUp_Solution_Spec.docx b/docs/TeamUp_Solution_Spec.docx new file mode 100644 index 0000000000000000000000000000000000000000..b342ccdf57622ea37c990a00c1acc907723d403a GIT binary patch literal 20669 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 literal 0 HcmV?d00001 diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs index 11d064a..2290422 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -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( + @"```(?[a-zA-Z0-9+#.]*)\s*\n(?.*?)```", + 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 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(); + 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 ListTeams( Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) {