Skip to content
Open
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
27 changes: 15 additions & 12 deletions packages/common/src/services/decision/getResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,26 @@ import { User } from '@op/supabase/lib';
import { assertAccess, permission } from 'access-zones';
import { count as countFn } from 'drizzle-orm';

import { NotFoundError, decodeCursor, encodeCursor } from '../../utils';
import {
NotFoundError,
type PaginatedResult,
decodeCursor,
encodeCursor,
} from '../../utils';
import { getOrgAccessUser } from '../access';
import { listProposals } from './listProposals';

// Uses selectionRank with id as tiebreaker for stable ordering
type SelectionCursor = { selectionRank: number | null; id: string };

type ResultProposalItem = Awaited<
ReturnType<typeof listProposals>
>['proposals'][number] & {
selectionRank: number | null;
voteCount: number;
allocated: string | null;
};

export const getLatestResultWithProposals = async ({
processInstanceId,
user,
Expand All @@ -28,17 +41,7 @@ export const getLatestResultWithProposals = async ({
user: User;
limit?: number;
cursor?: string | null;
}): Promise<{
items: Array<
Awaited<ReturnType<typeof listProposals>>['proposals'][number] & {
selectionRank: number | null;
voteCount: number;
allocated: string | null;
}
>;
next: string | null;
hasMore: boolean;
} | null> => {
}): Promise<PaginatedResult<ResultProposalItem> | null> => {
const instanceWithOrg = await db
.select({
instanceId: processInstances.id,
Expand Down
9 changes: 2 additions & 7 deletions packages/common/src/services/decision/listDecisionProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { User } from '@op/supabase/lib';

import {
type PaginatedResult,
constructTextSearch,
decodeCursor,
encodeCursor,
Expand Down Expand Up @@ -58,12 +59,6 @@ type DecisionProfileItem = Omit<
};
};

type ListDecisionProfilesResult = {
items: DecisionProfileItem[];
next: string | null;
hasMore: boolean;
};

export const listDecisionProfiles = async ({
user,
search,
Expand All @@ -82,7 +77,7 @@ export const listDecisionProfiles = async ({
dir?: 'asc' | 'desc';
cursor?: string | null;
ownerProfileId?: string | null;
}): Promise<ListDecisionProfilesResult> => {
}): Promise<PaginatedResult<DecisionProfileItem>> => {
// Get the column to order by
const orderByColumn =
orderBy === 'name'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const listOrganizations = async ({
});

const hasMore = result.length > limit;
const items = hasMore ? result.slice(0, limit) : result;
const items = result.slice(0, limit);
const lastItem = items[items.length - 1];

const orderByValue =
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/services/posts/listPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const listPosts = async ({
const filteredResult = result.filter((item) => item.post !== null);

const hasMore = filteredResult.length > limit;
const items = hasMore ? filteredResult.slice(0, limit) : filteredResult;
const items = filteredResult.slice(0, limit);
const lastItem = items[items.length - 1];
const nextCursor =
hasMore && lastItem && lastItem.createdAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const listAllRelatedOrganizationPosts = async (
]);

const hasMore = result.length > limit;
const items = hasMore ? result.slice(0, limit) : result;
const items = result.slice(0, limit);
const lastItem = items[items.length - 1];
const nextCursor =
hasMore && lastItem && lastItem.createdAt
Expand Down
161 changes: 138 additions & 23 deletions packages/common/src/services/profile/listProfileUsers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { and, db, eq, or, sql } from '@op/db/client';
import { and, db, eq, gt, lt, or, sql } from '@op/db/client';
import { profileUsers, profiles, users } from '@op/db/schema';
import type { User } from '@op/supabase/lib';
import { assertAccess, permission } from 'access-zones';

import { SortDir } from '../../utils/db';
import {
type PaginatedResult,
type SortDir,
decodeCursor,
encodeCursor,
} from '../../utils/db';
import { UnauthorizedError } from '../../utils/error';
import { getProfileAccessUser } from '../access';
import { assertProfile } from '../assert';
Expand All @@ -15,21 +20,39 @@ import type {
export type ProfileUserOrderBy = 'name' | 'email' | 'role';

/**
* List all members of a profile
* Builds a subquery to get the first role name (alphabetically) for a profile user.
* Used for both ORDER BY and cursor conditions to ensure consistency.
* Returns empty string if user has no roles (via COALESCE) to match JS cursor encoding.
*/
const buildRoleNameSubquery = (profileUserIdColumn: unknown) => sql`COALESCE((
SELECT ar.name
FROM "profileUser_to_access_roles" pur
INNER JOIN "access_roles" ar ON ar.id = pur.access_role_id
WHERE pur.profile_user_id = ${profileUserIdColumn}
ORDER BY ar.name
LIMIT 1
), '')`;

/**
* List all members of a profile with cursor-based pagination
*/
export const listProfileUsers = async ({
profileId,
user,
orderBy = 'name',
dir = 'asc',
query,
cursor,
limit = 25,
}: {
profileId: string;
user: User;
orderBy?: ProfileUserOrderBy;
dir?: SortDir;
query?: string;
}): Promise<ProfileUserWithRelations[]> => {
cursor?: string | null;
limit?: number;
}): Promise<PaginatedResult<ProfileUserWithRelations>> => {
const [profileAccessUser] = await Promise.all([
getProfileAccessUser({ user, profileId }),
assertProfile(profileId),
Expand Down Expand Up @@ -66,11 +89,57 @@ export const listProfileUsers = async ({
})()
: undefined;

const whereClause = searchFilter
? and(eq(profileUsers.profileId, profileId), searchFilter)
: eq(profileUsers.profileId, profileId);

// Fetch all profile users with their roles and user profiles
// Build cursor condition for pagination
// The cursor must match the ORDER BY columns for correct pagination
type ProfileUserCursor = { value: string; tiebreaker?: string };
const decodedCursor = cursor
? decodeCursor<ProfileUserCursor>(cursor)
: undefined;

const compareFn = dir === 'asc' ? gt : lt;

const buildCursorCondition = () => {
if (!decodedCursor) {
return undefined;
}

if (orderBy === 'email') {
// Email is unique, no tiebreaker needed
return compareFn(profileUsers.email, decodedCursor.value);
}

if (orderBy === 'name') {
// ORDER BY name, email - compound condition
return or(
compareFn(profileUsers.name, decodedCursor.value),
and(
eq(profileUsers.name, decodedCursor.value),
compareFn(profileUsers.email, decodedCursor.tiebreaker ?? ''),
),
);
}

// orderBy === 'role' - uses shared subquery helper
const roleSubquery = buildRoleNameSubquery(profileUsers.id);
const compareOp = dir === 'asc' ? sql`>` : sql`<`;
return sql`(
${roleSubquery} ${compareOp} ${decodedCursor.value}
OR (${roleSubquery} = ${decodedCursor.value} AND ${profileUsers.email} ${compareOp} ${decodedCursor.tiebreaker ?? ''})
)`;
};

const cursorCondition = buildCursorCondition();

// Combine all conditions
const baseCondition = eq(profileUsers.profileId, profileId);
const conditions = [baseCondition, searchFilter, cursorCondition].filter(
Boolean,
);
const whereClause =
conditions.length > 1 ? and(...conditions) : baseCondition;

// Fetch profile users with their roles and user profiles
// Request one extra to check if there are more results
const profileUserResults = await db._query.profileUsers.findMany({
where: whereClause,
with: {
Expand All @@ -93,29 +162,28 @@ export const listProfileUsers = async ({
const orderFn = dir === 'desc' ? desc : asc;

if (orderBy === 'role') {
// Use a subquery to get the first role name for sorting
// Note: Using raw SQL strings because Drizzle's sql template uses outer query aliases
const roleNameSubquery = sql`(
SELECT ar.name
FROM "profileUser_to_access_roles" pur
INNER JOIN "access_roles" ar ON ar.id = pur.access_role_id
WHERE pur.profile_user_id = ${table.id}
ORDER BY ar.name
LIMIT 1
)`;
return [orderFn(roleNameSubquery)];
// Use shared subquery helper for consistency with cursor condition
const roleNameSubquery = buildRoleNameSubquery(table.id);
// Add email as secondary sort for consistent cursor pagination
return [orderFn(roleNameSubquery), orderFn(table.email)];
}

if (orderBy === 'email') {
return [orderFn(table.email)];
}

// Default to name
return [orderFn(table.name)];
// Default to name, with email as secondary for consistent ordering
return [orderFn(table.name), orderFn(table.email)];
},
limit: limit + 1,
});

return profileUserResults.map((result) => {
// Check if there are more results
const hasMore = profileUserResults.length > limit;
const resultItems = profileUserResults.slice(0, limit);

// Transform results
const items = resultItems.map((result) => {
const { serviceUser, roles, ...baseProfileUser } =
result as ProfileUserQueryResult;
const userProfile = serviceUser?.profile;
Expand All @@ -128,4 +196,51 @@ export const listProfileUsers = async ({
roles: roles.map((roleJunction) => roleJunction.accessRole),
};
});

// Build next cursor from last item
// Cursor value must match the primary ORDER BY column
const lastResult = profileUserResults[resultItems.length - 1];
const buildNextCursor = (): string | null => {
if (!hasMore || !lastResult) {
return null;
}

if (orderBy === 'email') {
return encodeCursor<ProfileUserCursor>({ value: lastResult.email });
}

if (orderBy === 'name') {
return encodeCursor<ProfileUserCursor>({
value: lastResult.name ?? '',
tiebreaker: lastResult.email,
});
}

// orderBy === 'role' - get first role name alphabetically (matching the ORDER BY subquery)
// Use simple string comparison to match PostgreSQL's default collation
const sortedRoles = [...lastResult.roles].sort((a, b) => {
const nameA = a.accessRole?.name ?? '';
const nameB = b.accessRole?.name ?? '';
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
});
const firstRoleName = sortedRoles[0]?.accessRole?.name ?? '';
return encodeCursor<ProfileUserCursor>({
value: firstRoleName,
tiebreaker: lastResult.email,
});
};

const nextCursor = buildNextCursor();

return {
items,
next: nextCursor,
hasMore,
};
};
2 changes: 1 addition & 1 deletion packages/common/src/services/profile/listProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const listProfiles = async ({
}

const hasMore = result.length > limit;
const items = hasMore ? result.slice(0, limit) : result;
const items = result.slice(0, limit);
const lastItem = items[items.length - 1];

const cursorValue = match(orderBy, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { JoinProfileRequestStatus, joinProfileRequests } from '@op/db/schema';
import { User } from '@op/supabase/lib';
import { and, eq } from 'drizzle-orm';

import { decodeCursor, encodeCursor, getCursorCondition } from '../../../utils';
import {
type PaginatedResult,
decodeCursor,
encodeCursor,
getCursorCondition,
} from '../../../utils';
import { assertTargetProfileAdminAccess } from './assertTargetProfileAdminAccess';
import { JoinProfileRequestWithProfiles } from './types';

Expand All @@ -30,11 +35,7 @@ export const listProfileJoinRequests = async ({
cursor?: string | null;
limit?: number;
dir?: 'asc' | 'desc';
}): Promise<{
items: JoinProfileRequestWithProfiles[];
next: string | null;
hasMore: boolean;
}> => {
}): Promise<PaginatedResult<JoinProfileRequestWithProfiles>> => {
// Build cursor condition for pagination
const cursorCondition = cursor
? getCursorCondition({
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { CommonError } from './error';
/** Standard sort direction type for database queries */
export type SortDir = 'asc' | 'desc';

/** Generic paginated result type for cursor-based pagination */
export type PaginatedResult<T> = {
items: T[];
next: string | null;
hasMore: boolean;
};

// Cursor utilities
type GenericCursor = {
date: Date;
Expand Down
Loading