@@ -11,12 +11,15 @@ import {
1111 or ,
1212 schema ,
1313} from "@openstatus/db" ;
14- import { session , user } from "@openstatus/db/src/schema" ;
14+ import { user } from "@openstatus/db/src/schema" ;
1515import {
1616 monitorDeactivationEmail ,
1717 monitorPausedEmail ,
1818} from "@openstatus/emails" ;
19- import { sendBatchEmailHtml } from "@openstatus/emails/src/send" ;
19+ import {
20+ type EmailHtml ,
21+ sendBatchEmailHtml ,
22+ } from "@openstatus/emails/src/send" ;
2023import { Redis } from "@openstatus/upstash" ;
2124import { RateLimiter } from "limiter" ;
2225import { z } from "zod" ;
@@ -122,9 +125,15 @@ export async function LaunchMonitorWorkflow() {
122125 or ( isNull ( schema . workspace . plan ) , eq ( schema . workspace . plan , "free" ) ) ,
123126 ) ,
124127 ) ;
125- // Let's merge both results
126- const users = [ ...u , ...u1 ] ;
127- // iterate over users
128+ const usersMap = new Map < number , ( typeof u ) [ number ] > ( ) ;
129+ for ( const entry of [ ...u , ...u1 ] ) {
130+ usersMap . set ( entry . userId , entry ) ;
131+ }
132+ const users = Array . from ( usersMap . values ( ) ) ;
133+ const duplicatesRemoved = u . length + u1 . length - users . length ;
134+ if ( duplicatesRemoved > 0 ) {
135+ console . log ( `Removed ${ duplicatesRemoved } duplicate users` ) ;
136+ }
128137
129138 const allResult = [ ] ;
130139
@@ -154,8 +163,7 @@ async function workflowInit({
154163 } ;
155164} ) {
156165 console . log ( `Starting workflow for ${ user . userId } ` ) ;
157- // Let's check if the user is in the workflow
158- const isMember = await redis . sismember ( "workflow:users" , user . userId ) ;
166+ const isMember = await redis . exists ( `workflow:user:${ user . userId } ` ) ;
159167 if ( isMember ) {
160168 console . log ( `user workflow already started for ${ user . userId } ` ) ;
161169 return ;
@@ -173,30 +181,39 @@ async function workflowInit({
173181 console . log ( `user has no running monitors for ${ user . userId } ` ) ;
174182 return ;
175183 }
184+ const initialRun = new Date ( ) . getTime ( ) ;
176185 await CreateTask ( {
177186 parent,
178187 client : client ,
179188 step : "14days" ,
180189 userId : user . userId ,
181- initialRun : new Date ( ) . getTime ( ) ,
190+ initialRun,
191+ } ) ;
192+ await redis . set ( `workflow:user:${ user . userId } ` , initialRun , {
193+ ex : 30 * 86400 ,
182194 } ) ;
183- // // Add our user to the list of users that have started the workflow
184-
185- await redis . sadd ( "workflow:users" , user . userId ) ;
186195 console . log ( `user workflow started for ${ user . userId } ` ) ;
187196}
188197
189198export async function Step14Days ( userId : number , workFlowRunTimestamp : number ) {
190- const user = await getUser ( userId ) ;
199+ const hasConnected = await hasUserLoggedIn ( {
200+ userId,
201+ date : new Date ( workFlowRunTimestamp ) ,
202+ } ) ;
191203
192- // Send email saying we are going to pause the monitors
193- // The task has just been created we don't double check if the user has logged in :scary:
194- // send First email
195- // TODO: Send email
204+ if ( hasConnected ) {
205+ await redis . del ( `workflow:user:${ userId } ` ) ;
206+ return ;
207+ }
208+
209+ const user = await getUser ( userId ) ;
196210
197211 if ( user . email ) {
198- await sendBatchEmailHtml ( [
199- {
212+ await sendWorkflowEmail ( {
213+ userId,
214+ step : "14days" ,
215+ initialRun : workFlowRunTimestamp ,
216+ email : {
200217 to : user . email ,
201218 subject : "Your OpenStatus monitors will be paused in 14 days" ,
202219 from : "Thibault From OpenStatus <thibault@notifications.openstatus.dev>" ,
@@ -207,36 +224,37 @@ export async function Step14Days(userId: number, workFlowRunTimestamp: number) {
207224 ) . toDateString ( ) ,
208225 } ) ,
209226 } ,
210- ] ) ;
211-
212- await CreateTask ( {
213- parent,
214- client : client ,
215- step : "3days" ,
216- userId : user . id ,
217- initialRun : workFlowRunTimestamp ,
218227 } ) ;
219228 }
229+
230+ await CreateTask ( {
231+ parent,
232+ client : client ,
233+ step : "3days" ,
234+ userId : user . id ,
235+ initialRun : workFlowRunTimestamp ,
236+ } ) ;
220237}
221238
222239export async function Step3Days ( userId : number , workFlowRunTimestamp : number ) {
223- // check if user has connected
224240 const hasConnected = await hasUserLoggedIn ( {
225241 userId,
226242 date : new Date ( workFlowRunTimestamp ) ,
227243 } ) ;
228244
229245 if ( hasConnected ) {
230- //
231- await redis . srem ( "workflow:users" , userId ) ;
246+ await redis . del ( `workflow:user:${ userId } ` ) ;
232247 return ;
233248 }
234249
235250 const user = await getUser ( userId ) ;
236251
237252 if ( user . email ) {
238- await sendBatchEmailHtml ( [
239- {
253+ await sendWorkflowEmail ( {
254+ userId,
255+ step : "3days" ,
256+ initialRun : workFlowRunTimestamp ,
257+ email : {
240258 to : user . email ,
241259 subject : "Your OpenStatus monitors will be paused in 3 days" ,
242260 from : "Thibault From OpenStatus <thibault@notifications.openstatus.dev>" ,
@@ -247,12 +265,9 @@ export async function Step3Days(userId: number, workFlowRunTimestamp: number) {
247265 ) . toDateString ( ) ,
248266 } ) ,
249267 } ,
250- ] ) ;
268+ } ) ;
251269 }
252270
253- // Send second email
254- //TODO: Send email
255- // Let's schedule the next task
256271 await CreateTask ( {
257272 client,
258273 parent,
@@ -263,64 +278,48 @@ export async function Step3Days(userId: number, workFlowRunTimestamp: number) {
263278}
264279
265280export async function StepPaused ( userId : number , workFlowRunTimestamp : number ) {
266- const hasConnected = await hasUserLoggedIn ( {
267- userId,
268- date : new Date ( workFlowRunTimestamp ) ,
269- } ) ;
270- if ( ! hasConnected ) {
271- // sendSecond pause email
272- const users = await db
273- . select ( {
274- userId : schema . user . id ,
275- email : schema . user . email ,
276- workspaceId : schema . workspace . id ,
277- } )
278- . from ( user )
279- . innerJoin ( session , eq ( schema . user . id , schema . session . userId ) )
280- . innerJoin (
281- schema . usersToWorkspaces ,
282- eq ( schema . user . id , schema . usersToWorkspaces . userId ) ,
283- )
284- . innerJoin (
285- schema . workspace ,
286- eq ( schema . usersToWorkspaces . workspaceId , schema . workspace . id ) ,
287- )
288- . where (
289- and (
290- or ( isNull ( schema . workspace . plan ) , eq ( schema . workspace . plan , "free" ) ) ,
291- eq ( schema . user . id , userId ) ,
292- ) ,
293- )
294- . get ( ) ;
295- // We should only have one user :)
296- if ( ! users ) {
297- console . error ( `No user found for ${ userId } ` ) ;
281+ try {
282+ const hasConnected = await hasUserLoggedIn ( {
283+ userId,
284+ date : new Date ( workFlowRunTimestamp ) ,
285+ } ) ;
286+
287+ if ( hasConnected ) {
298288 return ;
299289 }
300290
301- await db
302- . update ( schema . monitor )
303- . set ( { active : false } )
304- . where ( eq ( schema . monitor . workspaceId , users . workspaceId ) ) ;
305- // Send last email with pause monitor
306- }
307-
308- const currentUser = await getUser ( userId ) ;
309- // TODO: Send email
310- // Remove user for workflow
291+ const userWorkspace = await getUserWorkspace ( userId ) ;
292+ if ( userWorkspace ) {
293+ await db
294+ . update ( schema . monitor )
295+ . set ( { active : false } )
296+ . where (
297+ and (
298+ eq ( schema . monitor . workspaceId , userWorkspace . workspaceId ) ,
299+ eq ( schema . monitor . active , true ) ,
300+ isNull ( schema . monitor . deletedAt ) ,
301+ ) ,
302+ ) ;
303+ }
311304
312- if ( currentUser . email ) {
313- await sendBatchEmailHtml ( [
314- {
315- to : currentUser . email ,
316- subject : "Your monitors have been paused" ,
317- from : "Thibault From OpenStatus <thibault@notifications.openstatus.dev>" ,
318- reply_to : "thibault@openstatus.dev" ,
319- html : monitorPausedEmail ( ) ,
320- } ,
321- ] ) ;
305+ const currentUser = await getUser ( userId ) ;
306+ if ( currentUser . email ) {
307+ await sendWorkflowEmail ( {
308+ userId,
309+ step : "paused" ,
310+ initialRun : workFlowRunTimestamp ,
311+ email : {
312+ to : currentUser . email ,
313+ subject : "Your monitors have been paused" ,
314+ from : "Thibault From OpenStatus <thibault@notifications.openstatus.dev>" ,
315+ reply_to : "thibault@openstatus.dev" ,
316+ html : monitorPausedEmail ( ) ,
317+ } ,
318+ } ) ;
319+ }
320+ } finally {
321+ await redis . del ( `workflow:user:${ userId } ` ) ;
322322 }
323- await redis . srem ( "workflow:users" , userId ) ;
324323}
325324
326325async function hasUserLoggedIn ( {
@@ -346,7 +345,7 @@ async function hasUserLoggedIn({
346345 return user . lastSession > date ;
347346}
348347
349- function CreateTask ( {
348+ async function CreateTask ( {
350349 parent,
351350 client,
352351 step,
@@ -361,10 +360,12 @@ function CreateTask({
361360} ) {
362361 const url = `https://openstatus-workflows.fly.dev/cron/monitors/${ step } ?userId=${ userId } &initialRun=${ initialRun } ` ;
363362 const timestamp = getScheduledTime ( step ) ;
363+ const taskName = `${ parent } /tasks/workflow-${ userId } -${ step } -${ initialRun } ` ;
364364 const newTask : google . cloud . tasks . v2beta3 . ITask = {
365+ name : taskName ,
365366 httpRequest : {
366367 headers : {
367- "Content-Type" : "application/json" , // Set content type to ensure compatibility your application's request parsing
368+ "Content-Type" : "application/json" ,
368369 Authorization : `${ env ( ) . CRON_SECRET } ` ,
369370 } ,
370371 httpMethod : "GET" ,
@@ -376,7 +377,17 @@ function CreateTask({
376377 } ;
377378
378379 const request = { parent : parent , task : newTask } ;
379- return client . createTask ( request ) ;
380+ try {
381+ return await client . createTask ( request ) ;
382+ } catch ( e ) {
383+ if ( e instanceof Error && "code" in e && e . code === 6 ) {
384+ console . log (
385+ `Task already exists for user ${ userId } step ${ step } , skipping` ,
386+ ) ;
387+ return ;
388+ }
389+ throw e ;
390+ }
380391}
381392
382393function getScheduledTime ( step : z . infer < typeof workflowStepSchema > ) {
@@ -408,8 +419,47 @@ async function getUser(userId: number) {
408419 if ( ! currentUser ) {
409420 throw new Error ( "User not found" ) ;
410421 }
411- if ( ! currentUser . email ) {
412- throw new Error ( "User email not found" ) ;
413- }
414422 return currentUser ;
415423}
424+
425+ async function sendWorkflowEmail ( {
426+ userId,
427+ step,
428+ initialRun,
429+ email,
430+ } : {
431+ userId : number ;
432+ step : string ;
433+ initialRun : number ;
434+ email : EmailHtml ;
435+ } ) {
436+ const key = `workflow:email:${ userId } :${ step } :${ initialRun } ` ;
437+ const alreadySent = await redis . exists ( key ) ;
438+ if ( alreadySent ) {
439+ console . log ( `Email already sent for user ${ userId } step ${ step } , skipping` ) ;
440+ return ;
441+ }
442+ await sendBatchEmailHtml ( [ email ] ) ;
443+ await redis . set ( key , 1 , { ex : 30 * 86400 } ) ;
444+ }
445+
446+ async function getUserWorkspace ( userId : number ) {
447+ return db
448+ . select ( { workspaceId : schema . workspace . id } )
449+ . from ( schema . user )
450+ . innerJoin (
451+ schema . usersToWorkspaces ,
452+ eq ( schema . user . id , schema . usersToWorkspaces . userId ) ,
453+ )
454+ . innerJoin (
455+ schema . workspace ,
456+ eq ( schema . usersToWorkspaces . workspaceId , schema . workspace . id ) ,
457+ )
458+ . where (
459+ and (
460+ eq ( schema . user . id , userId ) ,
461+ or ( isNull ( schema . workspace . plan ) , eq ( schema . workspace . plan , "free" ) ) ,
462+ ) ,
463+ )
464+ . get ( ) ;
465+ }
0 commit comments