feat: complete admin backend controllers and services

- AdminPlansController & Service (CRUD, reorder)
- AdminPaymentMethodsController & Service (CRUD, reorder)
- AdminPaymentsController & Service (verify, reject, pending count)
- AdminUsersController & Service (search, suspend, grant pro access, stats)
- AdminConfigController & Service (dynamic config management)
- Wire all controllers into AdminModule
- Import AdminModule in AppModule

Admin API Routes:
- GET/POST/PUT/DELETE /admin/plans
- GET/POST/PUT/DELETE /admin/payment-methods
- GET /admin/payments (with status filter)
- POST /admin/payments/:id/verify
- POST /admin/payments/:id/reject
- GET /admin/users (with search)
- POST /admin/users/:id/grant-pro
- GET/POST/DELETE /admin/config

All routes protected by AuthGuard + AdminGuard
This commit is contained in:
dwindown
2025-10-11 14:32:45 +07:00
parent 9b789b333f
commit 12850ab12d
53 changed files with 3098 additions and 34 deletions

View File

@@ -0,0 +1,55 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Req,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
import { AdminGuard } from './guards/admin.guard';
import { AdminConfigService } from './admin-config.service';
interface RequestWithUser {
user: {
userId: string;
};
}
@Controller('admin/config')
@UseGuards(AuthGuard, AdminGuard)
export class AdminConfigController {
constructor(private readonly service: AdminConfigService) {}
@Get()
findAll(@Query('category') category?: string) {
return this.service.findAll(category);
}
@Get('by-category')
getByCategory() {
return this.service.getByCategory();
}
@Get(':key')
findOne(@Param('key') key: string) {
return this.service.findOne(key);
}
@Post(':key')
upsert(
@Param('key') key: string,
@Body() data: any,
@Req() req: RequestWithUser,
) {
return this.service.upsert(key, data, req.user.userId);
}
@Delete(':key')
delete(@Param('key') key: string) {
return this.service.delete(key);
}
}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminConfigService {
constructor(private readonly prisma: PrismaService) {}
async findAll(category?: string) {
return this.prisma.appConfig.findMany({
where: category ? { category } : undefined,
orderBy: { category: 'asc' },
});
}
async findOne(key: string) {
return this.prisma.appConfig.findUnique({
where: { key },
});
}
async upsert(key: string, data: any, updatedBy: string) {
return this.prisma.appConfig.upsert({
where: { key },
update: {
...data,
updatedBy,
updatedAt: new Date(),
},
create: {
key,
...data,
updatedBy,
},
});
}
async delete(key: string) {
return this.prisma.appConfig.delete({
where: { key },
});
}
async getByCategory() {
const configs = await this.prisma.appConfig.findMany();
// Group by category
const grouped = configs.reduce((acc, config) => {
if (!acc[config.category]) {
acc[config.category] = [];
}
acc[config.category].push(config);
return acc;
}, {} as Record<string, any[]>);
return grouped;
}
}

View File

