Skip to content

Commit 39f7fd0

Browse files
committed
fix(microsoftteams): recreate Teams subscription when renewal PATCH 404s
If every renewal attempt in a subscription's 48h renewal window failed (revoked consent, prolonged Graph outage), the subscription actually expired on Microsoft's side and the cron kept PATCHing a deleted subscription forever, always 404ing, with the webhook silently dead. Now a 404/410 from the renewal PATCH triggers a POST to recreate the subscription from the stored chatId, closing the same "never comes back" failure mode the original bug had, for the narrower case where renewal itself keeps failing. Also dedupes getCredentialOwner onto the shared provider-subscription-utils helper instead of a local copy, and drops a couple of restated-in-code comments.
1 parent e81631b commit 39f7fd0

1 file changed

Lines changed: 84 additions & 34 deletions

File tree

  • apps/sim/app/api/cron/renew-subscriptions

apps/sim/app/api/cron/renew-subscriptions/route.ts

Lines changed: 84 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { account, webhook as webhookTable } from '@sim/db/schema'
2+
import { webhook as webhookTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateShortId } from '@sim/utils/id'
55
import { and, eq, or } from 'drizzle-orm'
@@ -8,31 +8,67 @@ import { verifyCronAuth } from '@/lib/auth/internal'
88
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
99
import { runDetached } from '@/lib/core/utils/background'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11-
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
11+
import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils'
12+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
1213

1314
const logger = createLogger('TeamsSubscriptionRenewal')
1415

1516
const LOCK_KEY = 'teams-subscription-renewal-lock'
1617
/** Lock TTL in seconds — generous enough to cover the Graph API renewal loop. */
1718
const LOCK_TTL_SECONDS = 300
1819

19-
async function getCredentialOwner(
20-
credentialId: string
21-
): Promise<{ userId: string; accountId: string } | null> {
22-
const resolved = await resolveOAuthAccountId(credentialId)
23-
if (!resolved) {
24-
logger.error(`Failed to resolve OAuth account for credential ${credentialId}`)
20+
/** Microsoft Graph subscriptions are hard-capped at ~3 days. */
21+
const MAX_LIFETIME_MINUTES = 4230
22+
23+
/**
24+
* Recreate a Teams chat subscription from scratch after the existing one has
25+
* actually expired on Microsoft's side (PATCH returns 404/410). Without this,
26+
* a subscription that expires while every renewal attempt in its 48h window
27+
* failed (revoked consent, prolonged Graph outage, etc.) would stay dead
28+
* forever — the webhook remains `isActive` but never receives events again.
29+
*/
30+
async function recreateSubscription(
31+
webhook: Record<string, unknown>,
32+
config: Record<string, any>,
33+
accessToken: string
34+
): Promise<{ id: string; expirationDateTime: string } | null> {
35+
const chatId = config.chatId as string | undefined
36+
if (!chatId) {
37+
logger.error(`Missing chatId for webhook ${webhook.id}, cannot recreate subscription`)
38+
return null
39+
}
40+
41+
const notificationUrl = getNotificationUrl(webhook)
42+
const expirationDateTime = new Date(Date.now() + MAX_LIFETIME_MINUTES * 60 * 1000).toISOString()
43+
44+
const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
45+
method: 'POST',
46+
headers: {
47+
Authorization: `Bearer ${accessToken}`,
48+
'Content-Type': 'application/json',
49+
},
50+
body: JSON.stringify({
51+
changeType: 'created,updated',
52+
notificationUrl,
53+
lifecycleNotificationUrl: notificationUrl,
54+
resource: `/chats/${chatId}/messages`,
55+
includeResourceData: false,
56+
expirationDateTime,
57+
clientState: webhook.id,
58+
}),
59+
})
60+
61+
if (!res.ok) {
62+
const error = await res.json()
63+
logger.error(`Failed to recreate Teams subscription for webhook ${webhook.id}`, {
64+
status: res.status,
65+
error: error.error,
66+
})
2567
return null
2668
}
27-
const [credentialRecord] = await db
28-
.select({ userId: account.userId })
29-
.from(account)
30-
.where(eq(account.id, resolved.accountId))
31-
.limit(1)
32-
33-
return credentialRecord
34-
? { userId: credentialRecord.userId, accountId: resolved.accountId }
35-
: null
69+
70+
const payload = await res.json()
71+
return { id: payload.id as string, expirationDateTime: payload.expirationDateTime as string }
3672
}
3773

