Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 34 additions & 35 deletions app/(main)/(auth)/applications/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import type { Metadata } from 'next';

import { z } from 'zod/v4';

import {
getApplications,
getApplicationsTotal,
getReviewablePositions,
} from '@/prisma/data/applications';

import { requireManagerOrAdminOr404 } from '@/lib/auth/guards';
import { REVIEWER_APPLICATION_STATUSES } from '@/lib/constants';
import type {
ApplicationFilters,
ApplicationSort,
ApplicationSortDirection,
ApplicationSortField,
ReviewerStatus,
} from '@/lib/types';
import {
APPLICATION_SORT_DIRECTIONS,
APPLICATION_SORT_FIELDS,
REVIEWER_APPLICATION_STATUSES,
} from '@/lib/constants';
import type { ApplicationFilters } from '@/lib/types';

import { ApplicationsTable } from '@/components/features/applications-table';
import { ApplicationsToolbar } from '@/components/features/applications-toolbar';
Expand All @@ -26,8 +26,26 @@ interface ApplicationsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}

const VALID_SORT_FIELDS: ApplicationSortField[] = ['date', 'name', 'status'];
const VALID_SORT_DIRECTIONS: ApplicationSortDirection[] = ['asc', 'desc'];
// A single-value param is a string; a repeated one arrives as string[] — reject both
// with .catch(undefined) rather than throwing, so one bad param never sinks the rest.
const searchParamsSchema = z.object({
positionId: z.string().trim().min(1).max(64).optional().catch(undefined),
userId: z.string().trim().min(1).max(64).optional().catch(undefined),
status: z.enum(REVIEWER_APPLICATION_STATUSES).optional().catch(undefined),
q: z.string().trim().min(1).max(200).optional().catch(undefined),
sort: z
.string()
.transform((value) => value.split(':'))
.pipe(
z.tuple([
z.enum(APPLICATION_SORT_FIELDS),
z.enum(APPLICATION_SORT_DIRECTIONS),
]),
)
.transform(([field, direction]) => ({ field, direction }))
.optional()
.catch(undefined),
});

