Skip to content

Commit

Permalink
Merge pull request #146 from lowercasename/rk/unattend-email-link
Browse files Browse the repository at this point in the history
Unattend events from RSVP email
  • Loading branch information
lowercasename authored May 26, 2024
2 parents 6f07216 + de518b4 commit 53288fa
Show file tree
Hide file tree
Showing 12 changed files with 161 additions and 9 deletions.
74 changes: 74 additions & 0 deletions cypress/e2e/event.cy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import eventData from "../fixtures/eventData.json";
import crypto from "crypto";

describe("Events", () => {
beforeEach(() => {
Expand Down Expand Up @@ -236,4 +237,77 @@ describe("Events", () => {

cy.get("#event-group").should("contain.text", "Test Group");
});

it("removes you from the event with a one-click unattend link", function () {
cy.get("button#attendEvent").click();
cy.get("#attendeeName").type("Test Attendee");
cy.get("#attendeeNumber").focus().clear();
cy.get("#attendeeNumber").type("2");
cy.get("#removalPassword")
.invoke("val")
.then((removalPassword) => {
cy.wrap(removalPassword).as("removalPassword");
cy.log(this.removalPassword);
cy.get("form#attendEventForm").submit();
cy.get("#attendees-alert").should(
"contain.text",
"8 spots remaining",
);
cy.get(".attendeesList").should(
"contain.text",
"Test Attendee (2 people)",
);
const removalPasswordHash = crypto
.createHash("sha256")
.update(removalPassword)
.digest("hex");
const unattendLink = `http://localhost:3000/event/${this.eventID}/unattend/${removalPasswordHash}`;
cy.visit(unattendLink);
cy.get("#event__message").should(
"contain.text",
"You have been removed from this event.",
);
cy.get("#attendees-alert").should(
"contain.text",
"10 spots remaining",
);
cy.get("#eventAttendees").should(
"contain.text",
"No attendees yet!",
);
});
});
describe("Query string editing tokens", function () {
it("given a valid editing token is in the URL, should add it to localStorage", function () {
cy.visit(`/${this.eventID}?${this.editToken}`).then(() => {
expect(localStorage.getItem("editTokens")).to.include(
this.editToken.split("=")[1],
);
});
});

it("given an invalid editing token is in the URL, should delete it from the URL", function () {
cy.visit(`/${this.eventID}?e=invalid`).then(() => {
expect(localStorage.getItem("editTokens")).to.not.include(
"invalid",
);
});
});

it("given a valid editing token in localStorage, should add it to the URL", function () {
cy.visit(`/${this.eventID}`).then(() => {
cy.url().should("include", this.editToken);
});
});

it("given an invalid editing token in localStorage, should remove it from localStorage", function () {
cy.clearAllLocalStorage();
localStorage.setItem("editTokens", "invalid");
cy.visit(`/${this.eventID}`).then(() => {
expect(localStorage.getItem("editTokens")).to.not.include(
"invalid",
);
});
});
});
});
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Event from "./models/Event.js";
import EventGroup from "./models/EventGroup.js";
import path from "path";
import { activityPubContentType } from "./lib/activitypub.js";
import { hashString } from "./util/generator.js";

const config = getConfig();
const domain = config.general.domain;
Expand Down Expand Up @@ -713,6 +714,7 @@ router.post("/attendevent/:eventID", async (req, res) => {
siteLogo,
domain,
removalPassword: req.body.removalPassword,
removalPasswordHash: hashString(req.body.removalPassword),
cache: true,
layout: "email.handlebars",
},
Expand Down
42 changes: 42 additions & 0 deletions src/routes/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
generateEditToken,
generateEventID,
generateRSAKeypair,
hashString,
} from "../util/generator.js";
import { validateEventData } from "../util/validation.js";
import { addToLog } from "../helpers.js";
Expand Down Expand Up @@ -712,4 +713,45 @@ router.delete(
},
);

// Used to one-click unattend an event from an email.
router.get(
"/event/:eventID/unattend/:removalPasswordHash",
async (req: Request, res: Response) => {
// Find the attendee by the unattendPasswordHash
const event = await Event.findOne({ id: req.params.eventID });
if (!event) {
return res.redirect("/404");
}
const attendee = event.attendees?.find(
(o) =>
hashString(o.removalPassword || "") ===
req.params.removalPasswordHash,
);
if (!attendee) {
return res.redirect(`/${req.params.eventID}`);
}
// Remove the attendee from the event
event.attendees = event.attendees?.filter(
(o) => o.removalPassword !== attendee.removalPassword,
);
await event.save();
// Send email to the attendee
if (req.app.locals.sendEmails && attendee.email) {
sendEmailFromTemplate(
attendee.email,
`You have been removed from ${event.name}`,
"unattendEvent",
{
event,
siteName: res.locals.config?.general.site_name,
siteLogo: res.locals.config?.general.email_logo_url,
domain: res.locals.config?.general.domain,
},
req,
);
}
return res.redirect(`/${req.params.eventID}?m=unattend`);
},
);

export default router;
2 changes: 2 additions & 0 deletions src/routes/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../lib/activitypub.js";
import MagicLink from "../models/MagicLink.js";
import { getConfigMiddleware } from "../lib/middleware.js";
import { getMessage } from "../util/messages.js";

const router = Router();

Expand Down Expand Up @@ -377,6 +378,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
image: event.image,
editToken: editingEnabled ? eventEditToken : null,
},
message: getMessage(req.query.m as string),
});
}
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions src/util/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export const generateRSAKeypair = () => {
},
});
};