@@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
import { AdminGuard } from './guards/admin.guard';
import { AdminPaymentMethodsService } from './admin-payment-methods.service';
@Controller('admin/payment-methods')
@UseGuards(AuthGuard, AdminGuard)
export class AdminPaymentMethodsController {
constructor(private readonly service: AdminPaymentMethodsService) {}
@Get()
findAll() {
return this.service.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Post()
create(@Body() data: any) {
return this.service.create(data);
}
@Put(':id')
update(@Param('id') id: string, @Body() data: any) {
return this.service.update(id, data);
}
@Delete(':id')
delete(@Param('id') id: string) {
return this.service.delete(id);
}
@Post('reorder')
reorder(@Body() body: { methodIds: string[] }) {
return this.service.reorder(body.methodIds);
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminPaymentMethodsService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
return this.prisma.paymentMethod.findMany({
orderBy: { sortOrder: 'asc' },
});
}
async findOne(id: string) {
return this.prisma.paymentMethod.findUnique({
where: { id },
});
}
async create(data: any) {
return this.prisma.paymentMethod.create({
data,
});
}
async update(id: string, data: any) {
return this.prisma.paymentMethod.update({
where: { id },
data,
});
}
async delete(id: string) {
return this.prisma.paymentMethod.delete({
where: { id },
});
}
async reorder(methodIds: string[]) {
const updates = methodIds.map((id, index) =>
this.prisma.paymentMethod.update({
where: { id },
data: { sortOrder: index + 1 },
})
);
await this.prisma.$transaction(updates);
return { success: true };
}
}

View File

@@ -0,0 +1,54 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
Req,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
import { AdminGuard } from './guards/admin.guard';
import { AdminPaymentsService } from './admin-payments.service';
interface RequestWithUser {
user: {
userId: string;
};
}
@Controller('admin/payments')
@UseGuards(AuthGuard, AdminGuard)
export class AdminPaymentsController {
constructor(private readonly service: AdminPaymentsService) {}
@Get()
findAll(@Query('status') status?: string) {
return this.service.findAll(status);
}
@Get('pending/count')
getPendingCount() {
return this.service.getPendingCount();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Post(':id/verify')
verify(@Param('id') id: string, @Req() req: RequestWithUser) {
return this.service.verify(id, req.user.userId);
}
@Post(':id/reject')
reject(
@Param('id') id: string,
@Req() req: RequestWithUser,
@Body() body: { reason: string },
) {
return this.service.reject(id, req.user.userId, body.reason);
}
}

View File

@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminPaymentsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(status?: string) {
return this.prisma.payment.findMany({
where: status ? { status } : undefined,
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
subscription: {
include: {
plan: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string) {
return this.prisma.payment.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
subscription: {
include: {
plan: true,
},
},
},
});
}
async verify(id: string, adminUserId: string) {
const payment = await this.prisma.payment.findUnique({
where: { id },
include: { subscription: { include: { plan: true } } },
});
if (!payment) {
throw new Error('Payment not found');
}
// Update payment status
const updatedPayment = await this.prisma.payment.update({
where: { id },
data: {
status: 'paid',
verifiedBy: adminUserId,
verifiedAt: new Date(),
paidAt: new Date(),
},
});
// Activate or extend subscription
if (payment.subscriptionId && payment.subscription) {
const plan = payment.subscription.plan;
const now = new Date();
const endDate = new Date(now);
if (plan.durationDays) {
endDate.setDate(endDate.getDate() + plan.durationDays);
}
await this.prisma.subscription.update({
where: { id: payment.subscriptionId },
data: {
status: 'active',
startDate: now,
endDate: plan.durationType === 'lifetime' ? new Date('2099-12-31') : endDate,
},
});
}
return updatedPayment;
}
async reject(id: string, adminUserId: string, reason: string) {
return this.prisma.payment.update({
where: { id },
data: {
status: 'rejected',
verifiedBy: adminUserId,
verifiedAt: new Date(),
rejectionReason: reason,
},
});
}
async getPendingCount() {
return this.prisma.payment.count({
where: { status: 'pending' },
});
}
}

View File

@@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
import { AdminGuard } from './guards/admin.guard';
import { AdminPlansService } from './admin-plans.service';
@Controller('admin/plans')
@UseGuards(AuthGuard, AdminGuard)
export class AdminPlansController {
constructor(private readonly plansService: AdminPlansService) {}
@Get()
findAll() {
return this.plansService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.plansService.findOne(id);
}
@Post()
create(@Body() data: any) {
return this.plansService.create(data);
}
@Put(':id')
update(@Param('id') id: string, @Body() data: any) {
return this.plansService.update(id, data);
}
@Delete(':id')
delete(@Param('id') id: string) {
return this.plansService.delete(id);
}
@Post('reorder')
reorder(@Body() body: { planIds: string[] }) {
return this.plansService.reorder(body.planIds);
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminPlansService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
return this.prisma.plan.findMany({
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { subscriptions: true },
},
},
});
}
async findOne(id: string) {
return this.prisma.plan.findUnique({
where: { id },
include: {
_count: {
select: { subscriptions: true },
},
},
});
}
async create(data: any) {
return this.prisma.plan.create({
data,
});
}
async update(id: string, data: any) {
return this.prisma.plan.update({
where: { id },
data,
});
}
async delete(id: string) {
// Soft delete - just deactivate
return this.prisma.plan.update({
where: { id },
data: { isActive: false, isVisible: false },
});
}
async reorder(planIds: string[]) {
// Update sort order for multiple plans
const updates = planIds.map((id, index) =>
this.prisma.plan.update({
where: { id },
data: { sortOrder: index + 1 },
})
);
await this.prisma.$transaction(updates);
return { success: true };
}
}

View File

