11import { db } from '@sim/db'
2- import { account , webhook as webhookTable } from '@sim/db/schema'
2+ import { webhook as webhookTable } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { generateShortId } from '@sim/utils/id'
55import { and , eq , or } from 'drizzle-orm'
@@ -8,31 +8,67 @@ import { verifyCronAuth } from '@/lib/auth/internal'
88import { acquireLock , releaseLock } from '@/lib/core/config/redis'
99import { runDetached } from '@/lib/core/utils/background'
1010import { 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
1314const logger = createLogger ( 'TeamsSubscriptionRenewal' )
1415
1516const LOCK_KEY = 'teams-subscription-renewal-lock'
1617/** Lock TTL in seconds — generous enough to cover the Graph API renewal loop. */
1718const 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