Skip to content
Open
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
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { AccessControlModule } from './access-control/access-control.module';
import { WaitlistModule } from './waitlist/waitlist.module';
import { EventsModule } from './events/events.module';
import { MembershipPlansModule } from './membership-plans/membership-plans.module';
import { EmailCampaignsModule } from './email-campaigns/email-campaigns.module';

@Module({
imports: [
Expand Down Expand Up @@ -114,6 +115,7 @@ import { MembershipPlansModule } from './membership-plans/membership-plans.modul
WaitlistModule,
EventsModule,
MembershipPlansModule,
EmailCampaignsModule,
],
controllers: [AppController],
providers: [
Expand Down
15 changes: 15 additions & 0 deletions backend/src/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,19 @@ export class DashboardController {
);
return { success: true, data };
}

@Get('admin/churn-risk')
@HttpCode(HttpStatus.OK)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
@UseGuards(JwtAuthGuard, RolesGuard)
async getChurnRisk(
@Query('page') page: string = '1',
@Query('limit') limit: string = '20',
) {
const data = await this.dashboardService.getChurnRisk(
Math.max(1, parseInt(page, 10) || 1),
Math.min(50, Math.max(1, parseInt(limit, 10) || 20)),
);
return { success: true, ...data };
}
}
59 changes: 59 additions & 0 deletions backend/src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,65 @@ export class DashboardService {
return this.memberDashboardProvider.getMemberCheckIns(userId, limit);
}

async getChurnRisk(page: number, limit: number) {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);

const activeUsers = await this.userRepository
.createQueryBuilder('u')
.where('u.isDeleted = :d', { d: false })
.andWhere('u.isVerified = :v', { v: true })
.getMany();

const results: any[] = [];
for (const user of activeUsers) {
const recentBooking = await this.bookingRepository
.createQueryBuilder('b')
.where('b.userId = :uid', { uid: user.id })
.andWhere('b.createdAt >= :thirtyDaysAgo', { thirtyDaysAgo })
.getCount();

const olderBooking = await this.bookingRepository
.createQueryBuilder('b')
.where('b.userId = :uid', { uid: user.id })
.andWhere('b.createdAt >= :ninetyDaysAgo', { ninetyDaysAgo })
.andWhere('b.createdAt < :thirtyDaysAgo', { thirtyDaysAgo })
.getCount();

const isAtRisk = (recentBooking === 0 && olderBooking > 0) ||
(user as any).membershipStatus === 'inactive';

if (isAtRisk) {
const lastBooking = await this.bookingRepository
.createQueryBuilder('b')
.where('b.userId = :uid', { uid: user.id })
.orderBy('b.createdAt', 'DESC')
.getOne();

const totalBookings = await this.bookingRepository.count({ where: { userId: user.id } as any });
const daysSince = lastBooking
? Math.floor((now.getTime() - lastBooking.createdAt.getTime()) / (24 * 60 * 60 * 1000))
: 90;
const riskScore = Math.min(100, Math.round(100 - (daysSince / 90) * 100));

results.push({
userId: user.id,
fullName: `${user.firstname} ${user.lastname}`,
email: user.email,
lastBookingDate: lastBooking?.createdAt ?? null,
totalBookingsAllTime: totalBookings,
riskScore,
});
}
}

results.sort((a, b) => b.riskScore - a.riskScore);
const total = results.length;
const items = results.slice((page - 1) * limit, page * limit);
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}

private async getMonthlyRegistrations(months: number) {
const result: { month: string; count: number }[] = [];
const now = new Date();
Expand Down
19 changes: 19 additions & 0 deletions backend/src/email-campaigns/dto/create-campaign.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CampaignSegment } from '../enums/campaign-segment.enum';

export class CreateCampaignDto {
@IsString()
@IsNotEmpty()
subject: string;

@IsString()
@IsNotEmpty()
bodyHtml: string;

@IsEnum(CampaignSegment)
targetSegment: CampaignSegment;

@IsOptional()
@IsDateString()
scheduledAt?: string;
}
60 changes: 60 additions & 0 deletions backend/src/email-campaigns/email-campaigns.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { EmailCampaignsService } from './email-campaigns.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard';
import { RolesGuard } from '../auth/guard/roles.guard';
import { Roles } from '../auth/decorators/roles.decorators';
import { CurrentUser } from '../auth/decorators/current.user.decorators';
import { UserRole } from '../users/enums/userRoles.enum';
import { User } from '../users/entities/user.entity';
import { CampaignStatus } from './enums/campaign-status.enum';

@ApiTags('Email Campaigns')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
@Controller('email-campaigns')
export class EmailCampaignsController {
constructor(private readonly service: EmailCampaignsService) {}

@Post()
async create(@Body() dto: CreateCampaignDto, @CurrentUser() user: User) {
const data = await this.service.create(dto, user.id);
return { message: 'Campaign created', data };
}

@Get()
async findAll(@Query('status') status?: CampaignStatus) {
const data = await this.service.findAll(status);
return { data };
}

@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string) {
const data = await this.service.findOne(id);
return { data };
}

