@@ -369,6 +369,92 @@ export const accessReviewRouter = createTRPCRouter({
369369
370370 // ==================== Items ====================
371371
372+ /**
373+ * Get items assigned to the current user for review
374+ */
375+ myReviews : protectedProcedure
376+ . input (
377+ z . object ( {
378+ status : z . enum ( [ 'pending' , 'completed' ] ) . optional ( ) ,
379+ page : z . number ( ) . default ( 1 ) ,
380+ limit : z . number ( ) . default ( 50 ) ,
381+ } )
382+ )
383+ . query ( async ( { ctx, input } ) => {
384+ const { status, page, limit } = input ;
385+ const skip = ( page - 1 ) * limit ;
386+
387+ // Build where clause for items assigned to current user
388+ let decisionFilter = { } ;
389+ if ( status === 'pending' ) {
390+ decisionFilter = { decision : null } ;
391+ } else if ( status === 'completed' ) {
392+ decisionFilter = { decision : { isNot : null } } ;
393+ }
394+
395+ const where = {
396+ assignedReviewerEmail : ctx . user . email ,
397+ campaign : {
398+ status : { in : [ 'in_review' , 'collecting' ] } ,
399+ } ,
400+ ...decisionFilter ,
401+ } ;
402+
403+ const [ items , total ] = await Promise . all ( [
404+ db . accessReviewItem . findMany ( {
405+ where,
406+ include : {
407+ campaign : {
408+ select : {
409+ id : true ,
410+ name : true ,
411+ status : true ,
412+ dueDate : true ,
413+ } ,
414+ } ,
415+ decision : {
416+ include : {
417+ reviewer : {
418+ select : { name : true , email : true } ,
419+ } ,
420+ } ,
421+ } ,
422+ } ,
423+ orderBy : [
424+ { campaign : { dueDate : 'asc' } } ,
425+ { createdAt : 'asc' } ,
426+ ] ,
427+ skip,
428+ take : limit ,
429+ } ) ,
430+ db . accessReviewItem . count ( { where } ) ,
431+ ] ) ;
432+
433+ // Get counts by campaign for summary
434+ const campaignCounts = await db . accessReviewItem . groupBy ( {
435+ by : [ 'campaignId' ] ,
436+ where : {
437+ assignedReviewerEmail : ctx . user . email ,
438+ decision : null ,
439+ campaign : { status : 'in_review' } ,
440+ } ,
441+ _count : true ,
442+ } ) ;
443+
444+ return {
445+ items,
446+ pendingByCampaign : campaignCounts . map ( c => ( {
447+ campaignId : c . campaignId ,
448+ count : c . _count ,
449+ } ) ) ,
450+ pagination : {
451+ total,
452+ page,
453+ totalPages : Math . ceil ( total / limit ) ,
454+ } ,
455+ } ;
456+ } ) ,
457+
372458 listItems : protectedProcedure
373459 . input (
374460 z . object ( {
@@ -673,25 +759,58 @@ export const accessReviewRouter = createTRPCRouter({
673759 return { schedules } ;
674760 } ) ,
675761
762+ /**
763+ * Get a single schedule by ID
764+ */
765+ getSchedule : protectedProcedure
766+ . input ( z . object ( { id : z . string ( ) } ) )
767+ . query ( async ( { input } ) => {
768+ const schedule = await db . scheduledReview . findUnique ( {
769+ where : { id : input . id } ,
770+ include : {
771+ createdBy : {
772+ select : { id : true , name : true , email : true } ,
773+ } ,
774+ } ,
775+ } ) ;
776+
777+ if ( ! schedule ) {
778+ throw new TRPCError ( { code : 'NOT_FOUND' , message : 'Schedule not found' } ) ;
779+ }
780+
781+ return { schedule } ;
782+ } ) ,
783+
676784 createSchedule : protectedProcedure
677785 . input (
678786 z . object ( {
679787 name : z . string ( ) . min ( 1 ) ,
680788 description : z . string ( ) . optional ( ) ,
681789 scope : scopeSchema ,
682790 frequency : z . enum ( [ 'weekly' , 'monthly' , 'quarterly' , 'yearly' ] ) ,
683- dayOfWeek : z . number ( ) . optional ( ) ,
684- dayOfMonth : z . number ( ) . optional ( ) ,
791+ dayOfWeek : z . number ( ) . min ( 0 ) . max ( 6 ) . optional ( ) , // 0=Sunday, 6=Saturday
792+ dayOfMonth : z . number ( ) . min ( 1 ) . max ( 31 ) . optional ( ) ,
793+ monthOfYear : z . number ( ) . min ( 1 ) . max ( 12 ) . optional ( ) , // 1=Jan, 12=Dec (for yearly)
685794 time : z . string ( ) . default ( '09:00' ) ,
795+ timezone : z . string ( ) . default ( 'UTC' ) ,
686796 reviewPeriodDays : z . number ( ) . default ( 14 ) ,
797+ reminderDays : z . array ( z . number ( ) ) . default ( [ 7 , 3 , 1 ] ) ,
687798 autoExecute : z . boolean ( ) . default ( false ) ,
799+ notifyAdmins : z . boolean ( ) . default ( true ) ,
688800 sendReportToOwners : z . boolean ( ) . default ( true ) ,
689801 adminEmails : z . array ( z . string ( ) ) . default ( [ ] ) ,
690802 } )
691803 )
692804 . mutation ( async ( { ctx, input } ) => {
693805 // Calculate next run date
694- const nextRunAt = calculateNextRun ( input . frequency , input . dayOfWeek , input . dayOfMonth , input . time ) ;
806+ const nextRunAt = calculateNextRun (
807+ input . frequency ,
808+ input . dayOfWeek ,
809+ input . dayOfMonth ,
810+ input . time ,
811+ input . monthOfYear ,
812+ input . timezone
813+ ) ;
695814
696815 const schedule = await db . scheduledReview . create ( {
697816 data : {
@@ -701,9 +820,13 @@ export const accessReviewRouter = createTRPCRouter({
701820 frequency : input . frequency ,
702821 dayOfWeek : input . dayOfWeek ?? null ,
703822 dayOfMonth : input . dayOfMonth ?? null ,
823+ monthOfYear : input . monthOfYear ?? null ,
704824 time : input . time ,
825+ timezone : input . timezone ,
705826 reviewPeriodDays : input . reviewPeriodDays ,
827+ reminderDays : input . reminderDays ,
706828 autoExecute : input . autoExecute ,
829+ notifyAdmins : input . notifyAdmins ,
707830 sendReportToOwners : input . sendReportToOwners ,
708831 adminEmails : input . adminEmails ,
709832 nextRunAt,
@@ -723,11 +846,15 @@ export const accessReviewRouter = createTRPCRouter({
723846 enabled : z . boolean ( ) . optional ( ) ,
724847 scope : scopeSchema . optional ( ) ,
725848 frequency : z . enum ( [ 'weekly' , 'monthly' , 'quarterly' , 'yearly' ] ) . optional ( ) ,
726- dayOfWeek : z . number ( ) . optional ( ) ,
727- dayOfMonth : z . number ( ) . optional ( ) ,
849+ dayOfWeek : z . number ( ) . min ( 0 ) . max ( 6 ) . optional ( ) ,
850+ dayOfMonth : z . number ( ) . min ( 1 ) . max ( 31 ) . optional ( ) ,
851+ monthOfYear : z . number ( ) . min ( 1 ) . max ( 12 ) . optional ( ) ,
728852 time : z . string ( ) . optional ( ) ,
853+ timezone : z . string ( ) . optional ( ) ,
729854 reviewPeriodDays : z . number ( ) . optional ( ) ,
855+ reminderDays : z . array ( z . number ( ) ) . optional ( ) ,
730856 autoExecute : z . boolean ( ) . optional ( ) ,
857+ notifyAdmins : z . boolean ( ) . optional ( ) ,
731858 sendReportToOwners : z . boolean ( ) . optional ( ) ,
732859 adminEmails : z . array ( z . string ( ) ) . optional ( ) ,
733860 } )
@@ -737,24 +864,40 @@ export const accessReviewRouter = createTRPCRouter({
737864
738865 // Calculate new next run if schedule parameters changed
739866 let nextRunAt : Date | undefined ;
740- if ( data . frequency || data . dayOfWeek !== undefined || data . dayOfMonth !== undefined || data . time ) {
867+ if ( data . frequency || data . dayOfWeek !== undefined || data . dayOfMonth !== undefined || data . monthOfYear !== undefined || data . time || data . timezone ) {
741868 const current = await db . scheduledReview . findUnique ( { where : { id } } ) ;
742869 if ( current ) {
743870 nextRunAt = calculateNextRun (
744871 data . frequency || current . frequency ,
745872 data . dayOfWeek ?? current . dayOfWeek ?? undefined ,
746873 data . dayOfMonth ?? current . dayOfMonth ?? undefined ,
747- data . time || current . time
874+ data . time || current . time ,
875+ data . monthOfYear ?? current . monthOfYear ?? undefined ,
876+ data . timezone || current . timezone
748877 ) ;
749878 }
750879 }
751880
752881 const schedule = await db . scheduledReview . update ( {
753882 where : { id } ,
754883 data : {
755- ...data ,
756- scope : data . scope ? ( data . scope as Prisma . InputJsonValue ) : undefined ,
757- ...( nextRunAt ? { nextRunAt } : { } ) ,
884+ ...( data . name !== undefined && { name : data . name } ) ,
885+ ...( data . description !== undefined && { description : data . description } ) ,
886+ ...( data . enabled !== undefined && { enabled : data . enabled } ) ,
887+ ...( data . scope && { scope : data . scope as Prisma . InputJsonValue } ) ,
888+ ...( data . frequency && { frequency : data . frequency } ) ,
889+ ...( data . dayOfWeek !== undefined && { dayOfWeek : data . dayOfWeek } ) ,
890+ ...( data . dayOfMonth !== undefined && { dayOfMonth : data . dayOfMonth } ) ,
891+ ...( data . monthOfYear !== undefined && { monthOfYear : data . monthOfYear } ) ,
892+ ...( data . time && { time : data . time } ) ,
893+ ...( data . timezone && { timezone : data . timezone } ) ,
894+ ...( data . reviewPeriodDays !== undefined && { reviewPeriodDays : data . reviewPeriodDays } ) ,
895+ ...( data . reminderDays && { reminderDays : data . reminderDays } ) ,
896+ ...( data . autoExecute !== undefined && { autoExecute : data . autoExecute } ) ,
897+ ...( data . notifyAdmins !== undefined && { notifyAdmins : data . notifyAdmins } ) ,
898+ ...( data . sendReportToOwners !== undefined && { sendReportToOwners : data . sendReportToOwners } ) ,
899+ ...( data . adminEmails && { adminEmails : data . adminEmails } ) ,
900+ ...( nextRunAt && { nextRunAt } ) ,
758901 } ,
759902 } ) ;
760903
@@ -1428,54 +1571,85 @@ export const accessReviewRouter = createTRPCRouter({
14281571 } ) ,
14291572} ) ;
14301573
1431- // Helper function to calculate next run date
1574+ // Helper function to calculate next run date with timezone support
14321575function calculateNextRun (
14331576 frequency : string ,
14341577 dayOfWeek ?: number ,
14351578 dayOfMonth ?: number ,
1436- time : string = '09:00'
1579+ time : string = '09:00' ,
1580+ monthOfYear ?: number ,
1581+ _timezone : string = 'UTC'
14371582) : Date {
14381583 const now = new Date ( ) ;
14391584 const [ hours , minutes ] = time . split ( ':' ) . map ( Number ) ;
1440- const next = new Date ( now ) ;
1585+ let next = new Date ( now ) ;
14411586
14421587 next . setHours ( hours , minutes , 0 , 0 ) ;
14431588
1589+ // Start from tomorrow to avoid running twice on the same day
1590+ if ( next <= now ) {
1591+ next . setDate ( next . getDate ( ) + 1 ) ;
1592+ }
1593+
14441594 switch ( frequency ) {
1445- case 'weekly' :
1595+ case 'weekly' : {
14461596 const targetDay = dayOfWeek ?? 1 ; // Default to Monday
14471597 const currentDay = next . getDay ( ) ;
14481598 let daysUntil = targetDay - currentDay ;
1449- if ( daysUntil <= 0 || ( daysUntil === 0 && next <= now ) ) {
1599+ if ( daysUntil <= 0 ) {
14501600 daysUntil += 7 ;
14511601 }
14521602 next . setDate ( next . getDate ( ) + daysUntil ) ;
14531603 break ;
1604+ }
14541605
1455- case 'monthly' :
1606+ case 'monthly' : {
14561607 const targetDate = dayOfMonth ?? 1 ;
14571608 next . setDate ( targetDate ) ;
14581609 if ( next <= now ) {
14591610 next . setMonth ( next . getMonth ( ) + 1 ) ;
14601611 }
1612+ // Handle months with fewer days
1613+ while ( next . getDate ( ) !== targetDate ) {
1614+ next . setDate ( 0 ) ; // Go to last day of previous month
1615+ next . setMonth ( next . getMonth ( ) + 1 ) ;
1616+ next . setDate ( Math . min ( targetDate , new Date ( next . getFullYear ( ) , next . getMonth ( ) + 1 , 0 ) . getDate ( ) ) ) ;
1617+ }
14611618 break ;
1462-
1463- case 'quarterly' :
1464- const quarterMonth = Math . floor ( now . getMonth ( ) / 3 ) * 3 + 3 ;
1465- next . setMonth ( quarterMonth ) ;
1619+ }
1620+
1621+ case 'quarterly' : {
1622+ const quarterMonths = [ 0 , 3 , 6 , 9 ] ; // Jan, Apr, Jul, Oct
1623+ const currentMonth = now . getMonth ( ) ;
1624+ let nextQuarterMonth = quarterMonths . find ( m => m > currentMonth ) ;
1625+ if ( nextQuarterMonth === undefined ) {
1626+ nextQuarterMonth = quarterMonths [ 0 ] ;
1627+ next . setFullYear ( next . getFullYear ( ) + 1 ) ;
1628+ }
1629+ next . setMonth ( nextQuarterMonth ) ;
14661630 next . setDate ( dayOfMonth ?? 1 ) ;
14671631 if ( next <= now ) {
1468- next . setMonth ( next . getMonth ( ) + 3 ) ;
1632+ // Move to next quarter
1633+ const idx = quarterMonths . indexOf ( nextQuarterMonth ) ;
1634+ if ( idx < quarterMonths . length - 1 ) {
1635+ next . setMonth ( quarterMonths [ idx + 1 ] ) ;
1636+ } else {
1637+ next . setFullYear ( next . getFullYear ( ) + 1 ) ;
1638+ next . setMonth ( quarterMonths [ 0 ] ) ;
1639+ }
14691640 }
14701641 break ;
1642+ }
14711643
1472- case 'yearly' :
1473- next . setMonth ( 0 ) ;
1644+ case 'yearly' : {
1645+ const targetMonth = ( monthOfYear ?? 1 ) - 1 ; // Convert 1-12 to 0-11
1646+ next . setMonth ( targetMonth ) ;
14741647 next . setDate ( dayOfMonth ?? 1 ) ;
14751648 if ( next <= now ) {
14761649 next . setFullYear ( next . getFullYear ( ) + 1 ) ;
14771650 }
14781651 break ;
1652+ }
14791653 }
14801654
14811655 return next ;
0 commit comments