Skip to content
Closed
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
72 changes: 64 additions & 8 deletions backend/src/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import { UserRole } from '../users/enums/userRoles.enum';
import { CurrentUser } from '../auth/decorators/current.user.decorators';
import { User } from '../users/entities/user.entity';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { GetCurrentUser } from 'src/auth/decorators/getCurrentUser.decorator';
import { MemberDashboardProvider } from './providers/member-dashboard.provide';

@ApiTags('dashboard')
@ApiBearerAuth()
@Controller('dashboard')
export class DashboardController {
constructor(
private readonly dashboardService: DashboardService,
private readonly memberDashboardProvider: MemberDashboardProvider,
private readonly adminAnalyticsProvider: AdminAnalyticsProvider,
) {}

Expand Down Expand Up @@ -66,15 +69,68 @@ export class DashboardController {
return { success: true, ...data };
}

@Get('admin/analytics')
// ──────────────────────────────────────────────
// Member endpoints
// ──────────────────────────────────────────────

@Get('member')
@HttpCode(HttpStatus.OK)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.STAFF)
@UseGuards(JwtAuthGuard, RolesGuard)
async getAdminAnalytics(@Query() query: AnalyticsQueryDto) {
const data = await this.adminAnalyticsProvider.getFullAdminDashboard(
query.from,
query.to,
);
async getMemberDashboard(@GetCurrentUser('id') userId: string) {
const data = await this.memberDashboardProvider.getMemberDashboard(userId);
return { success: true, data };
}

@Get('member/bookings')
@HttpCode(HttpStatus.OK)
async getMemberBookings(
@GetCurrentUser('id') userId: string,
@Query('page') page: string = '1',
@Query('limit') limit: string = '10',
) {
const parsedPage = Math.max(1, parseInt(page, 10) || 1);
const parsedLimit = Math.min(50, Math.max(1, parseInt(limit, 10) || 10));

const data = await this.dashboardService.getMemberBookings(
userId,
parsedPage,
parsedLimit,
);
return { success: true, ...data };
}

@Get('member/payments')
@HttpCode(HttpStatus.OK)
async getMemberPayments(
@GetCurrentUser('id') userId: string,
@Query('page') page: string = '1',
@Query('limit') limit: string = '10',
) {
const parsedPage = Math.max(1, parseInt(page, 10) || 1);
const parsedLimit = Math.min(50, Math.max(1, parseInt(limit, 10) || 10));

const data = await this.dashboardService.getMemberPayments(
userId,
parsedPage,
parsedLimit,
);
return { success: true, ...data };
}

@Get('member/invoices')
@HttpCode(HttpStatus.OK)
async getMemberInvoices(
@GetCurrentUser('id') userId: string,
@Query('page') page: string = '1',
@Query('limit') limit: string = '10',
) {
const parsedPage = Math.max(1, parseInt(page, 10) || 1);
const parsedLimit = Math.min(50, Math.max(1, parseInt(limit, 10) || 10));

const data = await this.dashboardService.getMemberInvoices(
userId,
parsedPage,
parsedLimit,
);
return { success: true, ...data };
}
}
96 changes: 88 additions & 8 deletions backend/src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThanOrEqual } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { NewsletterSubscriber } from '../newsletter/entities/newsletter.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Payment } from '../payments/entities/payment.entity';
import { Invoice } from '../invoices/entities/invoice.entity';
import { PaymentStatus } from '../payments/enums/paymentStatus.enum';

