Skip to content

Commit 9a55c2a

Browse files
committed
refactor(api): streamline user and team data retrieval with pagination
- Updated user and team listing functions to utilize paginated APIs, enhancing data retrieval efficiency. - Refactored related components to support the new pagination structure, ensuring consistency across the application. - Improved error handling and response structures for better user feedback during data operations. These changes optimize the performance of user and team management features in the dashboard.
1 parent b0d290e commit 9a55c2a

16 files changed

Lines changed: 104 additions & 103 deletions

File tree

apps/backend/src/app/api/latest/internal/session-replays/route.tsx

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,22 @@ import {
77
sessionReplayAdminRowToApiItem,
88
} from "./session-replay-admin-rows";
99
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
10+
import { KnownErrors } from "@stackframe/stack-shared";
1011
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
11-
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
12+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1213
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
1314

1415
const DEFAULT_LIMIT = 50;
1516
const MAX_LIMIT = 200;
1617
const CLICK_FILTER_ID_CAP = 1000;
17-
const MAX_CSV_IDS = 200;
1818

19-
function parseCsvIds(name: string, raw: string | undefined): string[] {
19+
function parseCsvIds(raw: string | undefined): string[] {
2020
if (!raw) return [];
21-
const values = raw.split(",").map(s => s.trim()).filter(Boolean);
22-
if (values.length > MAX_CSV_IDS) {
23-
throw new StatusError(StatusError.BadRequest, `${name} accepts at most ${MAX_CSV_IDS} comma-separated values`);
24-
}
25-
return values;
21+
return raw.split(",").map(s => s.trim()).filter(Boolean);
2622
}
2723

2824
function parseCsvUuids(name: string, raw: string | undefined): string[] {
29-
const values = parseCsvIds(name, raw);
25+
const values = parseCsvIds(raw);
3026
for (const value of values) {
3127
if (!isUuid(value)) {
3228
throw new StatusError(StatusError.BadRequest, `${name} must contain valid UUID values`);
@@ -35,7 +31,6 @@ function parseCsvUuids(name: string, raw: string | undefined): string[] {
3531
return values;
3632
}
3733

38-
// Escape ILIKE `%`/`_`/`\` so search text matches literally.
3934
function escapeLikePattern(input: string): string {
4035
return input.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
4136
}
@@ -160,19 +155,13 @@ export const GET = createSmartRouteHandler({
160155
}
161156

162157
// If click filter is active, get qualifying replay IDs from ClickHouse in one query
163-
let clickQualifiedIds: string[] | null = null;
164-
if (clickCountMin && clickCountMin > 0) {
165-
try {
166-
clickQualifiedIds = await loadClickQualifiedReplayIds({
167-
projectId: auth.tenancy.project.id,
168-
branchId: auth.tenancy.branchId,
169-
clickCountMin,
170-
});
171-
} catch (e) {
172-
captureError("session-replays-list-clickhouse", new Error(`ClickHouse query failed for click filter: tenancy=${auth.tenancy.id} project=${auth.tenancy.project.id} branch=${auth.tenancy.branchId} clickCountMin=${clickCountMin} cause=${e instanceof Error ? e.message : String(e)}`));
173-
throw new StatusError(503, "Session-replay search backend is temporarily unavailable. Try again in a moment.");
174-
}
175-
}
158+
const clickQualifiedIds = clickCountMin && clickCountMin > 0
159+
? await loadClickQualifiedReplayIds({
160+
projectId: auth.tenancy.project.id,
161+
branchId: auth.tenancy.branchId,
162+
clickCountMin,
163+
})
164+
: null;
176165

177166
if (clickQualifiedIds && clickQualifiedIds.length === 0) {
178167
return {
@@ -182,6 +171,7 @@ export const GET = createSmartRouteHandler({
182171
};
183172
}
184173

174+
// Handle cursor-based pagination
185175
const cursorId = query.cursor;
186176
let cursorPivot: { id: string, lastEventAt: Date } | null = null;
187177
if (cursorId) {
@@ -190,32 +180,10 @@ export const GET = createSmartRouteHandler({
190180
select: { id: true, lastEventAt: true },
191181
});
192182
if (!cursorPivot) {
193-
return {
194-
statusCode: 200,
195-
bodyType: "json",
196-
body: { items: [], pagination: { next_cursor: null } },
197-
};
183+
throw new KnownErrors.ItemNotFound(cursorId);
198184
}
199185
}
200186

201-
const cursorWhereSql = sortDirection === "asc"
202-
? cursorPivot
203-
? Prisma.sql`AND (
204-
sr."lastEventAt" > ${cursorPivot.lastEventAt}
205-
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" > ${cursorId})
206-
)`
207-
: Prisma.empty
208-
: cursorPivot
209-
? Prisma.sql`AND (
210-
sr."lastEventAt" < ${cursorPivot.lastEventAt}
211-
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
212-
)`
213-
: Prisma.empty;
214-
215-
const orderBySql = sortDirection === "asc"
216-
? Prisma.sql`ORDER BY sr."lastEventAt" ASC, sr."id" ASC`
217-
: Prisma.sql`ORDER BY sr."lastEventAt" DESC, sr."id" DESC`;
218-
219187
const suffixSql = Prisma.sql`
220188
${userIdsFilter.length > 0 ? Prisma.sql`AND sr."projectUserId" IN (${Prisma.join(userIdsFilter)})` : Prisma.empty}
221189
${lastEventAtFrom ? Prisma.sql`AND sr."lastEventAt" >= ${lastEventAtFrom}` : Prisma.empty}
@@ -230,11 +198,21 @@ export const GET = createSmartRouteHandler({
230198
${durationMsMin !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 >= ${durationMsMin}` : Prisma.empty}
231199
${durationMsMax !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 <= ${durationMsMax}` : Prisma.empty}
232200
${searchQuery ? Prisma.sql`AND (
233-
sr."id"::text ILIKE ${`%${escapeLikePattern(searchQuery)}%`} ESCAPE '\'
234-
OR pu."displayName" ILIKE ${`%${escapeLikePattern(searchQuery)}%`} ESCAPE '\'
201+
sr."id"::text ILIKE ${`%${escapeLikePattern(searchQuery)}%`}
202+
OR pu."displayName" ILIKE ${`%${escapeLikePattern(searchQuery)}%`}
235203
)` : Prisma.empty}
236-
${cursorWhereSql}
237-
${orderBySql}
204+
${cursorPivot ? (sortDirection === "asc"
205+
? Prisma.sql`AND (
206+
sr."lastEventAt" > ${cursorPivot.lastEventAt}
207+
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" > ${cursorId})
208+
)`
209+
: Prisma.sql`AND (
210+
sr."lastEventAt" < ${cursorPivot.lastEventAt}
211+
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
212+
)`) : Prisma.empty}
213+
${sortDirection === "asc"
214+
? Prisma.sql`ORDER BY sr."lastEventAt" ASC, sr."id" ASC`
215+
: Prisma.sql`ORDER BY sr."lastEventAt" DESC, sr."id" DESC`}
238216
LIMIT ${limit + 1}
239217
`;
240218

apps/backend/src/app/api/latest/teams/crud.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,9 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
288288

289289
let queryFilter: Prisma.TeamWhereInput | undefined;
290290
if (query.query) {
291-
const sanitized = query.query.replace(/[^a-zA-Z0-9\-_.]/g, '');
292291
queryFilter = {
293292
OR: [
294-
...isUuid(sanitized) ? [{
295-
teamId: {
296-
equals: sanitized,
297-
},
298-
}] : [],
293+
...isUuid(query.query) ? [{ teamId: { equals: query.query } }] : [],
299294
{
300295
displayName: {
301296
contains: query.query,
@@ -312,11 +307,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
312307
select: { teamId: true },
313308
});
314309
if (!cursorRow) {
315-
return {
316-
items: [],
317-
is_paginated: true,
318-
pagination: { next_cursor: null },
319-
};
310+
throw new KnownErrors.ItemNotFound(query.cursor);
320311
}
321312
}
322313

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/page-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import SetupPage from "./setup-page";
88
export default function PageClient() {
99
const adminApp = useAdminApp();
1010
const users = adminApp.useUsers({ limit: 1 });
11-
const [page, setPage] = useState<'setup' | 'metrics'>(users.items.length === 0 ? 'setup' : 'metrics');
11+
const [page, setPage] = useState<'setup' | 'metrics'>(users.length === 0 ? 'setup' : 'metrics');
1212

1313
switch (page) {
1414
case 'setup': {

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,6 @@ function RestrictionDialog({
347347
function RestrictionBanner({ user }: { user: ServerUser }) {
348348
if (!user.isRestricted) return null;
349349

350-
const restrictedByAdmin = user.restrictedByAdmin;
351350
const restrictedByAdminReason = user.restrictedByAdminReason;
352351
const restrictedByAdminPrivateDetails = user.restrictedByAdminPrivateDetails;
353352
const reasonText = getRestrictionReasonText(user);
@@ -1039,7 +1038,7 @@ function UserTeamsSection({ user }: { user: ServerUser }) {
10391038
const stackAdminApp = useAdminApp();
10401039
const router = useRouter();
10411040
const [sortDesc, setSortDesc] = useState<boolean | undefined>(undefined);
1042-
const teams = user.useTeams(sortDesc === undefined ? undefined : { orderBy: 'createdAt', desc: sortDesc });
1041+
const teams = user.useTeamsPaginated(sortDesc === undefined ? undefined : { orderBy: 'createdAt', desc: sortDesc }).items;
10431042
const [addTeamDialogOpen, setAddTeamDialogOpen] = useState(false);
10441043
const [teamToRemove, setTeamToRemove] = useState<ServerTeam | null>(null);
10451044

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export default function PageClient() {
106106
</div>
107107
}
108108
>
109-
{firstUserPage.items.length > 0 ? null : (
109+
{firstUserPage.length > 0 ? null : (
110110
<Alert variant='success'>
111111
Congratulations on starting your project! Check the <StyledLink href="https://docs.stack-auth.com">documentation</StyledLink> to add your first users.
112112
</Alert>

apps/dashboard/src/components/data-table/team-member-search-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function TeamMemberSearchTable(props: {
107107
? params.quickSearch.trim()
108108
: undefined;
109109
const cursor = typeof params.cursor === "string" ? params.cursor : undefined;
110-
const result = await stackAdminApp.listUsers({
110+
const result = await stackAdminApp.listUsersPaginated({
111111
limit: PAGE_SIZE,
112112
query,
113113
cursor,

apps/dashboard/src/components/data-table/team-member-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ export function TeamMemberTable(props: { team: ServerTeam }) {
393393
const search = typeof params.quickSearch === "string" && params.quickSearch.trim().length > 0
394394
? params.quickSearch.trim()
395395
: undefined;
396-
const result = await stackAdminApp.listUsers({
396+
const result = await stackAdminApp.listUsersPaginated({
397397
limit: PAGE_SIZE,
398398
teamId: props.team.id,
399399
orderBy: "lastActiveAt",

apps/dashboard/src/components/data-table/user-search-picker.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function UserSearchTable(props: {
2020
action: (user: ServerUser) => React.ReactNode,
2121
}) {
2222
const stackAdminApp = useAdminApp();
23-
const [filters, setFilters] = useState<Parameters<typeof stackAdminApp.listUsers>[0]>({
23+
const [filters, setFilters] = useState<Parameters<typeof stackAdminApp.listUsersPaginated>[0]>({
2424
limit: PAGE_SIZE,
2525
query: props.query || undefined,
2626
});
@@ -29,7 +29,7 @@ function UserSearchTable(props: {
2929
setFilters({ limit: PAGE_SIZE, query: props.query || undefined });
3030
}, [props.query]);
3131

32-
const users = extendUsers(stackAdminApp.useUsers(filters).items);
32+
const users = extendUsers(stackAdminApp.useUsersPaginated(filters).items);
3333

3434
const { action } = props;
3535
const columns: DataGridColumnDef<ServerUser>[] = useMemo(() => [

apps/dashboard/src/components/data-table/user-table.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ function UserTableBody(props: {
245245
const sortDesc = activeSort?.direction !== "asc";
246246
const cursor = typeof params.cursor === "string" ? params.cursor : undefined;
247247
const result = filters.onlyAnonymous
248-
? await stackAdminApp.listUsers({
248+
? await stackAdminApp.listUsersPaginated({
249249
limit: PAGE_SIZE,
250250
orderBy,
251251
desc: sortDesc,
@@ -255,7 +255,7 @@ function UserTableBody(props: {
255255
onlyAnonymous: true,
256256
cursor,
257257
})
258-
: await stackAdminApp.listUsers({
258+
: await stackAdminApp.listUsersPaginated({
259259
limit: PAGE_SIZE,
260260
orderBy,
261261
desc: sortDesc,

apps/dashboard/src/components/export-users-dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ async function fetchAllUsers(
270270
const limit = 100; // Fetch in batches of 100
271271

272272
do {
273-
const listUsersOptions: Parameters<typeof stackAdminApp.listUsers>[0] = {
273+
const listUsersOptions: Parameters<typeof stackAdminApp.listUsersPaginated>[0] = {
274274
limit,
275275
cursor,
276276
query: options?.search,
@@ -281,7 +281,7 @@ async function fetchAllUsers(
281281
if (options?.onlyAnonymous) {
282282
Object.assign(listUsersOptions, { onlyAnonymous: true });
283283
}
284-
const batch = await stackAdminApp.listUsers(listUsersOptions);
284+
const batch = await stackAdminApp.listUsersPaginated(listUsersOptions);
285285

286286
allUsers.push(...batch.items);
287287
cursor = batch.nextCursor ?? undefined;

0 commit comments

Comments
 (0)