Skip to content

Work orders: guided workflow, agency-signs-first e-signature, Google Drive and branded emails - #82

Merged
jlwaugh merged 39 commits into
stagingfrom
feature/work-orders-integration
Oct 27, 2026
Merged

jlwaugh merged 39 commits into
stagingfrom
feature/work-orders-integration

Conversation

@saadiqbal-dev

Copy link
Copy Markdown
Collaborator

Work orders: a clearer workflow for the agency, agency-signs-first e-signature, Google Drive, branded emails, and payout safeguards.

What changes

Workflow: the admin always knows where they stand

  • Work orders list becomes an inbox. Filters are Needs you / Waiting on builder / In progress / Done, plus All. Each row has a "Next step" column: who it's waiting on, for how long, and a one-click action. Rows that need you are highlighted, and the side menu shows a badge with their count.
  • Work order page:
    • A progress bar across the top: Draft → You sign → Builder signs → Active → Payout request → Approved → Paid → Ended.
    • A Next step card with a single button, plus "Needs attention" items, each with its fix.
    • Tabs: Overview, Documents & signatures, Payouts, Evidence and Activity. Activity lists every email sent and whether it was delivered.
  • A Ready to send work orders checklist appears on the list, in settings and at the top of the new work order form. It covers the agency name, representative, e-signature, emails, Google Drive and the payout agent, and each item has a fix link.
  • Faster creation:
    • Create and sign creates the work order, offers it and opens Documenso in one step.
    • Smart defaults: the period runs to month end, the next code is previewed, and recent budgets are offered as chips.
    • Duplicate into the next period copies an existing work order.

E-signature (Documenso)

  • The agency representative signs first. The builder's offer email, with their direct signing link, goes out once the representative has signed. The builder's signature completes the document and activates the work order.
  • Documenso sends no emails of its own: all emails come from MultiAgency.

Emails (Resend)

  • Branded MultiAgency templates.
  • To the builder: work order sent (direct signing link), active, payout request summary, payout sent, payout declined.
  • To the agency: work order signed, payout requested, renewed, payout on hold.
  • Agency emails go only to the representative email entered on the work order. There is no fallback to login emails.

Google Drive (optional, per agency)

  • "Sign in with Google" connection with the drive.file scope; the refresh token is stored encrypted.
  • The admin picks the template and folders with the Google Picker. Template fields are found automatically, and AI suggests the mapping.
  • Signed PDFs upload to the agency's Signed folder automatically. Failed uploads are retried on later ticks.

Payouts

  • Before you approve or offer, a warning shows when the payout agent can't cover the amount: low balance, monthly budget or per-payout cap.
  • An approved payout that can't go out is held with its reason, and the representative is emailed once.

Less retyping

  • The agency remembers a builder's GitHub login, and has a default representative.
  • A prompt appears to rename a placeholder agency name.

Database

4 new migrations. They apply automatically on deploy and come after 0029_owner_signing:

  • 0030_organization_builder_logins
  • 0031_work_order_representative
  • 0032_work_order_payout_hold
  • 0033_work_order_google_oauth

New environment variables (all optional)

  • DOCUMENSO_API_URL, DOCUMENSO_API_KEY: without them, builders accept in-app instead of through Documenso.
  • RESEND_API_KEY, NOTIFY_FROM_EMAIL (a sender on a verified Resend domain) and APP_ORIGIN (used for links in emails): without them, no emails are sent.
  • GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_PICKER_API_KEY, GOOGLE_CLOUD_PROJECT_NUMBER, GOOGLE_TOKENS_SECRET_KEY: without them, the Drive features stay hidden. The OAuth redirect URI is <APP_ORIGIN>/api/work-orders/google/callback.

Testing

  • The branch was checked against the current staging (simulated merge, no conflicts):
    • biome ci ., bun typecheck and bun run build are clean.
    • API: 692 passed. One test (trustHistoryOf) timed out under machine load and passes on its own.
    • UI: 246 passed. Plugins: 126 passed.
  • Manual end-to-end run with a $1 work order on a self-hosted Documenso: offer, agency signs, builder signs from the email, auto-active, payout request, approve. The payout was correctly held on low agent balance, and the representative was emailed.
  • Rebased on James's payout and signing fixes, with no merge commits.

How to test

  1. Settings: work through the checklist (representative, e-signature, optionally Google Drive).
  2. Create a work order with Create and sign, sign as the representative in the tab that opens, then sign as the builder from the email.
  3. Watch the progress bar and Next step card advance. Check the Activity tab for the emails.
  4. Submit a payout request as the builder and approve it as the agency.

