Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/appmixer/microsoft/calendar/EventCreated/EventCreated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'use strict';

const BASE_URL = 'https://graph.microsoft.com/v1.0';
const CLIENT_STATE = 'appmixer.microsoft.calendar';
const RENEW_BEFORE_MS = 300000;

const getSubscriptionExpirationDateTime = () => {
// Max expiration for calendar subscriptions is 4230 minutes (< 3 days).
// Renew every 2 days to stay safe.
return new Date(Date.now() + 3000 * 60 * 1000);
};

module.exports = {

async start(context) {

const expirationDateTime = getSubscriptionExpirationDateTime();
const { data } = await context.httpRequest({
url: `${BASE_URL}/subscriptions`,
method: 'POST',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`,
'Content-Type': 'application/json',
accept: 'application/json'
},
data: {
changeType: 'created',
notificationUrl: context.getWebhookUrl(),
resource: '/me/events',
expirationDateTime: expirationDateTime.toISOString(),
clientState: CLIENT_STATE
}
});

await context.saveState({ subscriptionId: data.id });
return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS);
},

async stop(context) {

const { subscriptionId } = context.state;
if (subscriptionId) {
await context.httpRequest({
url: `${BASE_URL}/subscriptions/${subscriptionId}`,
method: 'DELETE',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`
}
});
}
},

async receive(context) {

if (context.messages.timeout) {

const { subscriptionId } = context.state;
const expirationDateTime = getSubscriptionExpirationDateTime();

await context.httpRequest({
url: `${BASE_URL}/subscriptions/${subscriptionId}`,
method: 'PATCH',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`,
'Content-Type': 'application/json',
accept: 'application/json'
},
data: { expirationDateTime: expirationDateTime.toISOString() }
});

return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS);

} else if (context.messages.webhook) {

const { data, query } = context.messages.webhook.content;

if (query.validationToken) {
return context.response(query.validationToken, 200, { 'Content-type': 'text/plain' });
}

const value = data.value || [];
for (const notification of value) {
if (notification.clientState === CLIENT_STATE) {
try {
const { data: event } = await context.httpRequest({
url: `https://graph.microsoft.com/v1.0/me/events/${notification.resourceData.id}`,
method: 'GET',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`,
accept: 'application/json'
}
});
await context.sendJson(event, 'out');
} catch (err) {
// Event may no longer exist (e.g. deleted). Log and continue.
const resId = notification.resourceData?.id;
await context.log({ error: err.message, resId });
}
}
}

return context.response('', 200);
}
}
};
131 changes: 131 additions & 0 deletions src/appmixer/microsoft/calendar/EventCreated/component.json

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions src/appmixer/microsoft/calendar/EventDeleted/EventDeleted.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use strict';

const BASE_URL = 'https://graph.microsoft.com/v1.0';
const CLIENT_STATE = 'appmixer.microsoft.calendar';
const RENEW_BEFORE_MS = 300000;

const getSubscriptionExpirationDateTime = () => {
// Max expiration for calendar subscriptions is 4230 minutes (< 3 days).
// Renew every 2 days to stay safe.
return new Date(Date.now() + 3000 * 60 * 1000);
};

module.exports = {

async start(context) {

const expirationDateTime = getSubscriptionExpirationDateTime();
const { data } = await context.httpRequest({
url: `${BASE_URL}/subscriptions`,
method: 'POST',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`,
'Content-Type': 'application/json',
accept: 'application/json'
},
data: {
changeType: 'deleted',
notificationUrl: context.getWebhookUrl(),
resource: '/me/events',
expirationDateTime: expirationDateTime.toISOString(),
clientState: CLIENT_STATE
}
});

await context.saveState({ subscriptionId: data.id });
return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS);
},

