diff --git a/api/src/services/notifications.ts b/api/src/services/notifications.ts index f01cde9b..a0e5ff76 100644 --- a/api/src/services/notifications.ts +++ b/api/src/services/notifications.ts @@ -108,12 +108,16 @@ export const NOTIFICATION_KINDS = { }), work_order_accepted: (p) => ({ title: `${p.builderName} signed Work Order ${p.code}`, - body: `${p.builderName} signed Work Order ${p.code} on ${p.projectTitle}. Countersign it on MultiAgency, then activate it to start the work period.`, + body: `${p.builderName} signed Work Order ${p.code} on ${p.projectTitle}. It starts on its own once both of you have signed and nothing else blocks it.`, }), work_order_declined: (p) => ({ title: `${p.builderName} declined Work Order ${p.code}`, body: `${p.builderName} declined Work Order ${p.code} on ${p.projectTitle}${p.note ? `: ${p.note}` : "."} It is back in draft.`, }), + work_order_esign_cancelled: (p) => ({ + title: `The signing request for Work Order ${p.code} was cancelled`, + body: `The signing request for Work Order ${p.code} on ${p.projectTitle} was cancelled, so it is back in draft.`, + }), work_order_signed: (p) => ({ title: `Work Order ${p.code} is signed`, body: `Work Order ${p.code} on ${p.projectTitle} was signed by both parties. It starts automatically once nothing else blocks it.`, @@ -132,7 +136,7 @@ export const NOTIFICATION_KINDS = { }), work_order_payee_confirmed: (p) => ({ title: `${p.builderName} confirmed the payout account for ${p.code}`, - body: `${p.builderName} proved they control the payout account of Work Order ${p.code} on ${p.projectTitle}. You can activate it.`, + body: `${p.builderName} proved they control the payout account of Work Order ${p.code} on ${p.projectTitle}. It starts on its own unless something else blocks it.`, }), work_order_activated: (p) => ({ title: `Work Order ${p.code} is active`, @@ -198,6 +202,14 @@ export const NOTIFICATION_KINDS = { title: `Renewal of ${p.code} needs your decision`, body: `Work Order ${p.code} on ${p.projectTitle} missed ${p.missed} for renewal. Renew it anyway or let it end on MultiAgency.`, }), + work_order_terminated: (p) => ({ + title: `${p.agencyName} ended Work Order ${p.code}`, + body: `${p.agencyName} terminated Work Order ${p.code} on ${p.projectTitle}. You can't send new payout requests under it; payouts already sent aren't affected.`, + }), + work_order_withdrawn: (p) => ({ + title: `${p.agencyName} withdrew Work Order ${p.code}`, + body: `${p.agencyName} withdrew the offer of Work Order ${p.code} on ${p.projectTitle}, so there's nothing to sign. If you got a signing link for it, ignore it.`, + }), work_order_not_renewed: (p) => ({ title: `Work Order ${p.code} is not renewed`, body: `Work Order ${p.code} on ${p.projectTitle} ends with this period.`, diff --git a/api/src/services/work-orders/emails.ts b/api/src/services/work-orders/emails.ts index 3662a949..00bed077 100644 --- a/api/src/services/work-orders/emails.ts +++ b/api/src/services/work-orders/emails.ts @@ -71,6 +71,10 @@ export function composeEmails(e: WorkOrderEvent): WorkOrderEmail[] { return url ? { label, url } : null; }; + const starts = wo.periodStarted + ? "it starts right away" + : `its work period runs from ${wo.periodStart} to ${wo.periodEnd}`; + const send = (role: Role, subject: string, draft: Draft) => { const person = role === "contractor" ? e.contractor : e.representative; if (!person.email) return; @@ -83,13 +87,13 @@ export function composeEmails(e: WorkOrderEvent): WorkOrderEmail[] { case "work_order.offered": send("contractor", `${agency} sent you Work Order ${wo.id} to sign`, { preheader: e.links.sign - ? `${agency} has signed ${wo.title}. Sign it to accept; it starts right away.` + ? `${agency} has signed ${wo.title}. Sign it to accept; ${starts}.` : `${wo.title}: review the terms and sign to accept.`, eyebrow: "Work order sent", heading: `${agency} sent you a work order`, paragraphs: [ e.links.sign - ? `You're offered "${wo.title}" on ${wo.project}, and ${agency} has already signed it. Review the terms and sign to accept. As soon as you sign, it's complete and starts right away.` + ? `You're offered "${wo.title}" on ${wo.project}, and ${agency} has already signed it. Review the terms and sign to accept. As soon as you sign, it's complete: ${starts}.` : `You're offered "${wo.title}" on ${wo.project}. Review the terms and sign to accept. It only takes a minute.`, `Each approved payout request is paid to ${e.contractor.nearAccount}.`, ], @@ -129,7 +133,7 @@ export function composeEmails(e: WorkOrderEvent): WorkOrderEmail[] { heading: `Work Order ${wo.id} is signed`, paragraphs: [ `${builder} and ${agency} have both signed. The signed PDF is attached to the work order.`, - "It becomes active automatically when its period starts.", + "It starts on its own unless something still blocks it, such as a payout account to confirm. The work order shows anything that's left.", ], details: summary(["Builder", builder], ["Budget", wo.budget]), action: view("representative"), @@ -138,17 +142,157 @@ export function composeEmails(e: WorkOrderEvent): WorkOrderEmail[] { : [], }); break; - case "work_order.active": + case "work_order.active": { + const upcoming = !wo.periodStarted; send("contractor", `Work Order ${wo.id} is active`, { - preheader: `Your work period has started: ${period}.`, + preheader: upcoming + ? `Your work period starts on ${wo.periodStart}: ${period}.` + : `Your work period has started: ${period}.`, eyebrow: "Work order active", heading: `Work Order ${wo.id} is active`, - paragraphs: ["Your work period has started. Here's how your work is counted:"], + paragraphs: [ + upcoming + ? `Your work period starts on ${wo.periodStart}. Here's how your work is counted:` + : "Your work period has started. Here's how your work is counted:", + ], list: { title: null, items: COUNTING_RULES }, details: summary(["Budget", wo.budget], ["Paid to", e.contractor.nearAccount]), action: view("contractor"), }); break; + } + case "work_order.sign_first": + send("representative", `Sign Work Order ${wo.id} to send it to ${builder}`, { + preheader: `${builder} gets their signing link as soon as you've signed.`, + eyebrow: "Your signature needed", + heading: `Sign Work Order ${wo.id} first`, + paragraphs: [ + `"${wo.title}" for ${builder} is ready in Documenso. You sign first for ${agency}; ${builder} gets their signing link as soon as you have.`, + "Until you sign, nothing has gone to the builder.", + ], + details: summary(["Builder", builder], ["Budget", `up to ${wo.budget}`]), + action: e.links.countersign + ? { label: "Review and sign", url: e.links.countersign } + : view("representative", "Open the work order"), + secondary: + e.links.countersign && e.links.review + ? [{ label: "View it on MultiAgency", url: e.links.review }] + : [], + }); + break; + case "work_order.declined": + send("representative", `${builder} declined Work Order ${wo.id}`, { + preheader: `${wo.title} is back in draft.`, + eyebrow: "Work order declined", + heading: `${builder} declined Work Order ${wo.id}`, + paragraphs: [ + `${builder} declined "${wo.title}". It's back in draft: edit it and send it again, or delete it.`, + ...(e.note ? [`Their note: "${e.note}"`] : []), + ], + details: summary(["Builder", builder], ["Budget", wo.budget]), + action: view("representative"), + }); + break; + case "work_order.esign_cancelled": + send("representative", `The signing request for Work Order ${wo.id} was cancelled`, { + preheader: `${wo.title} is back in draft.`, + eyebrow: "Signing cancelled", + heading: `The signing request for Work Order ${wo.id} was cancelled`, + paragraphs: [ + `The signing request for "${wo.title}" was cancelled, so it's back in draft. Send it again, or delete it.`, + ], + details: summary(["Builder", builder], ["Budget", wo.budget]), + action: view("representative"), + }); + break; + case "work_order.confirm_payee": + send("contractor", `Confirm your payout account for Work Order ${wo.id}`, { + preheader: `Confirm ${e.contractor.nearAccount} so you can be paid.`, + eyebrow: "Payout account", + heading: "Confirm your payout account", + paragraphs: [ + `${agency} hasn't paid ${e.contractor.nearAccount} before. Open the work order signed in with that NEAR account and confirm it.`, + "It's free, takes a few seconds and doesn't move any money.", + ], + details: summary(["Paid to", e.contractor.nearAccount]), + action: e.links.confirm + ? { label: "Confirm the account", url: e.links.confirm } + : view("contractor", "Confirm the account"), + }); + break; + case "work_order.ending_soon": + send("contractor", `Work Order ${wo.id} ends on ${wo.periodEnd}`, { + preheader: `Your work period ends on ${wo.periodEnd}.`, + eyebrow: "Ending soon", + heading: `Your work period ends on ${wo.periodEnd}`, + paragraphs: [ + "Your report and payout request are drafted right after it ends; you review them and send the request.", + 'Make sure your pull requests close their assigned issues ("Closes #12") so they count.', + ], + details: summary(["Budget", wo.budget]), + action: view("contractor"), + }); + break; + case "work_order.renewal_needs_approval": { + const metrics = e.renewal?.assessment.metrics ?? []; + send("representative", `Renew ${builder}'s Work Order ${wo.id}? It needs your decision`, { + preheader: `It missed a renewal target, so it wasn't renewed automatically.`, + eyebrow: "Renewal needs a decision", + heading: `Renew ${builder}'s work order?`, + paragraphs: [ + `The period of "${wo.title}" is over and it missed at least one renewal target, so it wasn't renewed automatically. Renew it anyway or let it end.`, + ], + list: metrics.length + ? { + title: "Renewal targets", + items: metrics.map( + (m) => `${m.met ? "Met" : "Missed"}: ${m.label}: ${m.value} (needs ${m.target})`, + ), + } + : undefined, + details: summary(["Builder", builder], ["Budget", wo.budget]), + action: view("representative", "Decide on the renewal"), + }); + break; + } + case "work_order.not_renewed": + send("contractor", `Work Order ${wo.id} isn't renewed`, { + preheader: `${wo.title} ends with this period.`, + eyebrow: "Not renewed", + heading: `Work Order ${wo.id} isn't renewed`, + paragraphs: [ + `${agency} didn't renew "${wo.title}". It ends with this period.`, + "Payout requests for work done in this period still go through as usual.", + ], + details: summary(["Budget", wo.budget]), + action: view("contractor"), + }); + break; + case "work_order.terminated": + send("contractor", `${agency} ended Work Order ${wo.id}`, { + preheader: `${wo.title} is terminated.`, + eyebrow: "Work order ended", + heading: `${agency} ended Work Order ${wo.id}`, + paragraphs: [ + `${agency} terminated "${wo.title}". You can't send new payout requests under it.`, + `Payouts already sent aren't affected. Contact ${agency} if you have questions.`, + ], + details: summary(["Budget", wo.budget]), + action: view("contractor"), + }); + break; + case "work_order.withdrawn": + send("contractor", `${agency} withdrew Work Order ${wo.id}`, { + preheader: `There's nothing to sign: ${wo.title} was withdrawn.`, + eyebrow: "Work order withdrawn", + heading: `${agency} withdrew Work Order ${wo.id}`, + paragraphs: [ + `${agency} withdrew its offer of "${wo.title}", so there's nothing to sign. If you got a signing link for it, ignore it.`, + `Contact ${agency} if you have questions.`, + ], + details: summary(["Budget", `up to ${wo.budget}`]), + }); + break; case "work_order.renewed": { const unsent = e.renewal?.unsent ?? null; const signLink = unsent ? null : (e.links.countersign ?? null); @@ -185,6 +329,20 @@ export function composeEmails(e: WorkOrderEvent): WorkOrderEmail[] { }); break; } + case "request.report_ready": + send("contractor", `Your report for Work Order ${wo.id} is ready to send`, { + preheader: `Review your report and send your payout request${r?.requested ? ` for ${r.requested}` : ""}.`, + eyebrow: "Report ready", + heading: "Your report is ready to review", + paragraphs: [ + `Payout request ${r?.id ?? ""} was drafted from your GitHub activity. Review the report, add any work outside GitHub and send it to ${agency}.`, + "You're only paid once you send it.", + ], + details: summary(["Request", r?.id ?? ""], ["Requested", r?.requested ?? ""]), + action: view("contractor", "Review and send"), + block: e.reportMd ? { title: "Draft report", body: e.reportMd } : null, + }); + break; case "request.awaiting_approval": send("contractor", `Your payout request for ${wo.id} was sent`, { preheader: `${r?.requested ?? "Your request"} is waiting for ${agency}'s approval.`, diff --git a/api/src/services/work-orders/events.ts b/api/src/services/work-orders/events.ts index 0c31b4b0..c5fa7360 100644 --- a/api/src/services/work-orders/events.ts +++ b/api/src/services/work-orders/events.ts @@ -4,16 +4,20 @@ export const WORK_ORDER_EVENT_TYPES = [ "work_order.created", "work_order.signature_mismatch", "work_order.offered", + "work_order.sign_first", "work_order.builder_signed", "work_order.signed", "work_order.confirm_payee", "work_order.payee_confirmed", "work_order.active", "work_order.declined", + "work_order.esign_cancelled", "work_order.ending_soon", "work_order.renewed", "work_order.renewal_needs_approval", "work_order.not_renewed", + "work_order.terminated", + "work_order.withdrawn", "request.report_ready", "request.awaiting_approval", "request.approved", @@ -50,6 +54,7 @@ export type WorkOrderEvent = { project: string; periodStart: string; periodEnd: string; + periodStarted: boolean; budget: string; }; contractor: { name: string; email: string | null; github: string; nearAccount: string }; diff --git a/api/src/services/work-orders/service.ts b/api/src/services/work-orders/service.ts index 0e15c119..746fb078 100644 --- a/api/src/services/work-orders/service.ts +++ b/api/src/services/work-orders/service.ts @@ -211,6 +211,7 @@ export type WorkOrderFields = { type WorkOrderStatus = WorkOrderRow["status"]; type RequestStatus = CompensationRequestRow["status"]; type PendingEnvelope = { esign: EsignPort; envelopeId: string }; +type RequestDrafter = "schedule" | "agency" | "builder"; type GoogleTemplate = { google: GooglePort; templateId: string; @@ -506,13 +507,27 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { async function contactsOf(row: WorkOrderRow, builder: BuilderProfile | null) { const lookup = async (userId: string | null | undefined) => userId && deps.userEmail ? await deps.userEmail(userId).catch(() => null) : null; + const fallback = row.representativeEmail + ? null + : await integrationOf(row.organizationId).catch(() => null); + const useDefault = !!fallback?.defaultRepresentativeEmail; return { contractorEmail: row.contractorEmail ?? (await lookup(builder?.userId)), - representativeEmail: row.representativeEmail, + representativeEmail: useDefault + ? (fallback?.defaultRepresentativeEmail ?? null) + : row.representativeEmail, + representativeName: useDefault + ? (fallback?.defaultRepresentativeName ?? null) + : row.representativeName, }; } - async function announce(row: WorkOrderRow, type: WorkOrderEventType, extra: Announcement = {}) { + async function announce( + row: WorkOrderRow, + type: WorkOrderEventType, + extra: Announcement = {}, + workOrderId: string | null = row.id, + ) { if (!deps.outbox) return; try { const [project, agencyName, builder] = await Promise.all([ @@ -534,6 +549,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { project: project?.title ?? UNKNOWN_PROJECT, periodStart: row.periodStart, periodEnd: row.periodEnd, + periodStarted: today() >= row.periodStart, budget: display(row.budgetAmount, row.tokenId), }, contractor: { @@ -543,7 +559,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { nearAccount: row.builderNearAccount, }, representative: { - name: row.representativeName ?? agencyName, + name: contacts.representativeName ?? agencyName, email: contacts.representativeEmail, }, request: request @@ -597,7 +613,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { renewal: extra.renewal ?? null, hold: extra.hold ?? null, }; - await deps.outbox.enqueue(row.organizationId, row.id, event); + await deps.outbox.enqueue(row.organizationId, workOrderId, event); } catch (error) { console.warn(`[API] work order ${row.code} ${type} couldn't be queued`, error); } @@ -683,7 +699,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { project: projectTitle, contractorName: row.contractorName ?? builder?.name ?? row.builderNearAccount, contractorEmail: contacts.contractorEmail, - representativeName: row.representativeName ?? agencyName, + representativeName: contacts.representativeName ?? agencyName, representativeEmail: contacts.representativeEmail, periodStart: row.periodStart, periodEnd: row.periodEnd, @@ -896,7 +912,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ["Project", projectTitle], ["Builder (contractor)", row.contractorName ?? builder?.name ?? row.builderNearAccount], ["Builder's email", contacts.contractorEmail ?? ""], - ["Agency representative", row.representativeName ?? agencyName], + ["Agency representative", contacts.representativeName ?? agencyName], ["Representative's email", contacts.representativeEmail ?? ""], ["Work period", `${longDate(row.periodStart)} to ${longDate(row.periodEnd)}`], [ @@ -1376,6 +1392,15 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { }; } + async function sentToBuilder(row: WorkOrderRow, state?: EsignStatus | null): Promise { + if (row.status === "draft") return false; + if (row.status !== "offered") return true; + if (!(await signingOf(row)).awaitingRepresentative) return true; + const current = + state === undefined ? (await checkEnvelope(row).catch(() => null))?.state : state; + return !!current?.signers.some((s) => s.role === "representative" && s.signedAt); + } + async function syncPendingSignatures(): Promise { if (!deps.esign) return 0; const pending = await db @@ -1411,7 +1436,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const signers = [ { role: "representative" as const, - name: row.representativeName ?? agencyName, + name: contacts.representativeName ?? agencyName, email: contacts.representativeEmail ?? "", }, { @@ -1545,20 +1570,29 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { let current = row.esignError ? await update(row.id, { esignError: null }) : row; if (state.status === "rejected") { if (current.status !== "offered") return current; - const reason = state.signers.find((s) => s.rejected)?.rejectionReason ?? ""; + const rejecter = state.signers.find((s) => s.rejected); + const reason = rejecter?.rejectionReason ?? ""; const declined = await update(row.id, { status: "draft", offeredAt: null, esignEnvelopeId: null, esignBuilderUrl: null, esignRepresentativeUrl: null, - esignError: `Declined in Documenso${reason ? `: ${reason}` : ""}`, + esignError: rejecter + ? `Declined in Documenso${reason ? `: ${reason}` : ""}` + : "Cancelled in Documenso", }); - await notifyManagers( - declined, - "work_order_declined", - await payloadOf(declined, { note: reason }), - ); + if (rejecter?.role === "contractor") { + await notifyManagers( + declined, + "work_order_declined", + await payloadOf(declined, { note: reason }), + ); + await announce(declined, "work_order.declined", { note: reason || null }); + } else if (!rejecter) { + await notifyManagers(declined, "work_order_esign_cancelled", await payloadOf(declined)); + await announce(declined, "work_order.esign_cancelled"); + } return declined; } const existing = await signaturesOf(row.id, offered.sha256); @@ -2157,6 +2191,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { row: WorkOrderRow, kind: NotificationKind, payload: Record, + link = builderLink(row.id), ) { const builder = await deps.builders.get(row.builderNearAccount).catch(() => null); if (!builder?.userId) return; @@ -2166,7 +2201,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { organizationId: row.organizationId, kind, payload, - link: builderLink(row.id), + link, }) .catch((error: unknown) => console.warn("[API] work order notification failed:", error)); } @@ -2459,7 +2494,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { async function draftRequest( initial: WorkOrderRow, input: { coversFrom?: string; coversTo?: string }, - auto = false, + drafter: RequestDrafter, ): Promise { let row = initial; if (row.status !== "active" && row.status !== "ended") { @@ -2477,7 +2512,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { if (amountsOf(row, existing).remaining <= 0n) { throw badRequest("BUDGET_USED", "The work order budget is used up"); } - if (!auto) { + if (drafter !== "schedule") { try { row = await collect(row); } catch (error) { @@ -2525,16 +2560,18 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { updatedAt: now(), }) .returning(); - if (auto) await update(row.id, { autoDraftedFor: row.periodEnd }); - await notifyBuilder( - row, - "compensation_request_ready", - await payloadOf(row, { requestCode: `${row.code}-R${seq}` }), - ); - await announce(row, "request.report_ready", { - request: inserted, - reportMd: draftReportOf(contributionsMd, assessmentMd), - }); + if (drafter === "schedule") await update(row.id, { autoDraftedFor: row.periodEnd }); + if (drafter !== "builder") { + await notifyBuilder( + row, + "compensation_request_ready", + await payloadOf(row, { requestCode: `${row.code}-R${seq}` }), + ); + await announce(row, "request.report_ready", { + request: inserted, + reportMd: draftReportOf(contributionsMd, assessmentMd), + }); + } return inserted!; } @@ -3279,7 +3316,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { if (current.status === "ended" && current.autoDraftedFor !== current.periodEnd && !open) { const { remaining } = amountsOf(current, await requestsOf(current.id)); if (remaining > 0n) { - await draftRequest(current, {}, true); + await draftRequest(current, {}, "schedule"); counts.drafted = (counts.drafted ?? 0) + 1; } else { current = await update(current.id, { autoDraftedFor: current.periodEnd }); @@ -3795,6 +3832,12 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { "Offer the work order or have it signed before asking for the payout account", ); } + if (row.status === "offered" && row.esignEnvelopeId) { + throw badRequest( + "CONFIRMED_BY_SIGNING", + `The builder confirms ${row.builderNearAccount} by signing ${row.code} in Documenso`, + ); + } await announce(row, "work_order.confirm_payee"); await notifyBuilder(row, "work_order_confirm_payee", await payloadOf(row)); return adminView(await view(row)); @@ -3807,6 +3850,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { if (!offered.esignEnvelopeId) { await notifyBuilder(offered, "work_order_offered", await payloadOf(offered)); await announce(offered, "work_order.offered"); + } else if ((await signingOf(offered)).awaitingRepresentative) { + await announce(offered, "work_order.sign_first"); } return adminView(await view(offered)); }); @@ -3846,7 +3891,6 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { if (signatures.some((s) => s.role === "representative")) { throw badRequest("ALREADY_SIGNED", "You've already countersigned it"); } - const contacts = await contactsOf(row, null); await db.insert(workOrderSignatures).values({ id: crypto.randomUUID(), workOrderId: row.id, @@ -3854,7 +3898,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { role: "representative", name, email: - contacts.representativeEmail ?? + row.representativeEmail ?? (deps.userEmail ? await deps.userEmail(scope.actorId).catch(() => null) : null), userId: scope.actorId, nearAccount: null, @@ -3904,6 +3948,8 @@ 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`); + const builderWasSent = await sentToBuilder(row); + const withdrawsOffer = row.status === "offered"; const failed = await cancelEnvelope(row, "The work order was terminated"); const terminated = await update(row.id, { status: "terminated", @@ -3913,6 +3959,13 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } : {}), }); + if (builderWasSent && withdrawsOffer) { + await notifyBuilder(terminated, "work_order_withdrawn", await payloadOf(terminated)); + await announce(terminated, "work_order.withdrawn"); + } else if (builderWasSent) { + await notifyBuilder(terminated, "work_order_terminated", await payloadOf(terminated)); + await announce(terminated, "work_order.terminated"); + } return adminView(await view(terminated)); }); }, @@ -3952,7 +4005,16 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ); } } + const builderWasSent = + row.status === "offered" && + checked?.state.status !== "rejected" && + (await sentToBuilder(row, checked?.state ?? null)); + const payload = builderWasSent ? await payloadOf(row) : null; await db.delete(workOrders).where(eq(workOrders.id, row.id)); + if (payload) { + await notifyBuilder(row, "work_order_withdrawn", payload, "/dashboard"); + await announce(row, "work_order.withdrawn", {}, null); + } return { deleted: true as const }; }); }, @@ -3965,7 +4027,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { input: { id: string; coversFrom?: string; coversTo?: string }, ) => { const row = await ownRow(scope, input.id); - return requestView(await draftRequest(row, input), row); + return requestView(await draftRequest(row, input, "agency"), row); }, approveRequest: async ( @@ -4456,7 +4518,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { throw badRequest( "SIGN_IN_DOCUMENSO", (await signingOf(row)).awaitingRepresentative - ? `Work order ${row.code} isn't ready for your signature yet: ${row.representativeName ?? "the agency"} signs it first` + ? `Work order ${row.code} isn't ready for your signature yet: ${(await contactsOf(row, null)).representativeName ?? "the agency"} signs it first` : `Sign work order ${row.code} with the Documenso link in your email or on the work order page`, ); } @@ -4569,7 +4631,10 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ) => { const row = await mustRow(input.id); requireBuilder(context, row); - return { ...(await requestView(await draftRequest(row, input), row)), trust: null }; + return { + ...(await requestView(await draftRequest(row, input, "builder"), row)), + trust: null, + }; }, editRequest: async ( diff --git a/api/tests/integration/work-order-outbox.test.ts b/api/tests/integration/work-order-outbox.test.ts index 96f8abc2..903f50b0 100644 --- a/api/tests/integration/work-order-outbox.test.ts +++ b/api/tests/integration/work-order-outbox.test.ts @@ -29,6 +29,7 @@ function event(type: WorkOrderEvent["type"], over: Partial = {}) project: "Search", periodStart: "2026-09-01", periodEnd: "2026-09-30", + periodStarted: true, budget: "1000 USDC", }, contractor: { @@ -80,12 +81,20 @@ describe("work order emails", () => { expect(to("request.awaiting_approval")).toEqual(["carol@example.com", "rita@example.com"]); expect(to("work_order.builder_signed")).toEqual(["rita@example.com"]); expect(to("request.approved")).toEqual(["carol@example.com"]); - for (const quiet of [ - "work_order.created", - "work_order.confirm_payee", - "work_order.declined", + expect(to("work_order.sign_first")).toEqual(["rita@example.com"]); + expect(to("work_order.declined")).toEqual(["rita@example.com"]); + expect(to("work_order.renewal_needs_approval")).toEqual(["rita@example.com"]); + for (const builder of [ "request.report_ready", + "work_order.confirm_payee", + "work_order.ending_soon", + "work_order.not_renewed", + "work_order.terminated", + "work_order.withdrawn", ] as const) { + expect(to(builder)).toEqual(["carol@example.com"]); + } + for (const quiet of ["work_order.created", "work_order.payee_confirmed"] as const) { expect(to(quiet)).toEqual([]); } const request = { @@ -171,6 +180,43 @@ describe("work order emails", () => { expect(email?.html).toContain('href="https://sign.example/envelope_1/representative"'); }); + test("the representative is asked to sign a new Documenso order first", () => { + const base = event("work_order.sign_first"); + const [email] = composeEmails( + event("work_order.sign_first", { + links: { ...base.links, countersign: "https://sign.example/envelope_1/representative" }, + }), + ); + expect(email?.subject).toBe("Sign Work Order SI-004 to send it to Carol"); + expect(email?.html).toContain('href="https://sign.example/envelope_1/representative"'); + }); + + test("signed and active emails say when the work order starts", () => { + const [signed] = composeEmails(event("work_order.signed")); + expect(signed?.text).not.toContain("when its period starts"); + expect(signed?.text).toContain("starts on its own unless something still blocks it"); + const [started] = composeEmails(event("work_order.active")); + expect(started?.text).toContain("Your work period has started"); + const later = event("work_order.active"); + later.workOrder.periodStarted = false; + const [upcoming] = composeEmails(later); + expect(upcoming?.text).toContain("Your work period starts on 2026-09-01"); + expect(upcoming?.text).not.toContain("has started"); + const offer = event("work_order.offered"); + offer.links.sign = "https://sign.example/c"; + offer.workOrder.periodStarted = false; + const [offered] = composeEmails(offer); + expect(offered?.text).toContain("its work period runs from 2026-09-01 to 2026-09-30"); + expect(offered?.text).not.toContain("starts right away"); + }); + + test("a withdrawn order tells the builder to ignore any signing link", () => { + const [email] = composeEmails(event("work_order.withdrawn")); + expect(email?.subject).toBe("Studio withdrew Work Order SI-004"); + expect(email?.text).toContain("If you got a signing link for it, ignore it"); + expect(email?.html).not.toContain("https://app.example/work-orders/1"); + }); + test("emails go out once through Resend", async () => { const signed = event("work_order.signed"); signed.links.document = "https://drive.example/signed?id=1&v=2"; diff --git a/api/tests/integration/work-orders.test.ts b/api/tests/integration/work-orders.test.ts index c27ce0d4..209b1a25 100644 --- a/api/tests/integration/work-orders.test.ts +++ b/api/tests/integration/work-orders.test.ts @@ -12,6 +12,7 @@ import { workOrderDriveFiles, workOrderIntegrations, workOrderOutbox, + workOrderSignatures, workOrders, } from "../../src/db/schema"; import { rememberedLogin } from "../../src/services/builder-logins"; @@ -961,10 +962,87 @@ describe("work orders", () => { expect(event?.links.review).toBe(`https://app.example/admin/work-orders/${created.id}`); }); - test("agency emails go only to the representative's email on the work order", async () => { + test("agency emails go to the work order's representative, else the Agency's default one", async () => { await service.create(await studio(), fields({ tokenId: REAL_USDC })); - const event = events.find((e) => e.type === "work_order.created"); - expect(event?.representative.email).toBeNull(); + expect(events.find((e) => e.type === "work_order.created")?.representative.email).toBeNull(); + + await createWorkOrderIntegrations({ db }).saveRepresentative("studio", "studio-admin", { + name: "Dana Default", + email: "dana@example.com", + }); + events.length = 0; + await service.create(await studio(), fields({ tokenId: REAL_USDC })); + expect(events.find((e) => e.type === "work_order.created")?.representative).toEqual({ + name: "Dana Default", + email: "dana@example.com", + }); + + events.length = 0; + await service.create( + await studio(), + fields({ representativeName: "Rita Rep", representativeEmail: "rita@example.com" }), + ); + expect(events.find((e) => e.type === "work_order.created")?.representative).toEqual({ + name: "Rita Rep", + email: "rita@example.com", + }); + + events.length = 0; + await service.create(await studio(), fields({ representativeName: "Rita Rep" })); + expect(events.find((e) => e.type === "work_order.created")?.representative).toEqual({ + name: "Dana Default", + email: "dana@example.com", + }); + }); + + test("a countersignature records the signer's own email, not the default representative's", async () => { + await createWorkOrderIntegrations({ db }).saveRepresentative("studio", "studio-admin", { + name: "Dana Default", + email: "dana@example.com", + }); + const wo = await active(); + const [signature] = await db + .select() + .from(workOrderSignatures) + .where( + and( + eq(workOrderSignatures.workOrderId, wo.id), + eq(workOrderSignatures.role, "representative"), + ), + ); + expect(signature?.email).toBe("studio-admin@example.com"); + }); + + test("terminating tells the builder; a draft they never saw stays quiet", async () => { + const wo = await active(); + events.length = 0; + await service.terminate(await studio(), { id: wo.id }); + expect(events.map((e) => e.type)).toEqual(["work_order.terminated"]); + expect((await notificationsOf("carol")).map((n) => n.kind)).toContain( + "work_order_terminated", + ); + + const draft = await service.create(await studio(), fields()); + events.length = 0; + await service.terminate(await studio(), { id: draft.id }); + expect(events).toEqual([]); + }); + + test("deleting an offered work order tells the builder it was withdrawn", async () => { + const created = await service.create(await studio(), fields()); + await service.offer(await studio(), { id: created.id }); + events.length = 0; + await service.remove(await studio(), { id: created.id }); + expect(events.map((e) => e.type)).toEqual(["work_order.withdrawn"]); + const [notice] = (await notificationsOf("carol")).filter( + (n) => n.kind === "work_order_withdrawn", + ); + expect(notice?.link).toBe("/dashboard"); + + const draft = await service.create(await studio(), fields()); + events.length = 0; + await service.remove(await studio(), { id: draft.id }); + expect(events).toEqual([]); }); test("accepting in the app proves the payout account", async () => { @@ -994,7 +1072,7 @@ describe("work orders", () => { strongEvidence(); const wo = await active({ tokenId: REAL_USDC }); await service.collect(await studio(), { id: wo.id }); - const draft = await service.mineDraftRequest(carol(), { id: wo.id }); + const draft = await service.draftRequest(await studio(), { id: wo.id }); expect(draft.contributionsMd).toContain("Carol shipped the search feature."); expect(draft.assessmentMd).toContain("Search unblocks onboarding."); const edited = await service.editRequest(carol(), { @@ -1011,6 +1089,23 @@ describe("work orders", () => { expect(waiting?.request?.requested).toBe("500 USDC"); }); }); + test("whether the period has started is judged in the agency's time zone", async () => { + service = createWorkOrdersService({ ...serviceDeps, timeZone: "Pacific/Kiritimati" }); + await active(); + const started = events.find((e) => e.type === "work_order.active"); + expect(started?.workOrder.periodStarted).toBe(true); + expect(composeEmails(started!)[0]?.text).toContain("Your work period has started"); + }); + + test("a builder drafting their own request isn't told it's ready", async () => { + const wo = await active(); + const notes = (await notificationsOf("carol")).length; + const draft = await service.mineDraftRequest(carol(), { id: wo.id }); + expect(draft.status).toBe("draft"); + expect(events.map((e) => e.type)).not.toContain("request.report_ready"); + expect((await notificationsOf("carol")).length).toBe(notes); + }); + describe("Google Docs and Drive", () => { const REAL_USDC = "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"; @@ -1425,6 +1520,9 @@ describe("work orders", () => { expect((await notificationsOf("carol")).map((n) => n.kind)).not.toContain( "work_order_offered", ); + const signFirst = events.find((e) => e.type === "work_order.sign_first"); + expect(signFirst?.representative.email).toBe("rita@example.com"); + expect(signFirst?.links.countersign).toBe("https://sign.example/envelope_1/representative"); const admin = await service.get(await studio(), { id: created.id }); expect(admin.status).toBe("offered"); @@ -1629,6 +1727,60 @@ describe("work orders", () => { expect(events.map((e) => e.type)).not.toContain("work_order.offered"); }); + test("asking for the payout account of a Documenso offer is refused: signing confirms it", async () => { + const created = await offered(); + events.length = 0; + const notes = (await notificationsOf("carol")).length; + await refused( + service.requestPayeeConfirmation(await studio(), { id: created.id }), + "CONFIRMED_BY_SIGNING", + ); + expect(events).toEqual([]); + expect((await notificationsOf("carol")).length).toBe(notes); + }); + + test("an envelope cancelled in Documenso puts the order back in draft and tells the agency", async () => { + const created = await offered(); + events.length = 0; + documenso.last().status = "rejected"; + const back = await service.get(await studio(), { id: created.id }); + expect(back.status).toBe("draft"); + expect(back.signing.esign?.error).toBe("Cancelled in Documenso"); + expect(events.map((e) => e.type)).toEqual(["work_order.esign_cancelled"]); + expect(composeEmails(events[0]!).map((m) => m.to)).toEqual(["rita@example.com"]); + const kinds = (await notificationsOf("studio-admin")).map((n) => n.kind); + expect(kinds).toContain("work_order_esign_cancelled"); + expect(kinds).not.toContain("work_order_declined"); + }); + + test("a builder too early to sign is told the default representative signs first", async () => { + await createWorkOrderIntegrations({ db }).saveRepresentative("studio", "studio-admin", { + name: "Dana Default", + email: "dana@example.com", + }); + const created = await service.create(await studio(), fields()); + await service.offer(await studio(), { id: created.id }); + const doc = await service.myDocument(carol(), { id: created.id }); + await expect( + service.sign(carol(), { id: created.id, documentSha256: doc.sha256, name: "Carol" }), + ).rejects.toThrow("Dana Default signs it first"); + }); + + test("terminating when Documenso can't withdraw a live envelope still tells the builder to ignore its link", async () => { + const created = await offered(); + documenso.sign("representative", "2026-09-20T13:00:00.000Z"); + documenso.cancel = async () => { + throw new Error("timeout"); + }; + events.length = 0; + const terminated = await service.terminate(await studio(), { id: created.id }); + expect(terminated.signing.esign?.error).toContain("may still be able to sign there"); + expect(events.map((e) => e.type)).toEqual(["work_order.withdrawn"]); + expect(composeEmails(events[0]!)[0]?.text).toContain( + "If you got a signing link for it, ignore it", + ); + }); + async function completed() { const created = await offered(); documenso.sign("representative", "2026-09-20T13:00:00.000Z"); @@ -1880,6 +2032,7 @@ describe("work orders", () => { expect(back.status).toBe("draft"); expect(back.signing.esign?.error).toBe("Declined in Documenso: Wrong budget"); expect(events.map((e) => e.type)).not.toContain("work_order.offered"); + expect(events.map((e) => e.type)).not.toContain("work_order.declined"); await service.offer(await studio(), { id: created.id }); documenso.sign("representative", "2026-09-20T13:00:00.000Z"); @@ -1888,6 +2041,36 @@ describe("work orders", () => { const declined = await service.get(await studio(), { id: created.id }); expect(declined.status).toBe("draft"); expect(declined.signing.esign?.error).toBe("Declined in Documenso: Wrong budget"); + const email = events.find((e) => e.type === "work_order.declined"); + expect(email?.note).toBe("Wrong budget"); + }); + + test("terminating an offer the builder never accepted tells them it was withdrawn, once the representative signed in Documenso", async () => { + const created = await offered(); + documenso.sign("representative", "2026-09-20T13:00:00.000Z"); + events.length = 0; + await service.terminate(await studio(), { id: created.id }); + expect(events.map((e) => e.type)).toEqual(["work_order.withdrawn"]); + const kinds = (await notificationsOf("carol")).map((n) => n.kind); + expect(kinds).toContain("work_order_withdrawn"); + expect(kinds).not.toContain("work_order_terminated"); + }); + + test("terminating before the representative signed in Documenso doesn't tell the builder", async () => { + const created = await offered(); + events.length = 0; + await service.terminate(await studio(), { id: created.id }); + expect(events).toEqual([]); + }); + + test("withdrawing before the representative signed doesn't tell the builder", async () => { + const created = await offered(); + events.length = 0; + await service.remove(await studio(), { id: created.id }); + expect(events).toEqual([]); + expect((await notificationsOf("carol")).map((n) => n.kind)).not.toContain( + "work_order_withdrawn", + ); }); test("it isn't offered without both emails, or when Documenso refuses", async () => {