The representative signs at offer time; the builder's offer email with
their signing link goes out once the representative's signature is
recorded, and the builder's signature completes the envelope.
…ring

The admin page shows a Sign button right after offering and explains that
the builder is sent the work order once the representative has signed.
The builder sees the agency's signature and that it starts when they sign.
When a work order is created or edited with a GitHub login for a builder
whose profile has none, the agency remembers it (organization_builder_logins)
so the next work order and the builder picker are prefilled. The builder's
shared profile is never written: its githubLogin is the board member
identity.
Agencies set a default representative name and email in Settings > Work
orders. New work orders prefill from it, falling back to the signed-in
user only when their email is a real address (not a NEAR wallet
@near.email placeholder). The representative email field explains that
agency emails go only to that address.
Builders see the agency name in every work order email. When it looks like
a placeholder (empty, under 3 characters, test/demo/agency/my agency, or the
organization id) the admin work orders page and Settings > Organization show
an alert, and owners/admins can now rename the organization there.
…l the representative when a payout is on hold

- payoutReadiness route quotes an approval amount in NEAR at the live price and runs the agent readiness checks without persisting anything
- payoutCoverage route checks whether the agent balance and the remaining monthly budget cover a work order's unpaid budget
- request.payout_blocked event emails the representative once per request and funding reason (insufficient balance, monthly budget, per-payout cap), tracked in compensation_requests.auto_pay_blocked_code
…agent can't pay

- the approve dialog shows when approving won't pay right away, with a link to fund the agent
- the admin work order page and the offer action warn when the agent's balance or monthly budget can't cover the work order
…ields

Agencies connect their own Google account (drive.file scope) through an
OAuth flow: the refresh token is stored encrypted per organization, access
tokens refresh on demand and a revoked grant marks the connection for
reconnecting. The Google client takes a token provider, and work orders use
the agency's connection before the server's service account.

Templates are scanned for {{tags}}, blanks, bracketed placeholders and,
with an Anthropic key, sample values. Admins map each tag to a known field,
fixed text or a value asked on the work order; applying the fields tags a
copy of the template and selects it. Fills use the mapping, record tags
left unfilled on the work order, and the payout method text is neutral.

Signed PDFs from in-app signing and Documenso are saved to the Signed folder
(or the documents folder) once, retried on the scheduler tick after a
failure, and skipped by the signed-folder check.
Settings shows the connected Google account with Disconnect or Reconnect,
opens Google Picker for the template and folders, copies the starter
template, and keeps the pasted links under Advanced. A Template fields table
maps each tag, warns about missing important fields and makes a preview;
Set up fields reviews detected blanks and AI suggestions and applies them to
a tagged copy. The work order form asks for the template's custom fields,
and the work order page links the signed PDF in Drive with a retry.
Every work order view carries its steps (draft, signatures, active, payout
request, approved, paid, wrap-up), the current stage, who it waits on, a
one-line headline, the single action that moves it forward and the
problems that need attention.
GET /admin/work-order-setup reports agency name, default representative,
email, e-signature, Google Drive, payout agent and agent funding, each
isolated so one failing check can't break the route.
TanStack Link navigation doesn't fire hashchange, so also follow the
router location hash.
GET /admin/work-orders/needs-you returns how many listed work orders wait on the agency, from the same progress the list computes.
Filter by who each work order waits on (?view=), sort what needs you and what waited longest first, show the next step with its age and inline link actions, and stack rows as cards on small screens. The renewal banner is gone: those work orders show under Needs you.

@multi-review multi-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline findings for 776891e; see the summary comment for the rest.

Comment on lines +69 to 73
const ORDER: Record<EsignRole, number> = { representative: 1, contractor: 2 };

