initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength, IsOptional } from 'class-validator';
export class CreatePortalTicketDto {
@IsString()
@MinLength(5)
title: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,11 @@
import { IsString, MinLength } from 'class-validator';
export class PortalLoginDto {
@IsString()
@MinLength(1)
accountNumber: string;
@IsString()
@MinLength(1)
phone: string;
}

View File

@@ -0,0 +1,22 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class PortalJwtStrategy extends PassportStrategy(Strategy, 'portal-jwt') {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
});
}
validate(payload: any) {
if (payload.type !== 'portal') {
throw new UnauthorizedException('Invalid token type');
}
return { sub: payload.sub, tenantId: payload.tenantId, type: 'portal' };
}
}

View File

@@ -0,0 +1,59 @@
import { Controller, Get, Post, Body, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { SkipThrottle } from '@nestjs/throttler';
import { PortalService } from './portal.service';
import { PortalLoginDto } from './dto/portal-login.dto';
import { CreatePortalTicketDto } from './dto/create-portal-ticket.dto';
@Controller('portal')
export class PortalController {
constructor(private readonly portalService: PortalService) {}
@Post('auth/login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: PortalLoginDto) {
return this.portalService.login(dto);
}
@Get('profile')
@UseGuards(AuthGuard('portal-jwt'))
async getProfile(@Req() req: any) {
return this.portalService.getProfile(req.user.sub);
}
@Get('dashboard')
@UseGuards(AuthGuard('portal-jwt'))
async getDashboard(@Req() req: any) {
return this.portalService.getDashboard(req.user.sub, req.user.tenantId);
}
@Get('subscription')
@UseGuards(AuthGuard('portal-jwt'))
async getSubscription(@Req() req: any) {
return this.portalService.getSubscription(req.user.sub, req.user.tenantId);
}
@Get('invoices')
@UseGuards(AuthGuard('portal-jwt'))
async getInvoices(@Req() req: any) {
return this.portalService.getInvoices(req.user.sub, req.user.tenantId);
}
@Get('payments')
@UseGuards(AuthGuard('portal-jwt'))
async getPayments(@Req() req: any) {
return this.portalService.getPayments(req.user.sub, req.user.tenantId);
}
@Get('tickets')
@UseGuards(AuthGuard('portal-jwt'))
async getTickets(@Req() req: any) {
return this.portalService.getTickets(req.user.sub, req.user.tenantId);
}
@Post('tickets')
@UseGuards(AuthGuard('portal-jwt'))
async createTicket(@Req() req: any, @Body() dto: CreatePortalTicketDto) {
return this.portalService.createTicket(req.user.sub, req.user.tenantId, dto);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PortalController } from './portal.controller';
import { PortalService } from './portal.service';
import { PortalJwtStrategy } from './portal-jwt.strategy';
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '24h' as any },
}),
}),
],
controllers: [PortalController],
providers: [PortalService, PortalJwtStrategy],
})
export class PortalModule {}

View File

@@ -0,0 +1,132 @@
import { Injectable, UnauthorizedException, NotFoundException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import { PortalLoginDto } from './dto/portal-login.dto';
import { CreatePortalTicketDto } from './dto/create-portal-ticket.dto';
@Injectable()
export class PortalService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async login(dto: PortalLoginDto) {
const client = await this.prisma.client.findFirst({
where: {
accountNumber: dto.accountNumber,
phone: dto.phone,
status: 'active',
},
include: { area: { select: { name: true } } },
});
if (!client) {
throw new UnauthorizedException('Invalid account number or phone number');
}
const token = this.jwt.sign({
sub: client.id,
tenantId: client.tenantId,
type: 'portal',
});
return {
accessToken: token,
client: {
id: client.id,
accountNumber: client.accountNumber,
firstName: client.firstName,
lastName: client.lastName,
email: client.email,
phone: client.phone,
address: client.address,
area: client.area?.name,
},
};
}
async getProfile(clientId: string) {
const client = await this.prisma.client.findUnique({
where: { id: clientId },
include: {
area: { select: { name: true } },
subscriptions: {
where: { status: { in: ['active', 'pending'] } },
include: { plan: { select: { name: true, price: true, speedDown: true, speedUp: true, billingCycle: true } } },
take: 1,
},
},
});
if (!client) throw new NotFoundException('Client not found');
return client;
}
async getSubscription(clientId: string, tenantId: string) {
return this.prisma.subscription.findFirst({
where: { clientId, tenantId, status: { in: ['active', 'pending', 'suspended'] } },
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
}
async getInvoices(clientId: string, tenantId: string) {
return this.prisma.invoice.findMany({
where: { clientId, tenantId },
orderBy: { createdAt: 'desc' },
take: 20,
});
}
async getPayments(clientId: string, tenantId: string) {
return this.prisma.payment.findMany({
where: { clientId, tenantId },
include: { invoice: { select: { number: true } } },
orderBy: { createdAt: 'desc' },
take: 20,
});
}
async getTickets(clientId: string, tenantId: string) {
return this.prisma.ticket.findMany({
where: { clientId, tenantId },
include: { assignee: { select: { firstName: true, lastName: true } } },
orderBy: { createdAt: 'desc' },
take: 20,
});
}
async createTicket(clientId: string, tenantId: string, dto: CreatePortalTicketDto) {
// Find an admin user to attribute as creator
const admin = await this.prisma.user.findFirst({
where: { tenantId },
include: { roles: { where: { role: 'tenant_admin' } } },
});
return this.prisma.ticket.create({
data: {
tenantId,
clientId,
createdById: admin?.id || '',
type: 'support',
title: dto.title,
description: dto.description,
priority: 'normal',
},
});
}
async getDashboard(clientId: string, tenantId: string) {
const [subscription, unpaidInvoices, recentPayment, openTickets] = await Promise.all([
this.prisma.subscription.findFirst({
where: { clientId, tenantId, status: 'active' },
include: { plan: { select: { name: true, speedDown: true, speedUp: true, price: true } } },
}),
this.prisma.invoice.count({ where: { clientId, tenantId, status: { in: ['sent', 'partial'] } } }),
this.prisma.payment.findFirst({ where: { clientId, tenantId }, orderBy: { createdAt: 'desc' } }),
this.prisma.ticket.count({ where: { clientId, tenantId, status: { in: ['open', 'in_progress'] } } }),
]);
return { subscription, unpaidInvoices, recentPayment, openTickets };
}
}