Skip to content
Merged
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
88 changes: 88 additions & 0 deletions src/db/repositories/usersRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ export interface DashboardUser {
role: 'member' | 'admin' | 'superadmin';
}

export interface OrgUser {
id: string;
orgId: string;
email: string;
name: string;
role: string;
createdAt: Date | null;
updatedAt: Date | null;
}

export async function getUserByEmail(email: string) {
const db = getDb();
const [row] = await db.select().from(users).where(eq(users.email, email));
Expand Down Expand Up @@ -73,3 +83,81 @@ export async function deleteExpiredSessions(): Promise<void> {
const db = getDb();
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
}

// ============================================================================
// CRUD for users (org-scoped)
// ============================================================================

/**
* List all users in an org. Never returns passwordHash.
*/
export async function listOrgUsers(orgId: string): Promise<OrgUser[]> {
const db = getDb();
return db
.select({
id: users.id,
orgId: users.orgId,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.orgId, orgId));
}

/**
* Create a new user. The passwordHash must be pre-hashed by the caller.
* Returns the new user's id.
*/
export async function createUser(params: {
orgId: string;
email: string;
passwordHash: string;
name: string;
role: string;
}): Promise<{ id: string }> {
const db = getDb();
const [row] = await db
.insert(users)
.values({
orgId: params.orgId,
email: params.email,
passwordHash: params.passwordHash,
name: params.name,
role: params.role,
})
.returning({ id: users.id });
return row;
}

/**
* Sparse update for name, email, role, passwordHash. Sets updatedAt on every update.
*/
export async function updateUser(
id: string,
updates: {
name?: string;
email?: string;
role?: string;
passwordHash?: string;
},
): Promise<void> {
const db = getDb();
const setClause: Record<string, unknown> = { updatedAt: new Date() };
if (updates.name !== undefined) setClause.name = updates.name;
if (updates.email !== undefined) setClause.email = updates.email;
if (updates.role !== undefined) setClause.role = updates.role;
if (updates.passwordHash !== undefined) setClause.passwordHash = updates.passwordHash;

await db.update(users).set(setClause).where(eq(users.id, id));
}

/**
* Delete a user by id. Sessions cascade-delete via FK constraint.
*/
export async function deleteUser(id: string): Promise<void> {
const db = getDb();
await db.delete(users).where(eq(users.id, id));
}
199 changes: 169 additions & 30 deletions tests/unit/db/repositories/usersRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mockInsert = vi.fn();
const mockSelect = vi.fn();
const mockDelete = vi.fn();
const mockValues = vi.fn();
const mockReturning = vi.fn();
const mockWhere = vi.fn();
const mockFrom = vi.fn();
import { createMockDb } from '../../../helpers/mockDb.js';

vi.mock('../../../../src/db/client.js', () => ({
getDb: () => ({
insert: mockInsert,
select: mockSelect,
delete: mockDelete,
}),
getDb: vi.fn(),
}));

vi.mock('../../../../src/db/schema/index.js', () => ({
Expand All @@ -24,6 +13,8 @@ vi.mock('../../../../src/db/schema/index.js', () => ({
passwordHash: 'password_hash',
name: 'name',
role: 'role',
createdAt: 'created_at',
updatedAt: 'updated_at',
},
sessions: {
id: 'id',
Expand All @@ -33,22 +24,26 @@ vi.mock('../../../../src/db/schema/index.js', () => ({
},
}));

import { getDb } from '../../../../src/db/client.js';
import {
createSession,
createUser,
deleteExpiredSessions,
deleteSession,
deleteUser,
getSessionByToken,
getUserByEmail,
getUserById,
listOrgUsers,
updateUser,
} from '../../../../src/db/repositories/usersRepository.js';

describe('usersRepository', () => {
let mockDb: ReturnType<typeof createMockDb>;

beforeEach(() => {
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere });
mockDelete.mockReturnValue({ where: mockWhere });
mockDb = createMockDb();
vi.mocked(getDb).mockReturnValue(mockDb.db as never);
});

describe('getUserByEmail', () => {
Expand All @@ -61,14 +56,14 @@ describe('usersRepository', () => {
name: 'Test',
role: 'admin',
};
mockWhere.mockResolvedValue([mockUser]);
mockDb.chain.where.mockResolvedValueOnce([mockUser]);

const result = await getUserByEmail('test@example.com');
expect(result).toEqual(mockUser);
});

it('returns null when no user matches', async () => {
mockWhere.mockResolvedValue([]);
mockDb.chain.where.mockResolvedValueOnce([]);

const result = await getUserByEmail('noone@example.com');
expect(result).toBeNull();
Expand All @@ -84,14 +79,14 @@ describe('usersRepository', () => {
name: 'Test',
role: 'admin',
};
mockWhere.mockResolvedValue([dashboardUser]);
mockDb.chain.where.mockResolvedValueOnce([dashboardUser]);

const result = await getUserById('u1');
expect(result).toEqual(dashboardUser);
});

it('returns null when not found', async () => {
mockWhere.mockResolvedValue([]);
mockDb.chain.where.mockResolvedValueOnce([]);

const result = await getUserById('nonexistent');
expect(result).toBeNull();
Expand All @@ -100,13 +95,13 @@ describe('usersRepository', () => {

describe('createSession', () => {
it('inserts session and returns id', async () => {
mockReturning.mockResolvedValue([{ id: 'session-uuid' }]);
mockDb.chain.returning.mockResolvedValueOnce([{ id: 'session-uuid' }]);
const expiresAt = new Date('2099-01-01');

const result = await createSession('user-1', 'token-abc', expiresAt);

expect(result).toBe('session-uuid');
expect(mockValues).toHaveBeenCalledWith({
expect(mockDb.chain.values).toHaveBeenCalledWith({
userId: 'user-1',
token: 'token-abc',
expiresAt,
Expand All @@ -121,14 +116,14 @@ describe('usersRepository', () => {
userId: 'u1',
expiresAt: new Date('2099-01-01'),
};
mockWhere.mockResolvedValue([sessionRow]);
mockDb.chain.where.mockResolvedValueOnce([sessionRow]);

const result = await getSessionByToken('valid-token');
expect(result).toEqual(sessionRow);
});

it('returns null when no matching session', async () => {
mockWhere.mockResolvedValue([]);
mockDb.chain.where.mockResolvedValueOnce([]);

const result = await getSessionByToken('expired-token');
expect(result).toBeNull();
Expand All @@ -137,19 +132,163 @@ describe('usersRepository', () => {

describe('deleteSession', () => {
it('deletes session by token', async () => {
mockWhere.mockResolvedValue(undefined);
mockDb.chain.where.mockResolvedValueOnce(undefined);

await deleteSession('token-to-delete');
expect(mockDelete).toHaveBeenCalled();
expect(mockDb.db.delete).toHaveBeenCalled();
});
});

describe('deleteExpiredSessions', () => {
it('deletes sessions with past expiresAt', async () => {
mockWhere.mockResolvedValue(undefined);
mockDb.chain.where.mockResolvedValueOnce(undefined);

await deleteExpiredSessions();
expect(mockDelete).toHaveBeenCalled();
expect(mockDb.db.delete).toHaveBeenCalled();
});
});

describe('listOrgUsers', () => {
it('returns all users for org without passwordHash', async () => {
const mockUsers = [
{
id: 'u1',
orgId: 'org-1',
email: 'alice@example.com',
name: 'Alice',
role: 'admin',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'u2',
orgId: 'org-1',
email: 'bob@example.com',
name: 'Bob',
role: 'member',
createdAt: new Date('2024-02-01'),
updatedAt: new Date('2024-02-01'),
},
];
mockDb.chain.where.mockResolvedValueOnce(mockUsers);

const result = await listOrgUsers('org-1');

expect(result).toHaveLength(2);
expect(result[0]).toEqual(mockUsers[0]);
expect(result[1]).toEqual(mockUsers[1]);
// Verify passwordHash is not in the result
for (const user of result) {
expect(user).not.toHaveProperty('passwordHash');
}
});

it('returns empty array when no users in org', async () => {
mockDb.chain.where.mockResolvedValueOnce([]);

const result = await listOrgUsers('empty-org');
expect(result).toEqual([]);
});

it('queries by orgId', async () => {
mockDb.chain.where.mockResolvedValueOnce([]);

await listOrgUsers('org-123');

expect(mockDb.db.select).toHaveBeenCalledTimes(1);
});
});

describe('createUser', () => {
it('inserts user and returns id', async () => {
mockDb.chain.returning.mockResolvedValueOnce([{ id: 'new-user-uuid' }]);

const result = await createUser({
orgId: 'org-1',
email: 'newuser@example.com',
passwordHash: '$2b$10$hashed',
name: 'New User',
role: 'member',
});

expect(result).toEqual({ id: 'new-user-uuid' });
expect(mockDb.db.insert).toHaveBeenCalledTimes(1);
});

it('stores pre-hashed password without modification', async () => {
mockDb.chain.returning.mockResolvedValueOnce([{ id: 'u1' }]);
const hashedPassword = '$2b$10$somehash';

await createUser({
orgId: 'org-1',
email: 'test@example.com',
passwordHash: hashedPassword,
name: 'Test User',
role: 'admin',
});

expect(mockDb.chain.values).toHaveBeenCalledWith({
orgId: 'org-1',
email: 'test@example.com',
passwordHash: hashedPassword,
name: 'Test User',
role: 'admin',
});
});
});

describe('updateUser', () => {
it('updates specified fields and sets updatedAt', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await updateUser('u1', { name: 'New Name', email: 'new@example.com' });

expect(mockDb.db.update).toHaveBeenCalledTimes(1);
const setArg = mockDb.chain.set.mock.calls[0][0];
expect(setArg.name).toBe('New Name');
expect(setArg.email).toBe('new@example.com');
expect(setArg.updatedAt).toBeInstanceOf(Date);
});

it('only updates provided fields', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await updateUser('u1', { role: 'admin' });

const setArg = mockDb.chain.set.mock.calls[0][0];
expect(setArg.role).toBe('admin');
expect(setArg.name).toBeUndefined();
expect(setArg.email).toBeUndefined();
expect(setArg.passwordHash).toBeUndefined();
});

it('updates passwordHash when provided', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);
const newHash = '$2b$10$newhash';

await updateUser('u1', { passwordHash: newHash });

const setArg = mockDb.chain.set.mock.calls[0][0];
expect(setArg.passwordHash).toBe(newHash);
});

it('always sets updatedAt even with no other fields', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await updateUser('u1', {});

const setArg = mockDb.chain.set.mock.calls[0][0];
expect(setArg.updatedAt).toBeInstanceOf(Date);
});
});

describe('deleteUser', () => {
it('deletes user by id', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await deleteUser('u1');

expect(mockDb.db.delete).toHaveBeenCalledTimes(1);
});
});
});
Loading