diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts
index 78cc2ca..8870164 100644
--- a/cypress/e2e/event.cy.ts
+++ b/cypress/e2e/event.cy.ts
@@ -86,7 +86,7 @@ describe("Events", () => {
);
});
- it("allows you to attend an event", function () {
+ it("allows you to attend an event - visible in public list", function () {
cy.get("button#attendEvent").click();
cy.get("#attendeeName").type("Test Attendee");
cy.get("#attendeeNumber").focus().clear();
@@ -99,6 +99,20 @@ describe("Events", () => {
);
});
+ it("allows you to attend an event - hidden from public list", function () {
+ cy.get("button#attendEvent").click();
+ cy.get("#attendeeName").type("Test Attendee");
+ cy.get("#attendeeNumber").focus().clear();
+ cy.get("#attendeeNumber").type("2");
+ cy.get("#attendeeVisible").uncheck();
+ 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) (hidden from public list)",
+ );
+ });
+
it("allows you to comment on an event", function () {
cy.get("#commentAuthor").type("Test Author");
cy.get("#commentContent").type("Test Comment");
diff --git a/package.json b/package.json
index d573f7c..60fa957 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"license": "GPL-3.0-or-later",
"dependencies": {
"@sendgrid/mail": "^6.5.5",
+ "activitypub-types": "^1.0.3",
"cors": "^2.8.5",
"dompurify": "^3.0.6",
"express": "^4.18.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f303bc0..51126fe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ dependencies:
'@sendgrid/mail':
specifier: ^6.5.5
version: 6.5.5
+ activitypub-types:
+ specifier: ^1.0.3
+ version: 1.0.3
cors:
specifier: ^2.8.5
version: 2.8.5
@@ -875,6 +878,10 @@ packages:
hasBin: true
dev: true
+ /activitypub-types@1.0.3:
+ resolution: {integrity: sha512-70PzXqhskrXebCcIAxyvKeQAR8myxLlSF5GKcyN/5UkTpTPTlQyzehzd4tXFgR6ZE+Tvsy3/1WWKSPTQPXvFwA==}
+ dev: false
+
/agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
diff --git a/public/css/style.css b/public/css/style.css
index d50ab11..dd59d6b 100755
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -187,6 +187,28 @@ body, html {
font-weight: bold;
}
+.attendeesList > li.hidden-attendee {
+ border: 4px solid #ccc;
+ background: #eee;
+}
+
+.attendeesList > li.hidden-attendee a {
+ color: #555;
+}
+
+.hidden-attendees-message {
+ display: inline-block;
+ border: 4px solid #ccc;
+ text-align: center;
+ border-radius: 2em;
+ padding: 0.5em 1em;
+ background: #eee;
+ color: #555;
+ font-size: 0.95em;
+ font-weight: bold;
+ margin: 0;
+}
+
.expand {
-webkit-transition: height 0.2s;
-moz-transition: height 0.2s;
@@ -321,6 +343,10 @@ body, html {
color: #fff;
}
+li.hidden-attendee .attendee-name {
+ color: #555;
+}
+
.remove-attendee {
color: #fff;
}
diff --git a/src/activitypub.js b/src/activitypub.js
index a6f4ada..88e3768 100644
--- a/src/activitypub.js
+++ b/src/activitypub.js
@@ -10,7 +10,7 @@ const domain = config.general.domain;
const siteName = config.general.site_name;
const isFederated = config.general.is_federated;
import Event from "./models/Event.js";
-import { activityPubContentType, alternateActivityPubContentType } from "./lib/activitypub.js";
+import { handlePollResponse, activityPubContentType, alternateActivityPubContentType, getEventId, getNoteRecipient } from "./lib/activitypub.js";
// This alphabet (used to generate all event, group, etc. IDs) is missing '-'
// because ActivityPub doesn't like it in IDs
@@ -660,7 +660,7 @@ export function sendAcceptMessage(thebody, eventID, targetDomain, callback) {
function _handleFollow(req, res) {
const myURL = new URL(req.body.actor);
let targetDomain = myURL.hostname;
- let eventID = req.body.object.replace(`https://${domain}/`, "");
+ let eventID = getEventId(req.body.object);
// Add the user to the DB of accounts that follow the account
// get the follower's username
request(
@@ -735,11 +735,22 @@ function _handleFollow(req, res) {
"https://www.w3.org/ns/activitystreams",
name: `RSVP to ${event.name}`,
type: "Question",
- content: `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`,
+ content: `@${name} Will you attend ${event.name}?`,
oneOf: [
{
type: "Note",
- name: "Yes",
+ name: "Yes, and show me in the public list",
+ "replies": { "type": "Collection", "totalItems": 0 }
+ },
+ {
+ type: "Note",
+ name: "Yes, but hide me from the public list",
+ "replies": { "type": "Collection", "totalItems": 0 }
+ },
+ {
+ type: "Note",
+ name: "No",
+ "replies": { "type": "Collection", "totalItems": 0 }
},
],
endTime:
@@ -807,7 +818,7 @@ function _handleFollow(req, res) {
});
} else {
// this person is already a follower so just say "ok"
- return res.status(200);
+ return res.sendStatus(200);
}
},
);
@@ -867,11 +878,15 @@ function _handleUndoFollow(req, res) {
}
function _handleAcceptEvent(req, res) {
- let { name, attributedTo, inReplyTo, to, actor } = req.body;
- if (Array.isArray(to)) {
- to = to[0];
+ let { name, attributedTo, inReplyTo, actor } = req.body;
+ const recipient = getNoteRecipient(req.body);
+ if (!recipient) {
+ return res.status(400).send("No recipient found in the object");
+ }
+ const eventID = getEventId(recipient);
+ if (!eventID) {
+ return res.status(400).send("No event ID found in the recipient");
}
- const eventID = to.replace(`https://${domain}/`, "");
Event.findOne(
{
id: eventID,
@@ -989,7 +1004,7 @@ function _handleUndoAcceptEvent(req, res) {
);
if (message) {
// it's a match
- Event.update(
+ Event.updateOne(
{ id: eventID },
{ $pull: { attendees: { id: actor } } },
).then((response) => {
@@ -1005,134 +1020,6 @@ function _handleUndoAcceptEvent(req, res) {
);
}
-function _handleCreateNote(req, res) {
- // figure out what this is in reply to -- it should be addressed specifically to us
- let { name, attributedTo, inReplyTo, to } = req.body.object;
- // if it's an array just grab the first element, since a poll should only broadcast back to the pollster
- if (Array.isArray(to)) {
- to = to[0];
- }
- const eventID = to.replace(`https://${domain}/`, "");
- // make sure this person is actually a follower
- Event.findOne(
- {
- id: eventID,
- },
- function (err, event) {
- if (!event) return;
- // is this even someone who follows us
- const indexOfFollower = event.followers.findIndex(
- (el) => el.actorId === req.body.object.attributedTo,
- );
- if (indexOfFollower !== -1) {
- // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply
- const message = event.activityPubMessages.find((el) => {
- const content = JSON.parse(el.content);
- return inReplyTo === (content.object && content.object.id);
- });
- if (message) {
- const content = JSON.parse(message.content);
- // check if the message we sent out was sent to the actor this incoming message is attributedTo
- if (content.to[0] === attributedTo) {
- // it's a match, this is a valid poll response, add RSVP to database
- // fetch the profile information of the user
- request(
- {
- url: attributedTo,
- headers: {
- Accept: activityPubContentType,
- "Content-Type": activityPubContentType,
- },
- },
- function (error, response, body) {
- body = JSON.parse(body);
- // if this account is NOT already in our attendees list, add it
- if (
- !event.attendees
- .map((el) => el.id)
- .includes(attributedTo)
- ) {
- const attendeeName =
- body.preferredUsername ||
- body.name ||
- attributedTo;
- const newAttendee = {
- name: attendeeName,
- status: "attending",
- id: attributedTo,
- number: 1,
- };
- event.attendees.push(newAttendee);
- event
- .save()
- .then((fullEvent) => {
- addToLog(
- "addEventAttendee",
- "success",
- "Attendee added to event " +
- req.params.eventID,
- );
- // get the new attendee with its hidden id from the full event
- let fullAttendee =
- fullEvent.attendees.find(
- (el) =>
- el.id === attributedTo,
- );
- // send a "click here to remove yourself" link back to the user as a DM
- const jsonObject = {
- "@context":
- "https://www.w3.org/ns/activitystreams",
- name: `RSVP to ${event.name}`,
- type: "Note",
- content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`,
- tag: [
- {
- type: "Mention",
- href: newAttendee.id,
- name: newAttendee.name,
- },
- ],
- };
- // send direct message to user
- sendDirectMessage(
- jsonObject,
- newAttendee.id,
- event.id,
- );
- return res.sendStatus(200);
- })
- .catch((err) => {
- addToLog(
- "addEventAttendee",
- "error",
- "Attempt to add attendee to event " +
- req.params.eventID +
- " failed with error: " +
- err,
- );
- return res
- .status(500)
- .send(
- "Database error, please try again :(",
- );
- });
- } else {
- // it's a duplicate and this person is already rsvped so just say OK
- return res
- .status(200)
- .send(
- "Attendee is already registered.",
- );
- }
- },
- );
- }
- }
- }
- },
- );
-}
-
function _handleDelete(req, res) {
const deleteObjectId = req.body.object.id;
// find all events with comments from the author
@@ -1221,7 +1108,10 @@ function _handleCreateNoteComment(req, res) {
cc.includes("https://www.w3.org/ns/activitystreams#Public"))
) {
// figure out which event(s) of ours it was addressing
+ // Mastodon seems to put the event ID in the to field, Pleroma in the cc field
+ // This is because ActivityPub is a mess (love you ActivityPub)
let ourEvents = cc
+ .concat(to)
.filter((el) => el.includes(`https://${domain}/`))
.map((el) => el.replace(`https://${domain}/`, ""));
// comments should only be on one event. if more than one, ignore (spam, probably)
@@ -1320,28 +1210,31 @@ export function processInbox(req, res) {
try {
// if a Follow activity hits the inbox
if (typeof req.body.object === "string" && req.body.type === "Follow") {
+ console.log("Sending to _handleFollow");
_handleFollow(req, res);
}
// if an Undo activity with a Follow object hits the inbox
- if (
+ else if (
req.body &&
req.body.type === "Undo" &&
req.body.object &&
req.body.object.type === "Follow"
) {
+ console.log("Sending to _handleUndoFollow");
_handleUndoFollow(req, res);
}
// if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP
- if (
+ else if (
req.body &&
req.body.type === "Accept" &&
req.body.object &&
typeof req.body.object === "string"
) {
+ console.log("Sending to _handleAcceptEvent");
_handleAcceptEvent(req, res);
}
// if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP
- if (
+ else if (
req.body &&
req.body.type === "Undo" &&
req.body.object &&
@@ -1349,10 +1242,11 @@ export function processInbox(req, res) {
typeof req.body.object.object === "string" &&
req.body.object.type === "Accept"
) {
+ console.log("Sending to _handleUndoAcceptEvent");
_handleUndoAcceptEvent(req, res);
}
// if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll
- if (
+ else if (
req.body &&
req.body.type === "Create" &&
req.body.object &&
@@ -1360,22 +1254,27 @@ export function processInbox(req, res) {
req.body.object.inReplyTo &&
req.body.object.to
) {
- _handleCreateNote(req, res);
+ handlePollResponse(req, res);
}
// if a Delete activity hits the inbox, it might a deletion of a comment
- if (req.body && req.body.type === "Delete") {
+ else if (req.body && req.body.type === "Delete") {
+ console.log("Sending to _handleDelete");
_handleDelete(req, res);
}
// if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers
- if (
+ else if (
req.body &&
req.body.type === "Create" &&
req.body.object &&
req.body.object.type === "Note" &&
req.body.object.to
) {
+ console.log("Sending to _handleCreateNoteComment");
_handleCreateNoteComment(req, res);
} // CC'ed
+ else {
+ console.log("No action taken");
+ }
} catch (e) {
console.log("Error in processing inbox:", e);
}
diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts
index 11f0770..a06991d 100644
--- a/src/lib/activitypub.ts
+++ b/src/lib/activitypub.ts
@@ -1,4 +1,18 @@
import { Request, Response } from "express";
+import Event, { IAttendee } from "../models/Event.js";
+import { sendDirectMessage } from "../activitypub.js";
+import { successfulRSVPResponse } from "./activitypub/templates.js";
+
+interface APObject {
+ type: "Note";
+ actor?: string;
+ id: string;
+ to?: string | string[];
+ cc?: string | string[];
+ attributedTo: string;
+ inReplyTo: string;
+ name: string;
+}
// From https://www.w3.org/TR/activitypub/#client-to-server-interactions:
// "Servers MAY interpret a Content-Type or Accept header of application/activity+json
@@ -20,3 +34,157 @@ export const acceptsActivityPub = (req: Request) => {
(header) => req.headers.accept?.includes(header),
);
};
+
+// At least for poll responses, Mastodon stores the recipient (the poll-maker)
+// in the 'to' field, while Pleroma stores it in 'cc'
+export const getNoteRecipient = (object: APObject): string | null => {
+ const { to, cc } = object;
+ if (!to && !cc) {
+ return "";
+ }
+ if (to && to.length > 0) {
+ if (Array.isArray(to)) {
+ return to[0];
+ }
+ if (typeof to === "string") {
+ return to;
+ }
+ return null;
+ } else if (cc && cc.length > 0) {
+ if (Array.isArray(cc)) {
+ return cc[0];
+ }
+ return cc;
+ }
+ return null;
+};
+
+// Returns the event ID from a URL like http://localhost:3000/123abc
+// or https://gath.io/123abc
+export const getEventId = (url: string): string => {
+ try {
+ return new URL(url).pathname.replace("/", "");
+ } catch (error) {
+ // Apparently not a URL so maybe it's just the ID
+ return url;
+ }
+};
+
+export const handlePollResponse = async (req: Request, res: Response) => {
+ try {
+ // figure out what this is in reply to -- it should be addressed specifically to us
+ const { attributedTo, inReplyTo, name } = req.body.object as APObject;
+ const recipient = getNoteRecipient(req.body.object);
+ if (!recipient) throw new Error("No recipient found");
+
+ const eventID = getEventId(recipient);
+ const event = await Event.findOne({ id: eventID });
+ if (!event) throw new Error("Event not found");
+
+ // make sure this person is actually a follower of the event
+ const senderAlreadyFollows = event.followers?.some(
+ (el) => el.actorId === attributedTo,
+ );
+ if (!senderAlreadyFollows) {
+ throw new Error("Poll response sender does not follow event");
+ }
+
+ // compare the inReplyTo to its stored message, if it exists and
+ // it's going to the right follower then this is a valid reply
+ const matchingMessage = event.activityPubMessages?.find((el) => {
+ const content = JSON.parse(el.content || "");
+ return inReplyTo === content?.object?.id;
+ });
+ if (!matchingMessage) throw new Error("No matching message found");
+ const messageContent = JSON.parse(matchingMessage.content || "");
+ // check if the message we sent out was sent to the actor this incoming
+ // message is attributedTo
+ const messageRecipient = getNoteRecipient(messageContent.object);
+ if (!messageRecipient || messageRecipient !== attributedTo) {
+ throw new Error("Message recipient does not match attributedTo");
+ }
+
+ // it's a match, this is a valid poll response, add RSVP to database
+
+ // 'name' is the poll response
+ // - "Yes, and show me in the public list",
+ // - "Yes, but hide me from the public list",
+ // - "No"
+ if (
+ name !== "Yes, and show me in the public list" &&
+ name !== "Yes, but hide me from the public list" &&
+ name !== "No"
+ ) {
+ throw new Error("Invalid poll response");
+ }
+
+ if (name === "No") {
+ // Why did you even respond?
+ return res.status(200).send("Thanks I guess?");
+ }
+
+ const visibility =
+ name === "Yes, and show me in the public list"
+ ? "public"
+ : "private";
+
+ // fetch the profile information of the user
+ const response = await fetch(attributedTo, {
+ headers: {
+ Accept: activityPubContentType,
+ "Content-Type": activityPubContentType,
+ },
+ });
+ if (!response.ok) throw new Error("Actor not found");
+ const apActor = await response.json();
+
+ // If the actor is not already attending the event, add them
+ if (!event.attendees?.some((el) => el.id === attributedTo)) {
+ const attendeeName =
+ apActor.preferredUsername || apActor.name || attributedTo;
+ const newAttendee: Partial = {
+ name: attendeeName,
+ status: "attending",
+ id: attributedTo,
+ number: 1,
+ visibility,
+ };
+ const updatedEvent = await Event.findOneAndUpdate(
+ { id: eventID },
+ { $push: { attendees: newAttendee } },
+ { new: true },
+ ).exec();
+ const fullAttendee = updatedEvent?.attendees?.find(
+ (el) => el.id === attributedTo,
+ );
+ if (!fullAttendee) throw new Error("Full attendee not found");
+
+ // send a "click here to remove yourself" link back to the user as a DM
+ const jsonObject = {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ name: `RSVP to ${event.name}`,
+ type: "Note",
+ content: successfulRSVPResponse({
+ event,
+ newAttendee,
+ fullAttendee,
+ }),
+ tag: [
+ {
+ type: "Mention",
+ href: newAttendee.id,
+ name: newAttendee.name,
+ },
+ ],
+ };
+ // send direct message to user
+ sendDirectMessage(jsonObject, newAttendee.id, event.id);
+ return res.sendStatus(200);
+ } else {
+ return res.status(200).send("Attendee is already registered.");
+ }
+ } catch (error) {
+ console.error(error);
+ return res.status(500).send("An unexpected error occurred.");
+ }
+};
diff --git a/src/lib/activitypub/templates.ts b/src/lib/activitypub/templates.ts
new file mode 100644
index 0000000..cab9ada
--- /dev/null
+++ b/src/lib/activitypub/templates.ts
@@ -0,0 +1,14 @@
+import { IEvent } from "../../models/Event.js";
+import getConfig from "../config.js";
+const config = getConfig();
+
+export const successfulRSVPResponse = ({
+ event,
+ newAttendee,
+ fullAttendee,
+}: {
+ event: IEvent;
+ newAttendee: { id: string; name: string };
+ fullAttendee: { _id: string };
+}) =>
+ `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here.`;
diff --git a/src/models/Event.ts b/src/models/Event.ts
index 94be087..f67d40b 100644
--- a/src/models/Event.ts
+++ b/src/models/Event.ts
@@ -9,6 +9,7 @@ export interface IAttendee {
number?: number;
created?: Date;
_id: string;
+ visibility?: "public" | "private";
}
export interface IReply {
@@ -105,6 +106,11 @@ const Attendees = new mongoose.Schema({
trim: true,
default: 1,
},
+ visibility: {
+ type: String,
+ trim: true,
+ default: "public",
+ },
created: Date,
});
diff --git a/src/routes.js b/src/routes.js
index 360f387..ab12a3a 100755
--- a/src/routes.js
+++ b/src/routes.js
@@ -505,13 +505,12 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => {
},
);
}
- Event.update(
+ Event.updateOne(
{ _id: { $in: linkedEventIDs } },
{ $set: { eventGroup: null } },
{ multi: true },
)
.then((response) => {
- console.log(response);
addToLog(
"deleteEventGroup",
"success",
@@ -688,6 +687,7 @@ router.post("/attendevent/:eventID", async (req, res) => {
"attendees.$.name": req.body.attendeeName,
"attendees.$.email": req.body.attendeeEmail,
"attendees.$.number": req.body.attendeeNumber,
+ "attendees.$.visibility": !!req.body.attendeeVisible ? "public" : "private",
},
},
)
@@ -762,12 +762,11 @@ router.post("/unattendevent/:eventID", (req, res) => {
return res.sendStatus(500);
}
- Event.update(
+ Event.updateOne(
{ id: req.params.eventID },
{ $pull: { attendees: { removalPassword } } },
)
.then((response) => {
- console.log(response);
addToLog(
"unattendEvent",
"success",
@@ -836,15 +835,16 @@ router.post("/unattendevent/:eventID", (req, res) => {
// this is a one-click unattend that requires a secret URL that only the person who RSVPed over
// activitypub knows
router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => {
- // Mastodon will "click" links that sent to its users, presumably as a prefetch?
+ // Mastodon and Pleroma will "click" links that sent to its users, presumably as a prefetch?
// Anyway, this ignores the automated clicks that are done without the user's knowledge
if (
req.headers["user-agent"] &&
- req.headers["user-agent"].includes("Mastodon")
+ (req.headers["user-agent"].toLowerCase().includes("mastodon") ||
+ req.headers["user-agent"].toLowerCase().includes("pleroma"))
) {
return res.sendStatus(200);
}
- Event.update(
+ Event.updateOne(
{ id: req.params.eventID },
{ $pull: { attendees: { _id: req.params.attendeeID } } },
)
@@ -915,12 +915,11 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => {
});
router.post("/removeattendee/:eventID/:attendeeID", (req, res) => {
- Event.update(
+ Event.updateOne(
{ id: req.params.eventID },
{ $pull: { attendees: { _id: req.params.attendeeID } } },
)
.then((response) => {
- console.log(response);
addToLog(
"removeEventAttendee",
"success",
@@ -1071,12 +1070,11 @@ router.post("/subscribe/:eventGroupID", (req, res) => {
*/
router.get("/unsubscribe/:eventGroupID", (req, res) => {
const email = req.query.email;
- console.log(email);
if (!email) {
return res.sendStatus(500);
}
- EventGroup.update(
+ EventGroup.updateOne(
{ id: req.params.eventGroupID },
{ $pull: { subscribers: { email } } },
)
@@ -1434,7 +1432,10 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => {
router.post("/activitypub/inbox", (req, res) => {
if (!isFederated) return res.sendStatus(404);
// validate the incoming message
- const signature = req.get("Signature");
+ const signature = req.get("signature");
+ if (!signature) {
+ return res.status(401).send("No signature provided.");
+ }
let signature_header = signature
.split(",")
.map((pair) => {
@@ -1446,7 +1447,6 @@ router.post("/activitypub/inbox", (req, res) => {
acc[el[0]] = el[1];
return acc;
}, {});
-
// get the actor
// TODO if this is a Delete for an Actor this won't work
request(
@@ -1478,11 +1478,10 @@ router.post("/activitypub/inbox", (req, res) => {
}
})
.join("\n");
-
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(comparison_string, "ascii");
- const publicKeyBuf = new Buffer(publicKey, "ascii");
- const signatureBuf = new Buffer(
+ const publicKeyBuf = Buffer.from(publicKey, "ascii");
+ const signatureBuf = Buffer.from(
signature_header.signature,
"base64",
);
diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts
index cc97ab8..8ddfbf6 100644
--- a/src/routes/frontend.ts
+++ b/src/routes/frontend.ts
@@ -142,7 +142,11 @@ router.get("/:eventID", async (req: Request, res: Response) => {
if (el.number && el.number > 1) {
el.name = `${el.name} (${el.number} people)`;
}
- return el;
+ return {
+ ...el,
+ // Backwards compatibility - if visibility is not set, default to public
+ visibility: el.visibility || "public",
+ };
})
.filter((obj, pos, arr) => {
return (
@@ -159,6 +163,24 @@ router.get("/:eventID", async (req: Request, res: Response) => {
}
return acc;
}, 0) || 0;
+ const visibleAttendees = eventAttendees?.filter(
+ (attendee) => attendee.visibility === "public",
+ );
+ const hiddenAttendees = eventAttendees?.filter(
+ (attendee) => attendee.visibility === "private",
+ );
+ const numberOfHiddenAttendees = eventAttendees?.reduce(
+ (acc, attendee) => {
+ if (
+ attendee.status === "attending" &&
+ attendee.visibility === "private"
+ ) {
+ return acc + (attendee.number || 1);
+ }
+ return acc;
+ },
+ 0,
+ );
if (event.maxAttendees) {
spotsRemaining = event.maxAttendees - numberOfAttendees;
if (spotsRemaining <= 0) {
@@ -189,8 +211,10 @@ router.get("/:eventID", async (req: Request, res: Response) => {
title: event.name,
escapedName: escapedName,
eventData: event,
- eventAttendees: eventAttendees,
+ visibleAttendees,
+ hiddenAttendees,
numberOfAttendees,
+ numberOfHiddenAttendees,
spotsRemaining: spotsRemaining,
noMoreSpots: noMoreSpots,
eventStartISO: eventStartISO,
diff --git a/src/start.ts b/src/start.ts
index a6399ac..124a2fb 100755
--- a/src/start.ts
+++ b/src/start.ts
@@ -9,6 +9,7 @@ mongoose.connect(config.database.mongodb_url, {
useUnifiedTopology: true,
});
mongoose.set("useCreateIndex", true);
+mongoose.set("useFindAndModify", false);
mongoose.Promise = global.Promise;
mongoose.connection
.on("connected", () => {
diff --git a/views/event.handlebars b/views/event.handlebars
index 999a12b..4402578 100755
--- a/views/event.handlebars
+++ b/views/event.handlebars
@@ -131,7 +131,7 @@
{{#if eventData.usersCanAttend}}
-