function roleOf(signingOrder: number | null, index: number): EsignRole {
return (signingOrder ?? index + 1) <= 1 ? "contractor" : "representative";
return (signingOrder ?? index + 1) <= ORDER.representative ? "representative" : "contractor";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High · compatibility: Reversing signing order also reverses how existing in-flight envelopes are read, so builder signatures get recorded as the agency's

roleOf is used by status() to map Documenso recipients to roles, purely from signingOrder. Envelopes sent before this deploy have the builder at order 1 and the representative at order 2. After the deploy, status() reports the builder as representative and the agency signer as contractor. syncEnvelope runs on admin get, builder mineGet and every 15s tick. Its effects on those envelopes:

  • Builder already signed before deploy (status accepted, waiting on countersign): the next sync inserts a representative signature carrying the builder's name and email. needsCountersign becomes false, so the "Countersign the work order" blocker disappears. activateReady can then activate the work order without the agency ever signing, and the legal signature record is wrong.
  • Nobody signed yet: awaitingRepresentative is true because no representative signature is recorded. builderView hides the builder's signing URL and mine drops the work order from the builder's list, even though in Documenso the builder must sign first. When the builder does sign, the sync records it as the representative and re-sends the builder the "sent you a work order" email. The agency's later signature is then recorded as the contractor, with nearAccount set and the work order accepted.

Suggested fix: Don't derive roles from the signing order alone. Map recipients by email against the work order's contractor and representative contacts. Alternatively, store the order (or a version) on the work order when the envelope is sent and keep the old mapping for envelopes created before this change. A further option is a data migration that cancels and re-sends pending envelopes.

Comment on lines +1018 to +1028
async function retryDriveUploads(counts: Record<string, number>) {
const rows = await db
.select()
.from(workOrders)
.where(
and(
isNotNull(workOrders.signedAt),
isNull(workOrders.signedDriveFileId),
inArray(workOrders.signatureMethod, ["in_app", "documenso"]),
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium · correctness: Drive retry re-uploads every historically signed PDF because signed_drive_file_id is never backfilled

Before this PR, storeSigned uploaded the signed PDF to the Signed folder and stored the id in work_order_documents.drive_file_id. Migration 0033 adds work_orders.signed_drive_file_id without backfilling it, so every existing signed work order (in_app/documenso) has it NULL. On the first tick after deploy, retryDriveUploads selects all of them across all organizations. saveSignedToDrive checks only row.signedDriveFileId, so it uploads each PDF again. Organizations whose PDFs were already filed get a duplicate of every signed work order. Organizations with only a documents folder now get all historical signed PDFs dumped there, through the new ?? googleDocumentsFolderId fallback.

Suggested fix: Backfill signed_drive_file_id/signed_drive_url from the latest signed work_order_documents row in the migration. Alternatively, have saveSignedToDrive treat doc.driveFileId on the latest signed document as already saved, and set the work order columns from it.

Comment thread api/src/index.ts Outdated
return { ...(await integrationView(context.scope.organizationId)), url };
}),

googleCallback: builder.workOrders.googleCallback.handler(async ({ input }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Medium · security: OAuth callback doesn't check that the browser finishing the flow belongs to the user who started it (OAuth CSRF)

connectGoogle returns a Google consent URL whose state is bound only to the organization and the starting user. googleCallback is unauthenticated and accepts any request with a valid state. It never checks that the signed-in session is pending.userId.

Attack: a manager of agency X starts the flow and sends the resulting Google link to someone else, such as a member of another agency. If that person consents, their refresh token is stored on agency X. The attacker can then call googlePickerToken to receive that person's access token, and open the Google Picker on their Drive. With drive.file, picking a file grants the app access to it, so the attacker could read any file in the victim's Drive.

Uncertain: the victim must click through Google's consent screen. The impact is limited to what the Picker exposes.

Suggested fix: Make the callback require the session (use context.userId). Reject unless it equals pending.userId. Alternatively, bind the state to a cookie set when connectGoogle is called.

@multi-review

multi-review Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Multi Review · 1f6fa4d (changes since 9361585)

This PR changes four things. Renewals are now created as drafts and offered for signature instead of being activated right away. Approved payouts whose result is unknown get a needs_review code, and a funding hold is emailed once per code. The email layout is reworked, with the logo sent as an inline CID attachment. When a payout settles through intents, the email and UI now link to the builder's NearBlocks account instead of the broken explorer URL. The payout and email changes look correct on every caller I traced; the only problems I found are two low-severity issues in the new renewal path when Documenso isn't configured.

2 issue(s) · 6 still open from earlier

What was checked (11 areas)
  • blockPayout and its callers: autoPay (approveRequest, payNow), tryAutoPay (retryBlockedPayouts), followUpPayouts stuck-paying path; autoPayHoldNotified read/write and the 0035 backfill
  • settleAgentPayout needs_review branch and how its new autoPayBlockedReason/Code interacts with retryBlockedPayouts (filters on blockedAt), requestView.autoPayBlocked, progressOf, payNow/releasePaying
  • progressOf new needs_review branch and step state
  • renew and its 2 callers (assessIfDue on every tick, decideRenewal); offerRow shared with offer; draftDocument/prepareOfferDocument/sendForSignature failure paths and the esignError catch
  • announce link building (countersign/sign) and the composeEmails work_order.renewed / payout_released templates
  • builderView progress stripping vs the UI's use of progress.next and attention (admin-only NextStepCard/inbox)
  • sign gate on esignEnvelopeId vs builder-signing.tsx (in-app form only shown when the envelope isn't sent)
  • outbox attempt inline-image attachment, resendEmailSender attachments, other renderEmail users (only the outbox)
  • payouts SUCCESS txUrl change, 0036 migration, the contract's accountUrl and requests-panel rendering
  • progress-stepper caption/periodRange
  • earlier findings: retryDriveUploads/saveSignedToDrive and needsYouCount now look fixed; the other earlier findings are in files this diff doesn't touch

Findings

  • 🟡 Low api/src/services/work-orders/service.ts:2857 Without Documenso, a failed renewal offer leaves an esignError that nothing clears after the admin offers it successfully
    If offerRow fails for a renewal, the catch writes esignError: "The renewal wasn't sent for signature: …". One way this happens: an agency has Google Drive set up but no Documenso (deps.esign null), and prepareOfferDocument throws GOOGLE_FAILED during a transient Drive outage. Only two places clear esignError: a successful sendForSignature and syncEnvelope. Without Documenso, sendForSignature returns early and syncEnvelope never runs. So when the admin later offers the draft and it succeeds, the error stays on the row. esignOf keeps returning a documenso esign object with sent: false. progressOf keeps showing it as a "Needs attention" item on the offered, active and ended work order.
    Suggested fix: Clear esignError when offerRow succeeds, for example update(prepared.id, { status: "offered", offeredAt: now(), ...(deps.esign ? {} : { esignError: null }) }). Alternatively, store the renewal failure in a field that isn't tied to Documenso.
  • 🟡 Low api/src/services/work-orders/emails.ts:137 The renewal email tells the representative to sign first even when there is no Documenso flow
    When Documenso isn't configured, renew offers the child for in-app acceptance and sends the builder the work_order.offered email right away. The representative's work_order.renewed email still has the subject "Sign the renewal: Work Order …". Its preheader says "Sign it to send it to {builder}". Neither is true here: signLink is null, the builder has already been sent the work order, and the representative only countersigns after the builder accepts. The same subject is also used for the unsent draft case, where nothing can be signed.
    Suggested fix: Choose the subject and preheader from signLink and unsent. For example, use "Sign the renewal…" only when !unsent && signLink, and otherwise "Work Order X renews…" with a preheader that says the builder accepts it first.

Still open from earlier reviews

  • 🔴 High api/src/services/work-orders/google-oauth.ts:215 OAuth callback compares the stored actorId (a NEAR account when one is linked) with the session userId, so Google Drive never connects for NEAR users
  • 🔴 High api/src/services/work-orders/documenso.ts:69 Reversing signing order also reverses how existing in-flight envelopes are read, so builder signatures get recorded as the agency's
  • 🟠 Medium api/src/services/work-orders/service.ts:1018 Drive retry re-uploads every historically signed PDF because signed_drive_file_id is never backfilled
  • 🟠 Medium api/src/index.ts:1518 OAuth callback doesn't check that the browser finishing the flow belongs to the user who started it (OAuth CSRF)
  • 🟡 Low api/src/services/work-orders/service.ts:1029 Drive retry rescans and re-queries the same never-uploadable rows on every 15s tick
  • 🟡 Low api/src/services/work-orders/service.ts:3071 Sidebar badge count builds every work order's full admin view once a minute per open tab

Comment @multi-review to re-review new commits, or @multi-review full for a full review.

…eir own order; never re-upload signed PDFs already in Drive

Each work order now records whether its Documenso envelope was sent with
the agency first. Envelopes sent before that keep the builder-first reading,
so a builder's signature is never recorded as the agency's and those work
orders aren't shown as waiting on the agency. The signed-PDF Drive id is
backfilled from the documents already filed, and the retry only picks up
uploads that failed.
@saadiqbal-dev

Copy link
Copy Markdown
Collaborator Author

@multi-review

@multi-review multi-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline findings for 6a02ea4; see the summary comment for the rest.

if (!pending) {
throw new GoogleConnectError("The Google sign-in link expired. Start again from Settings");
}
if (!userId || pending.userId !== userId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High · correctness: OAuth callback compares the stored actorId (a NEAR account when one is linked) with the session userId, so Google Drive never connects for NEAR users

connectGoogle (api/src/index.ts:1441) calls googleConnections.start(context.scope.organizationId, context.scope.actorId). actorId comes from actorOf(context) in organization-access.ts, which returns context.near?.primaryAccountId ?? context.userId. So for any admin with a linked NEAR account, pending.userId holds something like alice.near. googleCallback then passes context.userId, the auth user id, and the new check pending.userId !== userId fails. Every attempt by these admins redirects back with "Finish connecting Google Drive in the browser where you're signed in…", and the state row is already deleted. On a NEAR-centric app this blocks the Drive feature for most admins. The tests miss it because they pass the same literal ("admin-1") to both start and finish.

Suggested fix: Record and compare the same identifier on both sides. Either pass context.userId to start() in connectGoogle (and keep googleOauthConnectedBy as needed), or compute the callback's identity with the same near.primaryAccountId ?? userId rule. Add an integration test where the actor id differs from the user id.

…arted the connection

Starting the connection records the organization actor (the NEAR account
when one is linked), so the callback checks the signed-in user's actor
the same way instead of the raw user id.
@saadiqbal-dev

Copy link
Copy Markdown
Collaborator Author

@multi-review

@jlwaugh

jlwaugh commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Nice! A few things to sort before staging:

  1. Security: builders get the rep's signing link in the API response (progress.next isn't stripped in builderView)
  2. Security: builders can sign via the API before the rep (sign only checks status === "offered")
  3. Hold emails repeat after an agent outage (blockPayout saves the code as null on non-funding errors)
  4. Payouts needing review show as "Sending…" (that path clears autoPayBlockedReason)

@saadiqbal-dev

Copy link
Copy Markdown
Collaborator Author

Nice! A few things to sort before staging:

  1. Security: builders get the rep's signing link in the API response (progress.next isn't stripped in builderView)
  2. Security: builders can sign via the API before the rep (sign only checks status === "offered")
  3. Hold emails repeat after an agent outage (blockPayout saves the code as null on non-funding errors)
  4. Payouts needing review show as "Sending…" (that path clears autoPayBlockedReason)

thanks for the feedback, looking into it

A renewal is now created as a draft with its own document for the new
period and offered the same way as a new work order: with Documenso the
agency signs first and the builder after; in-app the builder accepts and
the agency countersigns. It becomes active once both have signed, and the
signed PDF is filed in Drive like any other. If it can't be sent, it stays
a draft with the reason and the agency's email says so.
…umenso only, one hold email per hold, show payouts that need a check

- Builder views drop the agency's next step and attention actions, so the
  representative's Documenso link never reaches a builder.
- In-app signing is refused once a Documenso envelope exists; while the
  agency signs first it says so.
- The hold email is tracked in its own column, so an agent outage between
  two funding holds doesn't email the same hold again.
- A payout the agent can't confirm keeps its reason with a needs_review
  code (not retried), and progress asks for a check instead of showing it
  as sending.
…olors and type

Light, square layout with the site's 1px borders, Inter and Geist type,
the eyebrow as a bordered tag, details in a plain bordered table, and the
site's amber primary button with brown text next to outlined secondary
buttons. The old neon accent strip and the outdated icon are gone; the
header is the MultiAgency wordmark.
…text on the yellow button

The logo is the site header's grid mark as a small PNG sent as an inline
(cid) attachment, so it shows without hosting and before any deploy.
…ll-width buttons on phones

Buttons are separate inline tables so they wrap in any client; where media
queries work (Apple Mail, iOS, Gmail apps) phones get a full-width card,
tighter padding, a smaller heading, label-over-value details and
full-width buttons.
The intents explorer only knows 1Click swaps, so an agent payout's intent
hash always 404s there. Payouts link to the NEAR transaction when the
agent reports one, and otherwise to the builder's account on NearBlocks,
where the payment shows. Saved explorer links are cleared.
@saadiqbal-dev

Copy link
Copy Markdown
Collaborator Author

@multi-review

@jlwaugh
jlwaugh merged commit d175d65 into staging Oct 27, 2026
2 checks passed
@saadiqbal-dev

Copy link
Copy Markdown
Collaborator Author

@multi-review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants