diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d04376..ceba8131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Added +- Organizers have a Manage Submissions page: every team and where it stands with + its submission — nothing yet, drafts only, or final — with the teams still + owing an entry first. Tabs filter by standing, a search finds a team by its + name, its project or a member — while searching, the tabs count the matches, + so a team on another tab is never missed — and each team shows its link or + notes and any earlier versions. A banner says whether submissions are open, + and "Download CSV" saves every team as a spreadsheet. The page only shows + submissions: an organizer cannot submit or finalize one for a team. - Manage Teams can find a person by name: type part of a name to highlight everyone who matches, on teams and in Unassigned, and press Enter to jump from one to the next. Accents and case do not matter. diff --git a/components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte b/components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte new file mode 100644 index 00000000..aea28c75 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/SubmissionStandingTabs.svelte @@ -0,0 +1,56 @@ + + + diff --git a/components/frontend/src/lib/navigation/items.test.ts b/components/frontend/src/lib/navigation/items.test.ts index 84ded58b..5924a5d8 100644 --- a/components/frontend/src/lib/navigation/items.test.ts +++ b/components/frontend/src/lib/navigation/items.test.ts @@ -330,12 +330,29 @@ describe("manageNav", () => { "manage:projects", "manage:tracks", "manage:teams", + "manage:submissions", "manage:timeline", "manage:pages", "manage:voting", ]) }) + // Same longest-match split as Teams: the member page is where a team files, + // the manage page is where an organiser sees every team. + it("lights Submissions on the member page and Manage Submissions on its own", () => { + const items = [ + ...memberNav("hack-1", [], false, false, 2), + ...manageNav("hack-1", owner, false), + ] + + expect(activeNavId("/my/hackathon/hack-1/submissions", items)).toBe( + "member:submissions", + ) + expect(activeNavId("/my/hackathon/hack-1/submissions/manage", items)).toBe( + "manage:submissions", + ) + }) + // Same gate as the participant Timeline entry, and the reason it matters more // here: with Manage Timeline hidden, the "Where the hackathon is" card on // Settings is the only route to the first phase. diff --git a/components/frontend/src/lib/navigation/items.ts b/components/frontend/src/lib/navigation/items.ts index 5bfbbeb6..560a1940 100644 --- a/components/frontend/src/lib/navigation/items.ts +++ b/components/frontend/src/lib/navigation/items.ts @@ -28,6 +28,7 @@ import UserRoundCheck from "lucide-svelte/icons/user-round-check" import UserRoundCog from "lucide-svelte/icons/user-round-cog" import Send from "lucide-svelte/icons/send" import Ticket from "lucide-svelte/icons/ticket" +import Inbox from "lucide-svelte/icons/inbox" import CalendarClock from "lucide-svelte/icons/calendar-clock" import Globe from "lucide-svelte/icons/globe" import CalendarCog from "lucide-svelte/icons/calendar-cog" @@ -476,6 +477,16 @@ export function manageNav( icon: UserRoundCog, href: resolve(`/my/hackathon/${hackathonId}/teams/manage`), }, + // After Teams, as in the participant entries: submissions hang off a team. + // Unconditional, like Manage Voting — whether submissions are open is a + // switch the page itself reports, and "who has not submitted" is the + // question an organiser asks most once they have closed. + { + id: "manage:submissions", + label: "Manage Submissions", + icon: Inbox, + href: resolve(`/my/hackathon/${hackathonId}/submissions/manage`), + }, // Editing, deleting and declaring a phase current — but only once there is a // phase to do any of that to. Phases are optional, exactly like tracks, so // the way to the *first* one is the "Where the hackathon is" card on diff --git a/components/frontend/src/lib/server/hackathon/capabilities.ts b/components/frontend/src/lib/server/hackathon/capabilities.ts index 39ac3967..6d1633d7 100644 --- a/components/frontend/src/lib/server/hackathon/capabilities.ts +++ b/components/frontend/src/lib/server/hackathon/capabilities.ts @@ -240,6 +240,28 @@ export function mayFinalizeSubmissions( return membership !== undefined && !membership.isWaiting } +/** + * Whether to offer the organiser's overview of every team's submissions. + * + * Mirrors the backend exactly: `ListSubmissions` falls back to a hackathon-wide + * `submission:read` (`team_service.go:1103-1106`), a default policy granted to + * `Owner` outright (`rbac.go:238`) and to an admin through the global escape + * hatch. Not gated on `CREATE_PROJECT_SUBMISSIONS` — that capability is about + * teams filing work, and an organiser needs the overview most once it is closed. + * + * Read only. Creating and finalizing need `submission:write` scoped to the team, + * which only `Member` holds (`rbac.go:234`); with no role inheritance an owner + * cannot finalize for a team, so the overview offers no action at all. + */ +export function mayViewAllSubmissions( + membership: HackathonMember | undefined, + isAdmin = false, +): boolean { + if (isAdmin) return true + + return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER +} + /** * Whether to offer vote category management — create, edit, delete. * diff --git a/components/frontend/src/lib/server/hackathon/submissions.test.ts b/components/frontend/src/lib/server/hackathon/submissions.test.ts index 4955e50a..878d591c 100644 --- a/components/frontend/src/lib/server/hackathon/submissions.test.ts +++ b/components/frontend/src/lib/server/hackathon/submissions.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest" import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission" -import { submissionVersions } from "./submissions" +import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team" +import type { User } from "$lib/server/grpc/generated/user/entities/user" +import { + submissionOverview, + submissionStanding, + submissionVersions, +} from "./submissions" // SubmissionStatus numeric values: DRAFT=1, FINAL=2. const DRAFT = 1 @@ -150,3 +156,95 @@ describe("submissionVersions", () => { expect(r.latest?.result).toBeUndefined() }) }) + +describe("submissionStanding", () => { + it("is missing with nothing filed", () => { + expect(submissionStanding(submissionVersions([], names))).toBe("missing") + }) + + it("is draft while nothing is final", () => { + const r = submissionVersions( + [sub({ version: 1 }), sub({ version: 2 })], + names, + ) + + expect(submissionStanding(r)).toBe("draft") + }) + + it("stays final when a newer draft sits on top of the entry", () => { + const r = submissionVersions( + [sub({ version: 1, status: FINAL }), sub({ version: 2, status: DRAFT })], + names, + ) + + expect(submissionStanding(r)).toBe("final") + }) +}) + +describe("submissionOverview", () => { + const user = (id: string, displayName: string): User => + ({ id, displayName, username: id }) as User + + /** A team with only the fields the overview reads. */ + const team = (id: string, name: string, members: User[] = []): Team => + ({ id, name, projectId: "project-1", members }) as unknown as Team + + const titles = new Map([["project-1", "Flood maps"]]) + + it("puts teams with nothing first, then drafts, then finals, each by name", () => { + const teams = [ + team("t-final", "Alpha"), + team("t-draft-b", "Bravo"), + team("t-none", "Zulu"), + team("t-draft-a", "Able"), + ] + const byTeam = new Map([ + ["t-final", [sub({ version: 1, status: FINAL })]], + ["t-draft-b", [sub({ version: 1 })]], + ["t-draft-a", [sub({ version: 1 })]], + ]) + + const rows = submissionOverview(teams, byTeam, titles, names) + + expect(rows.map((r) => [r.teamName, r.standing])).toEqual([ + ["Zulu", "missing"], + ["Able", "draft"], + ["Bravo", "draft"], + ["Alpha", "final"], + ]) + }) + + it("names the project, and says so when it cannot", () => { + const rows = submissionOverview( + [ + team("t1", "Known"), + { ...team("t2", "Orphan"), projectId: "gone" } as Team, + ], + new Map(), + titles, + names, + ) + + expect(rows.map((r) => r.projectTitle)).toEqual([ + "Flood maps", + "Unknown project", + ]) + }) + + it("lists members by name, sorted, falling back to the username", () => { + const rows = submissionOverview( + [ + team("t1", "Team", [ + user("u-zed", "Zed"), + { id: "u-anon", displayName: "", username: "anon" } as User, + user("u-amy", "Amy"), + ]), + ], + new Map(), + titles, + names, + ) + + expect(rows[0]?.members).toEqual(["Amy", "anon", "Zed"]) + }) +}) diff --git a/components/frontend/src/lib/server/hackathon/submissions.ts b/components/frontend/src/lib/server/hackathon/submissions.ts index c83e42d9..78e2fcde 100644 --- a/components/frontend/src/lib/server/hackathon/submissions.ts +++ b/components/frontend/src/lib/server/hackathon/submissions.ts @@ -1,5 +1,11 @@ import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission" import { SubmissionStatus } from "$lib/server/grpc/generated/hackathon/entities/submission_status" +import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team" +import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service" +import { + SUBMISSION_STANDINGS, + type SubmissionStanding, +} from "$lib/utils/submissionFilter" /** * Server-only: reads the generated `Submission` type, so it must never be @@ -89,3 +95,101 @@ export function submissionVersions( .reverse(), } } + +/** + * Where a team stands: nothing filed, only drafts, or a final entry. + * + * Read off `latestFinal` rather than `latest`, for the reason `submissionVersions` + * keeps the two apart — a draft created after finalizing does not take the + * team's entry back, so that team is still `final`. + */ +export function submissionStanding(s: TeamSubmissions): SubmissionStanding { + if (s.latestFinal) return "final" + + return s.latest ? "draft" : "missing" +} + +/** One team on Manage Submissions — and one line of its export. */ +export interface TeamSubmissionRow extends TeamSubmissions { + teamId: string + teamName: string + projectTitle: string + /** Current members by name, sorted — who an organiser would chase. */ + members: string[] + standing: SubmissionStanding +} + +/** + * Every team's submissions, keyed by team id. + * + * One `ListSubmissions` per team, in parallel: `TeamService.List` returns teams + * without their submissions (`team_service.go:63-68`), and there is no RPC that + * lists a hackathon's submissions in one go. + * + * TODO(backend: submissions-hackathon-list): one call once the backend offers + * one — either `List` loading the submissions edge that `Get` already loads, or + * a hackathon-scoped `ListSubmissions`. + * + * `ListSubmissions` rather than the submissions nested in `team.get`, for the + * same reason the participant page gives: only this one is meant to be complete. + * Nothing is caught: an owner holds `submission:read` hackathon-wide + * (`rbac.go:238`), so a refusal here is a real fault worth surfacing. + */ +export async function submissionsByTeam( + team: TeamServiceClient, + teams: Team[], +): Promise> { + const lists = await Promise.all( + teams.map(async (t) => { + const { submissions } = await team.listSubmissions({ teamId: t.id }) + + return [t.id, submissions] as const + }), + ) + + return new Map(lists) +} + +/** + * Every team as a row of the organiser's overview, sorted the way it is shown: + * by standing, the teams with nothing first and the finished ones last, then + * by name. + * + * Problems first because the page is for finding who still owes an entry — the + * teams that are done are the ones nobody has to do anything about. Ties go by + * team name, not by activity, so a row does not move every time a team saves. + * + * @param projectTitles project id → title; a team whose project is missing from + * it shows "Unknown project" rather than dropping out of the overview. + * @param memberNames user id → name, for creators and finalizers — see + * `submissionVersions`. + */ +export function submissionOverview( + teams: Team[], + byTeam: Map, + projectTitles: Map, + memberNames: Map, +): TeamSubmissionRow[] { + const rank = (s: SubmissionStanding) => SUBMISSION_STANDINGS.indexOf(s) + + return teams + .map((t) => { + const versions = submissionVersions(byTeam.get(t.id) ?? [], memberNames) + + return { + teamId: t.id, + teamName: t.name, + projectTitle: projectTitles.get(t.projectId) ?? "Unknown project", + members: t.members + .map((m) => m.displayName || m.username) + .sort((a, b) => a.localeCompare(b)), + standing: submissionStanding(versions), + ...versions, + } + }) + .sort( + (a, b) => + rank(a.standing) - rank(b.standing) || + a.teamName.localeCompare(b.teamName), + ) +} diff --git a/components/frontend/src/lib/utils/submissionCsv.test.ts b/components/frontend/src/lib/utils/submissionCsv.test.ts new file mode 100644 index 00000000..b0b655ce --- /dev/null +++ b/components/frontend/src/lib/utils/submissionCsv.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest" +import { parseCsv } from "./csv" +import { + sheetTime, + submissionCsv, + type SubmissionCsvRow, +} from "./submissionCsv" + +const row = (over: Partial = {}): SubmissionCsvRow => ({ + team: "Byte Me", + project: "Flood maps", + members: ["Alice", "Bob"], + standing: "Final", + version: 3, + submittedAt: new Date("2026-08-01T10:05:42Z"), + submittedBy: "Bob", + finalizedAt: new Date("2026-08-02T09:00:00Z"), + finalizedBy: "Alice", + newerDraft: undefined, + result: "https://github.com/byte-me/floods", + ...over, +}) + +describe("submissionCsv", () => { + it("writes a header and one row per team, result last", () => { + const parsed = parseCsv(submissionCsv([row()])) + + expect(parsed[0]).toEqual([ + "Team", + "Project", + "Members", + "Standing", + "Version", + "Submitted (UTC)", + "Submitted By", + "Finalized (UTC)", + "Finalized By", + "Newer Draft", + "Result", + ]) + expect(parsed[1]).toEqual([ + "Byte Me", + "Flood maps", + "Alice, Bob", + "Final", + "3", + "2026-08-01 10:05", + "Bob", + "2026-08-02 09:00", + "Alice", + "", + "https://github.com/byte-me/floods", + ]) + }) + + it("is a header on its own when there are no teams", () => { + expect(parseCsv(submissionCsv([]))).toHaveLength(1) + }) + + it("leaves every submission column blank for a team with nothing filed", () => { + const parsed = parseCsv( + submissionCsv([ + row({ + standing: "No submission", + version: undefined, + submittedAt: undefined, + submittedBy: undefined, + finalizedAt: undefined, + finalizedBy: undefined, + result: undefined, + }), + ]), + ) + + expect(parsed[1]?.slice(3)).toEqual([ + "No submission", + "", + "", + "", + "", + "", + "", + "", + ]) + }) + + it("names a newer draft by its version", () => { + const parsed = parseCsv(submissionCsv([row({ newerDraft: 4 })])) + + expect(parsed[1]?.[9]).toBe("4") + }) + + it("survives notes holding commas, quotes and line breaks", () => { + const result = 'Repo is "private", ask us\nDemo on Friday' + const parsed = parseCsv(submissionCsv([row({ result })])) + + expect(parsed).toHaveLength(2) + expect(parsed[1]?.[10]).toBe(result) + }) +}) + +describe("sheetTime", () => { + it("is blank without a time", () => { + expect(sheetTime(undefined)).toBe("") + }) + + it("drops seconds and writes UTC whatever the local zone", () => { + expect(sheetTime(new Date("2026-12-31T23:59:59.999Z"))).toBe( + "2026-12-31 23:59", + ) + }) +}) diff --git a/components/frontend/src/lib/utils/submissionCsv.ts b/components/frontend/src/lib/utils/submissionCsv.ts new file mode 100644 index 00000000..9a58ed32 --- /dev/null +++ b/components/frontend/src/lib/utils/submissionCsv.ts @@ -0,0 +1,82 @@ +import { csvRow } from "./csv" + +/** + * Manage Submissions as a CSV, for checking entries off away from the screen. + * + * Write-only, like `projectCsv`. Its own module so the column order and the + * date format are pinned down in a test rather than inline in the route. + */ + +/** One team, as a row. Every field is already the text the cell shows. */ +export interface SubmissionCsvRow { + team: string + project: string + /** Current members by name, already sorted. */ + members: string[] + /** "No submission", "Draft only" or "Final" — as the page's tabs say it. */ + standing: string + /** The version that counts: the final entry if there is one. */ + version: number | undefined + submittedAt: Date | undefined + submittedBy: string | undefined + finalizedAt: Date | undefined + finalizedBy: string | undefined + /** A draft newer than the final entry, which does not count unless finalized. */ + newerDraft: number | undefined + result: string | undefined +} + +/** + * A time as a spreadsheet reads it: `2026-08-01 10:00`, in UTC. + * + * UTC because the file is built on the server, which knows nothing of the + * reader's time zone — the header says so rather than leaving a local-looking + * time to be misread. Without seconds or a `T`, which is the shape every + * spreadsheet parses as a date on its own. + */ +export function sheetTime(d: Date | undefined): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : "" +} + +/** + * The file as it goes out. + * + * `Result` last, because it is the one column that may hold a paragraph of + * notes rather than a link, and anything after it would sit beyond a tall cell. + */ +export function submissionCsv(rows: readonly SubmissionCsvRow[]): string { + const num = (n: number | undefined) => (n === undefined ? "" : String(n)) + + return ( + csvRow([ + "Team", + "Project", + "Members", + "Standing", + "Version", + "Submitted (UTC)", + "Submitted By", + "Finalized (UTC)", + "Finalized By", + "Newer Draft", + "Result", + ]) + + rows + .map((r) => + csvRow([ + r.team, + r.project, + r.members.join(", "), + r.standing, + num(r.version), + sheetTime(r.submittedAt), + r.submittedBy ?? "", + sheetTime(r.finalizedAt), + r.finalizedBy ?? "", + num(r.newerDraft), + r.result ?? "", + ]), + ) + .join("") + ) +} diff --git a/components/frontend/src/lib/utils/submissionFilter.test.ts b/components/frontend/src/lib/utils/submissionFilter.test.ts new file mode 100644 index 00000000..332da974 --- /dev/null +++ b/components/frontend/src/lib/utils/submissionFilter.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest" +import { + SUBMISSION_FILTERS, + inSubmissionFilter, + submissionFilterCounts, + submissionFilterFrom, + submissionFilterQuery, +} from "./submissionFilter" + +describe("submissionFilterFrom", () => { + it.each(SUBMISSION_FILTERS)("accepts %s", (filter) => { + expect(submissionFilterFrom(filter)).toBe(filter) + }) + + it.each([ + ["an absent parameter", null], + ["an empty parameter", ""], + ["a word we do not use", "pending"], + ["a numeric status", "2"], + ])("returns undefined for %s", (_name, raw) => { + expect(submissionFilterFrom(raw)).toBeUndefined() + }) +}) + +describe("submissionFilterQuery", () => { + it.each(SUBMISSION_FILTERS)("round-trips %s", (filter) => { + const raw = new URLSearchParams(submissionFilterQuery(filter)).get( + "standing", + ) + + expect(submissionFilterFrom(raw)).toBe(filter) + }) +}) + +describe("inSubmissionFilter", () => { + it("puts every standing on All", () => { + expect(inSubmissionFilter("missing", "all")).toBe(true) + expect(inSubmissionFilter("final", "all")).toBe(true) + }) + + it("puts a standing on its own tab and no other", () => { + expect(inSubmissionFilter("draft", "draft")).toBe(true) + expect(inSubmissionFilter("draft", "final")).toBe(false) + expect(inSubmissionFilter("draft", "missing")).toBe(false) + }) +}) + +describe("submissionFilterCounts", () => { + it("counts each tab, with All holding everyone", () => { + expect( + submissionFilterCounts(["missing", "draft", "draft", "final"]), + ).toEqual({ all: 4, missing: 1, draft: 2, final: 1 }) + }) + + it("is zero everywhere with no teams", () => { + expect(submissionFilterCounts([])).toEqual({ + all: 0, + missing: 0, + draft: 0, + final: 0, + }) + }) +}) diff --git a/components/frontend/src/lib/utils/submissionFilter.ts b/components/frontend/src/lib/utils/submissionFilter.ts new file mode 100644 index 00000000..da2c55cb --- /dev/null +++ b/components/frontend/src/lib/utils/submissionFilter.ts @@ -0,0 +1,80 @@ +// Where a team stands with its submission, and which of those an organiser is +// looking at on Manage Submissions. +// +// Three standings, and every team is in exactly one: nothing filed yet, only +// drafts, or a final version — the team's actual entry. A final version with a +// newer draft on top of it is still `final`: the draft does not undo what the +// team already declared (see `submissionVersions`), so the row shows the draft +// as a note rather than moving the team back a step. +// +// Words rather than numbers, like `projectFilter`: this vocabulary appears in the +// address bar, where `?standing=draft` says what it means. Nothing here imports +// the generated enums, so a component may import this file. + +/** Every standing, in the order the overview sorts them: the work first. */ +export const SUBMISSION_STANDINGS = ["missing", "draft", "final"] as const + +export type SubmissionStanding = (typeof SUBMISSION_STANDINGS)[number] + +/** The tabs: every standing, plus all of them at once, which comes first. */ +export const SUBMISSION_FILTERS = ["all", ...SUBMISSION_STANDINGS] as const + +export type SubmissionFilter = (typeof SUBMISSION_FILTERS)[number] + +/** + * Where an address that names no filter lands: every team. Unlike the project + * queue there is no one list that is "the line-up" here — the overview is the + * point, and the sort already puts the teams that need chasing on top. + */ +export const DEFAULT_SUBMISSION_FILTER: SubmissionFilter = "all" + +/** Tab labels. "Draft only", since a final team may hold drafts too. */ +export const SUBMISSION_FILTER_LABEL: Record = { + all: "All", + missing: "No submission", + draft: "Draft only", + final: "Final", +} + +/** + * The filter a query parameter asks for, or `undefined` when it asks for + * nothing recognizable. The caller applies `DEFAULT_SUBMISSION_FILTER`. + */ +export function submissionFilterFrom( + raw: string | null | undefined, +): SubmissionFilter | undefined { + return SUBMISSION_FILTERS.includes(raw as SubmissionFilter) + ? (raw as SubmissionFilter) + : undefined +} + +/** The query string that reproduces a filter, leading `?` included. */ +export function submissionFilterQuery(filter: SubmissionFilter): string { + return `?standing=${filter}` +} + +/** Whether a team with this standing belongs on this tab. */ +export function inSubmissionFilter( + standing: SubmissionStanding, + filter: SubmissionFilter, +): boolean { + return filter === "all" || standing === filter +} + +/** + * How many of these teams each tab would show. + * + * Handed the teams the tabs should count — every team, or only those matching + * a search — so the same tabs can say "where are the teams" and "where are the + * matches" without knowing which. + */ +export function submissionFilterCounts( + standings: readonly SubmissionStanding[], +): Record { + return Object.fromEntries( + SUBMISSION_FILTERS.map((f) => [ + f, + standings.filter((s) => inSubmissionFilter(s, f)).length, + ]), + ) as Record +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts new file mode 100644 index 00000000..7a416460 --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.server.ts @@ -0,0 +1,77 @@ +import type { PageServerLoad } from "./$types" +import { Capability } from "$lib/server/grpc/generated/hackathon/entities/capability" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { requireGrpc } from "$lib/server/grpc/client" +import { mayViewAllSubmissions } from "$lib/server/hackathon/capabilities" +import { namesByUserId } from "$lib/server/hackathon/membership" +import { + submissionOverview, + submissionsByTeam, +} from "$lib/server/hackathon/submissions" +import { listVisibleTeams } from "$lib/server/hackathon/teams" +import { error } from "@sveltejs/kit" + +// The organiser's overview: every team, where it stands with its submission, +// and what it has filed. +// +// **Read only.** An owner may read every submission but write none — creating +// and finalizing are team-scoped and granted to `Member` only (see +// `mayViewAllSubmissions`) — so the page has no actions. Chasing a team that +// has not submitted happens outside the app. +export const load: PageServerLoad = async (event) => { + const { hackathon, myMembership, hackathonState } = await event.parent() + + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + // Frontend-only gate, same shape as the other manage routes; the RPCs below + // enforce it for real. + if (!mayViewAllSubmissions(myMembership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can see every team's submissions") + } + + const { team } = requireGrpc(event.locals.grpc) + + // Whether teams can file new versions right now — the banner's subject. Closing + // it does not stop a team finalizing a draft it already holds (see + // `mayFinalizeSubmissions`), which is why the banner says so. + const submissionsOpen = hackathonState.enabled.includes( + Capability.CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + ) + + // Through `listVisibleTeams` rather than a bare `List`: an owner holds + // `team:read` unconditionally, but a global admin who does not own this + // hackathon is untested (see `submissions-need-view-teams`), and a refusal + // should read as one rather than as a 500. + const teams = await listVisibleTeams(team, hackathon.id) + if (teams === undefined) { + return { + hackathonId: hackathon.id, + submissionsOpen, + teamsReadable: false, + rows: [], + } + } + + // Creators and finalizers come back as bare ids. Owners included, as on the + // other manage pages: an organiser who never joined holds no member row. + const names = namesByUserId(hackathon.members, hackathon.owners) + const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title])) + + // Every team, whichever tab is open. The tab is applied on the page, for two + // reasons: a search has to see every team to say which tab its matches are + // on, and a load that never reads `?standing=` is one SvelteKit does not + // re-run when only the tab changes — switching tabs would otherwise repeat a + // `ListSubmissions` per team. + return { + hackathonId: hackathon.id, + submissionsOpen, + teamsReadable: true, + rows: submissionOverview( + teams, + await submissionsByTeam(team, teams), + projectTitles, + names, + ), + } +} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte new file mode 100644 index 00000000..7ce54e0c --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/+page.svelte @@ -0,0 +1,344 @@ + + +{#snippet marked(text: string, segments: NameSegment[] | null)} + {#if segments}{#each segments as s, i (i)}{#if s.hit}{s.text}{:else}{s.text}{/if}{/each}{:else}{text}{/if} +{/snippet} + +{#snippet resultLine(result: string | undefined)} + {#if !result} + +

