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 0000000..b342ccd Binary files /dev/null and b/docs/TeamUp_Solution_Spec.docx differ 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) {