3874
/**
@@ -53,7 +89,6 @@ async function renewExpiringSubscriptions(): Promise<{
5389
let totalFailed = 0
5490
let totalChecked = 0
5591

56-
// Get all active Microsoft Teams webhooks
5792
const webhooksWithWorkflows = await db
5893
.select({
5994
webhook: webhookTable,
@@ -73,22 +108,22 @@ async function renewExpiringSubscriptions(): Promise<{
73108
`Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions`
74109
)
75110

76-
// Renewal threshold: 48 hours before expiration
111+
/** Renew any subscription expiring within the next 48 hours. */
77112
const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000)
78113

79114
for (const { webhook } of webhooksWithWorkflows) {
80115
const config = (webhook.providerConfig as Record<string, any>) || {}
81116

82-
// Check if this is a Teams chat subscription that needs renewal
83117
if (config.triggerId !== 'microsoftteams_chat_subscription') continue
84118

85119
const expirationStr = config.subscriptionExpiration as string | undefined
86120
if (!expirationStr) continue
87121

88122
const expiresAt = new Date(expirationStr)
89-
if (expiresAt > renewalThreshold) continue // Not expiring soon
123+
if (expiresAt > renewalThreshold) continue
90124

91125
totalChecked++
126+
const requestId = `renewal-${webhook.id}`
92127

93128
try {
94129
logger.info(
@@ -104,18 +139,17 @@ async function renewExpiringSubscriptions(): Promise<{
104139
continue
105140
}
106141

107-
const credentialOwner = await getCredentialOwner(credentialId)
142+
const credentialOwner = await getCredentialOwner(credentialId, requestId)
108143
if (!credentialOwner) {
109144
logger.error(`Credential owner not found for credential ${credentialId}`)
110145
totalFailed++
111146
continue
112147
}
113148

114-
// Get fresh access token
115149
const accessToken = await refreshAccessTokenIfNeeded(
116150
credentialOwner.accountId,
117151
credentialOwner.userId,
118-
`renewal-${webhook.id}`
152+
requestId
119153
)
120154

121155
if (!accessToken) {
@@ -124,10 +158,8 @@ async function renewExpiringSubscriptions(): Promise<{
124158
continue
125159
}
126160

127-
// Extend subscription to maximum lifetime (4230 minutes = ~3 days)
128-
const maxLifetimeMinutes = 4230
129161
const newExpirationDateTime = new Date(
130-
Date.now() + maxLifetimeMinutes * 60 * 1000
162+
Date.now() + MAX_LIFETIME_MINUTES * 60 * 1000
131163
).toISOString()
132164

133165
const res = await fetch(
@@ -142,22 +174,40 @@ async function renewExpiringSubscriptions(): Promise<{
142174
}
143175
)
144176

177+
let newSubscriptionId: string | undefined
178+
let newExpiration: string | undefined
179+
145180
if (!res.ok) {
146181
const error = await res.json()
147182
logger.error(
148183
`Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`,
149184
{ status: res.status, error: error.error }
150185
)
151-
totalFailed++
152-
continue
153-
}
154186

155-
const payload = await res.json()
187+
if (res.status === 404 || res.status === 410) {
188+
const recreated = await recreateSubscription(webhook, config, accessToken)
189+
if (!recreated) {
190+
totalFailed++
191+
continue
192+
}
193+
newSubscriptionId = recreated.id
194+
newExpiration = recreated.expirationDateTime
195+
logger.info(
196+
`Recreated Teams subscription for webhook ${webhook.id} after the previous one expired (new id: ${newSubscriptionId})`
197+
)
198+
} else {
199+
totalFailed++
200+
continue
201+
}
202+
} else {
203+
const payload = await res.json()
204+
newExpiration = payload.expirationDateTime as string
205+
}
156206

157-
// Update webhook config with new expiration
158207
const updatedConfig = {
159208
...config,
160-
subscriptionExpiration: payload.expirationDateTime,
209+
...(newSubscriptionId ? { externalSubscriptionId: newSubscriptionId } : {}),
210+
subscriptionExpiration: newExpiration,
161211
}
162212

163213
await db
@@ -166,7 +216,7 @@ async function renewExpiringSubscriptions(): Promise<{
166216
.where(eq(webhookTable.id, webhook.id))
167217

168218
logger.info(
169-
`Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}`
219+
`Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${newExpiration}`
170220
)
171221
totalRenewed++
172222
} catch (error) {

0 commit comments

Comments
 (0)