From de518b4d2510980750d9c54eda3b0e0fe8d73ecd Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Sun, 21 Apr 2024 22:44:22 +0100 Subject: [PATCH] feat: unattend events from RSVP email --- cypress/e2e/event.cy.ts | 74 +++++++++++++++++++ pnpm-lock.yaml | 2 + src/routes.js | 2 + src/routes/event.ts | 42 +++++++++++ src/routes/frontend.ts | 2 + src/util/generator.ts | 3 + src/util/messages.ts | 9 +++ .../addEventAttendeeHtml.handlebars | 3 +- .../addEventAttendeeText.handlebars | 4 +- .../unattendEventHtml.handlebars | 2 +- .../unattendEventText.handlebars | 2 +- views/event.handlebars | 25 +++++-- 12 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 src/util/messages.ts diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index c49c518..d5366e2 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -1,4 +1,5 @@ import eventData from "../fixtures/eventData.json"; +import crypto from "crypto"; describe("Events", () => { beforeEach(() => { @@ -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", + ); + }); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b06042a..37cb43a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2689,6 +2689,7 @@ packages: /memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + requiresBuild: true dev: false optional: true @@ -3617,6 +3618,7 @@ packages: /sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + requiresBuild: true dependencies: memory-pager: 1.5.0 dev: false diff --git a/src/routes.js b/src/routes.js index 3d6902f..2cbec4f 100755 --- a/src/routes.js +++ b/src/routes.js @@ -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; @@ -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", }, diff --git a/src/routes/event.ts b/src/routes/event.ts index 3595e0a..1b79f12 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -6,6 +6,7 @@ import { generateEditToken, generateEventID, generateRSAKeypair, + hashString, } from "../util/generator.js"; import { validateEventData } from "../util/validation.js"; import { addToLog } from "../helpers.js"; @@ -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; diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 58128a0..4d977d7 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -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(); @@ -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) { diff --git a/src/util/generator.ts b/src/util/generator.ts index d959145..18dcf32 100644 --- a/src/util/generator.ts +++ b/src/util/generator.ts @@ -34,3 +34,6 @@ export const generateRSAKeypair = () => { }, }); }; + +export const hashString = (input: string) => + crypto.createHash("sha256").update(input).digest("hex"); diff --git a/src/util/messages.ts b/src/util/messages.ts new file mode 100644 index 0000000..ae1568c --- /dev/null +++ b/src/util/messages.ts @@ -0,0 +1,9 @@ +type MessageId = "unattend"; + +const queryStringMessages: Record = { + unattend: `You have been removed from this event.`, +}; + +export const getMessage = (id?: string) => { + return queryStringMessages[id as MessageId] || ""; +}; diff --git a/views/emails/addEventAttendee/addEventAttendeeHtml.handlebars b/views/emails/addEventAttendee/addEventAttendeeHtml.handlebars index 971364c..48cdb48 100644 --- a/views/emails/addEventAttendee/addEventAttendeeHtml.handlebars +++ b/views/emails/addEventAttendee/addEventAttendeeHtml.handlebars @@ -1,6 +1,7 @@

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.

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.

+

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

Love,

{{siteName}}


diff --git a/views/emails/addEventAttendee/addEventAttendeeText.handlebars b/views/emails/addEventAttendee/addEventAttendeeText.handlebars index 2e0eca7..3930e28 100644 --- a/views/emails/addEventAttendee/addEventAttendeeText.handlebars +++ b/views/emails/addEventAttendee/addEventAttendeeText.handlebars @@ -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, diff --git a/views/emails/unattendEvent/unattendEventHtml.handlebars b/views/emails/unattendEvent/unattendEventHtml.handlebars index 62dac8a..bc20d27 100644 --- a/views/emails/unattendEvent/unattendEventHtml.handlebars +++ b/views/emails/unattendEvent/unattendEventHtml.handlebars @@ -1,5 +1,5 @@

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}}

Love,

{{siteName}}

diff --git a/views/emails/unattendEvent/unattendEventText.handlebars b/views/emails/unattendEvent/unattendEventText.handlebars index dbe83b4..7e60dbf 100644 --- a/views/emails/unattendEvent/unattendEventText.handlebars +++ b/views/emails/unattendEvent/unattendEventText.handlebars @@ -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}} diff --git a/views/event.handlebars b/views/event.handlebars index 6485ec5..3ccbc08 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -17,6 +17,11 @@ {{/if}} +{{#if message}} + +{{/if}}
@@ -207,7 +212,7 @@
-

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 not be shown again.

+

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 not be shown again.

@@ -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)) { @@ -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}`; } }, @@ -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); } @@ -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); @@ -572,4 +587,4 @@ window.eventData = {{{ json jsonData }}}; }); - \ No newline at end of file +