@Injectable()
export class DashboardService {
Expand All @@ -11,6 +15,12 @@ export class DashboardService {
private readonly userRepository: Repository<User>,
@InjectRepository(NewsletterSubscriber)
private readonly newsletterRepository: Repository<NewsletterSubscriber>,
@InjectRepository(Booking)
private readonly bookingRepository: Repository<Booking>,
@InjectRepository(Payment)
private readonly paymentRepository: Repository<Payment>,
@InjectRepository(Invoice)
private readonly invoiceRepository: Repository<Invoice>,
) {}

/**
Expand All @@ -28,8 +38,11 @@ export class DashboardService {
return {
totalMembers,
verifiedMembers,
activeWorkspaces: 1, // placeholder until workspaces entity exists
deskOccupancy: Math.min(Math.round((verifiedMembers / Math.max(totalMembers, 1)) * 100), 100),
activeWorkspaces: 1,
deskOccupancy: Math.min(
Math.round((verifiedMembers / Math.max(totalMembers, 1)) * 100),
100,
),
};
}

Expand All @@ -40,7 +53,14 @@ export class DashboardService {
const recentUsers = await this.userRepository.find({
order: { createdAt: 'DESC' },
take: 10,
select: ['id', 'firstname', 'lastname', 'email', 'createdAt', 'isVerified'],
select: [
'id',
'firstname',
'lastname',
'email',
'createdAt',
'isVerified',
],
});

return recentUsers.map((u) => ({
Expand Down Expand Up @@ -71,8 +91,12 @@ export class DashboardService {
newSubscribersThisMonth,
] = await Promise.all([
this.userRepository.count({ where: { isDeleted: false } }),
this.userRepository.count({ where: { isActive: true, isDeleted: false } }),
this.userRepository.count({ where: { isSuspended: true, isDeleted: false } }),
this.userRepository.count({
where: { isActive: true, isDeleted: false },
}),
this.userRepository.count({
where: { isSuspended: true, isDeleted: false },
}),
this.userRepository.count({
where: { createdAt: MoreThanOrEqual(thirtyDaysAgo), isDeleted: false },
}),
Expand All @@ -84,7 +108,6 @@ export class DashboardService {
}),
]);

// Registration trend — last 6 months
const registrationTrend = await this.getMonthlyRegistrations(6);

return {
Expand Down Expand Up @@ -151,13 +174,71 @@ export class DashboardService {
};
}

async getMemberBookings(userId: string, page: number, limit: number) {
const [data, total] = await this.bookingRepository.findAndCount({
where: { userId },
relations: ['workspace'],
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});

return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

async getMemberPayments(userId: string, page: number, limit: number) {
const [data, total] = await this.paymentRepository.findAndCount({
where: { userId, status: PaymentStatus.SUCCESS },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});

return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

async getMemberInvoices(userId: string, page: number, limit: number) {
const [data, total] = await this.invoiceRepository.findAndCount({
where: { userId },
relations: ['booking', 'booking.workspace'],
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});

return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}

private async getMonthlyRegistrations(months: number) {
const result: { month: string; count: number }[] = [];
const now = new Date();

for (let i = months - 1; i >= 0; i--) {
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59);

const count = await this.userRepository.count({
where: {
Expand All @@ -166,7 +247,6 @@ export class DashboardService {
},
});

// We need a between query, but MoreThanOrEqual + manual filter works for trend
const monthLabel = start.toLocaleString('en', { month: 'short' });
result.push({ month: monthLabel, count });
}
Expand Down
68 changes: 68 additions & 0 deletions backend/src/dashboard/providers/member-dashboard.provide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';

@Injectable()
export class MemberDashboardProvider {
constructor(
@InjectRepository(Booking)
private readonly bookingRepository: Repository<Booking>,
@InjectRepository(Payment)
private readonly paymentRepository: Repository<Payment>,
@InjectRepository(Invoice)
private readonly invoiceRepository: Repository<Invoice>,
@InjectRepository(WorkspaceLog)
private readonly workspaceLogRepository: Repository<WorkspaceLog>,
) {}

async getMemberStats(userId: string) {
const [activeBookings, totalSpentResult, invoiceCount, lastLog] =
await Promise.all([
this.bookingRepository.count({
where: {
userId,
status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]),
},
}),
this.paymentRepository
.createQueryBuilder('payment')
.select('SUM(payment.amountKobo)', 'total')
.where('payment.userId = :userId', { userId })
.andWhere('payment.status = :status', {
status: PaymentStatus.SUCCESS,
})
.getRawOne<{ total: string | null }>(),
this.invoiceRepository.count({ where: { userId } }),
this.workspaceLogRepository.findOne({
where: { userId },
order: { checkedInAt: 'DESC' },
}),
]);

return {
activeBookings,
totalSpentKobo: parseInt(totalSpentResult?.total ?? '0', 10) || 0,
invoiceCount,
lastCheckIn: lastLog?.checkedInAt ?? null,
};
}

async getMemberDashboard(userId: string) {
const [stats, recentBookings, recentPayments] = await Promise.all([
this.getMemberStats(userId),
this.bookingRepository.find({
where: { userId },
relations: ['workspace'],
order: { createdAt: 'DESC' },
take: 5,
}),
this.paymentRepository.find({
where: { userId, status: PaymentStatus.SUCCESS },
order: { createdAt: 'DESC' },
take: 5,
}),
]);

return { stats, recentBookings, recentPayments };
}
}
Loading
Loading