async stop(context) {

const { subscriptionId } = context.state;
if (subscriptionId) {
await context.httpRequest({
url: `${BASE_URL}/subscriptions/${subscriptionId}`,
method: 'DELETE',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`
}
});
}
},

async receive(context) {

if (context.messages.timeout) {

const { subscriptionId } = context.state;
const expirationDateTime = getSubscriptionExpirationDateTime();

await context.httpRequest({
url: `${BASE_URL}/subscriptions/${subscriptionId}`,
method: 'PATCH',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`,
'Content-Type': 'application/json',
accept: 'application/json'
},
data: { expirationDateTime: expirationDateTime.toISOString() }
});

return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS);

} else if (context.messages.webhook) {

const { data, query } = context.messages.webhook.content;

if (query.validationToken) {
return context.response(query.validationToken, 200, { 'Content-type': 'text/plain' });
}

const value = data.value || [];
for (const notification of value) {
if (notification.clientState === CLIENT_STATE) {
try {
await context.sendJson({
id: notification.resourceData.id,
changeType: notification.changeType,
resource: notification.resource,
subscriptionId: notification.subscriptionId,
clientState: notification.clientState,
tenantId: notification.tenantId
}, 'out');
} catch (err) {
// Event may no longer exist (e.g. deleted). Log and continue.
const resId = notification.resourceData?.id;
await context.log({ error: err.message, resId });
}
}
}

return context.response('', 200);
}
}
};
55 changes: 55 additions & 0 deletions src/appmixer/microsoft/calendar/EventDeleted/component.json

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/appmixer/microsoft/calendar/EventStart/EventStart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use strict';

const BASE_URL = 'https://graph.microsoft.com/v1.0';

module.exports = {

async tick(context) {

const minutesBefore = context.properties.minutesBefore || 0;

// Look ahead window: check events starting within the next 15 minutes (+ minutesBefore offset).
const now = new Date();
const startDateTime = new Date(now.getTime() - minutesBefore * 60 * 1000);
const endDateTime = new Date(now.getTime() + 15 * 60 * 1000);
Comment on lines +11 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The fix is to extend endDateTime by minutesBefore so the query window always covers events that are eligible to fire in this tick.

Suggested change
// Look ahead window: check events starting within the next 15 minutes (+ minutesBefore offset).
const now = new Date();
const startDateTime = new Date(now.getTime() - minutesBefore * 60 * 1000);
const endDateTime = new Date(now.getTime() + 15 * 60 * 1000);
const endDateTime = new Date(now.getTime() + (minutesBefore + 15) * 60 * 1000);

Comment on lines +9 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — add validation and normalization for minutesBefore right after it's read. Suggested change to lines 9–10 (replace the minutesBefore assignment):

Suggested change
const minutesBefore = context.properties.minutesBefore || 0;
// Look ahead window: check events starting within the next 15 minutes (+ minutesBefore offset).
const now = new Date();
const startDateTime = new Date(now.getTime() - minutesBefore * 60 * 1000);
const endDateTime = new Date(now.getTime() + 15 * 60 * 1000);
const minutesBeforeRaw = context.properties.minutesBefore;
const minutesBefore = Math.max(0, Math.round(Number.isFinite(Number(minutesBeforeRaw)) ? Number(minutesBeforeRaw) : 0));

This clamps negative values to 0, rounds to an integer, and handles non-numeric inputs gracefully.


const { data } = await context.httpRequest({
url: `${BASE_URL}/me/calendarView`,
method: 'GET',
headers: {
Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`,
accept: 'application/json',
Prefer: 'outlook.timezone="UTC"'
},
params: {
startDateTime: startDateTime.toISOString(),
endDateTime: endDateTime.toISOString()
}
});

const events = data.value || [];
const firedEvents = context.state.firedEvents || {};

// Clean up old entries (older than 24h) to prevent state bloat.
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const key of Object.keys(firedEvents)) {
if (firedEvents[key] < cutoff) {
delete firedEvents[key];
}
}

for (const event of events) {
if (!firedEvents[event.id]) {
const eventStart = new Date(event.start.dateTime + 'Z');
const triggerTime = new Date(eventStart.getTime() - minutesBefore * 60 * 1000);

if (now >= triggerTime) {
firedEvents[event.id] = Date.now();
await context.sendJson(event, 'out');
}
}
}

await context.saveState({ firedEvents });
}
};
Loading
Loading