mirror of https://github.com/buster-so/buster.git
add pagination type responses
This commit is contained in:
parent
fb0e79065f
commit
094fc4c251
|
@ -14,12 +14,14 @@ const app = new Hono().get(
|
|||
const { id: userId } = c.get('busterUser');
|
||||
const { page, page_size, filters } = c.req.valid('json');
|
||||
|
||||
const users = await getUserToOrganization({
|
||||
const result = await getUserToOrganization({
|
||||
userId,
|
||||
page,
|
||||
page_size,
|
||||
filters,
|
||||
});
|
||||
|
||||
const response: GetUserToOrganizationResponse = users;
|
||||
const response: GetUserToOrganizationResponse = result;
|
||||
|
||||
return c.json(response);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,8 @@
|
|||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsoleLog": "off"
|
||||
"noConsoleLog": "off",
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
# Pagination Utilities
|
||||
|
||||
This directory contains reusable pagination utilities for Drizzle ORM queries.
|
||||
|
||||
## Overview
|
||||
|
||||
The pagination utilities provide a consistent way to add offset-based pagination to your database queries. They handle:
|
||||
|
||||
- Page calculation and offset computation
|
||||
- Total count queries
|
||||
- Consistent response format
|
||||
- Type safety
|
||||
|
||||
## Recommended Usage
|
||||
|
||||
### Primary Method: `withPaginationMeta` (Handles Count Automatically)
|
||||
|
||||
This is the **recommended approach** for most use cases as it handles the count query for you:
|
||||
|
||||
```typescript
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { db } from '@/database';
|
||||
import { users } from '@/schema';
|
||||
import { withPaginationMeta } from '@/queries/shared-types';
|
||||
|
||||
const result = await withPaginationMeta({
|
||||
query: db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.active, true))
|
||||
.$dynamic(),
|
||||
orderBy: desc(users.createdAt),
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
countFrom: users,
|
||||
countWhere: eq(users.active, true), // Optional: same conditions as main query
|
||||
});
|
||||
|
||||
// Result shape:
|
||||
// {
|
||||
// data: User[],
|
||||
// pagination: {
|
||||
// page: number,
|
||||
// page_size: number,
|
||||
// total: number, // Automatically calculated
|
||||
// total_pages: number // Automatically calculated
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
### Simple Pagination: `withPagination` (No Count Query)
|
||||
|
||||
Use this when you only need paginated results without metadata:
|
||||
|
||||
```typescript
|
||||
import { db } from '@/database';
|
||||
import { users } from '@/schema';
|
||||
import { withPagination } from '@/queries/shared-types';
|
||||
|
||||
// Your base query needs to use .$dynamic()
|
||||
const query = db.select().from(users).$dynamic();
|
||||
|
||||
// Apply pagination
|
||||
const paginatedQuery = withPagination(query, users.createdAt, 2, 10);
|
||||
const results = await paginatedQuery;
|
||||
// Note: This only returns the data, no pagination metadata
|
||||
```
|
||||
|
||||
### Special Cases: `createPaginatedResponse` (Manual Count)
|
||||
|
||||
Only use this when you:
|
||||
- Already have the total count from another source
|
||||
- Need to transform data after querying
|
||||
- Are working with non-database data
|
||||
|
||||
```typescript
|
||||
import { createPaginatedResponse } from '@/queries/shared-types';
|
||||
|
||||
// Example: When you already have count from elsewhere
|
||||
const transformedData = existingData.map(item => ({
|
||||
...item,
|
||||
displayName: item.name || 'Unnamed'
|
||||
}));
|
||||
|
||||
const response = createPaginatedResponse({
|
||||
data: transformedData,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
total: existingTotalCount, // You must provide this
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Types
|
||||
|
||||
#### `PaginationInput`
|
||||
```typescript
|
||||
interface PaginationInput {
|
||||
page?: number; // Current page (default: 1)
|
||||
page_size?: number; // Items per page (default: 250, max: 1000)
|
||||
}
|
||||
```
|
||||
|
||||
#### `PaginationMetadata`
|
||||
```typescript
|
||||
interface PaginationMetadata {
|
||||
page: number; // Current page
|
||||
page_size: number; // Items per page
|
||||
total: number; // Total number of items
|
||||
total_pages: number; // Total number of pages
|
||||
}
|
||||
```
|
||||
|
||||
#### `PaginatedResponse<T>`
|
||||
```typescript
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: PaginationMetadata;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `withPaginationMeta(options)` ⭐ Recommended
|
||||
Executes a paginated query and automatically handles the count query.
|
||||
|
||||
#### `withPagination(query, orderBy, page?, pageSize?)`
|
||||
Adds pagination to a dynamic query without executing it or counting.
|
||||
|
||||
#### `createPaginatedResponse(options)`
|
||||
Creates a paginated response when you already have the total count.
|
||||
|
||||
#### `createPaginationMetadata(options)`
|
||||
Creates just the pagination metadata from count and page info.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `withPaginationMeta` by default**: It handles counting automatically
|
||||
2. **Always use ordering**: Pagination without ordering can lead to inconsistent results
|
||||
3. **Match WHERE conditions**: When using `countWhere`, ensure it matches your query's WHERE clause
|
||||
4. **Consider performance**: For very large tables, consider cursor-based pagination
|
||||
5. **Set reasonable limits**: Default page size is 250, max is 1000 to prevent performance issues
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### API Endpoint with Automatic Count
|
||||
|
||||
```typescript
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const page_size = parseInt(searchParams.get('per_page') || '25');
|
||||
|
||||
// This handles everything for you
|
||||
const result = await withPaginationMeta({
|
||||
query: db.select().from(users).$dynamic(),
|
||||
orderBy: users.createdAt,
|
||||
page,
|
||||
page_size,
|
||||
countFrom: users,
|
||||
});
|
||||
|
||||
return Response.json(result);
|
||||
}
|
||||
```
|
||||
|
||||
### With Filters
|
||||
|
||||
```typescript
|
||||
const activeUsers = await withPaginationMeta({
|
||||
query: db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(
|
||||
eq(users.active, true),
|
||||
like(users.email, '%@company.com')
|
||||
))
|
||||
.$dynamic(),
|
||||
orderBy: users.email,
|
||||
page,
|
||||
page_size,
|
||||
countFrom: users,
|
||||
countWhere: and( // Same conditions as query
|
||||
eq(users.active, true),
|
||||
like(users.email, '%@company.com')
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you're updating existing code:
|
||||
|
||||
1. **Replace manual pagination logic** with `withPaginationMeta`:
|
||||
```typescript
|
||||
// Before: Manual offset/limit and count
|
||||
const offset = (page - 1) * pageSize;
|
||||
const data = await db.select().from(users).limit(pageSize).offset(offset);
|
||||
const [{ count }] = await db.select({ count: count() }).from(users);
|
||||
|
||||
// After: Automatic with withPaginationMeta
|
||||
const result = await withPaginationMeta({
|
||||
query: db.select().from(users).$dynamic(),
|
||||
orderBy: users.id,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
countFrom: users,
|
||||
});
|
||||
```
|
||||
|
||||
2. **Add `.$dynamic()`** to your query builder
|
||||
3. **Update response types** to use `PaginatedResponse<T>`
|
||||
|
||||
See `pagination.example.ts` for more complete examples.
|
|
@ -0,0 +1,3 @@
|
|||
// Export pagination types and utilities
|
||||
export * from './pagination.types';
|
||||
export * from './with-pagination';
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Example usage of pagination utilities
|
||||
*/
|
||||
|
||||
import { and, count, desc, eq, like } from 'drizzle-orm';
|
||||
import { db } from '../../connection';
|
||||
import { dashboards, users } from '../../schema';
|
||||
import { createPaginatedResponse, withPagination, withPaginationMeta } from './index';
|
||||
|
||||
// Example 1: RECOMMENDED - Pagination with metadata (handles count automatically)
|
||||
export async function getUsersWithMeta(page = 1, pageSize = 10) {
|
||||
const query = db.select().from(users).where(eq(users.name, 'John')).$dynamic();
|
||||
|
||||
// This is the recommended approach - it handles the count query for you
|
||||
return await withPaginationMeta({
|
||||
query,
|
||||
orderBy: desc(users.createdAt),
|
||||
page,
|
||||
page_size: pageSize,
|
||||
countFrom: users,
|
||||
countWhere: eq(users.name, 'John'), // Same condition as the main query
|
||||
});
|
||||
}
|
||||
|
||||
// Example 2: Simple pagination without metadata (when you don't need count)
|
||||
export async function getUsers(page = 1, pageSize = 10) {
|
||||
const query = db.select().from(users).$dynamic();
|
||||
const paginatedQuery = withPagination(query, users.createdAt, page, pageSize);
|
||||
return await paginatedQuery;
|
||||
}
|
||||
|
||||
// Example 3: Using in API endpoints
|
||||
export async function apiHandler(request: { query: { page?: string; per_page?: string } }) {
|
||||
const page = request.query.page ? Number.parseInt(request.query.page) : 1;
|
||||
const pageSize = request.query.per_page ? Number.parseInt(request.query.per_page) : 25;
|
||||
|
||||
// withPaginationMeta handles everything for you
|
||||
const result = await withPaginationMeta({
|
||||
query: db.select().from(users).$dynamic(),
|
||||
orderBy: desc(users.createdAt),
|
||||
page,
|
||||
page_size: pageSize,
|
||||
countFrom: users,
|
||||
});
|
||||
|
||||
// The result has the shape:
|
||||
// {
|
||||
// data: User[],
|
||||
// pagination: {
|
||||
// page: number,
|
||||
// page_size: number,
|
||||
// total: number, // Automatically calculated
|
||||
// total_pages: number // Automatically calculated
|
||||
// }
|
||||
// }
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Example 4: With complex filters
|
||||
export async function getFilteredDashboards(
|
||||
organizationId: string,
|
||||
searchTerm: string,
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
) {
|
||||
const whereCondition = and(
|
||||
eq(dashboards.organizationId, organizationId),
|
||||
like(dashboards.name, `%${searchTerm}%`)
|
||||
);
|
||||
|
||||
const paginationOptions = {
|
||||
query: db.select().from(dashboards).where(whereCondition).$dynamic(),
|
||||
orderBy: dashboards.createdAt,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
countFrom: dashboards,
|
||||
...(whereCondition && { countWhere: whereCondition }),
|
||||
};
|
||||
|
||||
return await withPaginationMeta(paginationOptions);
|
||||
}
|
||||
|
||||
// Example 5: SPECIAL CASE - Manual pagination with custom transformation
|
||||
// Only use when you need to transform data or already have the count
|
||||
export async function getDashboardsWithCustomResponse(
|
||||
organizationId: string,
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
) {
|
||||
// Sometimes you might already have the count from another source
|
||||
// or need to do custom transformations
|
||||
const query = db
|
||||
.select()
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.organizationId, organizationId))
|
||||
.$dynamic();
|
||||
|
||||
const paginatedQuery = withPagination(query, dashboards.createdAt, page, pageSize);
|
||||
const results = await paginatedQuery;
|
||||
|
||||
// Maybe you got the count from somewhere else or cached it
|
||||
const cachedTotal = await getCachedDashboardCount(organizationId);
|
||||
|
||||
// Transform results with custom logic
|
||||
const transformedData = results.map((dashboard) => ({
|
||||
...dashboard,
|
||||
displayName: dashboard.name || 'Untitled Dashboard',
|
||||
isRecent: new Date(dashboard.createdAt) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
}));
|
||||
|
||||
// Only use createPaginatedResponse when you already have the total
|
||||
return createPaginatedResponse({
|
||||
data: transformedData,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total: cachedTotal,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function (simulated)
|
||||
async function getCachedDashboardCount(_organizationId: string): Promise<number> {
|
||||
// This would normally come from cache or another source
|
||||
// In a real app, you'd use the organizationId to look up cached count
|
||||
return 42;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Pagination input schema for validation
|
||||
export const PaginationInputSchema = z.object({
|
||||
page: z.number().min(1).optional().default(1),
|
||||
page_size: z.number().min(1).max(1000).optional().default(250),
|
||||
});
|
||||
|
||||
export type PaginationInput = z.infer<typeof PaginationInputSchema>;
|
||||
|
||||
// Pagination metadata that's returned with results
|
||||
export interface PaginationMetadata {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// Generic paginated response type
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: PaginationMetadata;
|
||||
}
|
||||
|
||||
// Type helper for creating paginated API responses
|
||||
export type WithPagination<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {
|
||||
pagination: PaginationMetadata;
|
||||
};
|
|
@ -0,0 +1,152 @@
|
|||
import { SQL, count } from 'drizzle-orm';
|
||||
import { PgColumn, PgSelect, PgTable, TableConfig } from 'drizzle-orm/pg-core';
|
||||
import { db } from '../../connection';
|
||||
import type { PaginatedResponse, PaginationInput, PaginationMetadata } from './pagination.types';
|
||||
|
||||
/**
|
||||
* Adds pagination to a Drizzle query using the dynamic query builder pattern
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const query = db.select().from(users).$dynamic();
|
||||
* const paginatedQuery = withPagination(query, users.id, 2, 10);
|
||||
* const results = await paginatedQuery;
|
||||
* ```
|
||||
*/
|
||||
export function withPagination<T extends PgSelect>(
|
||||
qb: T,
|
||||
orderByColumn: PgColumn | SQL | SQL.Aliased,
|
||||
page = 1,
|
||||
pageSize = 250
|
||||
) {
|
||||
return qb
|
||||
.orderBy(orderByColumn)
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a paginated query and returns results with pagination metadata
|
||||
* This is the recommended approach as it handles the count query automatically.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await withPaginationMeta({
|
||||
* query: db.select().from(users).where(eq(users.active, true)).$dynamic(),
|
||||
* orderBy: users.id,
|
||||
* page: 2,
|
||||
* page_size: 10,
|
||||
* countFrom: users,
|
||||
* countWhere: eq(users.active, true)
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function withPaginationMeta<T extends PgSelect>({
|
||||
query,
|
||||
orderBy,
|
||||
page = 1,
|
||||
page_size = 250,
|
||||
countFrom,
|
||||
countWhere,
|
||||
}: {
|
||||
query: T;
|
||||
orderBy: PgColumn | SQL | SQL.Aliased;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
countFrom: PgTable<TableConfig>;
|
||||
countWhere?: SQL;
|
||||
}): Promise<PaginatedResponse<Awaited<ReturnType<T['execute']>>[number]>> {
|
||||
// Apply pagination to the query
|
||||
const paginatedQuery = withPagination(query, orderBy, page, page_size);
|
||||
|
||||
// Execute the paginated query
|
||||
const results = await paginatedQuery;
|
||||
|
||||
// Get total count
|
||||
const countQuery = db.select({ count: count() }).from(countFrom);
|
||||
const countWithWhere = countWhere ? countQuery.where(countWhere) : countQuery;
|
||||
const totalResult = await countWithWhere;
|
||||
const total = Number(totalResult[0]?.count || 0);
|
||||
const total_pages = Math.ceil(total / page_size);
|
||||
|
||||
return {
|
||||
data: results,
|
||||
pagination: {
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
total_pages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates pagination metadata from count and pagination parameters
|
||||
* Useful when you already have the total count
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const metadata = createPaginationMetadata({
|
||||
* total: 100,
|
||||
* page: 2,
|
||||
* page_size: 10
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPaginationMetadata({
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
}: {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): PaginationMetadata {
|
||||
return {
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
total_pages: Math.ceil(total / page_size),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a paginated response from existing data and count.
|
||||
*
|
||||
* Use this when:
|
||||
* - You already have both the data and total count from separate queries
|
||||
* - You need to transform the data before creating the response
|
||||
* - You're working with data that's not directly from a database query
|
||||
*
|
||||
* For most cases, prefer `withPaginationMeta` which handles the count query automatically.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Only use when you already have the count or need custom transformation
|
||||
* const users = await customUserQuery();
|
||||
* const total = await customCountQuery();
|
||||
*
|
||||
* return createPaginatedResponse({
|
||||
* data: users.map(u => ({ ...u, displayName: u.name })),
|
||||
* page: 1,
|
||||
* page_size: 10,
|
||||
* total
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPaginatedResponse<T>({
|
||||
data,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
}: {
|
||||
data: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
}): PaginatedResponse<T> {
|
||||
return {
|
||||
data,
|
||||
pagination: createPaginationMetadata({ total, page, page_size }),
|
||||
};
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { getUserOrganizationId } from '../organizations/organizations';
|
||||
import { withPaginationMeta } from '../shared-types';
|
||||
import { getUserToOrganization } from './users-to-organizations';
|
||||
|
||||
// Mock the organizations module
|
||||
|
@ -7,13 +8,20 @@ vi.mock('../organizations/organizations', () => ({
|
|||
getUserOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the shared-types module
|
||||
vi.mock('../shared-types', () => ({
|
||||
withPaginationMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the database connection
|
||||
vi.mock('../../connection', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
innerJoin: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve([])),
|
||||
where: vi.fn(() => ({
|
||||
$dynamic: vi.fn(() => 'mock-dynamic-query'),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
|
@ -22,6 +30,7 @@ vi.mock('../../connection', () => ({
|
|||
|
||||
describe('getUserToOrganization', () => {
|
||||
const mockGetUserOrganizationId = vi.mocked(getUserOrganizationId);
|
||||
const mockWithPaginationMeta = vi.mocked(withPaginationMeta);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
@ -63,16 +72,16 @@ describe('getUserToOrganization', () => {
|
|||
role: 'querier',
|
||||
});
|
||||
|
||||
const { db } = await import('../../connection');
|
||||
const mockDb = vi.mocked(db);
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(mockUsers),
|
||||
}),
|
||||
}),
|
||||
} as unknown as ReturnType<typeof db.select>);
|
||||
// Mock withPaginationMeta to return the expected structure
|
||||
mockWithPaginationMeta.mockResolvedValue({
|
||||
data: mockUsers,
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 250,
|
||||
total: 2,
|
||||
total_pages: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await getUserToOrganization({
|
||||
|
@ -80,8 +89,28 @@ describe('getUserToOrganization', () => {
|
|||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(result).toEqual({
|
||||
users: mockUsers,
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 250,
|
||||
total: 2,
|
||||
total_pages: 1,
|
||||
},
|
||||
});
|
||||
expect(mockGetUserOrganizationId).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000');
|
||||
expect(mockWithPaginationMeta).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'mock-dynamic-query',
|
||||
orderBy: expect.anything(),
|
||||
page: 1,
|
||||
page_size: 250,
|
||||
countFrom: expect.objectContaining({
|
||||
// Just check that it has a table name symbol
|
||||
[Symbol.for('drizzle:Name')]: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply userName filter when provided', async () => {
|
||||
|
@ -102,16 +131,15 @@ describe('getUserToOrganization', () => {
|
|||
role: 'querier',
|
||||
});
|
||||
|
||||
const { db } = await import('../../connection');
|
||||
const mockDb = vi.mocked(db);
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(mockUsers),
|
||||
}),
|
||||
}),
|
||||
} as unknown as ReturnType<typeof db.select>);
|
||||
mockWithPaginationMeta.mockResolvedValue({
|
||||
data: mockUsers,
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 250,
|
||||
total: 1,
|
||||
total_pages: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await getUserToOrganization({
|
||||
|
@ -120,8 +148,87 @@ describe('getUserToOrganization', () => {
|
|||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(result).toEqual({
|
||||
users: mockUsers,
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 250,
|
||||
total: 1,
|
||||
total_pages: 1,
|
||||
},
|
||||
});
|
||||
expect(mockGetUserOrganizationId).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000');
|
||||
expect(mockWithPaginationMeta).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'mock-dynamic-query',
|
||||
orderBy: expect.anything(),
|
||||
page: 1,
|
||||
page_size: 250,
|
||||
countFrom: expect.objectContaining({
|
||||
[Symbol.for('drizzle:Name')]: expect.any(String),
|
||||
}),
|
||||
countWhere: expect.anything(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle pagination correctly', async () => {
|
||||
// Arrange
|
||||
const mockUsers = [
|
||||
{
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
avatarUrl: null,
|
||||
role: 'querier',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
mockGetUserOrganizationId.mockResolvedValue({
|
||||
organizationId: 'org-123',
|
||||
role: 'querier',
|
||||
});
|
||||
|
||||
mockWithPaginationMeta.mockResolvedValue({
|
||||
data: mockUsers,
|
||||
pagination: {
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
total: 100,
|
||||
total_pages: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await getUserToOrganization({
|
||||
userId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
users: mockUsers,
|
||||
pagination: {
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
total: 100,
|
||||
total_pages: 10,
|
||||
},
|
||||
});
|
||||
expect(mockGetUserOrganizationId).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000');
|
||||
expect(mockWithPaginationMeta).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'mock-dynamic-query',
|
||||
orderBy: expect.anything(),
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
countFrom: expect.objectContaining({
|
||||
[Symbol.for('drizzle:Name')]: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate input with invalid UUID', async () => {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { type InferSelectModel, and, eq, isNull, like } from 'drizzle-orm';
|
||||
import { type InferSelectModel, and, asc, eq, isNull, like } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../../connection';
|
||||
import { users, usersToOrganizations } from '../../schema';
|
||||
import { getUserOrganizationId } from '../organizations/organizations';
|
||||
import { withPaginationMeta } from '../shared-types';
|
||||
|
||||
type RawOrganizationUser = InferSelectModel<typeof usersToOrganizations>;
|
||||
type RawUser = InferSelectModel<typeof users>;
|
||||
|
@ -10,6 +11,8 @@ type RawUser = InferSelectModel<typeof users>;
|
|||
// Input schema for type safety
|
||||
const GetUserToOrganizationInputSchema = z.object({
|
||||
userId: z.string().uuid('User ID must be a valid UUID'),
|
||||
page: z.number().optional().default(1),
|
||||
page_size: z.number().optional().default(250),
|
||||
filters: z
|
||||
.object({
|
||||
userName: z.string().optional(),
|
||||
|
@ -23,12 +26,24 @@ export type GetUserToOrganizationInput = z.infer<typeof GetUserToOrganizationInp
|
|||
export type OrganizationUser = Pick<RawUser, 'id' | 'name' | 'email' | 'avatarUrl'> &
|
||||
Pick<RawOrganizationUser, 'role' | 'status'>;
|
||||
|
||||
export type GetUserToOrganizationResult = {
|
||||
users: OrganizationUser[];
|
||||
pagination: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserToOrganization = async ({
|
||||
userId,
|
||||
page = 1,
|
||||
page_size = 250,
|
||||
filters,
|
||||
}: GetUserToOrganizationInput): Promise<OrganizationUser[]> => {
|
||||
}: GetUserToOrganizationInput): Promise<GetUserToOrganizationResult> => {
|
||||
// Validate input
|
||||
const validated = GetUserToOrganizationInputSchema.parse({ userId, filters });
|
||||
const validated = GetUserToOrganizationInputSchema.parse({ userId, page, page_size, filters });
|
||||
|
||||
// Get the user's organization ID
|
||||
const userOrg = await getUserOrganizationId(validated.userId);
|
||||
|
@ -52,8 +67,8 @@ export const getUserToOrganization = async ({
|
|||
...filterConditions
|
||||
);
|
||||
|
||||
// Execute the query
|
||||
const results = await db
|
||||
// Build the query with dynamic
|
||||
const query = db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
|
@ -64,8 +79,24 @@ export const getUserToOrganization = async ({
|
|||
})
|
||||
.from(users)
|
||||
.innerJoin(usersToOrganizations, eq(users.id, usersToOrganizations.userId))
|
||||
.where(whereCondition);
|
||||
.where(whereCondition)
|
||||
.$dynamic();
|
||||
|
||||
// Validate and return results
|
||||
return results;
|
||||
// Use withPaginationMeta to handle pagination and count automatically
|
||||
const paginationOptions = {
|
||||
query,
|
||||
orderBy: asc(users.name), // Order by name for consistent results
|
||||
page: validated.page,
|
||||
page_size: validated.page_size,
|
||||
countFrom: users,
|
||||
...(whereCondition && { countWhere: whereCondition }),
|
||||
};
|
||||
|
||||
const result = await withPaginationMeta(paginationOptions);
|
||||
|
||||
// Transform to match expected format
|
||||
return {
|
||||
users: result.data,
|
||||
pagination: result.pagination,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -27,7 +27,17 @@ export const UserListResponseSchema = z.array(
|
|||
|
||||
export const UserFavoriteResponseSchema = z.array(UserFavoriteSchema);
|
||||
|
||||
export const GetUserToOrganizationResponseSchema = z.array(OrganizationUserSchema);
|
||||
const PaginationSchema = z.object({
|
||||
page: z.number(),
|
||||
page_size: z.number(),
|
||||
total: z.number(),
|
||||
total_pages: z.number(),
|
||||
});
|
||||
|
||||
export const GetUserToOrganizationResponseSchema = z.object({
|
||||
users: z.array(OrganizationUserSchema),
|
||||
pagination: PaginationSchema,
|
||||
});
|
||||
|
||||
export type UserResponse = z.infer<typeof UserResponseSchema>;
|
||||
export type UserListResponse = z.infer<typeof UserListResponseSchema>;
|
||||
|
|
Loading…
Reference in New Issue