Repository navigation
Work orders: guided workflow, agency-signs-first e-signature, Google Drive and branded emails - #82
Conversation
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.
…w work order form
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.
…, details in tabs
| 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"; | ||
| } |
There was a problem hiding this comment.
🔴 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 arepresentativesignature carrying the builder's name and email.needsCountersignbecomes false, so the "Countersign the work order" blocker disappears.activateReadycan then activate the work order without the agency ever signing, and the legal signature record is wrong. - Nobody signed yet:
awaitingRepresentativeis true because no representative signature is recorded.builderViewhides the builder's signing URL andminedrops 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, withnearAccountset 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.
| 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"]), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
🟠 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.
| return { ...(await integrationView(context.scope.organizationId)), url }; | ||
| }), | ||
|
|
||
| googleCallback: builder.workOrders.googleCallback.handler(async ({ input }) => { |
There was a problem hiding this comment.
🟠 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 ·
|
…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.
…I contract, app origin trimmed once
|
@multi-review |
| if (!pending) { | ||
| throw new GoogleConnectError("The Google sign-in link expired. Start again from Settings"); | ||
| } | ||
| if (!userId || pending.userId !== userId) { |
There was a problem hiding this comment.
🔴 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.
|
@multi-review |
…riod as short dates
|
Nice! A few things to sort before staging:
|
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.
|
@multi-review |
|
@multi-review |
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
E-signature (Documenso)
Emails (Resend)
Google Drive (optional, per agency)
drive.filescope; the refresh token is stored encrypted.Payouts
Less retyping
Database
4 new migrations. They apply automatically on deploy and come after
0029_owner_signing:0030_organization_builder_logins0031_work_order_representative0032_work_order_payout_hold0033_work_order_google_oauthNew 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) andAPP_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
staging(simulated merge, no conflicts):biome ci .,bun typecheckandbun run buildare clean.trustHistoryOf) timed out under machine load and passes on its own.How to test