diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 67d3fa7e..55badbb1 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -47,6 +47,7 @@ export async function createDatabaseDriver(url: string): Promise mkdirSync(dirname(dataDir), { recursive: true }); } const pglite = new PGlite(dataDir); + await pglite.exec("SET TIME ZONE 'UTC'"); const db = drizzle(pglite, { schema }); return { db, @@ -67,6 +68,7 @@ export async function createDatabaseDriver(url: string): Promise max: Number(process.env.DB_POOL_MAX) || 10, connectionTimeoutMillis: Number(process.env.DB_CONNECTION_TIMEOUT_MS) || 30_000, idleTimeoutMillis: Number(process.env.DB_IDLE_TIMEOUT_MS) || 30_000, + options: "-c TimeZone=UTC", }); pool.on("error", (err: Error) => { console.error("[Database] Unexpected pool error:", err.message); diff --git a/api/src/index.ts b/api/src/index.ts index 44ce0015..15ac1655 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -63,7 +63,7 @@ import { createTokensService } from "./services/tokens"; import { createTreasuryService } from "./services/treasury"; import { createConsistencyChecker } from "./services/work-orders/consistency"; import { createDocumenso } from "./services/work-orders/documenso"; -import { cachedOwnership, createGitHub } from "./services/work-orders/github"; +import { cachedByName, createGitHub } from "./services/work-orders/github"; import { createGoogle, GoogleError, @@ -361,7 +361,8 @@ export default createPlugin.withPlugins()({ }, }, github, - ownership: cachedOwnership(github, 10 * 60_000), + ownership: cachedByName((repo) => github.getRepoOwnership(repo), 10 * 60_000), + orgRepos: cachedByName((org) => github.listOrgRepos(org), 10 * 60_000), proposal: (daoAccountId, proposalId) => findProposal(db, daoAccountId, proposalId), proposalBond: getProposalBond, prices: createPriceFeed(), @@ -1184,6 +1185,14 @@ export default createPlugin.withPlugins()({ .use(agencyManager) .handler(async ({ context }) => workOrders.nextCode(context.scope)), + githubRepos: builder.workOrders.githubRepos + .use(agencyManager) + .handler(async ({ context, input }) => workOrders.githubRepos(context.scope, input)), + + builderDefaults: builder.workOrders.builderDefaults + .use(agencyManager) + .handler(async ({ context, input }) => workOrders.builderDefaults(context.scope, input)), + offer: builder.workOrders.offer .use(agencyManager) .handler(async ({ context, input }) => workOrders.offer(context.scope, input)), diff --git a/api/src/services/work-orders/github.ts b/api/src/services/work-orders/github.ts index 3e2e0bf7..59231c4f 100644 --- a/api/src/services/work-orders/github.ts +++ b/api/src/services/work-orders/github.ts @@ -72,6 +72,13 @@ export type GhReviewBy = { submittedAt: string; }; +export type GhRepo = { + fullName: string; + url: string; + description: string | null; + pushedAt: string | null; +}; + export interface GitHubPort { getPull(repo: string, number: number): Promise; getClosingIssues(repo: string, number: number): Promise; @@ -82,6 +89,7 @@ export interface GitHubPort { getAssignmentEvents(repo: string, number: number): Promise; listPullsByAuthor(repo: string, login: string, since: string): Promise; listReviewsBy(repo: string, login: string, since: string): Promise; + listOrgRepos(org: string): Promise; } export class GitHubError extends Error { @@ -137,6 +145,15 @@ type RestSearchItem = { user?: { login: string } | null; }; +type RestRepo = { + full_name: string; + html_url: string; + description: string | null; + pushed_at: string | null; + archived?: boolean; + private?: boolean; +}; + type RestReview = { user: { login: string } | null; state: string; submitted_at?: string | null }; type ClosingData = { @@ -374,18 +391,40 @@ export function createGitHub(token: string | null, fetcher: typeof fetch = fetch } return out; }, + + async listOrgRepos(org) { + const repos = await pages( + `/orgs/${encodeURIComponent(org)}/repos?type=public&sort=pushed`, + ); + return repos + .filter((r) => !r.archived && !r.private) + .map((r) => ({ + fullName: r.full_name, + url: r.html_url, + description: r.description, + pushedAt: r.pushed_at, + })); + }, }; } -export function cachedOwnership(github: GitHubPort, ttlMs: number, now: () => number = Date.now) { - const cache = new Map }>(); - return (repo: string): Promise => { - const key = repo.toLowerCase(); +export function cachedByName( + load: (name: string) => Promise, + ttlMs: number, + now: () => number = Date.now, +) { + const cache = new Map }>(); + return (name: string): Promise => { + const key = name.toLowerCase(); + const at = now(); const hit = cache.get(key); - if (hit && hit.until > now()) return hit.value; - const value = github.getRepoOwnership(repo); - cache.set(key, { until: now() + ttlMs, value }); - value.catch(() => cache.delete(key)); + if (hit && hit.until > at) return hit.value; + for (const [stale, entry] of cache) if (entry.until <= at) cache.delete(stale); + const value = load(name); + cache.set(key, { until: at + ttlMs, value }); + value.catch(() => { + if (cache.get(key)?.value === value) cache.delete(key); + }); return value; }; } diff --git a/api/src/services/work-orders/progress.ts b/api/src/services/work-orders/progress.ts index 2db24a5e..e605e438 100644 --- a/api/src/services/work-orders/progress.ts +++ b/api/src/services/work-orders/progress.ts @@ -90,7 +90,6 @@ export type ProgressInput = { representativeUrl: string | null; esignError: string | null; blockers: string[]; - payeeNeedsConfirmation: boolean; documentWarning: string | null; driveError: string | null; renewalStatus: "renewed" | "needs_approval" | "declined" | null; @@ -372,13 +371,6 @@ export function progressOf(input: ProgressInput): Progress { }); } } - if (input.payeeNeedsConfirmation && !terminated && input.status !== "draft") { - attention.push({ - key: "payee", - message: `${builder} hasn't confirmed their payout account yet`, - action: null, - }); - } if (input.documentWarning) { attention.push({ key: "document", message: input.documentWarning, action: null }); } diff --git a/api/src/services/work-orders/service.ts b/api/src/services/work-orders/service.ts index 84668179..dbe147ad 100644 --- a/api/src/services/work-orders/service.ts +++ b/api/src/services/work-orders/service.ts @@ -31,7 +31,7 @@ import { type DaoProposal, proposalDescription } from "../sputnik"; import { baseUnitsToDisplay, getTokenMetadata, NATIVE_TOKEN_ID } from "../tokens"; import { createAutoApproval, trustViewOf } from "./auto-approval"; import type { ConsistencyChecker } from "./consistency"; -import type { EsignPort, EsignRole } from "./documenso"; +import type { EsignPort, EsignRole, EsignStatus } from "./documenso"; import { codeIn, documentFields, @@ -42,7 +42,7 @@ import { } from "./documents"; import { eventId, type WorkOrderEvent, type WorkOrderEventType } from "./events"; import { collectEvidence, summarizeActivity } from "./evidence"; -import type { GhRepoOwnership, GitHubPort } from "./github"; +import { type GhRepo, type GhRepoOwnership, GitHubError, type GitHubPort } from "./github"; import type { DriveFile, GooglePort } from "./google"; import { type PriceFeed, type PriceQuote, USD, USD_DECIMALS, usdToTokens } from "./prices"; import { type Progress, progressOf } from "./progress"; @@ -109,6 +109,7 @@ export type WorkOrderDeps = { builders: { get(nearAccount: string): Promise }; github: GitHubPort; ownership: (repo: string) => Promise; + orgRepos: (org: string) => Promise; proposal: (daoAccountId: string, proposalId: number) => Promise; proposalBond: (daoAccountId: string) => Promise; prices?: PriceFeed | null; @@ -209,6 +210,13 @@ export type WorkOrderFields = { type WorkOrderStatus = WorkOrderRow["status"]; type RequestStatus = CompensationRequestRow["status"]; +type PendingEnvelope = { esign: EsignPort; envelopeId: string }; +type GoogleTemplate = { + google: GooglePort; + templateId: string; + folderId: string; + fieldMap: string | null; +}; const COMMITTED: readonly RequestStatus[] = ["approved", "paying", "proposed", "paid"]; const OPEN_REQUEST: readonly RequestStatus[] = ["draft", "submitted"]; @@ -225,6 +233,7 @@ const TICK_LOCK_MS = 2 * 60_000; const SIGNATURE_LOCK_MS = 2 * 60_000; const AUTO_PAY_RETRY_MS = 5 * 60_000; const NO_AGENT = "No payout agent"; +const UNKNOWN_PROJECT = "Unknown project"; const IN_FLIGHT: AgentPayoutView["status"][] = [ "sending", @@ -522,7 +531,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { workOrder: { id: row.code, title: row.title, - project: project?.title ?? row.projectId, + project: project?.title ?? UNKNOWN_PROJECT, periodStart: row.periodStart, periodEnd: row.periodEnd, budget: display(row.budgetAmount, row.tokenId), @@ -647,19 +656,31 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return (await deps.googleFor?.(organizationId)) ?? deps.google ?? null; } - async function fillGoogleDocument(row: WorkOrderRow): Promise { - const google = await googleOf(row.organizationId); - const settings = google ? await integrationOf(row.organizationId) : null; + async function googleTemplateOf(organizationId: string) { + const google = await googleOf(organizationId); + const settings = google ? await integrationOf(organizationId) : null; if (!google || !settings?.googleTemplateId || !settings.googleDocumentsFolderId) return null; - const [project, agencyName, builder] = await Promise.all([ - projectOf(row), + return { + google, + templateId: settings.googleTemplateId, + folderId: settings.googleDocumentsFolderId, + fieldMap: settings.googleFieldMap, + }; + } + + async function fillGoogleDocument( + row: WorkOrderRow, + { google, templateId, folderId, fieldMap }: GoogleTemplate, + projectTitle: string, + ): Promise { + const [agencyName, builder] = await Promise.all([ deps.organizationName(row.organizationId).catch(() => row.organizationId), deps.builders.get(row.builderNearAccount).catch(() => null), ]); const contacts = await contactsOf(row, builder); const fields = documentFields({ code: row.code, - project: project?.title ?? row.projectId, + project: projectTitle, contractorName: row.contractorName ?? builder?.name ?? row.builderNearAccount, contractorEmail: contacts.contractorEmail, representativeName: row.representativeName ?? agencyName, @@ -677,12 +698,12 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { }); const { values, blank } = mappedValues({ fields, - map: parseFieldMap(settings.googleFieldMap), + map: parseFieldMap(fieldMap), custom: customFieldsOf(row.customFields), }); - const copy = await google.copy(settings.googleTemplateId, { + const copy = await google.copy(templateId, { name: `Work Order ${row.code}: ${row.title}`, - parentId: settings.googleDocumentsFolderId, + parentId: folderId, }); const occurrences = await google.fill(copy.id, values); const leftover = await google @@ -705,9 +726,21 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } async function draftDocument(row: WorkOrderRow, strict = false): Promise { + const template = await googleTemplateOf(row.organizationId); + if (!template) { + await announce(row, "work_order.created"); + return row; + } + let projectTitle: string; try { - const filled = await fillGoogleDocument(row); - if (filled) return filled; + projectTitle = await documentProjectTitle(row); + } catch (error) { + console.warn(`[API] ${errorText(error)}`); + if (strict) throw error; + return row; + } + try { + return await fillGoogleDocument(row, template, projectTitle); } catch (error) { const message = `Couldn't fill the Google Docs template for ${row.code}: ${errorText(error)}`; await recordGoogle(row.organizationId, message); @@ -715,8 +748,6 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { if (strict) throw badRequest("GOOGLE_FAILED", message); return row; } - await announce(row, "work_order.created"); - return row; } async function recordDriveFile(input: { @@ -853,8 +884,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } async function termsRows(row: WorkOrderRow): Promise<[string, string][]> { - const [project, agencyName, builder] = await Promise.all([ - projectOf(row), + const [projectTitle, agencyName, builder] = await Promise.all([ + documentProjectTitle(row), deps.organizationName(row.organizationId).catch(() => row.organizationId), deps.builders.get(row.builderNearAccount).catch(() => null), ]); @@ -862,7 +893,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return [ ["Work order", `${row.code}: ${row.title}`], ["Agency", agencyName], - ["Project", project?.title ?? row.projectId], + ["Project", projectTitle], ["Builder (contractor)", row.contractorName ?? builder?.name ?? row.builderNearAccount], ["Builder's email", contacts.contractorEmail ?? ""], ["Agency representative", row.representativeName ?? agencyName], @@ -893,15 +924,16 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { async function prepareOfferDocument(row: WorkOrderRow) { let current = row; let pdf: Uint8Array; - const google = await googleOf(row.organizationId); - const settings = google ? await integrationOf(row.organizationId) : null; - if (google && settings?.googleTemplateId && settings.googleDocumentsFolderId) { + const template = await googleTemplateOf(row.organizationId); + if (template) { + const refill = !current.documentId || !current.documentFields; + const projectTitle = refill ? await documentProjectTitle(current) : null; try { - if (!current.documentId || !current.documentFields) { - current = (await fillGoogleDocument(current)) ?? current; + if (projectTitle !== null) { + current = await fillGoogleDocument(current, template, projectTitle); } if (!current.documentId) throw new Error("The template wasn't filled"); - pdf = await google.exportPdf(current.documentId); + pdf = await template.google.exportPdf(current.documentId); } catch (error) { const message = `Couldn't prepare the document of ${row.code}: ${errorText(error)}`; await recordGoogle(row.organizationId, message); @@ -1108,7 +1140,15 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return false; } - async function payeeOf(row: WorkOrderRow) { + function paidBeforeOnce(row: WorkOrderRow, paidCache?: Map>) { + if (!paidCache) return paidBefore(row); + const key = `${row.organizationId}:${row.builderNearAccount}`; + const cached = paidCache.get(key) ?? paidBefore(row); + paidCache.set(key, cached); + return cached; + } + + async function payeeOf(row: WorkOrderRow, paidCache?: Map>) { if (row.payeeConfirmedAt) { return { account: row.builderNearAccount, @@ -1117,7 +1157,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { needsConfirmation: false, }; } - const known = EDITABLE.includes(row.status) ? await paidBefore(row) : null; + const known = EDITABLE.includes(row.status) ? await paidBeforeOnce(row, paidCache) : null; return { account: row.builderNearAccount, confirmedAt: null, @@ -1235,6 +1275,15 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return { committed, paid, remaining }; } + async function documentProjectTitle(row: WorkOrderRow): Promise { + const project = await projectOf(row); + if (project) return project.title; + throw badRequest( + "PROJECT_UNAVAILABLE", + `Couldn't load the project of ${row.code}, so its document wasn't made. If the project was deleted, edit the work order and choose another one.`, + ); + } + async function projectOf( row: Pick & Partial>, @@ -1286,9 +1335,18 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } async function signingOf(row: WorkOrderRow) { - const esign = esignOf(row); const offered = await latestDocument(row.id, "offered"); - if (!offered || offered.termsHash !== termsHash(row)) { + const current = offered && offered.termsHash === termsHash(row) ? offered : null; + return signingFrom(row, current, current ? await signaturesOf(row.id, current.sha256) : []); + } + + function signingFrom( + row: WorkOrderRow, + offered: { sha256: string } | null, + signatures: Pick[], + ) { + const esign = esignOf(row); + if (!offered) { return { documentSha256: null, builder: null, @@ -1298,7 +1356,6 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { esign, }; } - const signatures = await signaturesOf(row.id, offered.sha256); const signer = (role: "contractor" | "representative") => { const found = signatures.find((s) => s.role === role); return found ? { name: found.name, signedAt: found.signedAt.toISOString() } : null; @@ -1398,12 +1455,26 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } } - async function cancelEnvelope(row: WorkOrderRow, reason: string) { - if (!deps.esign || !row.esignEnvelopeId || row.signedAt) return; + async function cancelEnvelope(row: WorkOrderRow, reason: string): Promise { + if (!deps.esign || !row.esignEnvelopeId || row.signedAt) return null; try { await deps.esign.cancel(row.esignEnvelopeId, reason); + return null; } catch (error) { console.warn(`[API] couldn't cancel the Documenso envelope of ${row.code}`, error); + return errorText(error); + } + } + + async function envelopeStillSignable(row: WorkOrderRow): Promise { + if (!deps.esign || !row.esignEnvelopeId) return false; + try { + const state = await deps.esign.status(row.esignEnvelopeId, { + representativeFirst: row.esignRepresentativeFirst, + }); + return state.status === "pending"; + } catch { + return true; } } @@ -1437,18 +1508,38 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return synced?.value ?? row; } + function pendingEnvelope(row: WorkOrderRow): PendingEnvelope | null { + if (!deps.esign || !row.esignEnvelopeId || row.signedAt) return null; + if (row.status !== "offered" && row.status !== "accepted") return null; + return { esign: deps.esign, envelopeId: row.esignEnvelopeId }; + } + + async function checkEnvelope( + row: WorkOrderRow, + ): Promise<{ envelope: PendingEnvelope; state: EsignStatus } | null> { + const envelope = pendingEnvelope(row); + if (!envelope) return null; + const state = await envelope.esign.status(envelope.envelopeId, { + representativeFirst: row.esignRepresentativeFirst, + }); + return { envelope, state }; + } + async function syncEnvelope(row: WorkOrderRow): Promise { - const esign = deps.esign; - if (!esign || !row.esignEnvelopeId || row.signedAt) return row; - if (row.status !== "offered" && row.status !== "accepted") return row; - let state: Awaited>; + let checked: Awaited>; try { - state = await esign.status(row.esignEnvelopeId, { - representativeFirst: row.esignRepresentativeFirst, - }); + checked = await checkEnvelope(row); } catch (error) { return update(row.id, { esignError: `Couldn't check Documenso: ${errorText(error)}` }); } + return checked ? applyEnvelope(row, checked.envelope, checked.state) : row; + } + + async function applyEnvelope( + row: WorkOrderRow, + { esign, envelopeId }: PendingEnvelope, + state: EsignStatus, + ): Promise { const offered = await latestDocument(row.id, "offered"); if (!offered) return row; let current = row.esignError ? await update(row.id, { esignError: null }) : row; @@ -1520,7 +1611,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { if (state.status !== "completed") return current; let final: Uint8Array; try { - final = await esign.downloadSigned(row.esignEnvelopeId); + final = await esign.downloadSigned(envelopeId); } catch (error) { return update(row.id, { esignError: `Couldn't download the signed PDF: ${errorText(error)}`, @@ -1740,14 +1831,95 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { }; } - async function view(row: WorkOrderRow, cache?: { projects?: Map }) { + type ViewCache = { + projects?: Map; + requests?: Map; + builders?: Map>; + paid?: Map>; + signing?: Map>; + }; + + async function viewCacheOf(rows: WorkOrderRow[], projects: Map) { + const ids = rows.map((r) => r.id); + const [requestRows, offeredRows, signatureRows] = ids.length + ? await Promise.all([ + db + .select() + .from(compensationRequests) + .where(inArray(compensationRequests.workOrderId, ids)) + .orderBy(asc(compensationRequests.seq)), + db + .select({ + workOrderId: workOrderDocuments.workOrderId, + termsHash: workOrderDocuments.termsHash, + sha256: workOrderDocuments.sha256, + }) + .from(workOrderDocuments) + .where( + and( + inArray(workOrderDocuments.workOrderId, ids), + eq(workOrderDocuments.kind, "offered"), + ), + ) + .orderBy(desc(workOrderDocuments.createdAt)), + db + .select({ + workOrderId: workOrderSignatures.workOrderId, + documentSha256: workOrderSignatures.documentSha256, + role: workOrderSignatures.role, + name: workOrderSignatures.name, + signedAt: workOrderSignatures.signedAt, + }) + .from(workOrderSignatures) + .where(inArray(workOrderSignatures.workOrderId, ids)), + ]) + : [[], [], []]; + const requests = new Map(ids.map((id) => [id, []])); + for (const r of requestRows) requests.get(r.workOrderId)?.push(r); + const latestOffered = new Map(); + for (const doc of offeredRows) { + if (!latestOffered.has(doc.workOrderId)) latestOffered.set(doc.workOrderId, doc); + } + const signaturesByDocument = new Map(); + for (const s of signatureRows) { + const key = `${s.workOrderId}:${s.documentSha256}`; + const list = signaturesByDocument.get(key); + if (list) list.push(s); + else signaturesByDocument.set(key, [s]); + } + const signing = new Map( + rows.map((row) => { + const offered = latestOffered.get(row.id); + const current = offered && offered.termsHash === termsHash(row) ? offered : null; + return [ + row.id, + signingFrom( + row, + current, + current ? (signaturesByDocument.get(`${row.id}:${current.sha256}`) ?? []) : [], + ), + ] as const; + }), + ); + const builders = new Map>(); + for (const account of new Set(rows.map((r) => r.builderNearAccount))) { + builders.set( + account, + deps.builders.get(account).catch(() => null), + ); + } + return { projects, requests, builders, paid: new Map(), signing } satisfies ViewCache; + } + + async function view(row: WorkOrderRow, cache?: ViewCache) { const [requests, builder] = await Promise.all([ - requestsOf(row.id), - deps.builders.get(row.builderNearAccount).catch(() => null), + cache?.requests?.get(row.id) ?? requestsOf(row.id), + cache?.builders?.get(row.builderNearAccount) ?? + deps.builders.get(row.builderNearAccount).catch(() => null), ]); const project = cache?.projects?.get(row.projectId) ?? (await projectOf(row)); - const payee = await payeeOf(row); - const signing = await signingOf(row); + const payee = await payeeOf(row, cache?.paid); + const signing = cache?.signing?.get(row.id) ?? (await signingOf(row)); const { committed, paid, remaining } = amountsOf(row, requests); const renewal = parseJson(row.renewalAssessment, null); const blockers = activationBlockers( @@ -1780,7 +1952,6 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { representativeUrl: signing.esign?.representativeUrl ?? null, esignError: signing.esign?.error ?? null, blockers, - payeeNeedsConfirmation: payee.needsConfirmation, documentWarning: row.documentWarning, driveError: row.signedDriveFileId ? null : row.signedDriveError, renewalStatus: row.renewalStatus, @@ -1805,7 +1976,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { code: row.code, organizationId: row.organizationId, projectId: row.projectId, - projectTitle: project?.title ?? row.projectId, + projectTitle: project?.title ?? UNKNOWN_PROJECT, projectSlug: project?.slug ?? null, builder: { nearAccount: row.builderNearAccount, @@ -2008,7 +2179,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ]); return { code: row.code, - projectTitle: project?.title ?? row.projectId, + projectTitle: project?.title ?? UNKNOWN_PROJECT, agencyName, builderName: builder?.name ?? row.builderNearAccount, budget: display(row.budgetAmount, row.tokenId), @@ -2327,7 +2498,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ? await deps.summarizer .draftReport({ workOrderCode: row.code, - project: project?.title ?? row.projectId, + project: project?.title ?? UNKNOWN_PROJECT, contractorGithub: row.githubLogin ?? "", periodStart: coversFrom, periodEnd: coversTo, @@ -2350,6 +2521,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { summary: draft?.summary ?? null, contributionsMd, assessmentMd, + createdAt: now(), + updatedAt: now(), }) .returning(); if (auto) await update(row.id, { autoDraftedFor: row.periodEnd }); @@ -2794,7 +2967,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const description = proposalDescription({ title: `${row.code}-R${request.seq}: ${row.title}`, notes: [ - `Project: ${project?.title ?? row.projectId}`, + `Project: ${project?.title ?? UNKNOWN_PROJECT}`, `Covers ${request.coversFrom} to ${request.coversTo}`, payout.priceUsd ? `${display(request.approvedAmount ?? "0", row.tokenId)} at ${payout.priceUsd} USD per ${symbolOf(payout.tokenId)}` @@ -2954,6 +3127,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { code, organizationId: parent.organizationId, projectId: parent.projectId, + projectTitle: parent.projectTitle, + projectSlug: parent.projectSlug, builderNearAccount: parent.builderNearAccount, githubLogin: parent.githubLogin, title: parent.title, @@ -2979,6 +3154,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { renewalRoot: root, renewalSeq: seq, createdBy: "renewal", + createdAt: now(), + updatedAt: now(), }) .returning(); await tx @@ -3285,8 +3462,9 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { .catch(() => []) ).map((p) => [p.id, p]), ); + const cache = await viewCacheOf(rows, projects); return { - data: await Promise.all(rows.map(async (row) => adminView(await view(row, { projects })))), + data: await Promise.all(rows.map(async (row) => adminView(await view(row, cache)))), }; } @@ -3331,6 +3509,56 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { nextCode: async (scope: OrganizationScope) => ({ code: await nextCode(scope.organizationId) }), + githubRepos: async (_scope: OrganizationScope, input: { org: string }) => { + try { + return { org: input.org, repos: await deps.orgRepos(input.org) }; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) { + throw badRequest("GITHUB_ORG_NOT_FOUND", `GitHub has no organization "${input.org}"`); + } + throw badRequest( + "GITHUB_FAILED", + `Couldn't load repositories from GitHub: ${errorText(error)}`, + ); + } + }, + + builderDefaults: async (scope: OrganizationScope, input: { nearAccount: string }) => { + const nearAccount = input.nearAccount.trim(); + const latestOf = ( + column: typeof workOrders.contractorEmail | typeof workOrders.githubLogin, + ) => + db + .select({ value: column }) + .from(workOrders) + .where( + and( + eq(workOrders.organizationId, scope.organizationId), + eq(workOrders.builderNearAccount, nearAccount), + isNotNull(column), + ), + ) + .orderBy(desc(workOrders.createdAt)) + .limit(1) + .then(([row]) => row?.value ?? null); + const [builder, remembered, previousEmail, previousLogin] = await Promise.all([ + nearAccount ? deps.builders.get(nearAccount).catch(() => null) : null, + nearAccount ? rememberedLogin(db, scope.organizationId, nearAccount) : null, + latestOf(workOrders.contractorEmail), + latestOf(workOrders.githubLogin), + ]); + const accountEmail = + builder?.userId && deps.userEmail + ? !!(await deps.userEmail(builder.userId).catch(() => null)) + : false; + return { + githubLogin: builder?.githubLogin?.toLowerCase() ?? remembered ?? previousLogin, + contractorEmail: previousEmail, + accountEmail, + needsEmails: !!deps.esign, + }; + }, + create: async (scope: OrganizationScope, input: WorkOrderFields) => { const code = input.code?.trim() || (await nextCode(scope.organizationId)); const builder = await deps.builders.get(input.builderNearAccount); @@ -3382,6 +3610,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { customFields: customFieldsText(fields.customFields), status: "draft", createdBy: scope.actorId, + createdAt: now(), + updatedAt: now(), }) .returning(); await rememberBuilderLogin( @@ -3674,8 +3904,16 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const row = await ownRow(scope, input.id); if (FINAL.includes(row.status)) throw badRequest("CLOSED", `Work order ${row.code} is already terminated`); - await cancelEnvelope(row, "The work order was terminated"); - return adminView(await view(await update(row.id, { status: "terminated" }))); + const failed = await cancelEnvelope(row, "The work order was terminated"); + const terminated = await update(row.id, { + status: "terminated", + ...(failed && (await envelopeStillSignable(row)) + ? { + esignError: `Couldn't withdraw it in Documenso, so the builder may still be able to sign there: ${failed}`, + } + : {}), + }); + return adminView(await view(terminated)); }); }, @@ -3689,7 +3927,31 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { "Only a draft or offered work order can be deleted; terminate it instead", ); } - await cancelEnvelope(row, "The work order was withdrawn"); + let checked: Awaited>; + try { + checked = await checkEnvelope(row); + } catch (error) { + throw badRequest( + "ESIGN_UNCHECKED", + `Couldn't check Documenso, so ${row.code} wasn't deleted: ${errorText(error)}`, + ); + } + if (checked?.state.signers.some((s) => s.role === "contractor" && s.signedAt)) { + await applyEnvelope(row, checked.envelope, checked.state); + throw badRequest( + "ALREADY_SIGNED", + `The builder has already signed ${row.code}; terminate it instead`, + ); + } + if (checked?.state.status !== "rejected") { + const failed = await cancelEnvelope(row, "The work order was withdrawn"); + if (failed) { + throw badRequest( + "ESIGN_CANCEL_FAILED", + `Couldn't withdraw ${row.code} in Documenso, so it wasn't deleted: ${failed}`, + ); + } + } await db.delete(workOrders).where(eq(workOrders.id, row.id)); return { deleted: true as const }; }); @@ -4387,7 +4649,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const frozen = renderReport({ requestId: `${row.code}-R${request.seq}`, workOrderCode: row.code, - projectTitle: project?.title ?? row.projectId, + projectTitle: project?.title ?? UNKNOWN_PROJECT, contractor: builder?.name ?? row.builderNearAccount, github: row.githubLogin ?? "", coversFrom: request.coversFrom, diff --git a/api/src/services/work-orders/setup-status.ts b/api/src/services/work-orders/setup-status.ts index b0ba1742..ee192ebc 100644 --- a/api/src/services/work-orders/setup-status.ts +++ b/api/src/services/work-orders/setup-status.ts @@ -84,7 +84,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { return missing( name ? `“${name}” looks like a placeholder; builders see it in every email and document` - : "Your agency has no name; builders see it in every email and document", + : "No name yet; builders see it in every email and document", ); } @@ -104,9 +104,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { async function email(organizationId: string): Promise { if (!deps.emailEnabled) { - return missing( - "Emails are off, so builders and your representative only get notifications in MultiAgency", - ); + return missing("Emails are off on this server. Ask your server admin to turn them on."); } const { email } = await deps.representative(organizationId); return ok( @@ -119,7 +117,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { async function signing(): Promise { return deps.esignEnabled ? ok("Builders sign the work order document") - : warning("No e-signature: builders accept the terms in MultiAgency"); + : warning("E-signature is off on this server. Builders accept the terms in MultiAgency."); } async function google(organizationId: string): Promise { @@ -138,14 +136,14 @@ export function createSetupStatus(deps: SetupStatusDeps) { ].filter((item): item is string => !!item); if (!linked) { return lacking.length === 3 - ? optional("Connect Google Drive to fill your own work order template") - : warning("Connect Google Drive to use the template you chose"); + ? optional("Connect Drive to use your own template") + : warning("Connect Drive to use the template you chose"); } const via = connection.connected ? `Connected${connection.email ? ` as ${connection.email}` : ""}` : "Using the server's Google account"; if (lacking.length === 3) { - return optional(`${via}; choose a template and folders to use your own document`); + return optional(`${via}; choose a template and folders`); } if (lacking.length > 0) return warning(`${via}; choose ${listOf(lacking)}`); if (settings?.googleLastError) return warning(`Google Drive: ${settings.googleLastError}`); @@ -162,16 +160,14 @@ export function createSetupStatus(deps: SetupStatusDeps) { const agent = async (): Promise => { const current = await viewOnce(); if (!current.available) { - return optional( - "Not available on this server: approved requests are paid through DAO proposals", - ); + return optional("Not on this server; requests are paid by DAO proposal"); } if (!current.agentId) { - return optional("No payout agent: approved requests are paid through DAO proposals"); + return optional("None; requests are paid by DAO proposal"); } if (current.error) return warning(`Couldn't check: ${current.error}`); if (!current.ready) return warning(current.blockers.join(". ")); - return ok("The agent's rules are applied and MultiAgency can pay approved requests"); + return ok("Rules applied; can pay approved requests"); }; const funding = async (): Promise => { @@ -196,7 +192,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { } if (check.budgetTooLow) { problems.push( - `The $${check.monthlyBudgetUsd} monthly payout limit is below the ≈$${check.monthlyNeededUsd} active work orders need`, + `$${check.monthlyBudgetUsd} monthly limit is below the ≈$${check.monthlyNeededUsd} active work orders need`, ); } else if ( check.monthlyRemainingUsd !== null && @@ -210,7 +206,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { if (problems.length > 0) return warning(problems.join(". ")); return ok( needed === 0n - ? `Holds ${check.balanceNear} NEAR; no unpaid work order budgets yet` + ? `Holds ${check.balanceNear} NEAR; no unpaid budgets yet` : `Holds ${check.balanceNear} NEAR, enough for the ≈${check.neededNear} NEAR active work orders need`, ); }; diff --git a/api/src/work-orders-contract.ts b/api/src/work-orders-contract.ts index 09948a7e..452fa8ca 100644 --- a/api/src/work-orders-contract.ts +++ b/api/src/work-orders-contract.ts @@ -623,6 +623,43 @@ export const workOrdersContract = { .output(z.object({ code: z.string() })) .errors({ UNAUTHORIZED, FORBIDDEN }), + githubRepos: oc + .route({ method: "GET", path: "/admin/work-order-github-repos" }) + .input( + z.object({ + org: z + .string() + .regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/, "Enter a GitHub organization name"), + }), + ) + .output( + z.object({ + org: z.string(), + repos: z.array( + z.object({ + fullName: z.string(), + url: z.string(), + description: z.string().nullable(), + pushedAt: z.string().nullable(), + }), + ), + }), + ) + .errors(errors), + + builderDefaults: oc + .route({ method: "GET", path: "/admin/work-order-builder-defaults" }) + .input(z.object({ nearAccount: z.string() })) + .output( + z.object({ + githubLogin: z.string().nullable(), + contractorEmail: z.string().nullable(), + accountEmail: z.boolean(), + needsEmails: z.boolean(), + }), + ) + .errors({ UNAUTHORIZED, FORBIDDEN }), + offer: oc .route({ method: "POST", path: "/admin/work-orders/{id}/offer" }) .input(byId) diff --git a/api/tests/fakes/github.ts b/api/tests/fakes/github.ts index 195854a7..44cf2b9f 100644 --- a/api/tests/fakes/github.ts +++ b/api/tests/fakes/github.ts @@ -4,11 +4,13 @@ import type { GhIssueRef, GhPull, GhPullSummary, + GhRepo, GhRepoOwnership, GhReview, GhReviewBy, GitHubPort, } from "../../src/services/work-orders/github"; +import { GitHubError } from "../../src/services/work-orders/github"; type PullRec = { pull: GhPull; @@ -30,6 +32,7 @@ type RepoRec = { export class FakeGitHub implements GitHubPort { repos = new Map(); + orgs = new Map(); calls: string[] = []; private failures = new Map(); @@ -126,6 +129,13 @@ export class FakeGitHub implements GitHubPort { this.failures.set(method, list); } + async listOrgRepos(org: string): Promise { + this.enter("listOrgRepos"); + const repos = this.orgs.get(org.toLowerCase()); + if (!repos) throw new GitHubError(404, `GitHub GET /orgs/${org}/repos → 404`); + return structuredClone(repos); + } + private enter(method: string) { this.calls.push(method); const error = this.failures.get(method)?.shift(); diff --git a/api/tests/integration/work-order-auto-approval.test.ts b/api/tests/integration/work-order-auto-approval.test.ts index 009a2afc..e8158750 100644 --- a/api/tests/integration/work-order-auto-approval.test.ts +++ b/api/tests/integration/work-order-auto-approval.test.ts @@ -69,6 +69,7 @@ describe("work order trust tiers and automatic approval", () => { builders: { get: async (nearAccount) => builders.get(nearAccount) ?? null }, github, ownership: (repo) => github.getRepoOwnership(repo), + orgRepos: (org) => github.listOrgRepos(org), proposal: async () => null, proposalBond: async () => "100000000000000000000000", prices: { price: async () => ({ priceUsd: "4.99", at: "2026-09-20T12:00:00.000Z" }) }, diff --git a/api/tests/integration/work-order-setup.test.ts b/api/tests/integration/work-order-setup.test.ts index ded20580..c069258b 100644 --- a/api/tests/integration/work-order-setup.test.ts +++ b/api/tests/integration/work-order-setup.test.ts @@ -133,7 +133,9 @@ describe("work order setup status", () => { detail: "Add the default representative's name and email", fixHref: "/admin/settings#work-orders", }); - expect(check.signing.detail).toBe("No e-signature: builders accept the terms in MultiAgency"); + expect(check.signing.detail).toBe( + "E-signature is off on this server. Builders accept the terms in MultiAgency.", + ); expect(check.google.fixHref).toBe("/admin/settings#work-orders"); expect(check.payouts_agent.fixHref).toBe("/admin/settings#payouts"); }); diff --git a/api/tests/integration/work-orders.test.ts b/api/tests/integration/work-orders.test.ts index 88975afc..c27ce0d4 100644 --- a/api/tests/integration/work-orders.test.ts +++ b/api/tests/integration/work-orders.test.ts @@ -7,6 +7,7 @@ import { billings, compensationRequests, notifications, + organizationBuilderLogins, workOrderDocuments, workOrderDriveFiles, workOrderIntegrations, @@ -110,6 +111,7 @@ describe("work orders", () => { builders: { get: async (nearAccount) => builders.get(nearAccount) ?? null }, github, ownership: (repo) => github.getRepoOwnership(repo), + orgRepos: (org) => github.listOrgRepos(org), proposal: async (dao, id) => proposals.get(`${dao}#${id}`) ?? null, proposalBond: async () => "100000000000000000000000", prices: { price: async () => ({ priceUsd, at: "2026-09-20T12:00:00.000Z" }) }, @@ -228,6 +230,23 @@ describe("work orders", () => { expect((await service.create(await studio(), fields())).code).toBe("WO-0002"); }); + test("timestamps follow the service clock, so a new draft is waiting since now", async () => { + const created = await service.create(await studio(), fields()); + expect(created.createdAt).toBe("2026-09-20T12:00:00.000Z"); + expect(created.progress.since).toBe("2026-09-20T12:00:00.000Z"); + }); + + test("a project that can't be found shows as unknown, never as its id", async () => { + const created = await service.create(await studio(), fields()); + await db + .update(workOrders) + .set({ projectId: "proj_gone", projectTitle: null, projectSlug: null }) + .where(eq(workOrders.id, created.id)); + const view = await service.get(await studio(), { id: created.id }); + expect(view.projectId).toBe("proj_gone"); + expect(view.projectTitle).toBe("Unknown project"); + }); + test("draft → offered → accepted by the builder's own account → active", async () => { const created = await service.create(await studio(), fields()); expect(created.code).toBe("WO-0001"); @@ -801,6 +820,8 @@ describe("work orders", () => { signature: { method: null }, }); expect(child.signing.builder).toBeNull(); + const [childRow] = await db.select().from(workOrders).where(eq(workOrders.id, child.id)); + expect(childRow?.projectTitle).toBe(parent.projectTitle); expect( (await service.get(await studio(), { id: parent.id })).requests.map((r) => r.status), ).toEqual(["draft"]); @@ -1016,6 +1037,25 @@ describe("work orders", () => { ]; }; + test("a work order whose project is gone is refused without blaming Google", async () => { + const created = await service.create(await studio(), fields()); + await db + .update(workOrders) + .set({ projectId: "deleted-project", projectTitle: null }) + .where(eq(workOrders.id, created.id)); + const copies = google.copies.length; + await refused( + service.prepareDocument(await studio(), { id: created.id }), + "PROJECT_UNAVAILABLE", + ); + expect(google.copies).toHaveLength(copies); + const [integration] = await db + .select() + .from(workOrderIntegrations) + .where(eq(workOrderIntegrations.organizationId, "studio")); + expect(integration?.googleLastError).toBeNull(); + }); + test("a new work order gets its filled Google Doc and the representative is told", async () => { const created = await service.create( await studio(), @@ -1506,6 +1546,89 @@ describe("work orders", () => { expect(documenso.cancelled.at(-1)?.envelopeId).toBe("envelope_3"); }); + test("an offered work order the builder already signed in Documenso can't be deleted", async () => { + const created = await offered(); + documenso.sign("representative", "2026-09-20T13:00:00.000Z"); + documenso.sign("contractor", "2026-09-20T14:00:00.000Z"); + await refused(service.remove(await studio(), { id: created.id }), "ALREADY_SIGNED"); + expect(documenso.cancelled).toEqual([]); + expect((await service.get(await studio(), { id: created.id })).status).not.toBe("offered"); + }); + + test("deleting stops when Documenso can't be checked or the envelope can't be cancelled", async () => { + const created = await offered(); + const status = documenso.status.bind(documenso); + documenso.status = async () => { + throw new Error("Documenso is down"); + }; + await refused(service.remove(await studio(), { id: created.id }), "ESIGN_UNCHECKED"); + documenso.status = status; + const cancel = documenso.cancel.bind(documenso); + documenso.cancel = async () => { + throw new Error("timeout"); + }; + await refused(service.remove(await studio(), { id: created.id }), "ESIGN_CANCEL_FAILED"); + documenso.cancel = cancel; + expect((await service.get(await studio(), { id: created.id })).status).toBe("offered"); + await service.remove(await studio(), { id: created.id }); + }); + + test("deleting after the representative signed doesn't send the builder the offer", async () => { + const created = await offered(); + documenso.sign("representative", "2026-09-20T13:00:00.000Z"); + await service.remove(await studio(), { id: created.id }); + expect(events.map((e) => e.type)).not.toContain("work_order.offered"); + expect((await notificationsOf("carol")).map((n) => n.kind)).not.toContain( + "work_order_offered", + ); + expect(documenso.cancelled.at(-1)?.reason).toBe("The work order was withdrawn"); + }); + + test("deleting a work order the builder declined in Documenso tells no one", async () => { + const created = await offered(); + documenso.reject("contractor"); + await service.remove(await studio(), { id: created.id }); + expect((await notificationsOf("studio-admin")).map((n) => n.kind)).not.toContain( + "work_order_declined", + ); + expect(documenso.cancelled).toEqual([]); + }); + + test("terminating goes through when Documenso can't withdraw the envelope, and says so", async () => { + const created = await offered(); + documenso.status = async () => { + throw new Error("Documenso is down"); + }; + documenso.cancel = async () => { + throw new Error("timeout"); + }; + const terminated = await service.terminate(await studio(), { id: created.id }); + expect(terminated.status).toBe("terminated"); + expect(terminated.signing.esign?.error).toBe( + "Couldn't withdraw it in Documenso, so the builder may still be able to sign there: timeout", + ); + expect(terminated.progress.attention.map((a) => a.key)).toContain("esign"); + }); + + test("terminating doesn't warn about an envelope that was already rejected in Documenso", async () => { + const created = await offered(); + documenso.reject("contractor"); + const terminated = await service.terminate(await studio(), { id: created.id }); + expect(terminated.status).toBe("terminated"); + expect(terminated.signing.esign?.error ?? null).toBeNull(); + expect(documenso.cancelled).toEqual([]); + }); + + test("terminating withdraws the envelope without recording signatures first", async () => { + const created = await offered(); + documenso.sign("representative", "2026-09-20T13:00:00.000Z"); + const terminated = await service.terminate(await studio(), { id: created.id }); + expect(terminated.status).toBe("terminated"); + expect(terminated.signing.esign?.error ?? null).toBeNull(); + expect(documenso.cancelled.at(-1)?.reason).toBe("The work order was terminated"); + expect(events.map((e) => e.type)).not.toContain("work_order.offered"); + }); + async function completed() { const created = await offered(); documenso.sign("representative", "2026-09-20T13:00:00.000Z"); @@ -2996,4 +3119,122 @@ describe("work orders", () => { } }); }); + + describe("importing repositories from GitHub", () => { + test("lists an organization's public repositories", async () => { + github.orgs.set("nearbuilders", [ + { + fullName: "NEARBuilders/citynode.app", + url: "https://github.com/NEARBuilders/citynode.app", + description: "City node", + pushedAt: "2026-10-01T00:00:00Z", + }, + ]); + const listed = await service.githubRepos(await studio(), { org: "NEARBuilders" }); + expect(listed.repos.map((r) => r.fullName)).toEqual(["NEARBuilders/citynode.app"]); + }); + + test("an unknown organization or a GitHub outage is a clear error", async () => { + await refused(service.githubRepos(await studio(), { org: "nobody" }), "GITHUB_ORG_NOT_FOUND"); + github.orgs.set("down", []); + github.failNext("listOrgRepos", new Error("timeout")); + await refused(service.githubRepos(await studio(), { org: "down" }), "GITHUB_FAILED"); + }); + }); + + describe("builder defaults for a new work order", () => { + const rival = () => world.manager("rival-admin", "rival"); + + test("the profile login, a linked account email, and no email demand without e-signature", async () => { + expect(await service.builderDefaults(await studio(), { nearAccount: CAROL })).toEqual({ + githubLogin: "carol-gh", + contractorEmail: null, + accountEmail: true, + needsEmails: false, + }); + }); + + test("an unknown builder gets empty defaults, and e-signature asks for emails", async () => { + service = createWorkOrdersService({ ...serviceDeps, esign: new FakeDocumenso() }); + expect(await service.builderDefaults(await studio(), { nearAccount: "ghost.near" })).toEqual({ + githubLogin: null, + contractorEmail: null, + accountEmail: false, + needsEmails: true, + }); + }); + + test("no account email when the linked user has none", async () => { + service = createWorkOrdersService({ ...serviceDeps, userEmail: async () => null }); + const defaults = await service.builderDefaults(await studio(), { nearAccount: CAROL }); + expect(defaults.accountEmail).toBe(false); + }); + + test("the remembered login and latest email stay inside the organization", async () => { + const first = await service.create( + await studio(), + fields({ + builderNearAccount: "nogit.near", + githubLogin: "nogit-gh", + contractorEmail: "old@nogit.example", + }), + ); + clock = new Date("2026-09-21T12:00:00.000Z"); + await service.create( + await studio(), + fields({ + builderNearAccount: "nogit.near", + githubLogin: "nogit-gh", + contractorEmail: "new@nogit.example", + }), + ); + clock = new Date("2026-09-22T12:00:00.000Z"); + const leaked = await service.create( + await studio(), + fields({ + builderNearAccount: "nogit.near", + githubLogin: "nogit-gh", + contractorEmail: "rival@nogit.example", + }), + ); + await db + .update(workOrders) + .set({ organizationId: "rival", githubLogin: "rival-gh" }) + .where(eq(workOrders.id, leaked.id)); + await db.update(workOrders).set({ contractorEmail: null }).where(eq(workOrders.id, first.id)); + + expect(await service.builderDefaults(await studio(), { nearAccount: "nogit.near" })).toEqual({ + githubLogin: "nogit-gh", + contractorEmail: "new@nogit.example", + accountEmail: true, + needsEmails: false, + }); + expect(await service.builderDefaults(await rival(), { nearAccount: "nogit.near" })).toEqual({ + githubLogin: "rival-gh", + contractorEmail: "rival@nogit.example", + accountEmail: true, + needsEmails: false, + }); + }); + + test("the latest login on the organization's work orders fills in when none is remembered", async () => { + await service.create( + await studio(), + fields({ builderNearAccount: "nogit.near", githubLogin: "old-gh" }), + ); + clock = new Date("2026-09-21T12:00:00.000Z"); + await service.create( + await studio(), + fields({ builderNearAccount: "nogit.near", githubLogin: "new-gh" }), + ); + await db.delete(organizationBuilderLogins); + + expect( + (await service.builderDefaults(await studio(), { nearAccount: "nogit.near" })).githubLogin, + ).toBe("new-gh"); + expect( + (await service.builderDefaults(await rival(), { nearAccount: "nogit.near" })).githubLogin, + ).toBeNull(); + }); + }); }); diff --git a/api/tests/unit/cached-by-name.test.ts b/api/tests/unit/cached-by-name.test.ts new file mode 100644 index 00000000..a9768fd8 --- /dev/null +++ b/api/tests/unit/cached-by-name.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { cachedByName } from "../../src/services/work-orders/github"; + +function counted(fail = new Set()) { + const calls: string[] = []; + const load = async (name: string) => { + calls.push(name); + if (fail.has(name)) throw new Error(`${name} failed`); + return `${name}:${calls.length}`; + }; + return { calls, load }; +} + +describe("cachedByName", () => { + it("serves a name from the cache regardless of case until it expires", async () => { + let clock = 0; + const { calls, load } = counted(); + const cached = cachedByName(load, 1000, () => clock); + expect(await cached("NEARBuilders")).toBe("NEARBuilders:1"); + expect(await cached("nearbuilders")).toBe("NEARBuilders:1"); + clock = 1000; + expect(await cached("nearbuilders")).toBe("nearbuilders:2"); + expect(calls).toEqual(["NEARBuilders", "nearbuilders"]); + }); + + it("doesn't keep a failure", async () => { + const fail = new Set(["down"]); + const { calls, load } = counted(fail); + const cached = cachedByName(load, 1000, () => 0); + await expect(cached("down")).rejects.toThrow("down failed"); + fail.clear(); + expect(await cached("down")).toBe("down:2"); + expect(calls).toEqual(["down", "down"]); + }); + + it("drops expired names when it loads a new one", async () => { + let clock = 0; + const { calls, load } = counted(); + const cached = cachedByName(load, 1000, () => clock); + await cached("a"); + clock = 2000; + await cached("b"); + clock = 0; + await cached("a"); + expect(calls).toEqual(["a", "b", "a"]); + }); +}); diff --git a/api/tests/unit/progress.test.ts b/api/tests/unit/progress.test.ts index efd8cc54..9257c371 100644 --- a/api/tests/unit/progress.test.ts +++ b/api/tests/unit/progress.test.ts @@ -25,7 +25,6 @@ const base: ProgressInput = { representativeUrl: null, esignError: null, blockers: [], - payeeNeedsConfirmation: false, documentWarning: null, driveError: null, renewalStatus: null, @@ -297,8 +296,22 @@ describe("work order progress", () => { ...signedActive, esignError: "Couldn't check Documenso", driveError: "403", - payeeNeedsConfirmation: true, }); - expect(p.attention.map((a) => a.key)).toEqual(["esign", "payee", "drive"]); + expect(p.attention.map((a) => a.key)).toEqual(["esign", "drive"]); + }); + + test("an accepted work order lists the blockers its headline doesn't already show", () => { + const p = progressOf({ + ...base, + status: "accepted", + acceptedAt: "2026-10-26T10:05:00.000Z", + builderSignedAt: "a", + representativeSignedAt: "b", + blockers: [ + "Add the builder's GitHub login", + "The builder hasn't confirmed the payout account", + ], + }); + expect(p.attention.map((a) => a.key)).toEqual(["blocker-1"]); }); }); diff --git a/ui/src/components/admin-sidebar.tsx b/ui/src/components/admin-sidebar.tsx index a8da52b7..ebc64c48 100644 --- a/ui/src/components/admin-sidebar.tsx +++ b/ui/src/components/admin-sidebar.tsx @@ -11,7 +11,7 @@ import { workspaceNavigation, } from "@/lib/navigation"; -const WORK_ORDERS = "/admin/work-orders"; +const NEEDS_YOU_BADGE = new Set(["/admin", "/admin/work-orders"]); const VIEW_LABELS: Record = { agency: "Agency", client: "Client" }; @@ -35,7 +35,7 @@ export function AdminSidebar() { const matching = groups .flatMap((group) => group.items) - .filter((item) => !!matchRoute({ to: item.match ?? item.to, fuzzy: true })); + .filter((item) => !!matchRoute({ to: item.match ?? item.to, fuzzy: !item.exact })); const activeTo = matching.reduce( (best, item) => (best === null || item.to.length > best.length ? item.to : best), null, @@ -89,7 +89,7 @@ export function AdminSidebar() { > {item.label} - {item.to === WORK_ORDERS && } + {NEEDS_YOU_BADGE.has(item.to) && } ); diff --git a/ui/src/components/admin/applications-section.tsx b/ui/src/components/admin/applications-section.tsx index 091ddbbc..69bd157d 100644 --- a/ui/src/components/admin/applications-section.tsx +++ b/ui/src/components/admin/applications-section.tsx @@ -128,9 +128,7 @@ export function ApplicationsAdminSection() {

Applications

- - Founder, builder and client applications from the public site. - + From the public site. {apps.length} @@ -233,7 +231,7 @@ function ApplicationActions({ application }: { application: Application }) { await refreshAfter(queryClient, { type: "applications" }); toast.success("Status updated"); }, - onError: (err: Error) => toast.error(err.message || "Failed to update status"), + onError: (err: Error) => toast.error(err.message || "Couldn't update the status"), }); const convertMutation = useMutation({ @@ -242,7 +240,7 @@ function ApplicationActions({ application }: { application: Application }) { await refreshAfter(queryClient, { type: "applications" }, { type: "builders" }); toast.success("Converted to builder"); }, - onError: (err: Error) => toast.error(err.message || "Failed to convert"), + onError: (err: Error) => toast.error(err.message || "Couldn't convert"), }); return ( diff --git a/ui/src/components/admin/contributors-section.tsx b/ui/src/components/admin/contributors-section.tsx index f8ce4d47..64cacdf9 100644 --- a/ui/src/components/admin/contributors-section.tsx +++ b/ui/src/components/admin/contributors-section.tsx @@ -175,9 +175,7 @@ export function ContributorsAdminSection() {

Builder directory

- - Profiles are shared across Agencies and keyed by NEAR account. - + Shared across Agencies. {!creating && ( {!importing && ( @@ -237,7 +235,7 @@ function ContributorCreateForm({ onDone }: { onDone: () => void }) { toast.success("Builder created"); onDone(); }, - onError: (err: Error) => toast.error(err.message || "Failed to create builder"), + onError: (err: Error) => toast.error(err.message || "Couldn't create the builder"), }); const isPending = createMutation.isPending; @@ -380,7 +378,7 @@ export function ContributorProfileForm({ await refreshAfter(queryClient, { type: "builders" }); toast.success("Profile saved"); }, - onError: (err: Error) => toast.error(err.message || "Failed to save profile"), + onError: (err: Error) => toast.error(err.message || "Couldn't save"), }); const isPending = saveMutation.isPending; @@ -502,7 +500,7 @@ export function RemoveBuilderButton({ open={confirming} onOpenChange={setConfirming} title={`Remove ${name}?`} - description="Their profile is deleted from the builder directory. Billings already recorded for them are kept." + description="Their profile leaves the directory. Recorded billings are kept." confirmLabel="Remove builder" destructive onConfirm={async () => { diff --git a/ui/src/components/admin/project-form.tsx b/ui/src/components/admin/project-form.tsx index 4fedd164..f76ca0e7 100644 --- a/ui/src/components/admin/project-form.tsx +++ b/ui/src/components/admin/project-form.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardDescription, CardFooter, CardHeader, CardTitle, @@ -129,7 +128,7 @@ export function ProjectForm({ }, onError: (err: Error) => { if (isSlugTakenError(err)) return; - toast.error(err.message || (mode === "create" ? "Failed to create" : "Failed to update")); + toast.error(err.message || "Couldn't save the project"); }, }); @@ -187,11 +186,6 @@ export function ProjectForm({

{mode === "create" ? "New project" : "Project details"}

- - {mode === "create" - ? "Projects hold the team, budget and billings for work you deliver." - : "Title, notes, repository, NEARN link, status and visibility."} -

Your projects

- - Open a project to manage its builders, budget and billings. - {!creating && ( {!importing && ( @@ -302,9 +299,7 @@ function NearnSponsorBountiesPanel({

Unlinked NEARN bounties

- - Bounties from your NEARN sponsor account that no project tracks yet. - + No project tracks these yet. diff --git a/ui/src/components/home/home-work-order-lists.tsx b/ui/src/components/home/home-work-order-lists.tsx new file mode 100644 index 00000000..6a803d69 --- /dev/null +++ b/ui/src/components/home/home-work-order-lists.tsx @@ -0,0 +1,125 @@ +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; +import { LoadError } from "@/components/load-error"; +import { SectionHeader } from "@/components/page-header"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from "@/components/ui/item"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { WorkOrder } from "@/components/work-orders/work-order-status"; +import { waitingPhrase } from "@/lib/work-orders-inbox"; + +function builderName(workOrder: WorkOrder): string { + return workOrder.builder.name ?? workOrder.builder.nearAccount; +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ); +} + +export function WaitingOnYouList({ + workOrders, + isLoading, + error, + onRetry, +}: { + workOrders: WorkOrder[]; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +}) { + return ( +
+ + {error ? ( + + ) : isLoading ? ( + + ) : workOrders.length === 0 ? ( +

+ No work orders are waiting on you. +

+ ) : ( + + {workOrders.map((workOrder) => ( +
  • + + + + {workOrder.code} + + {builderName(workOrder)} + + + {workOrder.progress.headline} + + + + + +
  • + ))} +
    + )} +
    + ); +} + +export function InProgressList({ workOrders }: { workOrders: WorkOrder[] }) { + if (workOrders.length === 0) return null; + return ( +
    + + + All work orders + + + + } + /> + + {workOrders.map((workOrder) => ( +
  • + + + + + {workOrder.code} + + {builderName(workOrder)} + + + + + {waitingPhrase(workOrder.progress.waitingOn, builderName(workOrder)) ?? + "In progress"} + + + +
  • + ))} +
    +
    + ); +} diff --git a/ui/src/components/home/home-work-orders.ts b/ui/src/components/home/home-work-orders.ts new file mode 100644 index 00000000..add4a2eb --- /dev/null +++ b/ui/src/components/home/home-work-orders.ts @@ -0,0 +1,19 @@ +import { type InboxItem, inboxCategory, sortInbox } from "@/lib/work-orders-inbox"; + +export const IN_PROGRESS_LIMIT = 5; + +export function waitingOnYou>(items: T[]): T[] { + return sortInbox(items.filter((item) => item.progress.needsYou)); +} + +export function activeWorkOrders>( + items: T[], + limit = IN_PROGRESS_LIMIT, +): T[] { + return sortInbox( + items.filter((item) => { + const category = inboxCategory(item); + return category === "in-progress" || category === "builder"; + }), + ).slice(0, limit); +} diff --git a/ui/src/components/user-nav.tsx b/ui/src/components/user-nav.tsx index b2153b10..535c923a 100644 --- a/ui/src/components/user-nav.tsx +++ b/ui/src/components/user-nav.tsx @@ -15,6 +15,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useNearSignIn } from "@/hooks/use-near-sign-in"; +import { signOut } from "@/lib/account"; import { useApiClient } from "@/lib/api"; import { sessionQueryOptions } from "@/lib/auth"; import { formatCount } from "@/lib/format-count"; @@ -49,11 +50,7 @@ export function UserNav() { const invitationCount = invitationsQuery.data?.length ?? 0; const signOutMutation = useMutation({ - mutationFn: async () => { - const { error } = await authClient.signOut(); - if (error) throw new Error(error.message || "Failed to sign out"); - await authClient.near.disconnect().catch(() => {}); - }, + mutationFn: () => signOut(authClient), onSuccess: async () => { queryClient.clear(); navigate({ to: "/", replace: true }); diff --git a/ui/src/components/work-orders/activity-timeline.tsx b/ui/src/components/work-orders/activity-timeline.tsx index 182be307..d526fef3 100644 --- a/ui/src/components/work-orders/activity-timeline.tsx +++ b/ui/src/components/work-orders/activity-timeline.tsx @@ -34,11 +34,7 @@ export function ActivityTimeline({ workOrder }: { workOrder: WorkOrderDetail })

    Activity

    - - {activity.isError - ? "Everything that happened to this work order. The emails couldn't be loaded." - : "Everything that happened to this work order, newest first, with the emails it sent."} - + {activity.isError && The emails couldn't be loaded.} {events.length === 0 ? ( diff --git a/ui/src/components/work-orders/builder-signing.tsx b/ui/src/components/work-orders/builder-signing.tsx index 30e8854a..b2e72f70 100644 --- a/ui/src/components/work-orders/builder-signing.tsx +++ b/ui/src/components/work-orders/builder-signing.tsx @@ -67,18 +67,18 @@ export function BuilderSigning({

    {waiting ? `Sign Work Order ${wo.code}` : "Work order document"}

    - + {agencyFirst ? `${wo.agencyName} is signing this work order first. We'll email you a link to sign it as soon as they have.` : waiting && esign - ? `${wo.agencyName} signed this work order. Sign it to accept; it starts right away. We emailed you a link to sign it; you can also open it from here. You'll be paid to ${wo.builder.nearAccount}.` + ? `${wo.agencyName} signed. Sign to accept; it starts right away. You'll be paid to ${wo.builder.nearAccount}.` : waiting - ? `${wo.agencyName} sent you this work order to sign. Read it, then sign with your full name below. You're signed in with ${wo.builder.nearAccount}, the account you'll be paid to.` + ? `Read it, then sign with your full name. You'll be paid to ${wo.builder.nearAccount}.` : complete ? `Signed by you on ${signedByBuilder.signedAt.slice(0, 10)} and by ${wo.signing.representative?.name} on ${wo.signing.representative?.signedAt.slice(0, 10)}.` : signedByBuilder ? `You signed on ${signedByBuilder.signedAt.slice(0, 10)}. Waiting for ${wo.agencyName} to countersign.` - : "The document of this work order."} + : null} diff --git a/ui/src/components/work-orders/delivered-work-section.tsx b/ui/src/components/work-orders/delivered-work-section.tsx index a165ca36..36c199f4 100644 --- a/ui/src/components/work-orders/delivered-work-section.tsx +++ b/ui/src/components/work-orders/delivered-work-section.tsx @@ -1,13 +1,5 @@ import { useState } from "react"; -import { - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components"; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@/components"; import { formatTokenAmount } from "@/lib/format-amount"; import { ReportMarkdown } from "./report-markdown"; @@ -71,10 +63,6 @@ export function DeliveredWorkSection({ items }: { items: DeliveredWork[] }) {

    Delivered work

    - - Work accepted for this period: the GitHub issues each builder was assigned, the pull - requests the repository owner approved or merged, and their reviews. -
      diff --git a/ui/src/components/work-orders/duplicate-work-order-button.tsx b/ui/src/components/work-orders/duplicate-work-order-button.tsx deleted file mode 100644 index b3077a93..00000000 --- a/ui/src/components/work-orders/duplicate-work-order-button.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; -import { Button, Dialog, DialogContent } from "@/components"; -import { WorkOrderForm } from "./work-order-form"; -import type { WorkOrder } from "./work-order-status"; - -export function DuplicateWorkOrderButton({ - workOrder, - variant = "outline", - className, -}: { - workOrder: WorkOrder; - variant?: "outline" | "ghost" | "secondary"; - className?: string; -}) { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - return ( - <> - - - event.preventDefault()} - > - {open && ( - { - setOpen(false); - void navigate({ to: "/admin/work-orders/$id", params: { id: created.id } }); - }} - /> - )} - - - - ); -} diff --git a/ui/src/components/work-orders/evidence-list.tsx b/ui/src/components/work-orders/evidence-list.tsx index 0ccbe878..2590ade5 100644 --- a/ui/src/components/work-orders/evidence-list.tsx +++ b/ui/src/components/work-orders/evidence-list.tsx @@ -17,12 +17,7 @@ const STATE_LABEL: Record = { export function EvidenceList({ evidence }: { evidence: Evidence[] }) { if (evidence.length === 0) { - return ( -

      - Nothing collected yet. Evidence comes from GitHub: issues assigned to the builder, pull - requests the repository owner approved or merged, and reviews. -

      - ); + return

      Nothing collected from GitHub yet.

      ; } return (
      diff --git a/ui/src/components/work-orders/github-repo-picker.tsx b/ui/src/components/work-orders/github-repo-picker.tsx new file mode 100644 index 00000000..7fd1c0b3 --- /dev/null +++ b/ui/src/components/work-orders/github-repo-picker.tsx @@ -0,0 +1,145 @@ +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { type KeyboardEvent, useState } from "react"; +import { Button, Input } from "@/components"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useApiClient } from "@/lib/api"; +import { workOrderGithubReposQueryOptions } from "@/lib/queries"; + +export const DEFAULT_GITHUB_ORGS = ["NEARBuilders", "MultiAgency"] as const; +const OTHER = "other"; + +const sameRepo = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +export function GithubRepoPicker({ + selected, + onToggle, + onClose, +}: { + selected: string[]; + onToggle: (repo: string, checked: boolean) => void; + onClose: () => void; +}) { + const apiClient = useApiClient(); + const [choice, setChoice] = useState(DEFAULT_GITHUB_ORGS[0]); + const [otherInput, setOtherInput] = useState(""); + const [otherOrg, setOtherOrg] = useState(""); + const [search, setSearch] = useState(""); + const org = choice === OTHER ? otherOrg : choice; + const reposQuery = useQuery(workOrderGithubReposQueryOptions(apiClient, org)); + const term = search.trim().toLowerCase(); + const repos = (reposQuery.data?.repos ?? []).filter( + (r) => + !term || + r.fullName.toLowerCase().includes(term) || + r.description?.toLowerCase().includes(term), + ); + + const applyOther = () => setOtherOrg(otherInput.trim()); + const onOtherEnter = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + applyOther(); + }; + + return ( +
      +
      +

      Import from GitHub

      + +
      + { + if (value) setChoice(value); + }} + aria-label="GitHub organization" + > + {DEFAULT_GITHUB_ORGS.map((name) => ( + + {name} + + ))} + Other + + {choice === OTHER && ( +
      + setOtherInput(e.target.value)} + /> + +
      + )} + {org !== "" && ( + setSearch(e.target.value)} + /> + )} + {org === "" ? null : reposQuery.isPending ? ( +
      + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
      + ) : reposQuery.error ? ( +

      {reposQuery.error.message}

      + ) : repos.length === 0 ? ( +

      No public repositories match.

      + ) : ( +
        + {repos.map((repo) => { + const id = `gh-repo-${repo.fullName}`; + const checked = selected.some((s) => sameRepo(s, repo.fullName)); + return ( +
      • + onToggle(repo.fullName, value === true)} + /> + + + + +
      • + ); + })} +
      + )} +
      + ); +} diff --git a/ui/src/components/work-orders/google-settings.tsx b/ui/src/components/work-orders/google-settings.tsx index 1ad710df..311cc1fd 100644 --- a/ui/src/components/work-orders/google-settings.tsx +++ b/ui/src/components/work-orders/google-settings.tsx @@ -114,7 +114,7 @@ export function GoogleDriveSettings({ mutationFn: () => apiClient.workOrders.copyStarterTemplate({}), onSuccess: (next) => { onUpdate(next); - toast.success("The starter template was copied to your folder and selected"); + toast.success("Starter template copied"); }, onError: (err: Error) => toast.error(err.message || "Couldn't copy the starter template"), }); @@ -148,8 +148,7 @@ export function GoogleDriveSettings({ Google Docs and Drive

      - MultiAgency fills your template in your Drive when you create a work order, and saves the - signed PDF, with its signature certificate, to the Signed folder. + Each work order is filled from your template. Signed PDFs go to the Signed folder.

      diff --git a/ui/src/components/work-orders/integration-settings.tsx b/ui/src/components/work-orders/integration-settings.tsx index 01b6d650..3b453471 100644 --- a/ui/src/components/work-orders/integration-settings.tsx +++ b/ui/src/components/work-orders/integration-settings.tsx @@ -6,7 +6,6 @@ import { Badge, Card, CardContent, - CardDescription, CardHeader, CardTitle, Table, @@ -49,10 +48,6 @@ export function WorkOrderIntegrationSettings() {

      Work orders: documents and email

      - - MultiAgency fills your Google Docs template for each work order, collects both signatures - in the dashboard and emails the builder and your representative at each step. -
      @@ -67,10 +62,8 @@ export function WorkOrderIntegrationSettings() { Emails are off on this server - - Set RESEND_API_KEY and NOTIFY_FROM_EMAIL. Until then people get in-app notifications - only. - + Set RESEND_API_KEY and NOTIFY_FROM_EMAIL. Until then, + notifications are in-app only. )} diff --git a/ui/src/components/work-orders/next-step-card.tsx b/ui/src/components/work-orders/next-step-card.tsx index eecb6772..52da2faf 100644 --- a/ui/src/components/work-orders/next-step-card.tsx +++ b/ui/src/components/work-orders/next-step-card.tsx @@ -10,11 +10,15 @@ import type { WorkOrderProgress } from "./progress-stepper"; export type ProgressAction = NonNullable; export type InPageAction = Exclude; -function waitingLine(progress: WorkOrderProgress, builderName: string): string | null { - const since = progress.since ? relativeTime(progress.since) : null; - const who = waitingPhrase(progress.waitingOn, builderName); - if (who) return since ? `${who} · since ${since}` : who; - return since ? `Since ${since}` : null; +function metaLine(progress: WorkOrderProgress, builderName: string): string | null { + const since = progress.since ? `since ${relativeTime(progress.since)}` : null; + const who = + progress.waitingOn === "builder" && !progress.headline.includes(builderName) + ? waitingPhrase(progress.waitingOn, builderName) + : null; + if (who && since) return `${who} · ${since}`; + if (who) return who; + return since ? since.charAt(0).toUpperCase() + since.slice(1) : null; } export function ActionButton({ @@ -75,40 +79,37 @@ export function NextStepCard({ children?: ReactNode; }) { const needsYou = progress.waitingOn === "you"; - const waiting = waitingLine(progress, builderName); + const meta = metaLine(progress, builderName); return ( - + - - Next step + + {needsYou ? "Your turn" : "Next step"}

      {progress.headline}

      - {waiting && ( - - {waiting} - - )} + {meta && {meta}}
      - + {canManage && progress.next && (
      )} {progress.attention.length > 0 && ( -
      +

      Needs attention

      -
        +
          {progress.attention.map((item) => (
        • {children}; - return <>Ask your MultiAgency operator to turn this on.; + return <>Ask your server admin to turn this on.; } diff --git a/ui/src/components/work-orders/payee-payouts-prompt.tsx b/ui/src/components/work-orders/payee-payouts-prompt.tsx index 6250ceba..9afa46b9 100644 --- a/ui/src/components/work-orders/payee-payouts-prompt.tsx +++ b/ui/src/components/work-orders/payee-payouts-prompt.tsx @@ -28,8 +28,8 @@ export function PayeePayoutsPrompt({ workOrder }: { workOrder: WorkOrderDetail } The payout agent can't pay {name} yet - The rules you signed don't include {payee}. Sign the updated rules with your wallet so - approved requests are paid automatically. + The rules you signed don't include {payee}. Sign the updated rules so approved requests + are paid automatically. {agent.changes.length > 0 ? ( diff --git a/ui/src/components/work-orders/quick-create-builder.tsx b/ui/src/components/work-orders/quick-create-builder.tsx new file mode 100644 index 00000000..6ca79893 --- /dev/null +++ b/ui/src/components/work-orders/quick-create-builder.tsx @@ -0,0 +1,95 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type KeyboardEvent, useState } from "react"; +import { toast } from "sonner"; +import { Button, FieldError, Input } from "@/components"; +import { Field } from "@/components/admin-form"; +import { type ApiClient, useApiClient } from "@/lib/api"; +import { isValidNearAccountId } from "@/lib/near-account"; +import { refreshAfter } from "@/lib/queries"; + +type CreatedBuilder = Awaited>["contributor"]; + +export function QuickCreateBuilder({ + onCreated, + onCancel, +}: { + onCreated: (builder: CreatedBuilder) => void; + onCancel?: () => void; +}) { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const [nearAccount, setNearAccount] = useState(""); + const [name, setName] = useState(""); + const nearTrimmed = nearAccount.trim(); + const nearOk = nearTrimmed !== "" && isValidNearAccountId(nearTrimmed); + + const create = useMutation({ + mutationFn: async () => { + const { contributor } = await apiClient.contributors.create({ + nearAccount: nearTrimmed, + name: name.trim() || undefined, + }); + return contributor; + }, + onSuccess: async (builder) => { + await refreshAfter(queryClient, { type: "builders" }); + toast.success(`${builder.name ?? builder.nearAccount} added`); + onCreated(builder); + }, + onError: (err: Error) => toast.error(err.message || "Failed to add the builder"), + }); + + const canSubmit = nearOk && !create.isPending; + const submit = () => { + if (canSubmit) create.mutate(); + }; + const onEnter = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + submit(); + }; + + return ( +
          +

          New builder

          + + setNearAccount(e.target.value)} + /> + {nearTrimmed !== "" && !nearOk && Invalid NEAR account ID} + + + setName(e.target.value)} + /> + +
          + + {onCancel && ( + + )} +
          +
          + ); +} diff --git a/ui/src/components/work-orders/quick-create-project.tsx b/ui/src/components/work-orders/quick-create-project.tsx new file mode 100644 index 00000000..427f7c61 --- /dev/null +++ b/ui/src/components/work-orders/quick-create-project.tsx @@ -0,0 +1,112 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type KeyboardEvent, useState } from "react"; +import { toast } from "sonner"; +import { Button, Input } from "@/components"; +import { Field } from "@/components/admin-form"; +import { type ApiClient, useApiClient } from "@/lib/api"; +import { refreshAfter } from "@/lib/queries"; +import { isSlugTakenError, slugify } from "@/lib/slugify"; +import { isHttpUrl } from "@/lib/url"; + +type CreatedProject = Awaited>["project"]; + +const SLUG_ATTEMPTS = 5; + +export function QuickCreateProject({ + onCreated, + onCancel, +}: { + onCreated: (project: CreatedProject) => void; + onCancel?: () => void; +}) { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const [title, setTitle] = useState(""); + const [repository, setRepository] = useState(""); + const repositoryTrimmed = repository.trim(); + const repositoryOk = isHttpUrl(repositoryTrimmed); + + const create = useMutation({ + mutationFn: async () => { + const base = slugify(title) || "project"; + let slug = base; + for (let attempt = 0; ; attempt++) { + try { + const { project } = await apiClient.agency.projects.create({ + slug, + title: title.trim(), + repository: repositoryTrimmed, + kind: "project", + status: "active", + visibility: "private", + }); + return project; + } catch (err) { + if (!isSlugTakenError(err) || attempt >= SLUG_ATTEMPTS) throw err; + const suffix = `-${attempt + 2}`; + slug = `${base.slice(0, 80 - suffix.length).replace(/-+$/, "")}${suffix}`; + } + } + }, + onSuccess: async (project) => { + await refreshAfter(queryClient, { type: "projects" }); + toast.success(`${project.title} created`); + onCreated(project); + }, + onError: (err: Error) => toast.error(err.message || "Failed to create the project"), + }); + + const canSubmit = title.trim() !== "" && repositoryOk && !create.isPending; + const submit = () => { + if (canSubmit) create.mutate(); + }; + const onEnter = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + submit(); + }; + + return ( +
          +

          New project

          + + setTitle(e.target.value)} + /> + + + setRepository(e.target.value)} + /> + +
          + + {onCancel && ( + + )} +
          +
          + ); +} diff --git a/ui/src/components/work-orders/renewal-panel.tsx b/ui/src/components/work-orders/renewal-panel.tsx index ff5e4dba..6946e9a8 100644 --- a/ui/src/components/work-orders/renewal-panel.tsx +++ b/ui/src/components/work-orders/renewal-panel.tsx @@ -45,9 +45,8 @@ export function RenewalPanel({

          Monthly renewal

          - After {workOrder.periodEnd} and {workOrder.graceDays} grace days, this period's work is - measured against the base metrics. All met: next month's work order starts by itself. - Any missed: you decide whether it renews. + Checked after {workOrder.periodEnd} + {workOrder.graceDays} grace days. Renews by itself + if every base metric is met; otherwise you decide. @@ -153,8 +152,8 @@ function DecideRenewal({ workOrder }: { workOrder: WorkOrder }) { Renewal needs your decision - Renew anyway to start next month's work order with the same terms, or let {workOrder.code}{" "} - end here. The builder is told either way. + Renew with the same terms, or let {workOrder.code} end here. The builder is told either + way. { queryClient.setQueryData(workOrderRepresentativeQueryOptions(apiClient).queryKey, data); setForm(null); - toast.success("Default representative saved"); + toast.success("Representative saved"); }, onError: (err: Error) => toast.error(err.message || "Couldn't save"), }); @@ -50,8 +50,7 @@ export function WorkOrderRepresentativeSettings() {

          Default representative

          - Who signs work orders for your Agency. New work orders start with these; you can change - them on each one. + Signs first and gets agency emails. You can change it per work order. @@ -66,11 +65,7 @@ export function WorkOrderRepresentativeSettings() { >
          - +

          Compensation requests

          - - Each period the builder sends a report and you approve an amount within the budget. Your - payout agent pays it right away, or you pay it through an Agency DAO proposal. - {canDraft && ( {dismissible && summary.ready && ( )}
            - {query.data.map((check) => ( + {groups.main.map((check) => ( ))}
          + {groups.optional.length > 0 && ( + + + + + +
            + {groups.optional.map((check) => ( + + ))} +
          +
          +
          + )}
          ); @@ -240,10 +263,7 @@ export function SetupPreflight() {
        • ))}
        - - Fix links open in a new tab, so this draft stays open. You can still save it and fix these - before you send it. - + Fix links open in a new tab. You can still save this draft. ); diff --git a/ui/src/components/work-orders/signing-panel.tsx b/ui/src/components/work-orders/signing-panel.tsx index a30237dd..f46754a8 100644 --- a/ui/src/components/work-orders/signing-panel.tsx +++ b/ui/src/components/work-orders/signing-panel.tsx @@ -1,21 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; -import { - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Input, -} from "@/components"; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from "@/components"; import { Checkbox } from "@/components/ui/checkbox"; import { useApiClient } from "@/lib/api"; import { adminWorkOrdersQueryKey } from "@/lib/queries"; import { OpenDocumentButton } from "./open-document-button"; -import type { WorkOrderDetail } from "./work-order-status"; +import { payeeConfirmedBySigning, type WorkOrderDetail } from "./work-order-status"; function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -35,6 +26,7 @@ export function SigningPanel({ canManage: boolean; onAttach: () => void; }) { + const confirmsOnSigning = payeeConfirmedBySigning(wo); const apiClient = useApiClient(); const queryClient = useQueryClient(); const [github, setGithub] = useState(""); @@ -66,11 +58,6 @@ export function SigningPanel({

        Signature and payout account

        - - Offering the work order sends it for e-signature: the representative signs first, then the - builder, and it starts as soon as the builder signs. A payout account this Agency hasn't - paid before is confirmed by its wallet. -
        @@ -88,27 +75,31 @@ export function SigningPanel({ Confirmed {date(wo.payee.confirmedAt)} ) : wo.payee.paidBefore ? ( Paid by this Agency before + ) : confirmsOnSigning ? ( + Confirmed when they sign ) : wo.payee.needsConfirmation ? ( Needs the builder's confirmation ) : null} - {wo.payee.needsConfirmation && ( + {wo.payee.needsConfirmation && !confirmsOnSigning && ( - This Agency hasn't paid this account before, so the builder confirms it by signing - in with that NEAR wallet. Accepting in MultiAgency confirms it too. + New account: the builder confirms it by signing in with that NEAR wallet. )} - {canManage && wo.payee.needsConfirmation && wo.status !== "draft" && ( - - )} + {canManage && + wo.payee.needsConfirmation && + !confirmsOnSigning && + wo.status !== "draft" && ( + + )}
      @@ -154,7 +145,6 @@ export function SigningPanel({ · {wo.representativeEmail} ) : null} - {wo.ipModel && {wo.ipModel.replace("_", " ")}}
      @@ -267,8 +257,8 @@ function SignatureStatus({
      {canManage - ? "Sign it in Documenso to send it to the builder. They're emailed a link to sign as soon as you've signed." - : `Waiting for ${wo.representativeName ?? "the representative"} to sign it in Documenso; then it's sent to the builder.`} + ? "Sign it in Documenso to send it to the builder." + : `Waiting for ${wo.representativeName ?? "the representative"} to sign it in Documenso.`} {esignError}
      @@ -294,8 +284,7 @@ function SignatureStatus({
      {representative ? `Signed by ${representative.name}. ` : ""}Sent to {builderName} on{" "} - {day(representative?.signedAt ?? wo.offeredAt)}; waiting for their signature. It becomes - active as soon as they sign. + {day(representative?.signedAt ?? wo.offeredAt)}; waiting for their signature. {esignError} +

      + )}
      - - { - const project = projects.find((p) => p.id === value); - setDraft((current) => ({ - ...current, - projectId: value, - repos: current.repos || repoFromProject(project?.repository), - })); - }} - options={projects.map((p) => ({ value: p.id, label: p.title }))} - /> - - { - const builder = builders.find((b) => b.nearAccount === value); - setDraft((current) => ({ - ...current, - builderNearAccount: value, - githubLogin: builder?.githubLogin ?? "", - })); - }} - options={builders.map((b) => ({ - value: b.nearAccount, - label: `${b.name ?? b.nearAccount}${b.githubLogin ? ` · @${b.githubLogin}` : " · no GitHub login"}`, - }))} - /> - -
      -
      - - set("code", e.target.value)} - /> + {builders.length === 0 || creatingBuilder ? ( + 0 ? () => setCreatingBuilder(false) : undefined} + onCreated={(builder) => { + setCreatingBuilder(false); + setDraft((current) => ({ + ...current, + builderNearAccount: builder.nearAccount, + githubLogin: builder.githubLogin ?? "", + contractorEmail: "", + })); + }} + /> + ) : ( + { + if (value === NEW_ITEM) { + setCreatingBuilder(true); + return; + } + const builder = builders.find((b) => b.nearAccount === value); + setDraft((current) => ({ + ...current, + builderNearAccount: value, + githubLogin: builder?.githubLogin ?? "", + contractorEmail: "", + })); + }} + options={[ + ...builders.map((b) => ({ + value: b.nearAccount, + label: `${b.name ?? b.nearAccount}${b.githubLogin ? ` · @${b.githubLogin}` : ""}`, + })), + { value: NEW_ITEM, label: "+ New builder" }, + ]} + /> + )} - - set("title", e.target.value)} - /> + + {projects.length === 0 || creatingProject ? ( + 0 ? () => setCreatingProject(false) : undefined} + onCreated={(project) => { + setCreatingProject(false); + setDraft((current) => ({ + ...current, + projectId: project.id, + repos: current.repos.trim() + ? current.repos + : repoFromProject(project.repository), + })); + }} + /> + ) : ( + { + if (value === NEW_ITEM) { + setCreatingProject(true); + return; + } + const project = projects.find((p) => p.id === value); + setDraft((current) => ({ + ...current, + projectId: value, + repos: current.repos.trim() + ? current.repos + : repoFromProject(project?.repository), + })); + }} + options={[ + ...projects.map((p) => ({ value: p.id, label: p.title })), + { value: NEW_ITEM, label: "+ New project" }, + ]} + /> + )}
      - -