@Patch(':id')
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateCampaignDto>) {
const data = await this.service.update(id, dto);
return { message: 'Campaign updated', data };
}

@Post(':id/send')
async send(@Param('id', ParseUUIDPipe) id: string) {
const data = await this.service.send(id);
return { message: 'Campaign sent', data };
}
}
13 changes: 13 additions & 0 deletions backend/src/email-campaigns/email-campaigns.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailCampaign } from './entities/email-campaign.entity';
import { EmailCampaignsService } from './email-campaigns.service';
import { EmailCampaignsController } from './email-campaigns.controller';

@Module({
imports: [TypeOrmModule.forFeature([EmailCampaign])],
controllers: [EmailCampaignsController],
providers: [EmailCampaignsService],
exports: [EmailCampaignsService],
})
export class EmailCampaignsModule {}
50 changes: 50 additions & 0 deletions backend/src/email-campaigns/email-campaigns.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EmailCampaign } from './entities/email-campaign.entity';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { CampaignStatus } from './enums/campaign-status.enum';

@Injectable()
export class EmailCampaignsService {
constructor(
@InjectRepository(EmailCampaign)
private readonly repo: Repository<EmailCampaign>,
) {}

async create(dto: CreateCampaignDto, adminId: string): Promise<EmailCampaign> {
return this.repo.save(this.repo.create({
...dto,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
createdByAdminId: adminId,
}));
}

async findAll(status?: CampaignStatus) {
const where = status ? { status } : {};
return this.repo.find({ where, order: { createdAt: 'DESC' } });
}

async findOne(id: string): Promise<EmailCampaign> {
const item = await this.repo.findOne({ where: { id }, relations: ['createdByAdmin'] });
if (!item) throw new NotFoundException(`Campaign ${id} not found`);
return item;
}

async update(id: string, dto: Partial<CreateCampaignDto>): Promise<EmailCampaign> {
const item = await this.findOne(id);
if (item.status !== CampaignStatus.DRAFT) throw new BadRequestException('Only draft campaigns can be edited');
Object.assign(item, dto);
return this.repo.save(item);
}

async send(id: string): Promise<EmailCampaign> {
const item = await this.findOne(id);
item.status = CampaignStatus.SENDING;
const saved = await this.repo.save(item);
// In a real implementation this would enqueue a Bull job
saved.status = CampaignStatus.SENT;
saved.sentAt = new Date();
return this.repo.save(saved);
}
}
48 changes: 48 additions & 0 deletions backend/src/email-campaigns/entities/email-campaign.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { CampaignStatus } from '../enums/campaign-status.enum';
import { CampaignSegment } from '../enums/campaign-segment.enum';

@Entity('email_campaigns')
export class EmailCampaign {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
subject: string;

@Column({ type: 'text' })
bodyHtml: string;

@Column({ type: 'enum', enum: CampaignSegment, default: CampaignSegment.ALL })
targetSegment: CampaignSegment;

@Column({ type: 'timestamptz', nullable: true })
scheduledAt: Date | null;

@Column({ type: 'timestamptz', nullable: true })
sentAt: Date | null;

@Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.DRAFT })
status: CampaignStatus;

@Column({ default: 0 })
recipientCount: number;

@Column('uuid')
createdByAdminId: string;

@ManyToOne(() => User, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'createdByAdminId' })
createdByAdmin: User;

@CreateDateColumn()
createdAt: Date;
}
6 changes: 6 additions & 0 deletions backend/src/email-campaigns/enums/campaign-segment.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum CampaignSegment {
ALL = 'ALL',
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
STAFF = 'STAFF',
}
6 changes: 6 additions & 0 deletions backend/src/email-campaigns/enums/campaign-status.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum CampaignStatus {
DRAFT = 'DRAFT',
SCHEDULED = 'SCHEDULED',
SENDING = 'SENDING',
SENT = 'SENT',
}
14 changes: 13 additions & 1 deletion backend/src/invoices/entities/invoice.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,19 @@ export class Invoice {
@JoinColumn({ name: 'paymentId' })
payment: Payment;

/** Amount in kobo */
/** Subtotal before tax, in kobo */
@Column({ type: 'bigint', default: 0 })
subtotalKobo: number;

/** VAT rate percent (default 7.5) */
@Column({ type: 'decimal', precision: 5, scale: 2, default: 7.5 })
taxRatePercent: number;

/** Tax amount in kobo */
@Column({ type: 'bigint', default: 0 })
taxAmountKobo: number;

/** Total amount (subtotal + tax), in kobo */
@Column({ type: 'bigint' })
amountKobo: number;

Expand Down
Loading
Loading