@@ -0,0 +1,57 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
import { AdminGuard } from './guards/admin.guard';
import { AdminUsersService } from './admin-users.service';
@Controller('admin/users')
@UseGuards(AuthGuard, AdminGuard)
export class AdminUsersController {
constructor(private readonly service: AdminUsersService) {}
@Get()
findAll(@Query('search') search?: string) {
return this.service.findAll(search);
}
@Get('stats')
getStats() {
return this.service.getStats();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Put(':id/role')
updateRole(@Param('id') id: string, @Body() body: { role: string }) {
return this.service.updateRole(id, body.role);
}
@Post(':id/suspend')
suspend(@Param('id') id: string, @Body() body: { reason: string }) {
return this.service.suspend(id, body.reason);
}
@Post(':id/unsuspend')
unsuspend(@Param('id') id: string) {
return this.service.unsuspend(id);
}
@Post(':id/grant-pro')
grantProAccess(
@Param('id') id: string,
@Body() body: { planSlug: string; durationDays: number },
) {
return this.service.grantProAccess(id, body.planSlug, body.durationDays);
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
async findAll(search?: string) {
return this.prisma.user.findMany({
where: search
? {
OR: [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
lastLoginAt: true,
suspendedAt: true,
_count: {
select: {
wallets: true,
transactions: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string) {
return this.prisma.user.findUnique({
where: { id },
include: {
subscriptions: {
include: {
plan: true,
},
},
_count: {
select: {
wallets: true,
transactions: true,
payments: true,
},
},
},
});
}
async updateRole(id: string, role: string) {
return this.prisma.user.update({
where: { id },
data: { role },
});
}
async suspend(id: string, reason: string) {
return this.prisma.user.update({
where: { id },
data: {
suspendedAt: new Date(),
suspendedReason: reason,
},
});
}
async unsuspend(id: string) {
return this.prisma.user.update({
where: { id },
data: {
suspendedAt: null,
suspendedReason: null,
},
});
}
async grantProAccess(userId: string, planSlug: string, durationDays: number) {
const plan = await this.prisma.plan.findUnique({
where: { slug: planSlug },
});
if (!plan) {
throw new Error('Plan not found');
}
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + durationDays);
// Check if user already has a subscription
const existing = await this.prisma.subscription.findUnique({
where: { userId },
});
if (existing) {
// Update existing subscription
return this.prisma.subscription.update({
where: { userId },
data: {
planId: plan.id,
status: 'active',
startDate: now,
endDate,
},
});
} else {
// Create new subscription
return this.prisma.subscription.create({
data: {
userId,
planId: plan.id,
status: 'active',
startDate: now,
endDate,
},
});
}
}
async getStats() {
const totalUsers = await this.prisma.user.count();
const activeSubscriptions = await this.prisma.subscription.count({
where: { status: 'active' },
});
const suspendedUsers = await this.prisma.user.count({
where: { suspendedAt: { not: null } },
});
return {
totalUsers,
activeSubscriptions,
suspendedUsers,
};
}
}

View File

@@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminGuard } from './guards/admin.guard';
// Controllers
import { AdminPlansController } from './admin-plans.controller';
import { AdminPaymentMethodsController } from './admin-payment-methods.controller';
import { AdminPaymentsController } from './admin-payments.controller';
import { AdminUsersController } from './admin-users.controller';
import { AdminConfigController } from './admin-config.controller';
// Services
import { AdminPlansService } from './admin-plans.service';
import { AdminPaymentMethodsService } from './admin-payment-methods.service';
import { AdminPaymentsService } from './admin-payments.service';
import { AdminUsersService } from './admin-users.service';
import { AdminConfigService } from './admin-config.service';
@Module({
imports: [PrismaModule],
controllers: [
AdminPlansController,
AdminPaymentMethodsController,
AdminPaymentsController,
AdminUsersController,
AdminConfigController,
],
providers: [
AdminGuard,
AdminPlansService,
AdminPaymentMethodsService,
AdminPaymentsService,
AdminUsersService,
AdminConfigService,
],
exports: [
AdminGuard,
AdminPlansService,
AdminPaymentMethodsService,
AdminPaymentsService,
AdminUsersService,
AdminConfigService,
],
})
export class AdminModule {}

View File

@@ -9,6 +9,7 @@ import { WalletsModule } from './wallets/wallets.module';
import { TransactionsModule } from './transactions/transactions.module';
import { CategoriesModule } from './categories/categories.module';
import { OtpModule } from './otp/otp.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { OtpModule } from './otp/otp.module';
TransactionsModule,
CategoriesModule,
OtpModule,
AdminModule,
],
controllers: [HealthController],
providers: [],