export default async function ApplicationsPage({
searchParams,
Expand All @@ -36,33 +54,14 @@ export default async function ApplicationsPage({
const user = await requireManagerOrAdminOr404();

const sp = await searchParams;

const rawStatus = typeof sp.status === 'string' ? sp.status : undefined;
const validStatus: ReviewerStatus | undefined =
rawStatus &&
(REVIEWER_APPLICATION_STATUSES as readonly string[]).includes(rawStatus)
? (rawStatus as ReviewerStatus)
: undefined;

const rawSort = typeof sp.sort === 'string' ? sp.sort : undefined;
let validSort: ApplicationSort | undefined;
if (rawSort) {
const [rawField, rawDir] = rawSort.split(':');
const field = rawField as ApplicationSortField;
const direction = rawDir as ApplicationSortDirection;
if (
VALID_SORT_FIELDS.includes(field) &&
VALID_SORT_DIRECTIONS.includes(direction)
)
validSort = { field, direction };
}
const parsed = searchParamsSchema.parse(sp);

const filters: ApplicationFilters = {
positionId: typeof sp.positionId === 'string' ? sp.positionId : undefined,
status: validStatus,
userId: typeof sp.userId === 'string' ? sp.userId : undefined,
q: typeof sp.q === 'string' && sp.q.trim() ? sp.q.trim() : undefined,
sort: validSort,
positionId: parsed.positionId,
status: parsed.status,
userId: parsed.userId,
q: parsed.q,
sort: parsed.sort,
};

const hasActiveFilters = !!(
Expand Down
39 changes: 39 additions & 0 deletions lib/auth/scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'server-only';

import type { Prisma } from '@/prisma/client';

import {
NON_REVIEWABLE_APPLICATION_STATUSES,
PUBLISHED_POSITION_WHERE,
} from '@/lib/constants';
import type { Reviewer } from '@/lib/types';

// Admin sees every published position; a manager only the ones they manage.
export function buildReviewablePositionWhere(
user: Reviewer,
): Prisma.PositionWhereInput {
return user.isAdmin
? PUBLISHED_POSITION_WHERE
: { ...PUBLISHED_POSITION_WHERE, managers: { some: { id: user.id } } };
}

// `status` omitted so a caller's own filter can't overwrite the position scoping.
export function buildApplicationScopeWhere(
user: Reviewer,
): Omit<Prisma.ApplicationWhereInput, 'status'> {
return { deletedAt: null, position: buildReviewablePositionWhere(user) };
}

// listable keeps withdrawn rows, reviewable drops them (and draft, in both).
export function buildApplicationWhere(
user: Reviewer,
scope: 'listable' | 'reviewable',
): Prisma.ApplicationWhereInput {
return {
...buildApplicationScopeWhere(user),
status:
scope === 'reviewable'
? { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }
: { not: 'draft' },
};
}
4 changes: 4 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ export const REVIEWER_APPLICATION_STATUSES = [
export const REVIEWER_APPLICATION_STATUS_OPTIONS =
APPLICATION_STATUS_OPTIONS.filter((o) => o.value !== 'draft');

// Single source for the /applications sort union and its zod enum.
export const APPLICATION_SORT_FIELDS = ['date', 'name', 'status'] as const;
export const APPLICATION_SORT_DIRECTIONS = ['asc', 'desc'] as const;

// Single source of truth for the server guard and the rendered quick actions.
// Array order is display order; `draft`/`withdrawn` have no reviewer moves.
export const APPLICATION_STATUS_TRANSITIONS = {
Expand Down
11 changes: 8 additions & 3 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import type {
} from '@/prisma/client';
import type { $Enums, Prisma } from '@/prisma/client';

import type { REVIEWER_APPLICATION_STATUSES } from '@/lib/constants';
import type {
APPLICATION_SORT_DIRECTIONS,
APPLICATION_SORT_FIELDS,
REVIEWER_APPLICATION_STATUSES,
} from '@/lib/constants';

import type { BadgeVariant } from '@/components/ui/badge';

Expand Down Expand Up @@ -213,8 +217,9 @@ export type Reviewer = { id: string; isAdmin: boolean };

export type ReviewerStatus = (typeof REVIEWER_APPLICATION_STATUSES)[number];

export type ApplicationSortField = 'date' | 'name' | 'status';
export type ApplicationSortDirection = 'asc' | 'desc';
export type ApplicationSortField = (typeof APPLICATION_SORT_FIELDS)[number];
export type ApplicationSortDirection =
(typeof APPLICATION_SORT_DIRECTIONS)[number];
export type ApplicationSort = {
field: ApplicationSortField;
direction: ApplicationSortDirection;
Expand Down
50 changes: 10 additions & 40 deletions prisma/actions/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import type {
} from '@/prisma/client';

import { requireOwnership } from '@/lib/auth/guards';
import {
buildApplicationScopeWhere,
buildApplicationWhere,
} from '@/lib/auth/scopes';
import { getCurrentUser } from '@/lib/auth/server';
import {
ANSWER_LONG_MAX_LENGTH,
ANSWER_MAX_VALUES,
APPLICANT_EDITABLE_APPLICATION_STATUSES,
APPLICATION_STATUS_LABELS,
NON_REVIEWABLE_APPLICATION_STATUSES,
PUBLISHED_POSITION_WHERE,
REVIEWER_APPLICATION_STATUSES,
SHORT_ANSWER_FORMAT_ERROR_MESSAGES,
TERMINAL_DECISION_STATUSES,
Expand Down Expand Up @@ -485,26 +487,8 @@ export async function updateApplicationStatus(
const { applicationId, status } = parsed.data;

// Authorization folded into the query, as in getApplicationForReview.
const where = user.isAdmin
? {
id: applicationId,
deletedAt: null,
status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES },
position: PUBLISHED_POSITION_WHERE,
}
: {
id: applicationId,
deletedAt: null,
status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES },
// Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere.
position: {
...PUBLISHED_POSITION_WHERE,
managers: { some: { id: user.id } },
},
};

const application = await prisma.application.findFirst({
where,
where: { id: applicationId, ...buildApplicationWhere(user, 'reviewable') },
select: { id: true, status: true },
});

Expand Down Expand Up @@ -556,26 +540,12 @@ export async function updateApplicationStatuses(

// Forward-only sources: a bulk move-back would silently walk an already-
// decided row backward, so it's skipped like any other ineligible id.
const where = user.isAdmin
? {
id: { in: applicationIds },
deletedAt: null,
status: { in: getApplicationStatusForwardSources(status) },
position: PUBLISHED_POSITION_WHERE,
}
: {
id: { in: applicationIds },
deletedAt: null,
status: { in: getApplicationStatusForwardSources(status) },
// Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere.
position: {
...PUBLISHED_POSITION_WHERE,
managers: { some: { id: user.id } },
},
};

const result = await prisma.application.updateMany({
where,
where: {
id: { in: applicationIds },
...buildApplicationScopeWhere(user),
status: { in: getApplicationStatusForwardSources(status) },
},
data: { status, updatedById: user.id },
});

Expand Down
50 changes: 12 additions & 38 deletions prisma/data/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import 'server-only';

import { $Enums, type Prisma } from '@/prisma/client';

import {
buildApplicationWhere,
buildReviewablePositionWhere,
} from '@/lib/auth/scopes';
import {
PUBLISHED_POSITION_WHERE,
VISIBLE_POSITION_WHERE,
Expand Down Expand Up @@ -91,25 +95,6 @@ function normalizeApplicationAnswers(application: ApplicationAnswersPayload): {
};
}

// Keeps list, denominator, and detail page agreeing: drafts out, withdrawn in.
function buildBaseWhere(user: Reviewer) {
return user.isAdmin
? {
deletedAt: null,
status: { not: 'draft' as const },
position: PUBLISHED_POSITION_WHERE,
}
: {
deletedAt: null,
status: { not: 'draft' as const },
// Merge, don't overwrite: losing the managers scoping is an authorization regression.
position: {
...PUBLISHED_POSITION_WHERE,
managers: { some: { id: user.id } },
},
};
}

// Scoped to the caller (no IDOR); returns the caller's application at any status
// (draft, withdrawn, or otherwise) so the apply route decides what to render.
// Predicate must match createDraftApplication's pre-create lookup, or the page
Expand Down Expand Up @@ -167,7 +152,7 @@ export async function getApplicationForReview(
user: Reviewer,
): Promise<ApplicationForReview | null> {
const application = await prisma.application.findFirst({
where: { id, ...buildBaseWhere(user) },
where: { id, ...buildApplicationWhere(user, 'listable') },
select: {
id: true,
status: true,
Expand Down Expand Up @@ -202,11 +187,7 @@ export async function getApplicationStatusCounts(
): Promise<Partial<Record<$Enums.ApplicationStatus, number>>> {
const rows = await prisma.application.groupBy({
by: ['status'],
// buildBaseWhere only excludes draft — withdrawn must be excluded after the spread.
where: {
...buildBaseWhere(reviewer),
status: { notIn: ['draft', 'withdrawn'] },
},
where: buildApplicationWhere(reviewer, 'reviewable'),
_count: true,
});

Expand All @@ -219,11 +200,7 @@ export async function getRecentApplications(
take = 10,
): Promise<AdminApplicationListItem[]> {
return prisma.application.findMany({
// buildBaseWhere only excludes draft — withdrawn must be excluded after the spread.
where: {
...buildBaseWhere(reviewer),
status: { notIn: ['draft', 'withdrawn'] },
},
where: buildApplicationWhere(reviewer, 'reviewable'),
select: {
id: true,
status: true,
Expand All @@ -242,7 +219,7 @@ export async function getApplications(
user: Reviewer,
filters: ApplicationFilters,
): Promise<AdminApplicationListItem[]> {
const baseWhere = buildBaseWhere(user);
const baseWhere = buildApplicationWhere(user, 'listable');

// Prisma DateTime filters are range-based, so a date query becomes a range.
const MONTH_NAMES = [
Expand Down Expand Up @@ -380,19 +357,16 @@ export async function getMyRecentActivity(
}

export async function getApplicationsTotal(user: Reviewer): Promise<number> {
return prisma.application.count({ where: buildBaseWhere(user) });
return prisma.application.count({
where: buildApplicationWhere(user, 'listable'),
});
}

export async function getReviewablePositions(
user: Reviewer,
): Promise<{ id: string; title: string }[]> {
// Drafts excluded: a filter shouldn't offer a position with zero visible rows.
const where = user.isAdmin
? PUBLISHED_POSITION_WHERE
: { ...PUBLISHED_POSITION_WHERE, managers: { some: { id: user.id } } };

return prisma.position.findMany({
where,
where: buildReviewablePositionWhere(user),
select: { id: true, title: true },
orderBy: { title: 'asc' },
});
Expand Down
Loading
Loading