diff --git a/apps/server/src/api/v2/users/index.ts b/apps/server/src/api/v2/users/index.ts index a4fdeb5dc..c57880799 100644 --- a/apps/server/src/api/v2/users/index.ts +++ b/apps/server/src/api/v2/users/index.ts @@ -7,6 +7,8 @@ const app = new Hono() // Apply authentication globally to ALL routes in this router .use('*', requireAuth) .get('/', (c) => { + const _user = c.get('busterUser'); + // Stub data for user listing (only accessible to authenticated users) const stubUsers = [ { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin' }, diff --git a/packages/database/src/queries/users/index.ts b/packages/database/src/queries/users/index.ts index e5abc8565..6e4f8a7e4 100644 --- a/packages/database/src/queries/users/index.ts +++ b/packages/database/src/queries/users/index.ts @@ -1 +1,2 @@ export * from './user'; +export * from './users-to-organizations'; diff --git a/packages/database/src/queries/users/users-to-organizations.test.ts b/packages/database/src/queries/users/users-to-organizations.test.ts new file mode 100644 index 000000000..41c5f502c --- /dev/null +++ b/packages/database/src/queries/users/users-to-organizations.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getUserOrganizationId } from '../organizations/organizations'; +import { getUserToOrganization } from './users-to-organizations'; + +// Mock the organizations module +vi.mock('../organizations/organizations', () => ({ + getUserOrganizationId: vi.fn(), +})); + +// Mock the database connection +vi.mock('../../connection', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve([])), + })), + })), + })), + }, +})); + +describe('getUserToOrganization', () => { + const mockGetUserOrganizationId = vi.mocked(getUserOrganizationId); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should throw error when user is not found in any organization', async () => { + // Arrange + mockGetUserOrganizationId.mockResolvedValue(null); + + // Act & Assert + await expect( + getUserToOrganization({ userId: '123e4567-e89b-12d3-a456-426614174000' }) + ).rejects.toThrow('User not found in any organization'); + }); + + test('should return users in the same organization without filters', async () => { + // Arrange + const mockUsers = [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'John Doe', + email: 'john@example.com', + avatarUrl: null, + role: 'querier', + status: 'active', + }, + { + id: '123e4567-e89b-12d3-a456-426614174001', + name: 'Jane Smith', + email: 'jane@example.com', + avatarUrl: 'https://example.com/avatar.jpg', + role: 'workspace_admin', + status: 'active', + }, + ]; + + mockGetUserOrganizationId.mockResolvedValue({ + organizationId: 'org-123', + 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); + + // Act + const result = await getUserToOrganization({ + userId: '123e4567-e89b-12d3-a456-426614174000', + }); + + // Assert + expect(result).toEqual(mockUsers); + expect(mockGetUserOrganizationId).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + }); + + test('should apply userName filter when provided', 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', + }); + + 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); + + // Act + const result = await getUserToOrganization({ + userId: '123e4567-e89b-12d3-a456-426614174000', + filters: { userName: 'John' }, + }); + + // Assert + expect(result).toEqual(mockUsers); + expect(mockGetUserOrganizationId).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + }); + + test('should validate input with invalid UUID', async () => { + // Act & Assert + await expect(getUserToOrganization({ userId: 'invalid-uuid' })).rejects.toThrow( + 'User ID must be a valid UUID' + ); + }); +}); diff --git a/packages/database/src/queries/users/users-to-organizations.ts b/packages/database/src/queries/users/users-to-organizations.ts new file mode 100644 index 000000000..d4798b14e --- /dev/null +++ b/packages/database/src/queries/users/users-to-organizations.ts @@ -0,0 +1,90 @@ +import { and, eq, isNull, like } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '../../connection'; +import { users, usersToOrganizations } from '../../schema'; +import { getUserOrganizationId } from '../organizations/organizations'; + +// Input schema for type safety +const GetUserToOrganizationInputSchema = z.object({ + userId: z.string().uuid('User ID must be a valid UUID'), + filters: z + .object({ + userName: z.string().optional(), + email: z.string().optional(), + }) + .optional(), +}); + +export type GetUserToOrganizationInput = z.infer; + +// Output schema for type safety +export const UserOutputSchema = z.object({ + id: z.string().uuid(), + name: z.string().nullable(), + email: z.string(), + avatarUrl: z.string().nullable(), + role: z.string(), + status: z.string(), +}); + +export type UserWithRole = z.infer; + +export const getUserToOrganization = async ({ + userId, + filters, +}: GetUserToOrganizationInput): Promise => { + // Validate input + const validated = GetUserToOrganizationInputSchema.parse({ userId, filters }); + + try { + // First, get the user's organization ID using the existing function + const userOrg = await getUserOrganizationId(validated.userId); + + if (!userOrg) { + throw new Error('User not found in any organization'); + } + + const organizationId = userOrg.organizationId; + + // Build the where conditions for the join + const joinConditions = and( + eq(users.id, usersToOrganizations.userId), + eq(usersToOrganizations.organizationId, organizationId), + isNull(usersToOrganizations.deletedAt) + ); + + // Build the where conditions for filtering + const conditions = []; + if (validated.filters?.userName) { + conditions.push(like(users.name, `%${validated.filters.userName}%`)); + } + if (validated.filters?.email) { + conditions.push(like(users.email, `%${validated.filters.email}%`)); + } + + const baseQuery = db + .select({ + id: users.id, + name: users.name, + email: users.email, + avatarUrl: users.avatarUrl, + role: usersToOrganizations.role, + status: usersToOrganizations.status, + }) + .from(users) + .innerJoin(usersToOrganizations, joinConditions); + + const results = + conditions.length > 0 + ? await baseQuery.where(conditions.length === 1 ? conditions[0] : and(...conditions)) + : await baseQuery; + + // Validate and return results + return results.map((user) => UserOutputSchema.parse(user)); + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error(`Invalid input: ${error.errors.map((e) => e.message).join(', ')}`); + } + throw error; + } +};