admin area
This commit is contained in:
324
apps/website/lib/view-models/AdminUserViewModel.test.ts
Normal file
324
apps/website/lib/view-models/AdminUserViewModel.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel';
|
||||
import type { UserDto, DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||
|
||||
describe('AdminUserViewModel', () => {
|
||||
const createBaseDto = (): UserDto => ({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
isSystemAdmin: false,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
lastLoginAt: new Date('2024-01-15T10:30:00Z'),
|
||||
primaryDriverId: 'driver-456',
|
||||
});
|
||||
|
||||
it('maps core fields from DTO', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.id).toBe('user-123');
|
||||
expect(vm.email).toBe('test@example.com');
|
||||
expect(vm.displayName).toBe('Test User');
|
||||
expect(vm.roles).toEqual(['user']);
|
||||
expect(vm.status).toBe('active');
|
||||
expect(vm.isSystemAdmin).toBe(false);
|
||||
expect(vm.primaryDriverId).toBe('driver-456');
|
||||
});
|
||||
|
||||
it('converts dates to Date objects', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.createdAt).toBeInstanceOf(Date);
|
||||
expect(vm.updatedAt).toBeInstanceOf(Date);
|
||||
expect(vm.lastLoginAt).toBeInstanceOf(Date);
|
||||
expect(vm.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('handles missing lastLoginAt', () => {
|
||||
const dto = createBaseDto();
|
||||
delete dto.lastLoginAt;
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.lastLoginAt).toBeUndefined();
|
||||
expect(vm.lastLoginFormatted).toBe('Never');
|
||||
});
|
||||
|
||||
it('formats role badges correctly', () => {
|
||||
const owner = new AdminUserViewModel({ ...createBaseDto(), roles: ['owner'] });
|
||||
const admin = new AdminUserViewModel({ ...createBaseDto(), roles: ['admin'] });
|
||||
const user = new AdminUserViewModel({ ...createBaseDto(), roles: ['user'] });
|
||||
const custom = new AdminUserViewModel({ ...createBaseDto(), roles: ['custom-role'] });
|
||||
|
||||
expect(owner.roleBadges).toEqual(['Owner']);
|
||||
expect(admin.roleBadges).toEqual(['Admin']);
|
||||
expect(user.roleBadges).toEqual(['User']);
|
||||
expect(custom.roleBadges).toEqual(['custom-role']);
|
||||
});
|
||||
|
||||
it('derives status badge correctly', () => {
|
||||
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
|
||||
|
||||
expect(active.statusBadge).toEqual({ label: 'Active', variant: 'performance-green' });
|
||||
expect(suspended.statusBadge).toEqual({ label: 'Suspended', variant: 'yellow-500' });
|
||||
expect(deleted.statusBadge).toEqual({ label: 'Deleted', variant: 'racing-red' });
|
||||
});
|
||||
|
||||
it('formats dates for display', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.lastLoginFormatted).toBe('1/15/2024');
|
||||
expect(vm.createdAtFormatted).toBe('1/1/2024');
|
||||
});
|
||||
|
||||
it('derives action permissions correctly', () => {
|
||||
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
|
||||
|
||||
expect(active.canSuspend).toBe(true);
|
||||
expect(active.canActivate).toBe(false);
|
||||
expect(active.canDelete).toBe(true);
|
||||
|
||||
expect(suspended.canSuspend).toBe(false);
|
||||
expect(suspended.canActivate).toBe(true);
|
||||
expect(suspended.canDelete).toBe(true);
|
||||
|
||||
expect(deleted.canSuspend).toBe(false);
|
||||
expect(deleted.canActivate).toBe(false);
|
||||
expect(deleted.canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple roles', () => {
|
||||
const dto = { ...createBaseDto(), roles: ['owner', 'admin'] };
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
|
||||
expect(vm.roleBadges).toEqual(['Owner', 'Admin']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardStatsViewModel', () => {
|
||||
const createBaseData = (): DashboardStats => ({
|
||||
totalUsers: 100,
|
||||
activeUsers: 70,
|
||||
suspendedUsers: 10,
|
||||
deletedUsers: 20,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 25,
|
||||
newUsersToday: 3,
|
||||
userGrowth: [
|
||||
{ label: 'Mon', value: 5, color: 'text-primary-blue' },
|
||||
{ label: 'Tue', value: 8, color: 'text-primary-blue' },
|
||||
],
|
||||
roleDistribution: [
|
||||
{ label: 'Owner', value: 2, color: 'text-purple-500' },
|
||||
{ label: 'Admin', value: 3, color: 'text-blue-500' },
|
||||
{ label: 'User', value: 95, color: 'text-gray-500' },
|
||||
],
|
||||
statusDistribution: {
|
||||
active: 70,
|
||||
suspended: 10,
|
||||
deleted: 20,
|
||||
},
|
||||
activityTimeline: [
|
||||
{ date: 'Mon', newUsers: 2, logins: 10 },
|
||||
{ date: 'Tue', newUsers: 3, logins: 15 },
|
||||
],
|
||||
});
|
||||
|
||||
it('maps all core fields from data', () => {
|
||||
const data = createBaseData();
|
||||
const vm = new DashboardStatsViewModel(data);
|
||||
|
||||
expect(vm.totalUsers).toBe(100);
|
||||
expect(vm.activeUsers).toBe(70);
|
||||
expect(vm.suspendedUsers).toBe(10);
|
||||
expect(vm.deletedUsers).toBe(20);
|
||||
expect(vm.systemAdmins).toBe(5);
|
||||
expect(vm.recentLogins).toBe(25);
|
||||
expect(vm.newUsersToday).toBe(3);
|
||||
});
|
||||
|
||||
it('computes active rate correctly', () => {
|
||||
const vm = new DashboardStatsViewModel(createBaseData());
|
||||
|
||||
expect(vm.activeRate).toBe(70); // 70%
|
||||
expect(vm.activeRateFormatted).toBe('70%');
|
||||
});
|
||||
|
||||
it('computes admin ratio correctly', () => {
|
||||
const vm = new DashboardStatsViewModel(createBaseData());
|
||||
// 5 admins, 95 non-admins => 1:19
|
||||
expect(vm.adminRatio).toBe('1:19');
|
||||
});
|
||||
|
||||
it('derives activity level correctly', () => {
|
||||
const lowEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 10, // 10% engagement
|
||||
});
|
||||
expect(lowEngagement.activityLevel).toBe('low');
|
||||
|
||||
const mediumEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 35, // 35% engagement
|
||||
});
|
||||
expect(mediumEngagement.activityLevel).toBe('medium');
|
||||
|
||||
const highEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 60, // 60% engagement
|
||||
});
|
||||
expect(highEngagement.activityLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('handles zero users safely', () => {
|
||||
const vm = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
});
|
||||
|
||||
expect(vm.activeRate).toBe(0);
|
||||
expect(vm.activeRateFormatted).toBe('0%');
|
||||
expect(vm.adminRatio).toBe('1:1');
|
||||
expect(vm.activityLevel).toBe('low');
|
||||
});
|
||||
|
||||
it('preserves arrays from input', () => {
|
||||
const data = createBaseData();
|
||||
const vm = new DashboardStatsViewModel(data);
|
||||
|
||||
expect(vm.userGrowth).toEqual(data.userGrowth);
|
||||
expect(vm.roleDistribution).toEqual(data.roleDistribution);
|
||||
expect(vm.activityTimeline).toEqual(data.activityTimeline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserListViewModel', () => {
|
||||
const createDto = (overrides: Partial<UserDto> = {}): UserDto => ({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
isSystemAdmin: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('wraps user DTOs in AdminUserViewModel instances', () => {
|
||||
const data = {
|
||||
users: [createDto({ id: 'user-1' }), createDto({ id: 'user-2' })],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const vm = new UserListViewModel(data);
|
||||
|
||||
expect(vm.users).toHaveLength(2);
|
||||
expect(vm.users[0]).toBeInstanceOf(AdminUserViewModel);
|
||||
expect(vm.users[0].id).toBe('user-1');
|
||||
expect(vm.users[1].id).toBe('user-2');
|
||||
});
|
||||
|
||||
it('exposes pagination metadata', () => {
|
||||
const data = {
|
||||
users: [createDto()],
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
};
|
||||
|
||||
const vm = new UserListViewModel(data);
|
||||
|
||||
expect(vm.total).toBe(50);
|
||||
expect(vm.page).toBe(2);
|
||||
expect(vm.limit).toBe(10);
|
||||
expect(vm.totalPages).toBe(5);
|
||||
});
|
||||
|
||||
it('derives hasUsers correctly', () => {
|
||||
const withUsers = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const withoutUsers = new UserListViewModel({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
expect(withUsers.hasUsers).toBe(true);
|
||||
expect(withoutUsers.hasUsers).toBe(false);
|
||||
});
|
||||
|
||||
it('derives showPagination correctly', () => {
|
||||
const withPagination = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
total: 20,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 2,
|
||||
});
|
||||
|
||||
const withoutPagination = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
total: 5,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
expect(withPagination.showPagination).toBe(true);
|
||||
expect(withoutPagination.showPagination).toBe(false);
|
||||
});
|
||||
|
||||
it('calculates start and end indices correctly', () => {
|
||||
const vm = new UserListViewModel({
|
||||
users: [createDto(), createDto(), createDto()],
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
});
|
||||
|
||||
expect(vm.startIndex).toBe(11); // (2-1) * 10 + 1
|
||||
expect(vm.endIndex).toBe(13); // min(2 * 10, 50)
|
||||
});
|
||||
|
||||
it('handles empty list indices', () => {
|
||||
const vm = new UserListViewModel({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
expect(vm.startIndex).toBe(0);
|
||||
expect(vm.endIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
220
apps/website/lib/view-models/AdminUserViewModel.ts
Normal file
220
apps/website/lib/view-models/AdminUserViewModel.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
|
||||
|
||||
/**
|
||||
* AdminUserViewModel
|
||||
*
|
||||
* View Model for admin user management.
|
||||
* Transforms API DTO into UI-ready state with formatting and derived fields.
|
||||
*/
|
||||
export class AdminUserViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
isSystemAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
|
||||
// UI-specific derived fields
|
||||
readonly roleBadges: string[];
|
||||
readonly statusBadge: { label: string; variant: string };
|
||||
readonly lastLoginFormatted: string;
|
||||
readonly createdAtFormatted: string;
|
||||
readonly canSuspend: boolean;
|
||||
readonly canActivate: boolean;
|
||||
readonly canDelete: boolean;
|
||||
|
||||
constructor(dto: UserDto) {
|
||||
this.id = dto.id;
|
||||
this.email = dto.email;
|
||||
this.displayName = dto.displayName;
|
||||
this.roles = dto.roles;
|
||||
this.status = dto.status;
|
||||
this.isSystemAdmin = dto.isSystemAdmin;
|
||||
this.createdAt = new Date(dto.createdAt);
|
||||
this.updatedAt = new Date(dto.updatedAt);
|
||||
this.lastLoginAt = dto.lastLoginAt ? new Date(dto.lastLoginAt) : undefined;
|
||||
this.primaryDriverId = dto.primaryDriverId;
|
||||
|
||||
// Derive role badges
|
||||
this.roleBadges = this.roles.map(role => {
|
||||
switch (role) {
|
||||
case 'owner': return 'Owner';
|
||||
case 'admin': return 'Admin';
|
||||
case 'user': return 'User';
|
||||
default: return role;
|
||||
}
|
||||
});
|
||||
|
||||
// Derive status badge
|
||||
this.statusBadge = this.getStatusBadge();
|
||||
|
||||
// Format dates
|
||||
this.lastLoginFormatted = this.lastLoginAt
|
||||
? this.lastLoginAt.toLocaleDateString()
|
||||
: 'Never';
|
||||
this.createdAtFormatted = this.createdAt.toLocaleDateString();
|
||||
|
||||
// Derive action permissions
|
||||
this.canSuspend = this.status === 'active';
|
||||
this.canActivate = this.status === 'suspended';
|
||||
this.canDelete = this.status !== 'deleted';
|
||||
}
|
||||
|
||||
private getStatusBadge(): { label: string; variant: string } {
|
||||
switch (this.status) {
|
||||
case 'active':
|
||||
return { label: 'Active', variant: 'performance-green' };
|
||||
case 'suspended':
|
||||
return { label: 'Suspended', variant: 'yellow-500' };
|
||||
case 'deleted':
|
||||
return { label: 'Deleted', variant: 'racing-red' };
|
||||
default:
|
||||
return { label: this.status, variant: 'gray-500' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardStatsViewModel
|
||||
*
|
||||
* View Model for admin dashboard statistics.
|
||||
* Provides formatted statistics and derived metrics for UI.
|
||||
*/
|
||||
export class DashboardStatsViewModel {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
|
||||
// UI-specific derived fields
|
||||
readonly activeRate: number;
|
||||
readonly activeRateFormatted: string;
|
||||
readonly adminRatio: string;
|
||||
readonly activityLevel: 'low' | 'medium' | 'high';
|
||||
|
||||
constructor(data: {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
}) {
|
||||
this.totalUsers = data.totalUsers;
|
||||
this.activeUsers = data.activeUsers;
|
||||
this.suspendedUsers = data.suspendedUsers;
|
||||
this.deletedUsers = data.deletedUsers;
|
||||
this.systemAdmins = data.systemAdmins;
|
||||
this.recentLogins = data.recentLogins;
|
||||
this.newUsersToday = data.newUsersToday;
|
||||
this.userGrowth = data.userGrowth;
|
||||
this.roleDistribution = data.roleDistribution;
|
||||
this.statusDistribution = data.statusDistribution;
|
||||
this.activityTimeline = data.activityTimeline;
|
||||
|
||||
// Derive active rate
|
||||
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
|
||||
this.activeRateFormatted = `${Math.round(this.activeRate)}%`;
|
||||
|
||||
// Derive admin ratio
|
||||
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
|
||||
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
|
||||
|
||||
// Derive activity level
|
||||
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
|
||||
if (engagementRate < 20) {
|
||||
this.activityLevel = 'low';
|
||||
} else if (engagementRate < 50) {
|
||||
this.activityLevel = 'medium';
|
||||
} else {
|
||||
this.activityLevel = 'high';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UserListViewModel
|
||||
*
|
||||
* View Model for user list with pagination and filtering state.
|
||||
*/
|
||||
export class UserListViewModel {
|
||||
users: AdminUserViewModel[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
|
||||
// UI-specific derived fields
|
||||
readonly hasUsers: boolean;
|
||||
readonly showPagination: boolean;
|
||||
readonly startIndex: number;
|
||||
readonly endIndex: number;
|
||||
|
||||
constructor(data: {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}) {
|
||||
this.users = data.users.map(dto => new AdminUserViewModel(dto));
|
||||
this.total = data.total;
|
||||
this.page = data.page;
|
||||
this.limit = data.limit;
|
||||
this.totalPages = data.totalPages;
|
||||
|
||||
// Derive UI state
|
||||
this.hasUsers = this.users.length > 0;
|
||||
this.showPagination = this.totalPages > 1;
|
||||
this.startIndex = this.users.length > 0 ? (this.page - 1) * this.limit + 1 : 0;
|
||||
this.endIndex = this.users.length > 0 ? (this.page - 1) * this.limit + this.users.length : 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user