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
109 changes: 109 additions & 0 deletions specs/attendees/writing-to-the-people-who-booked.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
@story:attendees.writing-to-the-people-who-booked
@owner:attendees @risk:medium
@actor:organiser
@edition:managed @edition:self-hosted
@surface:admin
Feature: An owner writes to the people who booked
The owner can write one message to everyone booked onto a listing. They
reach it from the listing itself, write the message, and are shown it before
anything goes out. For the site to send it for them they need an email
provider of their own, and a message that promotes something leaves out
anyone who asked not to hear from them.

@rule:attendees.the-message-is-shown-before-it-goes
Rule: The message is shown before it goes
Writing a message never sends it. The owner is shown what they wrote and
who it would reach, and only then offered a way to send it.

@case:writing.see-it-before-it-goes
Scenario: The owner is shown the message before sending
Given the owner has an email provider of their own
And 2 people have booked onto "the Gig"
When the owner writes to "the Gig" saying "Doors open at seven."
Then the owner is shown the message before it goes
And the owner is shown that it would reach 2 people
And the site offers to send it for them

@case:writing.sending-writes-to-everyone-who-booked
Scenario: Sending reaches everyone who booked
Given the owner has an email provider of their own
And 2 people have booked onto "the Gig"
And the owner has written to "the Gig" saying "Doors open at seven."
When the owner sends it
Then the owner is told it went to 2 people
And it was written to everyone who booked onto "the Gig"

@rule:attendees.the-site-only-sends-for-them-with-a-provider-of-their-own
Rule: The site only sends for them with a provider of their own
Without a provider of their own the owner can still write and check a
message, and the preview still offers to open it as a draft in their own
email app. What needs a provider is the site sending it for them, and the
preview says so rather than leaving a button that would not work.

@case:writing.no-provider-no-send
Scenario: The owner has set up no email provider
Given 2 people have booked onto "the Gig"
When the owner writes to "the Gig" saying "Doors open at seven."
Then the owner is shown the message before it goes
And the owner is told sending is switched off
And the site does not offer to send it for them
And the owner is still offered a draft to send themselves

@rule:attendees.a-promotion-leaves-out-anyone-who-asked-not-to-hear
Rule: A promotion leaves out anyone who asked not to hear
A message marked as a promotion skips anyone who has unsubscribed. The
owner is told how many are being left out, and a promotion with nobody
left to write to is refused rather than sent to no one.

@case:writing.a-promotion-skips-the-unsubscribed
Scenario: One of the two has unsubscribed
Given the owner has an email provider of their own
And 2 people have booked onto "the Gig"
And one of them has asked not to hear about promotions
When the owner writes a promotion to "the Gig" saying "Half price Friday."
Then the owner is told 1 person will be left out
When the owner sends it
Then it was written to everyone who booked onto "the Gig" but the one who asked

@case:writing.a-promotion-with-nobody-left
Scenario: Everybody has unsubscribed
Given the owner has an email provider of their own
And 1 person has booked onto "the Gig"
And they have asked not to hear about promotions
And the owner has written a promotion to "the Gig" saying "Half price Friday."
When the owner sends it
Then the owner is told everyone has asked not to hear
And nothing was written to anybody

@rule:attendees.news-about-a-booking-still-reaches-everyone
Rule: News about a booking still reaches everyone
A message that is not a promotion is news about something the person
booked, so it reaches them whether or not they unsubscribed from
promotions.

@case:writing.news-still-reaches-the-unsubscribed
Scenario: Someone who unsubscribed still hears about their own booking
Given the owner has an email provider of their own
And 2 people have booked onto "the Gig"
And one of them has asked not to hear about promotions
And the owner has written to "the Gig" saying "The doors have moved."
When the owner sends it
Then it was written to everyone who booked onto "the Gig"

@rule:attendees.the-way-in-is-only-offered-where-it-works
Rule: The way in is only offered where it works
The listing offers a way to write to its attendees only when there is
somebody there to write to, so the owner is never given a link that leads
nowhere.

@case:writing.no-addresses-no-way-in
Scenario: Nobody who booked left an address
Given the owner has an email provider of their own
And 1 person has booked onto "the Gig" leaving no address
Then "the Gig" offers no way to write to the people who booked

@case:writing.somebody-to-write-to-offers-the-way-in
Scenario: Somebody left an address
Given the owner has an email provider of their own
And 1 person has booked onto "the Gig"
Then "the Gig" offers a way to write to the people who booked
27 changes: 12 additions & 15 deletions src/features/admin/bulk-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { defineRoutes } from "#routes/router.ts";
* button (formaction="/admin/emails/templates") creates or updates a template.
*/

