Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions api/src/services/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand All @@ -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`,
Expand Down Expand Up @@ -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.`,
Expand Down
170 changes: 164 additions & 6 deletions api/src/services/work-orders/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}.`,
],
Expand Down Expand Up @@ -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"),
Expand All @@ -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);
Expand Down Expand Up @@ -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.`,
Expand Down
5 changes: 5 additions & 0 deletions api/src/services/work-orders/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading