Skip to content

Commit 071895a

Browse files
committed
Added missing access review apis
1 parent 5c70e92 commit 071895a

1 file changed

Lines changed: 197 additions & 23 deletions

File tree

src/trpc/routers/accessReview.ts

Lines changed: 197 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
14321575
function 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

Comments
 (0)