Skip to content

Commit 29e6c8a

Browse files
Merge pull request #1256 from NteinPrecious/feature/nteinprecious-issues-1210-1211-1212-1213
feat: Email campaigns module, VAT on invoices, and churn risk tracking
2 parents cd77824 + e956ece commit 29e6c8a

15 files changed

Lines changed: 466 additions & 1 deletion

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { AccessControlModule } from './access-control/access-control.module';
2929
import { WaitlistModule } from './waitlist/waitlist.module';
3030
import { EventsModule } from './events/events.module';
3131
import { MembershipPlansModule } from './membership-plans/membership-plans.module';
32+
import { EmailCampaignsModule } from './email-campaigns/email-campaigns.module';
3233
import { InventoryModule } from './inventory/inventory.module';
3334
import { AuditLogModule } from './audit-log/audit-log.module';
3435
import { LockersModule } from './lockers/lockers.module';
@@ -123,6 +124,7 @@ import { TeamsModule } from './teams/teams.module';
123124
WaitlistModule,
124125
EventsModule,
125126
MembershipPlansModule,
127+
EmailCampaignsModule,
126128
InventoryModule,
127129
AuditLogModule,
128130
LockersModule,

backend/src/dashboard/dashboard.controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,19 @@ export class DashboardController {
143143
);
144144
return { success: true, data };
145145
}
146+
147+
@Get('admin/churn-risk')
148+
@HttpCode(HttpStatus.OK)
149+
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
150+
@UseGuards(JwtAuthGuard, RolesGuard)
151+
async getChurnRisk(
152+
@Query('page') page: string = '1',
153+
@Query('limit') limit: string = '20',
154+
) {
155+
const data = await this.dashboardService.getChurnRisk(
156+
Math.max(1, parseInt(page, 10) || 1),
157+
Math.min(50, Math.max(1, parseInt(limit, 10) || 20)),
158+
);
159+
return { success: true, ...data };
160+
}
146161
}

backend/src/dashboard/dashboard.service.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,65 @@ export class DashboardService {
194194
return this.memberDashboardProvider.getMemberCheckIns(userId, limit);
195195
}
196196