No link or notes.

+ {:else if isHttpUrl(result)} + + + {result} + + + {:else} +

{result}

+ {/if} +{/snippet} + +{#snippet versionMeta(v: Version)} + + Version {v.version} · submitted {formatDate(v.createdAt)}{#if v.creator} + by {v.creator}{/if}{#if v.finalizedAt} + · finalized {formatDate(v.finalizedAt)}{#if v.finalizedBy} by {v.finalizedBy}{/if}{/if} + +{/snippet} + + +
+
+ +

Manage Submissions

+
+ + +
+
+ + {#if !data.teamsReadable} +

+ The teams of this hackathon cannot be read with your account, so there are no + submissions to show. +

+ {:else} +
+
+ + + {#if searching} + + Counting teams that match “{query.trim()}”. + + {/if} +
+
+ + + + +
+
+ +
+ {#if data.rows.length === 0} +

+ There are no teams yet, so nobody can submit. + + Set up teams + +

+ {:else if shown.length === 0 && searching} + +

+ {#if elsewhere.length === 0} + No team, project or person matches “{query.trim()}”. + {:else} + Nothing on {SUBMISSION_FILTER_LABEL[filter]} matches “{query.trim()}” — + {#each elsewhere as s, i (s)}{#if i > 0}, + {/if}{counts[s]} on {SUBMISSION_FILTER_LABEL[ + s + ]}{/each}. + {/if} +

+ {:else if shown.length === 0} +

+ {#if filter === 'missing'} + Every team has submitted something. + {:else if filter === 'draft'} + No team is left with only drafts. + {:else} + No team has a final entry yet. + {/if} +

+ {:else} + {#each shown as { row, team, project } (row.teamId)} + {@const entry = entryOf(row)} + {@const badge = STANDING_BADGE[row.standing]} +
+
+
+

+ {@render marked(row.teamName, team)} +

+ + {@render marked(row.projectTitle, project)} + + + {#if row.members.length > 0} + {#each row.members as m, i (i)}{#if i > 0}, + {/if}{@render marked(m, nameSegments(m, query))}{/each} + {:else} + No members + {/if} + +
+ {badge.label} +
+ + {#if entry} +
+ {@render resultLine(entry.result)} + {@render versionMeta(entry)} +
+ {/if} + + {#if row.latest && row.latestFinal && row.latest.id !== row.latestFinal.id} + +
+ + Newer draft, not finalized + + {@render resultLine(row.latest.result)} + {@render versionMeta(row.latest)} +
+ {/if} + + {#if row.earlier.length > 0} +
+ + {row.earlier.length === 1 + ? '1 earlier version' + : `${row.earlier.length} earlier versions`} + +
+ {#each row.earlier as v (v.id)} +
+ + {submissionStatusLabel(v.status) ?? 'Unknown'} + + {@render resultLine(v.result)} + {@render versionMeta(v)} +
+ {/each} +
+
+ {/if} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/export/+server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/export/+server.ts new file mode 100644 index 00000000..8488eb1e --- /dev/null +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/submissions/manage/export/+server.ts @@ -0,0 +1,109 @@ +import type { RequestHandler } from "./$types" +import { GlobalRole } from "$lib/server/grpc/generated/user/entities/global_role" +import { requireGrpc } from "$lib/server/grpc/client" +import { mayViewAllSubmissions } from "$lib/server/hackathon/capabilities" +import { + namesByUserId, + viewerMembership, +} from "$lib/server/hackathon/membership" +import { + submissionOverview, + submissionsByTeam, +} from "$lib/server/hackathon/submissions" +import { listVisibleTeams } from "$lib/server/hackathon/teams" +import { csvFilename } from "$lib/utils/csv" +import { submissionCsv, type SubmissionCsvRow } from "$lib/utils/submissionCsv" +import { SUBMISSION_FILTER_LABEL } from "$lib/utils/submissionFilter" +import { error } from "@sveltejs/kit" +import { ClientError, Status } from "nice-grpc-common" + +/** + * Manage Submissions as a CSV — every team, where it stands, and its entry. + * + * A `+server.ts` rather than a load, same reason the other exports give: this + * is a download, not a page, and the hackathon-dependent filename rides in + * `Content-Disposition`. + * + * **Every team, whatever tab the page is on**, in the page's order. A download + * whose contents depend on which tab was open is a trap, the one the project + * export refuses too; the Standing column is what keeps the three apart. + */ +export const GET: RequestHandler = async (event) => { + const { hackathon, team } = requireGrpc(event.locals.grpc) + + // Same translation the hackathon layout does: without it a non-member's + // PermissionDenied from `Get` surfaces as a 500 on a download. + let h + try { + h = (await hackathon.get({ hackathonId: event.params.id })).hackathon + } catch (e) { + if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED) { + error(403, "You are not a confirmed member of this hackathon") + } + if (e instanceof ClientError && e.code === Status.NOT_FOUND) { + error(404, "Hackathon not found") + } + throw e + } + if (!h) error(404, "Hackathon not found") + + // A `+server.ts` runs no layout loads, so the page's gate is re-derived here, + // through `viewerMembership` so an organiser who never joined is still found. + const isAdmin = (event.locals.platformUser?.roles ?? []).includes( + GlobalRole.GLOBAL_ROLE_ADMIN, + ) + const membership = viewerMembership( + h.members, + h.owners, + event.locals.platformUser?.id, + h.createdAt, + ) + if (!mayViewAllSubmissions(membership ?? undefined, isAdmin)) { + error(403, "Only the hackathon organizer can export submissions") + } + + const teams = await listVisibleTeams(team, h.id) + if (teams === undefined) { + error(403, "The teams of this hackathon cannot be read with your account") + } + + const overview = submissionOverview( + teams, + await submissionsByTeam(team, teams), + new Map(h.projects.map((p) => [p.id, p.title])), + namesByUserId(h.members, h.owners), + ) + + const rows: SubmissionCsvRow[] = overview.map((r) => { + // The page's "entry that counts", and its newer draft when there is one. + const entry = r.latestFinal ?? r.latest + const newerDraft = + r.latestFinal && r.latest && r.latest.id !== r.latestFinal.id + ? r.latest.version + : undefined + + return { + team: r.teamName, + project: r.projectTitle, + members: r.members, + standing: SUBMISSION_FILTER_LABEL[r.standing], + version: entry?.version, + submittedAt: entry?.createdAt, + submittedBy: entry?.creator, + finalizedAt: entry?.finalizedAt, + finalizedBy: entry?.finalizedBy, + newerDraft, + result: entry?.result, + } + }) + + return new Response(submissionCsv(rows), { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${csvFilename(h.name, "submissions")}"`, + // A team can submit at any moment; a cached copy from before is worse + // than a second round trip. + "Cache-Control": "no-store", + }, + }) +}