diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a909a297..c9e9a0c6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ @@ -114,6 +115,7 @@ import { MembershipPlansModule } from './membership-plans/membership-plans.modul WaitlistModule, EventsModule, MembershipPlansModule, + EmailCampaignsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts index b2f10dea..c1033b59 100644 --- a/backend/src/dashboard/dashboard.controller.ts +++ b/backend/src/dashboard/dashboard.controller.ts @@ -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 }; + } } diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts index b65c53a9..3c2e7af4 100644 --- a/backend/src/dashboard/dashboard.service.ts +++ b/backend/src/dashboard/dashboard.service.ts @@ -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(); diff --git a/backend/src/email-campaigns/dto/create-campaign.dto.ts b/backend/src/email-campaigns/dto/create-campaign.dto.ts new file mode 100644 index 00000000..e935886c --- /dev/null +++ b/backend/src/email-campaigns/dto/create-campaign.dto.ts @@ -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; +} diff --git a/backend/src/email-campaigns/email-campaigns.controller.ts b/backend/src/email-campaigns/email-campaigns.controller.ts new file mode 100644 index 00000000..1594ec18 --- /dev/null +++ b/backend/src/email-campaigns/email-campaigns.controller.ts @@ -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) { + 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 }; + } +} diff --git a/backend/src/email-campaigns/email-campaigns.module.ts b/backend/src/email-campaigns/email-campaigns.module.ts new file mode 100644 index 00000000..a3e64b89 --- /dev/null +++ b/backend/src/email-campaigns/email-campaigns.module.ts @@ -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 {} diff --git a/backend/src/email-campaigns/email-campaigns.service.ts b/backend/src/email-campaigns/email-campaigns.service.ts new file mode 100644 index 00000000..765fdc63 --- /dev/null +++ b/backend/src/email-campaigns/email-campaigns.service.ts @@ -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, + ) {} + + async create(dto: CreateCampaignDto, adminId: string): Promise { + 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 { + 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): Promise { + 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 { + 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); + } +} diff --git a/backend/src/email-campaigns/entities/email-campaign.entity.ts b/backend/src/email-campaigns/entities/email-campaign.entity.ts new file mode 100644 index 00000000..d339f8bc --- /dev/null +++ b/backend/src/email-campaigns/entities/email-campaign.entity.ts @@ -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; +} diff --git a/backend/src/email-campaigns/enums/campaign-segment.enum.ts b/backend/src/email-campaigns/enums/campaign-segment.enum.ts new file mode 100644 index 00000000..9b36395b --- /dev/null +++ b/backend/src/email-campaigns/enums/campaign-segment.enum.ts @@ -0,0 +1,6 @@ +export enum CampaignSegment { + ALL = 'ALL', + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', + STAFF = 'STAFF', +} diff --git a/backend/src/email-campaigns/enums/campaign-status.enum.ts b/backend/src/email-campaigns/enums/campaign-status.enum.ts new file mode 100644 index 00000000..ddefd083 --- /dev/null +++ b/backend/src/email-campaigns/enums/campaign-status.enum.ts @@ -0,0 +1,6 @@ +export enum CampaignStatus { + DRAFT = 'DRAFT', + SCHEDULED = 'SCHEDULED', + SENDING = 'SENDING', + SENT = 'SENT', +} diff --git a/backend/src/invoices/entities/invoice.entity.ts b/backend/src/invoices/entities/invoice.entity.ts index e8d9b329..d88049ef 100644 --- a/backend/src/invoices/entities/invoice.entity.ts +++ b/backend/src/invoices/entities/invoice.entity.ts @@ -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; diff --git a/frontend/app/admin/campaigns/page.tsx b/frontend/app/admin/campaigns/page.tsx new file mode 100644 index 00000000..d8a55a38 --- /dev/null +++ b/frontend/app/admin/campaigns/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetEmailCampaigns } from "@/lib/react-query/hooks/admin/campaigns/useGetEmailCampaigns"; +import { useCreateEmailCampaign } from "@/lib/react-query/hooks/admin/campaigns/useCreateEmailCampaign"; +import { useSendEmailCampaign } from "@/lib/react-query/hooks/admin/campaigns/useSendEmailCampaign"; +import { Mail, Plus, X, Send } from "lucide-react"; + +const SEGMENTS = ["ALL", "ACTIVE", "INACTIVE", "STAFF"]; + +const statusBadge: Record = { + DRAFT: "bg-gray-100 text-gray-600", + SCHEDULED: "bg-blue-100 text-blue-700", + SENDING: "bg-yellow-100 text-yellow-700", + SENT: "bg-green-100 text-green-700", +}; + +const emptyForm = { subject: "", bodyHtml: "", targetSegment: "ALL" }; + +export default function AdminCampaignsPage() { + const [showComposer, setShowComposer] = useState(false); + const [form, setForm] = useState(emptyForm); + + const { data, isLoading } = useGetEmailCampaigns(); + const createCampaign = useCreateEmailCampaign(); + const sendCampaign = useSendEmailCampaign(); + const campaigns = (data as any)?.data ?? []; + + const handleSaveDraft = async () => { + await createCampaign.mutateAsync(form); + setShowComposer(false); + setForm(emptyForm); + }; + + const handleSendNow = async (id: string) => { + await sendCampaign.mutateAsync(id); + }; + + return ( + +
+
+

Email Campaigns

+

Create and send targeted email campaigns to member segments.

+
+ +
+ + {isLoading ? ( +
Loading...
+ ) : campaigns.length === 0 ? ( +
+ +

No campaigns yet.

+
+ ) : ( +
+ {campaigns.map((c: any) => ( +
+
+

{c.subject}

+
+ {c.targetSegment} + {c.status} +
+
+
+ {new Date(c.createdAt).toLocaleDateString()} + {c.status === "DRAFT" && ( + + )} +
+
+ ))} +
+ )} + + {showComposer && ( +
+
+
+

New Campaign

+ +
+
+
+ + setForm({ ...form, subject: e.target.value })} + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" + placeholder="Campaign subject..." + /> +
+
+ + +
+
+ +