197+
async getChurnRisk(page: number, limit: number) {
198+
const now = new Date();
199+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
200+
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
201+
202+
const activeUsers = await this.userRepository
203+
.createQueryBuilder('u')
204+
.where('u.isDeleted = :d', { d: false })
205+
.andWhere('u.isVerified = :v', { v: true })
206+
.getMany();
207+
208+
const results: any[] = [];
209+
for (const user of activeUsers) {
210+
const recentBooking = await this.bookingRepository
211+
.createQueryBuilder('b')
212+
.where('b.userId = :uid', { uid: user.id })
213+
.andWhere('b.createdAt >= :thirtyDaysAgo', { thirtyDaysAgo })
214+
.getCount();
215+
216+
const olderBooking = await this.bookingRepository
217+
.createQueryBuilder('b')
218+
.where('b.userId = :uid', { uid: user.id })
219+
.andWhere('b.createdAt >= :ninetyDaysAgo', { ninetyDaysAgo })
220+
.andWhere('b.createdAt < :thirtyDaysAgo', { thirtyDaysAgo })
221+
.getCount();
222+
223+
const isAtRisk = (recentBooking === 0 && olderBooking > 0) ||
224+
(user as any).membershipStatus === 'inactive';
225+
226+
if (isAtRisk) {
227+
const lastBooking = await this.bookingRepository
228+
.createQueryBuilder('b')
229+
.where('b.userId = :uid', { uid: user.id })
230+
.orderBy('b.createdAt', 'DESC')
231+
.getOne();
232+
233+
const totalBookings = await this.bookingRepository.count({ where: { userId: user.id } as any });
234+
const daysSince = lastBooking
235+
? Math.floor((now.getTime() - lastBooking.createdAt.getTime()) / (24 * 60 * 60 * 1000))
236+
: 90;
237+
const riskScore = Math.min(100, Math.round(100 - (daysSince / 90) * 100));
238+
239+
results.push({
240+
userId: user.id,
241+
fullName: `${user.firstname} ${user.lastname}`,
242+
email: user.email,
243+
lastBookingDate: lastBooking?.createdAt ?? null,
244+
totalBookingsAllTime: totalBookings,
245+
riskScore,
246+
});
247+
}
248+
}
249+
250+
results.sort((a, b) => b.riskScore - a.riskScore);
251+
const total = results.length;
252+
const items = results.slice((page - 1) * limit, page * limit);
253+
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
254+
}
255+
197256
private async getMonthlyRegistrations(months: number) {
198257
const result: { month: string; count: number }[] = [];
199258
const now = new Date();
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
2+
import { CampaignSegment } from '../enums/campaign-segment.enum';
3+
4+
export class CreateCampaignDto {
5+
@IsString()
6+
@IsNotEmpty()
7+
subject: string;
8+
9+
@IsString()
10+
@IsNotEmpty()
11+
bodyHtml: string;
12+
13+
@IsEnum(CampaignSegment)
14+
targetSegment: CampaignSegment;
15+
16+
@IsOptional()
17+
@IsDateString()
18+
scheduledAt?: string;
19+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
Controller,
3+
Get,
4+
Post,
5+
Patch,
6+
Param,
7+
Body,
8+
Query,
9+
UseGuards,
10+
ParseUUIDPipe,
11+
} from '@nestjs/common';
12+
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
13+
import { EmailCampaignsService } from './email-campaigns.service';
14+
import { CreateCampaignDto } from './dto/create-campaign.dto';
15+
import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard';
16+
import { RolesGuard } from '../auth/guard/roles.guard';
17+
import { Roles } from '../auth/decorators/roles.decorators';
18+
import { CurrentUser } from '../auth/decorators/current.user.decorators';
19+
import { UserRole } from '../users/enums/userRoles.enum';
20+
import { User } from '../users/entities/user.entity';
21+
import { CampaignStatus } from './enums/campaign-status.enum';
22+
23+
@ApiTags('Email Campaigns')
24+
@ApiBearerAuth()
25+
@UseGuards(JwtAuthGuard, RolesGuard)
26+
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
27+
@Controller('email-campaigns')
28+
export class EmailCampaignsController {
29+
constructor(private readonly service: EmailCampaignsService) {}
30+
31+
@Post()
32+
async create(@Body() dto: CreateCampaignDto, @CurrentUser() user: User) {
33+
const data = await this.service.create(dto, user.id);
34+
return { message: 'Campaign created', data };
35+
}
36+
37+
@Get()
38+
async findAll(@Query('status') status?: CampaignStatus) {
39+
const data = await this.service.findAll(status);
40+
return { data };
41+
}
42+
43+
@Get(':id')
44+
async findOne(@Param('id', ParseUUIDPipe) id: string) {
45+
const data = await this.service.findOne(id);
46+
return { data };
47+
}
48+
49+
@Patch(':id')
50+
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateCampaignDto>) {
51+
const data = await this.service.update(id, dto);
52+
return { message: 'Campaign updated', data };
53+
}
54+
55+
@Post(':id/send')
56+
async send(@Param('id', ParseUUIDPipe) id: string) {
57+
const data = await this.service.send(id);
58+
return { message: 'Campaign sent', data };
59+
}
60+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { EmailCampaign } from './entities/email-campaign.entity';
4+
import { EmailCampaignsService } from './email-campaigns.service';
5+
import { EmailCampaignsController } from './email-campaigns.controller';
6+
7+
@Module({
8+
imports: [TypeOrmModule.forFeature([EmailCampaign])],
9+
controllers: [EmailCampaignsController],
10+
providers: [EmailCampaignsService],
11+
exports: [EmailCampaignsService],
12+
})
13+
export class EmailCampaignsModule {}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { EmailCampaign } from './entities/email-campaign.entity';
5+
import { CreateCampaignDto } from './dto/create-campaign.dto';
6+
import { CampaignStatus } from './enums/campaign-status.enum';
7+
8+
@Injectable()
9+
export class EmailCampaignsService {
10+
constructor(
11+
@InjectRepository(EmailCampaign)
12+
private readonly repo: Repository<EmailCampaign>,
13+
) {}
14+
15+
async create(dto: CreateCampaignDto, adminId: string): Promise<EmailCampaign> {
16+
return this.repo.save(this.repo.create({
17+
...dto,
18+
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
19+
createdByAdminId: adminId,
20+
}));
21+
}
22+
23+
async findAll(status?: CampaignStatus) {
24+
const where = status ? { status } : {};
25+
return this.repo.find({ where, order: { createdAt: 'DESC' } });
26+
}
27+
28+
async findOne(id: string): Promise<EmailCampaign> {
29+
const item = await this.repo.findOne({ where: { id }, relations: ['createdByAdmin'] });
30+
if (!item) throw new NotFoundException(`Campaign ${id} not found`);
31+
return item;
32+
}
33+
34+
async update(id: string, dto: Partial<CreateCampaignDto>): Promise<EmailCampaign> {
35+
const item = await this.findOne(id);
36+
if (item.status !== CampaignStatus.DRAFT) throw new BadRequestException('Only draft campaigns can be edited');
37+
Object.assign(item, dto);
38+
return this.repo.save(item);
39+
}
40+
41+
async send(id: string): Promise<EmailCampaign> {
42+
const item = await this.findOne(id);
43+
item.status = CampaignStatus.SENDING;
44+
const saved = await this.repo.save(item);
45+
// In a real implementation this would enqueue a Bull job
46+
saved.status = CampaignStatus.SENT;
47+
saved.sentAt = new Date();
48+
return this.repo.save(saved);
49+
}
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
ManyToOne,
6+
JoinColumn,
7+
CreateDateColumn,
8+
} from 'typeorm';
9+
import { User } from '../../users/entities/user.entity';
10+
import { CampaignStatus } from '../enums/campaign-status.enum';
11+
import { CampaignSegment } from '../enums/campaign-segment.enum';
12+
13+
@Entity('email_campaigns')
14+
export class EmailCampaign {
15+
@PrimaryGeneratedColumn('uuid')
16+
id: string;
17+
18+
@Column()
19+
subject: string;
20+
21+
@Column({ type: 'text' })
22+
bodyHtml: string;
23+
24+
@Column({ type: 'enum', enum: CampaignSegment, default: CampaignSegment.ALL })
25+
targetSegment: CampaignSegment;
26+
27+
@Column({ type: 'timestamptz', nullable: true })
28+
scheduledAt: Date | null;
29+
30+
@Column({ type: 'timestamptz', nullable: true })
31+
sentAt: Date | null;
32+
33+
@Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.DRAFT })
34+
status: CampaignStatus;
35+
36+
@Column({ default: 0 })
37+
recipientCount: number;
38+
39+
@Column('uuid')
40+
createdByAdminId: string;
41+
42+
@ManyToOne(() => User, { onDelete: 'RESTRICT' })
43+
@JoinColumn({ name: 'createdByAdminId' })
44+
createdByAdmin: User;
45+
46+
@CreateDateColumn()
47+
createdAt: Date;
48+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum CampaignSegment {
2+
ALL = 'ALL',
3+
ACTIVE = 'ACTIVE',
4+
INACTIVE = 'INACTIVE',
5+
STAFF = 'STAFF',
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum CampaignStatus {
2+
DRAFT = 'DRAFT',
3+
SCHEDULED = 'SCHEDULED',
4+
SENDING = 'SENDING',
5+
SENT = 'SENT',
6+
}

0 commit comments

Comments
 (0)