419 lines
26 KiB
JavaScript
419 lines
26 KiB
JavaScript
const { ACTIVITY_TYPES, ActivitiesHandlerMixin, matchActivity } = require('@semapps/activitypub');
|
|
const { arrayOf } = require('@semapps/ldp');
|
|
const CONFIG = require('../config/config');
|
|
const { isInActorPod } = require('./actor-pod');
|
|
const { createKeyedSerializer } = require('./keyed-serializer');
|
|
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
|
|
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = require('./emitted-activity');
|
|
const { participationSecretOf, memberUrn } = require('./member-urn');
|
|
const { readItems, expectItem } = require('./collection-items');
|
|
|
|
const AS = 'https://www.w3.org/ns/activitystreams#';
|
|
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
|
|
const APODS_CONTACTS = 'http://activitypods.org/ns/core#contacts';
|
|
const DC_CREATOR = 'http://purl.org/dc/terms/creator';
|
|
|
|
// Routing matches the activity TYPE alone: routing is a hint, the verified handler re-reads the
|
|
// activity and its object from the pod and decides (see emitted-activity.js).
|
|
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN };
|
|
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE };
|
|
|
|
/** The time the pod stamped on a stored activity (`published`, under any of its spellings). */
|
|
const publishedOf = activity => {
|
|
if (!activity || typeof activity !== 'object') return undefined;
|
|
const value = activity.published ?? activity['as:published'] ?? activity[`${AS}published`];
|
|
return value && typeof value === 'object' ? value['@value'] : value;
|
|
};
|
|
|
|
/** The single URI a node names under the first of `keys` it carries (compacted, CURIE or full IRI). */
|
|
const linkOf = (node, ...keys) => singleUriOf(keys.map(key => node[key]).find(value => value !== undefined));
|
|
|
|
/** The first string a property holds (a string, a value node, or a list of either). */
|
|
const firstStringOf = value =>
|
|
arrayOf(value)
|
|
.map(v => (typeof v === 'string' ? v : v && typeof v === 'object' ? v['@value'] : undefined))
|
|
.find(v => typeof v === 'string' && v.trim().length > 0);
|
|
|
|
/**
|
|
* What a Join/Leave targets: an `as:Event` whose `dc:creator` owns the pod it lies in. A meeting point
|
|
* is anchored to its event by `as:context`; an event signed up to directly is its own anchor. Only on
|
|
* the copy the creator's pod serves (`meetingPointOf`) is that creator believed; on the copy read
|
|
* through the participant's pod, it only names where to read that one. Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
|
*/
|
|
function meetingPointTarget(meetingPoint, meetingPointUri) {
|
|
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri;
|
|
const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
|
|
if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` };
|
|
if (!isInActorPod(meetingPointUri, creatorUri)) {
|
|
return { ok: false, reason: `the meeting point ${meetingPointUri} is not in the pod of its creator ${creatorUri}` };
|
|
}
|
|
return { ok: true, eventUri, creatorUri };
|
|
}
|
|
|
|
/**
|
|
* The notification template for the creator of a target (`pod-notifications.send`, rendered in the
|
|
* creator's language): always ANONYMOUS, linking to the event, or to its meeting points when the target
|
|
* is one. Every value travels as a template PARAMETER, never inside the template, so no user text is
|
|
* compiled.
|
|
*
|
|
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
|
|
* participant's contacts, and the only one the app can read — the participant's `apods:contacts` list —
|
|
* is forgeable (a stranger's received `Accept` of a contact `Offer` is added to it with no prior
|
|
* request checked; platform defect reported upstream). Until that list can be trusted, every
|
|
* notification is anonymous.
|
|
*/
|
|
function notificationTemplate(change, isMeetingPoint) {
|
|
const joined = change === 'join';
|
|
const nameParam = isMeetingPoint ? 'meetingPointName' : 'eventName';
|
|
const subject = isMeetingPoint
|
|
? { en: 'your meeting point', fr: 'votre point de rencontre' }
|
|
: { en: 'your event', fr: 'votre événement' };
|
|
const title = {
|
|
en: `A participant ${joined ? 'joined' : 'left'} ${subject.en} "{{{${nameParam}}}}"`,
|
|
fr: `Un participant a ${joined ? 'rejoint' : 'quitté'} ${subject.fr} « {{{${nameParam}}}} »`
|
|
};
|
|
const link = isMeetingPoint ? '/events/{{encodeUri eventUri}}/meeting-points' : '/events/{{encodeUri eventUri}}';
|
|
return { title, actions: [{ caption: { en: 'View', fr: 'Voir' }, link }] };
|
|
}
|
|
|
|
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
|
|
// decision_2026-11-03_participation-delivered-to-the-app): a person joins (leaves) a meeting point with an `as:Join`
|
|
// (`as:Leave`) posted to their OWN outbox, naming the meeting point, addressed to the app actor alone —
|
|
// so only its author and the app read it. Their pod delivers it to the app's inbox; the creator's inbox
|
|
// receives nothing. The backend acts on RECEPTION in the app's inbox (`onReceive` of the semapps
|
|
// `ActivitiesHandlerMixin`, as the app framework handles an app registration), and records:
|
|
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
|
|
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
|
|
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
|
|
// - in the participant's participations collection (their pod, acting as the participant): the
|
|
// meeting point URI — the collection created at the first Join and kept readable by the
|
|
// participant's `/contacts` WAC group only (`participations.service.js`).
|
|
// Then the creator is notified ANONYMOUSLY — always: naming would need a trustworthy "is the creator a
|
|
// contact of the participant" signal, and the only one the app can read (the participant's
|
|
// `apods:contacts` list) is forgeable (platform defect reported upstream, see `notificationTemplate`).
|
|
// The creator has no other right on the participant: they learn a count and opaque URNs.
|
|
// The delivery that reports the act is a pointer (emitted-activity.js, `verifiedDelivery`): the handler
|
|
// acts only on the activity and the meeting point as the pods store them, and only for the people they
|
|
// really name:
|
|
// - the participant is the delivered activity's `actor` — the app's inbox accepts a delivery only
|
|
// signed by its actor — and the activity must lie in that actor's pod, its stored copy naming them:
|
|
// any server may deliver, nobody acts for someone else;
|
|
// - the meeting point is the copy its creator's pod serves (`meetingPointOf`), never the one read
|
|
// through the participant's server; the creator is its `dc:creator`, accepted only when the meeting
|
|
// point lies in that creator's pod (`meetingPointTarget`);
|
|
// - the collections written to are the ones the STORED meeting point and the participant's STORED
|
|
// profile name, and must lie in the pod of whoever owns them.
|
|
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
|
|
// order they reach that lock does not decide: under the lock, an act is applied only when it is newer —
|
|
// by the time the participant's pod stamped on it — than the last act applied for that participant and
|
|
// meeting point (`attendance-ledger.js`, persisted, which also holds the member URN that act applied). Whatever order the notifications run in, the
|
|
// participant ends in the state of their latest act. The member URN is STICKY per (meeting point,
|
|
// participant): chosen once and persisted BEFORE any attendee-list write, so every later act reuses it —
|
|
// the pair is never listed under two URNs, even after a half-way failure and a secret rewrite. An act is
|
|
// recorded only once BOTH collections, READ BACK, show it (collection-items.js — the platform's own
|
|
// collection calls answer "empty" on a failed read and resolve on a refused write); one that fails
|
|
// half-way is not, nor notified, and the participant's next act on that meeting point re-applies it
|
|
// (the two writes are not atomic).
|
|
module.exports = {
|
|
name: 'registration',
|
|
mixins: [ActivitiesHandlerMixin],
|
|
dependencies: ['participations'],
|
|
created() {
|
|
this.serialize = createKeyedSerializer();
|
|
},
|
|
async started() {
|
|
this.logger.info("Registration listens to the Joins and Leaves delivered to the app's inbox");
|
|
},
|
|
methods: {
|
|
/**
|
|
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, its verified
|
|
* emitter: resolves the meeting point and the participant, applies the act, then notifies the
|
|
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
|
|
*/
|
|
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: claimedMeetingPoint }, participantUri) {
|
|
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
|
|
const claimed = meetingPointTarget(claimedMeetingPoint, meetingPointUri);
|
|
if (!claimed.ok) return refuse(claimed.reason);
|
|
const target = await this.meetingPointOf(ctx, meetingPointUri, claimed.creatorUri);
|
|
if (!target.ok) return refuse(target.reason);
|
|
const { meetingPoint } = target;
|
|
const participant = await this.participantOf(ctx, participantUri);
|
|
if (!participant.ok) return refuse(participant.reason);
|
|
const outcome = await this.updateAttendance(ctx, {
|
|
change,
|
|
eventUri: target.eventUri,
|
|
secret: participant.secret,
|
|
meetingPointUri,
|
|
meetingPoint,
|
|
activity,
|
|
creatorUri: target.creatorUri,
|
|
participantUri,
|
|
profileUri: participant.profileUri,
|
|
contactsUri: participant.contactsUri
|
|
});
|
|
if (outcome && outcome.changed) {
|
|
await this.notifyCreator(ctx, {
|
|
change,
|
|
meetingPointUri,
|
|
meetingPoint,
|
|
eventUri: target.eventUri,
|
|
creatorUri: target.creatorUri,
|
|
participantUri
|
|
});
|
|
}
|
|
},
|
|
/**
|
|
* The meeting point as the pod that holds it serves it. The verification read it through the
|
|
* participant's pod — for a participant on another server, through the proxy THEIR actor document
|
|
* declares, which answers what it wants (another meeting point's list, another name). Only the
|
|
* creator's pod speaks for the meeting point: it is read again there, through the creator's access
|
|
* (a GET to that pod, signed by the app), and the act uses that copy alone. It must still be an
|
|
* `as:Event` anchored by `as:context` whose `dc:creator` is that same creator. Resolves
|
|
* `{ ok, meetingPoint, eventUri, creatorUri }` or a refusal.
|
|
*/
|
|
async meetingPointOf(ctx, meetingPointUri, creatorUri) {
|
|
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri: creatorUri });
|
|
if (status !== 200 || !body || uriOf(body) !== meetingPointUri) {
|
|
return { ok: false, reason: `the pod of ${creatorUri} does not return the meeting point ${meetingPointUri} (HTTP ${status})` };
|
|
}
|
|
if (!hasAsType(body, 'Event')) return { ok: false, reason: `the meeting point ${meetingPointUri} its pod returns is not an Event` };
|
|
const target = meetingPointTarget(body, meetingPointUri);
|
|
if (!target.ok) return target;
|
|
if (target.creatorUri !== creatorUri) {
|
|
return { ok: false, reason: `the meeting point ${meetingPointUri} names ${target.creatorUri} as its creator in its pod, not ${creatorUri}` };
|
|
}
|
|
return { ok: true, meetingPoint: body, eventUri: target.eventUri, creatorUri };
|
|
},
|
|
/**
|
|
* The participant as their pod describes them, read as the participant: their actor document
|
|
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
|
|
* profile, which must carry a usable participation secret. Resolves `{ ok, profileUri,
|
|
* contactsUri, secret }` or a refusal. `contactsUri` is the participant's `apods:contacts`
|
|
* collection — whose `/contacts` WAC group is what may read their participations collection.
|
|
*/
|
|
async participantOf(ctx, participantUri) {
|
|
const read = async uri => {
|
|
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri: participantUri });
|
|
return status === 200 && body && uriOf(body) === uri ? body : undefined;
|
|
};
|
|
const actor = await read(participantUri);
|
|
if (!actor) return { ok: false, reason: `the pod does not return the actor ${participantUri}` };
|
|
const profileUri = linkOf(actor, 'url', 'as:url', `${AS}url`);
|
|
if (!profileUri || !isInActorPod(profileUri, participantUri)) {
|
|
return { ok: false, reason: `${participantUri} names no single profile in their pod (${profileUri})` };
|
|
}
|
|
const profile = await read(profileUri);
|
|
if (!profile) return { ok: false, reason: `the pod does not return the profile ${profileUri}` };
|
|
const secret = participationSecretOf(profile);
|
|
if (!secret) return { ok: false, reason: `the profile ${profileUri} carries no usable participation secret` };
|
|
const contactsUri = linkOf(actor, 'apods:contacts', APODS_CONTACTS);
|
|
return { ok: true, profileUri, contactsUri, secret };
|
|
},
|
|
/**
|
|
* The attendees collection of a meeting point of `actorUri`'s pod, from the STORED meeting point.
|
|
* It is attached asynchronously when the meeting point is created (attendees service), so a
|
|
* Join/Leave processed in that window sees none: the meeting point is read again a bounded number
|
|
* of times until it names one; only then give up. The collection must lie in the same pod.
|
|
*/
|
|
async attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri) {
|
|
let collectionUri = linkOf(meetingPoint, 'apods:attendees', APODS_ATTENDEES);
|
|
for (let attempt = 0; !collectionUri && attempt < 3; attempt++) {
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
try {
|
|
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri });
|
|
if (status === 200 && body) collectionUri = linkOf(body, 'apods:attendees', APODS_ATTENDEES);
|
|
} catch (e) {
|
|
this.logger.warn(`Could not re-read ${meetingPointUri} while waiting for its attendees collection: ${e.message}`);
|
|
}
|
|
}
|
|
if (collectionUri && !isInActorPod(collectionUri, actorUri)) {
|
|
this.logger.warn(`${meetingPointUri} names an attendees collection outside its pod: ignored`);
|
|
return undefined;
|
|
}
|
|
return collectionUri;
|
|
},
|
|
/**
|
|
* The participant's participations collection, under the meeting point's lock: for a Join, the one
|
|
* `participations.ensure` makes ready (created if none, contacts-only); for a Leave, the one the
|
|
* profile links now, if any. Resolves `{ ok, collectionUri }` — `collectionUri` undefined for a
|
|
* Leave without a collection — or `{ ok: false }` once the failure is logged: the act must then
|
|
* write nothing and stay unrecorded, so that the next act retries.
|
|
*/
|
|
async participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri }) {
|
|
try {
|
|
const collectionUri =
|
|
change === 'join'
|
|
? await ctx.call('participations.ensure', { actorUri: participantUri, profileUri, contactsUri })
|
|
: await ctx.call('participations.collectionOf', { actorUri: participantUri, profileUri });
|
|
return { ok: true, collectionUri };
|
|
} catch (e) {
|
|
this.logger.error(
|
|
`The participations collection of ${participantUri} is not ready (${e.message}): the ${change} is NOT recorded, the next act retries`
|
|
);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
/**
|
|
* Applies one participant act (`join` / `leave`, the stored activity `activity`) — under the
|
|
* meeting point's lock, and only when the act is newer than the last one applied for that member
|
|
* there (see the header). A stale act changes nothing; an act without a pod stamp cannot be placed
|
|
* and changes nothing. The act is applied to the meeting point's attendees (as the creator), then
|
|
* to the participant's participations collection (as the participant), each write read back
|
|
* (`applyToAttendees`, `applyToParticipations`), and recorded once both show it; a failed read, a
|
|
* refused write or a write that does not show leaves the act unrecorded (logged). Gives up before
|
|
* any write once its time is out (`signal`). Resolves `{ changed }` when the act was applied —
|
|
* `changed` when it added or removed the member, or flipped the state the ledger held — and
|
|
* undefined when it was not.
|
|
*
|
|
* KNOWN LIMIT — a write in flight at the timeout: an add or remove ALREADY SENT when the time ran
|
|
* out still lands, and is not recorded. If a newer act of the same participant was applied in the
|
|
* meantime (on a read taken before the late write landed), the collection shows the older act's
|
|
* effect until the participant acts again. It is deliberately NOT corrected from the ledger
|
|
* afterwards: when nothing superseded the late act (a rejoin, say), the late write is right and it
|
|
* is the ledger that is behind — re-applying the ledger would undo it, and the two cases cannot be
|
|
* told apart here. Needs a pod slower than the 30 s limit.
|
|
*/
|
|
async updateAttendance(ctx, { change, eventUri, secret, meetingPointUri, meetingPoint, activity, creatorUri, participantUri, profileUri, contactsUri }) {
|
|
const act = { activityUri: uriOf(activity), published: publishedOf(activity), change };
|
|
if (Number.isNaN(Date.parse(act.published))) {
|
|
this.logger.warn(`${change} ${act.activityUri} of ${participantUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
|
|
return undefined;
|
|
}
|
|
return this.serialize(meetingPointUri, async signal => {
|
|
const dataset = CONFIG.MAIN_DATASET;
|
|
const last = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
|
|
if (!isNewerAct(act, last)) {
|
|
this.logger.info(`${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
|
|
return undefined;
|
|
}
|
|
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri);
|
|
if (!collectionUri) {
|
|
this.logger.error(`${meetingPointUri} has no attendees collection: the ${change} ${act.activityUri} of ${participantUri} is NOT recorded, the next act retries`);
|
|
return undefined;
|
|
}
|
|
if (signal.aborted) return undefined;
|
|
const participations = await this.participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri });
|
|
if (!participations.ok || signal.aborted) return undefined;
|
|
// The member URN the act applies is STICKY per (meeting point, participant): the one the ledger
|
|
// already holds for the pair, else one computed from the secret on the profile now and persisted
|
|
// write-once BEFORE any attendee-list write — the act then applies whatever member the ledger holds
|
|
// after that write (another act's, if one landed first) — so that every later act (Join or Leave,
|
|
// after any half-way failure, timeout or secret rewrite) uses that same URN and the pair is never
|
|
// listed under two.
|
|
if (signal.aborted) return undefined;
|
|
let wrote;
|
|
try {
|
|
const memberUri =
|
|
last && last.member
|
|
? last.member
|
|
: await persistMember(ctx, { dataset, meetingPointUri, participantUri, member: memberUrn({ participantUri, eventUri, secret }) });
|
|
if (signal.aborted) return undefined;
|
|
wrote = await this.applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal });
|
|
await this.applyToParticipations(ctx, { change, collectionUri: participations.collectionUri, meetingPointUri, participantUri, signal });
|
|
} catch (e) {
|
|
this.logger.error(`The ${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is NOT recorded (${e.message}): the next act retries`);
|
|
return undefined;
|
|
}
|
|
if (signal.aborted) return undefined;
|
|
await recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act });
|
|
const wasAttending = Boolean(last && last.change === 'join');
|
|
return { changed: wrote || wasAttending !== (change === 'join') };
|
|
});
|
|
},
|
|
/**
|
|
* Applies `change` for `memberUri` to the meeting point's attendee list `collectionUri`, in the
|
|
* creator's pod and as the creator: reads the list — a failed read throws, it never means "not
|
|
* listed" — then adds the member on a Join when absent, removes it on a Leave when present (the
|
|
* attendees handler's `add` / `remove`, the reference-app calls) and reads the list back: throws
|
|
* unless the write shows. Resolves whether it wrote (false when the list already matched the act).
|
|
* Throws instead of writing once its time is out (`signal`).
|
|
*/
|
|
async applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal }) {
|
|
const joining = change === 'join';
|
|
const attending = (await readItems(ctx, { collectionUri, actorUri: creatorUri })).includes(memberUri);
|
|
if (attending === joining) {
|
|
this.logger.info(`${memberUri} already ${joining ? 'attends' : 'does not attend'} ${meetingPointUri}`);
|
|
return false;
|
|
}
|
|
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
|
|
await ctx.call(joining ? 'attendees.add' : 'attendees.remove', { collectionUri, itemUri: memberUri, actorUri: creatorUri });
|
|
await expectItem(ctx, { collectionUri, itemUri: memberUri, actorUri: creatorUri, present: joining });
|
|
return true;
|
|
},
|
|
/**
|
|
* Applies `change` to the participant's participations collection `collectionUri`, in their pod and
|
|
* as them: adds the meeting point on a Join, removes it on a Leave (the participations handler's
|
|
* `add` / `remove`, the reference-app calls — adding an item already there or removing one that is
|
|
* not changes nothing, so no read first), then reads the collection back: throws unless it shows
|
|
* the act. Nothing to do on a Leave when the participant has no collection (`collectionUri`
|
|
* undefined). Throws instead of writing once its time is out (`signal`).
|
|
*/
|
|
async applyToParticipations(ctx, { change, collectionUri, meetingPointUri, participantUri, signal }) {
|
|
if (!collectionUri) {
|
|
this.logger.info(`${participantUri} has no participations collection: nothing to remove ${meetingPointUri} from`);
|
|
return;
|
|
}
|
|
const joining = change === 'join';
|
|
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
|
|
await ctx.call(joining ? 'participations.add' : 'participations.remove', { collectionUri, itemUri: meetingPointUri, actorUri: participantUri });
|
|
await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining });
|
|
},
|
|
/**
|
|
* Tells the creator of the target (a meeting point or an event) that a participant joined or left
|
|
* it — ALWAYS anonymously.
|
|
* The participant is never named: the only membership signal the app can read (the participant's
|
|
* `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported
|
|
* upstream, see `notificationTemplate`). The notification carries no `activity`: given one,
|
|
* `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting
|
|
* point or event is not notified. A failure is logged, never thrown: the act stands.
|
|
*/
|
|
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
|
|
const isMeetingPoint = eventUri !== meetingPointUri;
|
|
try {
|
|
if (creatorUri === participantUri) {
|
|
this.logger.info(`${participantUri} acted on their own ${meetingPointUri}: no notification`);
|
|
return;
|
|
}
|
|
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
|
|
await ctx.call('pod-notifications.send', {
|
|
template: notificationTemplate(change, isMeetingPoint),
|
|
recipientUri: creatorUri,
|
|
context: meetingPointUri,
|
|
[isMeetingPoint ? 'meetingPointName' : 'eventName']: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
|
|
eventUri
|
|
});
|
|
} catch (e) {
|
|
this.logger.error(`Could not notify ${creatorUri} of a ${change} on ${meetingPointUri}: ${e.message}`);
|
|
}
|
|
}
|
|
},
|
|
activities: {
|
|
join: {
|
|
match: neverThrowingMatcher(function (activity, fetcher) {
|
|
return matchActivity(JOIN_PATTERN, activity, fetcher);
|
|
}),
|
|
// Delivered to the app's inbox: the participant is the verified emitter; the meeting point may lie
|
|
// in any pod (its creator's — checked by `meetingPointTarget`).
|
|
onReceive: verifiedDelivery(
|
|
'Join',
|
|
async function (ctx, verdict, participantUri) {
|
|
await this.onParticipation(ctx, 'join', verdict, participantUri);
|
|
},
|
|
{ objectType: 'Event', objectIn: 'any' }
|
|
)
|
|
},
|
|
leave: {
|
|
match: neverThrowingMatcher(function (activity, fetcher) {
|
|
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
|
}),
|
|
onReceive: verifiedDelivery(
|
|
'Leave',
|
|
async function (ctx, verdict, participantUri) {
|
|
await this.onParticipation(ctx, 'leave', verdict, participantUri);
|
|
},
|
|
{ objectType: 'Event', objectIn: 'any' }
|
|
)
|
|
}
|
|
}
|
|
};
|