import { t } from "#i18n";
import { createConfirmedHandlers } from "#routes/admin/confirmation.ts";
import {
formPost,
Expand Down Expand Up @@ -243,7 +244,7 @@ const validateFormBody = async (
): Promise<Response | ValidatedEmailForm> => {
const target = await targetFromForm(form);
if (!target) {
return errorRedirect(COMPOSE_PATH, "That listing no longer exists.");
return errorRedirect(COMPOSE_PATH, t("bulk_email.listing_gone"));
}
const validation = validateDraftInput({
body: form.getString("body"),
Expand Down Expand Up @@ -279,7 +280,7 @@ const validatedEmailPost = (
/** POST /admin/emails/preview — validate, persist the draft, redirect to preview. */
const handlePreviewPost = validatedEmailPost(async (fields) => {
await saveDraft({ ...fields });
return ok(PREVIEW_PATH, "Review your email below before sending.");
return ok(PREVIEW_PATH, t("bulk_email.review_before_sending"));
});

/** GET /admin/emails/preview — render the saved draft for confirmation. */
Expand Down Expand Up @@ -326,19 +327,16 @@ const handlePreviewGet = ownerResponsePage(async (session) => {
const handleSendPost = gatedPost(OWNER_FORM)(async (_session, _form) => {
const draft = await parseSavedDraft(await requireRequestPrivateKey());
if (!draft) {
return errorRedirect(COMPOSE_PATH, "There's no email to send.");
return errorRedirect(COMPOSE_PATH, t("bulk_email.no_draft"));
}
const { privateKey, recipients, config } = await loadSendContext(
draft.target,
);
if (!config) {
return errorRedirect(
PREVIEW_PATH,
"Configure your own email provider before sending bulk email.",
);
return errorRedirect(PREVIEW_PATH, t("bulk_email.no_provider"));
}
if (recipients.length === 0) {
return errorRedirect(PREVIEW_PATH, "There are no recipients to send to.");
return errorRedirect(PREVIEW_PATH, t("bulk_email.no_recipients"));
}
const unsubscribed = draft.marketing
? await getUnsubscribedHashSet()
Expand All @@ -352,10 +350,7 @@ const handleSendPost = gatedPost(OWNER_FORM)(async (_session, _form) => {
unsubscribed,
});
if (payload.recipients.length === 0) {
return errorRedirect(
PREVIEW_PATH,
"Everyone in this audience has unsubscribed.",
);
return errorRedirect(PREVIEW_PATH, t("bulk_email.all_unsubscribed"));
}
const result = await sendBulkEmails(config, payload);
await recordContacts(
Expand All @@ -374,9 +369,11 @@ const handleSendPost = gatedPost(OWNER_FORM)(async (_session, _form) => {
);
return ok(
COMPOSE_PATH,
`Sent to ${recipientLabel} via ${
EMAIL_PROVIDER_LABELS[config.provider]
}. ${providerSummary}`,
t("bulk_email.sent_flash", {
count: result.attempted,
provider: EMAIL_PROVIDER_LABELS[config.provider],
summary: providerSummary,
}),
);
});

Expand Down
7 changes: 7 additions & 0 deletions src/locales/en/bulk-email.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
"bulk_email.template_to_update_label": "Template to update",
"bulk_email.save_as_new_template": "Save as new template",
"bulk_email.update_template_button": "Update template",
"bulk_email.listing_gone": "That listing no longer exists.",
"bulk_email.review_before_sending": "Review your email below before sending.",
"bulk_email.no_draft": "There's no email to send.",
"bulk_email.no_provider": "Configure your own email provider before sending bulk email.",
"bulk_email.no_recipients": "There are no recipients to send to.",
"bulk_email.all_unsubscribed": "Everyone in this audience has unsubscribed.",
"bulk_email.sent_flash": "Sent to {count, plural, one {# recipient} other {# recipients}} via {provider}. {summary}",
"bulk_email.delete_template_heading": "Delete template",
"bulk_email.delete_template_intro": "Are you sure you want to delete the saved template with subject:",
"bulk_email.delete_template_prompt": "Type the template subject to confirm:",
Expand Down
6 changes: 3 additions & 3 deletions test/integration/server/bulk-email/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ export const seedDraft = async (draft: BulkEmailDraft) =>
),
});

/** Seed a two-attendee listing where one attendee has unsubscribed, then
* save a marketing draft targeting it. Shared by the preview and send
* tests that both check unsubscribed recipients are excluded. */
/** Seed a two-attendee listing where one attendee has unsubscribed, then save
* a marketing draft targeting it. Shared by the preview and send tests that
* own the direct cover of the skipping branches. */
export const seedMarketingDraftWithUnsubscriber = async () => {
const listing = await seedListingWithAttendees();
await unsubscribeHash(await hashEmail("alice@example.com"));
Expand Down
30 changes: 6 additions & 24 deletions test/integration/server/bulk-email/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,30 +70,12 @@ describeWithEnv("server bulk email > links", { db: true }, () => {
});

describe("listing page Email link", () => {
test("owners see the email action on the listing Actions tab", async () => {
const listing = await seedListingWithAttendees();
const html = await (
await adminGet(`/admin/listing/${listing.id}/actions`)
).text();
expect(html).toContain(`/admin/emails?listing=${listing.id}`);
expect(html).toContain("<span>Email</span>");
});

test("hides the email action when no attendee has an email", async () => {
// The compose page 404s for a listing target with zero recipients, so the
// Actions tab must not render a dead Email link (AGENTS.md: never render a
// forbidden link).
const listing = await createTestListing({
maxAttendees: 5,
name: "Solo",
});
await createTestAttendeeDirect(listing.id, "Nemo", "");
const html = await (
await adminGet(`/admin/listing/${listing.id}/actions`)
).text();
expect(html).not.toContain(`/admin/emails?listing=${listing.id}`);
});

/**
* Whether the action is offered at all — to an owner, and only when
* somebody left an address — is told by the story
* `attendees.writing-to-the-people-who-booked`. What stays here is the
* manager's view of the same page, which no story has an actor for.
*/
test("managers do not see the email action", async () => {
const listing = await seedListingWithAttendees();
const cookie = await createTestManagerSession();
Expand Down
18 changes: 12 additions & 6 deletions test/integration/server/bulk-email/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,23 @@ describeWithEnv("server bulk email > preview", { db: true }, () => {
expect(html).not.toContain("1 recipients");
});

test("labels a target whose listing has since been deleted", async () => {
useResend();
const html = await staleListingDraftHtml();
expect(html).toContain("Listing attendees");
});

/**
* The skipped-count the preview only renders when somebody is being left
* out. The story `attendees.writing-to-the-people-who-booked` tells the
* same thing in the owner's terms; this owns the direct cover of the
* branch, which a Cucumber journey may never be the only one of.
*/
test("notes how many unsubscribed recipients are skipped", async () => {
useResend();
await seedMarketingDraftWithUnsubscriber();
const html = await getPreviewHtml();
expect(html).toContain("1 unsubscribed will be skipped");
});

test("labels a target whose listing has since been deleted", async () => {
useResend();
const html = await staleListingDraftHtml();
expect(html).toContain("Listing attendees");
});
});
});
39 changes: 23 additions & 16 deletions test/integration/server/bulk-email/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,13 @@ describeWithEnv("server bulk email > send", { db: true }, () => {
expect(entry?.listing_id).toBe(null);
});

test("errors when the audience has no recipients", async () => {
useResend();
const empty = await createTestListing({ maxAttendees: 5, name: "Empty" });
await adminFormPost("/admin/emails/preview", {
body: "Body",
listing_id: String(empty.id),
subject: "Subject",
});
const { response } = await adminFormPost("/admin/emails/send", {});
await expectFlashRedirect(
"/admin/emails/preview",
"There are no recipients to send to.",
false,
)(response);
});

/**
* The two branches that decide who a promotion reaches: the unsubscribed
* set the send is built against, and the refusal when it leaves nobody.
* The story `attendees.writing-to-the-people-who-booked` tells both in the
* owner's terms; these own the direct cover, which a Cucumber journey may
* never be the only one of.
*/
test("errors when every marketing recipient has unsubscribed", async () => {
useResend();
const listing = await createTestListing({
Expand Down Expand Up @@ -164,6 +155,22 @@ describeWithEnv("server bulk email > send", { db: true }, () => {
expect(body).toHaveLength(1);
expect(body[0].to).toEqual(["bob@example.com"]);
});

test("errors when the audience has no recipients", async () => {
useResend();
const empty = await createTestListing({ maxAttendees: 5, name: "Empty" });
await adminFormPost("/admin/emails/preview", {
body: "Body",
listing_id: String(empty.id),
subject: "Subject",
});
const { response } = await adminFormPost("/admin/emails/send", {});
await expectFlashRedirect(
"/admin/emails/preview",
"There are no recipients to send to.",
false,
)(response);
});
});

describe("single-attendee email (?attendee)", () => {
Expand Down
1 change: 1 addition & 0 deletions test/scripts/specs/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("Cucumber story catalog", () => {
"attendees.removing-one-part-of-an-order",
"attendees.the-record-kept-about-someone",
"attendees.the-states-a-booking-can-be-in",
"attendees.writing-to-the-people-who-booked",
"bookings.add-ons-sold-on-their-own",
"bookings.adding-a-booking-by-hand",
"bookings.book-through-the-site",
Expand Down
Loading