export const hashString = (input: string) =>
crypto.createHash("sha256").update(input).digest("hex");
9 changes: 9 additions & 0 deletions src/util/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type MessageId = "unattend";

const queryStringMessages: Record<MessageId, string> = {
unattend: `You have been removed from this event.`,
};

export const getMessage = (id?: string) => {
return queryStringMessages[id as MessageId] || "";
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You just marked yourself as attending an event on {{siteName}}. Thank you! We'll send you another email if there are any updates to the event. Your email will be automatically removed from the database once the event finishes.</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">Follow this link to open the event page any time: <a href="https://{{domain}}/{{eventID}}">https://{{domain}}/{{eventID}}</a></p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">Need to remove yourself from this event? Head to the event page and use this <strong>deletion password</strong>: {{removalPassword}}</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">Need to remove yourself from this event? <a href="https://{{domain}}/event/{{eventID}}/unattend/{{removalPasswordHash}}">Click this link</a>.</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You can also head to the event page and use this <strong>deletion password</strong>: {{removalPassword}}</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">Love,</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">{{siteName}}</p>
<hr/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ You just marked yourself as attending an event on {{siteName}}. Thank you! We'll

Follow this link to open the event page any time: https://{{domain}}/{{eventID}}

Need to remove yourself from this event? Head to the event page and use this deletion password: {{removalPassword}}
Need to remove yourself from this event? Click this link: https://{{domain}}/event/{{eventID}}/unattend/{{removalPasswordHash}}

You can also head to the event page and use this deletion password: {{removalPassword}}

Love,

Expand Down
2 changes: 1 addition & 1 deletion views/emails/unattendEvent/unattendEventHtml.handlebars
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You just removed yourself from an event on {{siteName}}. You will no longer receive update emails for this event.</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">If you didn't mean to do this, someone else who knows your email removed you from the event.</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">If you didn't mean to do this, an admin may have removed you from the event.</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">Follow this link to open the event page any time: <a href="https://{{domain}}/{{eventID}}">https://{{domain}}/{{eventID}}</a></p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">Love,</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">{{siteName}}</p>
Expand Down
2 changes: 1 addition & 1 deletion views/emails/unattendEvent/unattendEventText.handlebars
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
You just removed yourself from an event on {{siteName}}. You will no longer receive update emails for this event.

If you didn't mean to do this, someone else who knows your email removed you from the event.
If you didn't mean to do this, an admin may have removed you from the event.

Follow this link to open the event page any time: https://{{domain}}/{{eventID}}

Expand Down
25 changes: 20 additions & 5 deletions views/event.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
{{/if}}
</div>
</div>
{{#if message}}
<div class="alert alert-info mt-3 text-center" role="alert" id="event__message">
{{message}}
</div>
{{/if}}
<div id="event__basics">
<div class="card" id="event__data">
<div class="card-body">
Expand Down Expand Up @@ -207,7 +212,7 @@
</div>
<div class="form-group">
<label for="removalPassword">Deletion password</label>
<p class="form-text small">You will need this password if you want to remove yourself from the list of event attendees. If you provided your email, you'll receive it by email. Otherwise, write it down now because it will <strong>not be shown again</strong>.</p>
<p class="form-text small">You can use this password to remove yourself from the list of event attendees. If you provided your email, you'll receive it by email. Otherwise, write it down now because it will <strong>not be shown again</strong>.</p>
<input type="text" class="form-control" readonly id="removalPassword"
name="removalPassword">
</div>
Expand Down Expand Up @@ -456,7 +461,9 @@ window.eventData = {{{ json jsonData }}};
error: function(response, status, xhr) {
// The editing token is wrong - remove it
removeStoredToken(eventID);
window.location = window.location.pathname;
// Remove the token from the URL
url.searchParams.delete('e');
window.location = url.href;
}
});
} else if (getStoredToken(eventID)) {
Expand All @@ -467,6 +474,9 @@ window.eventData = {{{ json jsonData }}};
data: { editToken },
success: function(response, status, xhr) {
if (xhr.status === 200) {
// Redirect to the same URL with the editing token in the URL
// We reload the page to force the server to load a page with
// the editing form accessible.
window.location.search = `?e=${editToken}`;
}
},
Expand All @@ -479,7 +489,12 @@ window.eventData = {{{ json jsonData }}};
if (urlParams.has('show_edit')) {
$('#editModal').modal('show');
url.searchParams.delete('show_edit');
url.searchParams.delete('show_edit');
history.replaceState(history.state, '', url.href);
}
if (urlParams.has('m')) {
url.searchParams.delete('m');
history.replaceState(history.state, '', url.href);
}
Expand Down Expand Up @@ -538,7 +553,7 @@ window.eventData = {{{ json jsonData }}};
.attr('data-validation-allowing', `range[1;${response.data.freeSpots}]`)
.attr('data-validation-error-msg', `Please enter a number between 1 and ${response.data.freeSpots}`);
}
modal.modal();
modal.modal();
})
.catch((error) => {
console.error(error);
Expand Down Expand Up @@ -572,4 +587,4 @@ window.eventData = {{{ json jsonData }}};
});
</script>
</main>
</main>

0 comments on commit 53288fa

Please sign in to comment.