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 { id: userId } = c.get('busterUser');
|
||||||
const { page, page_size, filters } = c.req.valid('json');
|
const { page, page_size, filters } = c.req.valid('json');
|
||||||
|
|
||||||
const users = await getUserToOrganization({
|
const result = await getUserToOrganization({
|
||||||
userId,
|
userId,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: GetUserToOrganizationResponse = users;
|
const response: GetUserToOrganizationResponse = result;
|
||||||
|
|
||||||
return c.json(response);
|
return c.json(response);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,8 @@
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"suspicious": {
|
"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 { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { getUserOrganizationId } from '../organizations/organizations';
|
import { getUserOrganizationId } from '../organizations/organizations';
|
||||||
|
import { withPaginationMeta } from '../shared-types';
|
||||||
import { getUserToOrganization } from './users-to-organizations';
|
import { getUserToOrganization } from './users-to-organizations';
|
||||||
|
|
||||||
// Mock the organizations module
|
// Mock the organizations module
|
||||||
|
@ -7,13 +8,20 @@ vi.mock('../organizations/organizations', () => ({
|
||||||
getUserOrganizationId: vi.fn(),
|
getUserOrganizationId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the shared-types module
|
||||||
|
vi.mock('../shared-types', () => ({
|
||||||
|
withPaginationMeta: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the database connection
|
// Mock the database connection
|
||||||
vi.mock('../../connection', () => ({
|
vi.mock('../../connection', () => ({
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(() => ({
|
select: vi.fn(() => ({
|
||||||
from: vi.fn(() => ({
|
from: vi.fn(() => ({
|
||||||
innerJoin: 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', () => {
|
describe('getUserToOrganization', () => {
|
||||||
const mockGetUserOrganizationId = vi.mocked(getUserOrganizationId);
|
const mockGetUserOrganizationId = vi.mocked(getUserOrganizationId);
|
||||||
|
const mockWithPaginationMeta = vi.mocked(withPaginationMeta);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
@ -63,16 +72,16 @@ describe('getUserToOrganization', () => {
|
||||||
role: 'querier',
|
role: 'querier',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { db } = await import('../../connection');
|
// Mock withPaginationMeta to return the expected structure
|
||||||
const mockDb = vi.mocked(db);
|
mockWithPaginationMeta.mockResolvedValue({
|
||||||
|
data: mockUsers,
|
||||||
mockDb.select.mockReturnValue({
|
pagination: {
|
||||||
from: vi.fn().mockReturnValue({
|
page: 1,
|
||||||
innerJoin: vi.fn().mockReturnValue({
|
page_size: 250,
|
||||||
where: vi.fn().mockResolvedValue(mockUsers),
|
total: 2,
|
||||||
}),
|
total_pages: 1,
|
||||||
}),
|
},
|
||||||
} as unknown as ReturnType<typeof db.select>);
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await getUserToOrganization({
|
const result = await getUserToOrganization({
|
||||||
|
@ -80,8 +89,28 @@ describe('getUserToOrganization', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// 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(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 () => {
|
test('should apply userName filter when provided', async () => {
|
||||||
|
@ -102,16 +131,15 @@ describe('getUserToOrganization', () => {
|
||||||
role: 'querier',
|
role: 'querier',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { db } = await import('../../connection');
|
mockWithPaginationMeta.mockResolvedValue({
|
||||||
const mockDb = vi.mocked(db);
|
data: mockUsers,
|
||||||
|
pagination: {
|
||||||
mockDb.select.mockReturnValue({
|
page: 1,
|
||||||
from: vi.fn().mockReturnValue({
|
page_size: 250,
|
||||||
innerJoin: vi.fn().mockReturnValue({
|
total: 1,
|
||||||
where: vi.fn().mockResolvedValue(mockUsers),
|
total_pages: 1,
|
||||||
}),
|
},
|
||||||
}),
|
});
|
||||||
} as unknown as ReturnType<typeof db.select>);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await getUserToOrganization({
|
const result = await getUserToOrganization({
|
||||||
|
@ -120,8 +148,87 @@ describe('getUserToOrganization', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// 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(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 () => {
|
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 { z } from 'zod';
|
||||||
import { db } from '../../connection';
|
import { db } from '../../connection';
|
||||||
import { users, usersToOrganizations } from '../../schema';
|
import { users, usersToOrganizations } from '../../schema';
|
||||||
import { getUserOrganizationId } from '../organizations/organizations';
|
import { getUserOrganizationId } from '../organizations/organizations';
|
||||||
|
import { withPaginationMeta } from '../shared-types';
|
||||||
|
|
||||||
type RawOrganizationUser = InferSelectModel<typeof usersToOrganizations>;
|
type RawOrganizationUser = InferSelectModel<typeof usersToOrganizations>;
|
||||||
type RawUser = InferSelectModel<typeof users>;
|
type RawUser = InferSelectModel<typeof users>;
|
||||||
|
@ -10,6 +11,8 @@ type RawUser = InferSelectModel<typeof users>;
|
||||||
// Input schema for type safety
|
// Input schema for type safety
|
||||||
const GetUserToOrganizationInputSchema = z.object({
|
const GetUserToOrganizationInputSchema = z.object({
|
||||||
userId: z.string().uuid('User ID must be a valid UUID'),
|
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
|
filters: z
|
||||||
.object({
|
.object({
|
||||||
userName: z.string().optional(),
|
userName: z.string().optional(),
|
||||||
|
@ -23,12 +26,24 @@ export type GetUserToOrganizationInput = z.infer<typeof GetUserToOrganizationInp
|
||||||
export type OrganizationUser = Pick<RawUser, 'id' | 'name' | 'email' | 'avatarUrl'> &
|
export type OrganizationUser = Pick<RawUser, 'id' | 'name' | 'email' | 'avatarUrl'> &
|
||||||
Pick<RawOrganizationUser, 'role' | 'status'>;
|
Pick<RawOrganizationUser, 'role' | 'status'>;
|
||||||
|
|
||||||
|
export type GetUserToOrganizationResult = {
|
||||||
|
users: OrganizationUser[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getUserToOrganization = async ({
|
export const getUserToOrganization = async ({
|
||||||
userId,
|
userId,
|
||||||
|
page = 1,
|
||||||
|
page_size = 250,
|
||||||
filters,
|
filters,
|
||||||
}: GetUserToOrganizationInput): Promise<OrganizationUser[]> => {
|
}: GetUserToOrganizationInput): Promise<GetUserToOrganizationResult> => {
|
||||||
// Validate input
|
// Validate input
|
||||||
const validated = GetUserToOrganizationInputSchema.parse({ userId, filters });
|
const validated = GetUserToOrganizationInputSchema.parse({ userId, page, page_size, filters });
|
||||||
|
|
||||||
// Get the user's organization ID
|
// Get the user's organization ID
|
||||||
const userOrg = await getUserOrganizationId(validated.userId);
|
const userOrg = await getUserOrganizationId(validated.userId);
|
||||||
|
@ -52,8 +67,8 @@ export const getUserToOrganization = async ({
|
||||||
...filterConditions
|
...filterConditions
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute the query
|
// Build the query with dynamic
|
||||||
const results = await db
|
const query = db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
|
@ -64,8 +79,24 @@ export const getUserToOrganization = async ({
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.innerJoin(usersToOrganizations, eq(users.id, usersToOrganizations.userId))
|
.innerJoin(usersToOrganizations, eq(users.id, usersToOrganizations.userId))
|
||||||
.where(whereCondition);
|
.where(whereCondition)
|
||||||
|
.$dynamic();
|
||||||
|
|
||||||
// Validate and return results
|
// Use withPaginationMeta to handle pagination and count automatically
|
||||||
return results;
|
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 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 UserResponse = z.infer<typeof UserResponseSchema>;
|
||||||
export type UserListResponse = z.infer<typeof UserListResponseSchema>;
|
export type UserListResponse = z.infer<typeof UserListResponseSchema>;
|
||||||
|
|
Loading…
Reference in New Issue