9e2df2478b
A participation activity is now addressed to the Festipod app actor alone and handled on reception in the app's inbox, the way the ActivityPods developer advised: use the ActivityPub protocol rather than a side channel. Any compatible sender may deliver; the inbox accepts only the activity's own signer, and the backend still requires the activity to lie in that actor's pod and acts on the stored copy. The meeting point is read again in its creator's pod before anything is written: read through the participant's pod, a foreign server's proxy chose the attendee list and the notification's name. Confidentiality is unchanged: the Join stays readable by its author and the app only. Recorded in decision_2026-11-03_participation-delivered-to-the-app, a named exception to festipod-only-reference-app-patterns.
424 lines
26 KiB
JavaScript
424 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: the stored object must be a meeting point — an `as:Event` (checked by
|
|
* `verifiedDelivery`, and by `meetingPointOf` on the creator's copy) anchored to an event by
|
|
* `as:context` — whose creator is its `dc:creator`, required to own the pod the meeting point lies
|
|
* in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod
|
|
* stamps the creator, and nobody can store a resource in someone else's pod); 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`);
|
|
if (!eventUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single as:context (its event)` };
|
|
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 meeting point (`pod-notifications.send`, rendered in
|
|
* the creator's language): always ANONYMOUS, the meeting point named through `meetingPointName`, the
|
|
* link to its event's meeting points (`eventUri`). 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) {
|
|
const title =
|
|
change === 'join'
|
|
? {
|
|
en: 'A participant joined your meeting point "{{{meetingPointName}}}"',
|
|
fr: 'Un participant a rejoint votre point de rencontre « {{{meetingPointName}}} »'
|
|
}
|
|
: {
|
|
en: 'A participant left your meeting point "{{{meetingPointName}}}"',
|
|
fr: 'Un participant a quitté votre point de rencontre « {{{meetingPointName}}} »'
|
|
};
|
|
return {
|
|
title,
|
|
actions: [{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }]
|
|
};
|
|
}
|
|
|
|
// 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.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
|
|
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 meeting point 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 is not notified. A failure is logged, never thrown: the act stands.
|
|
*/
|
|
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
|
|
try {
|
|
if (creatorUri === participantUri) {
|
|
this.logger.info(`${participantUri} acted on their own meeting point ${meetingPointUri}: no notification`);
|
|
return;
|
|
}
|
|
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
|
|
await ctx.call('pod-notifications.send', {
|
|
template: notificationTemplate(change),
|
|
recipientUri: creatorUri,
|
|
context: meetingPointUri,
|
|
meetingPointName: 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' }
|
|
)
|
|
}
|
|
}
|
|
};
|