initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:37:07 +08:00
commit 5382f3b4e5
87 changed files with 5278 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import { IsString, IsOptional, IsEmail, IsObject } from 'class-validator';
export class CreateTenantDto {
@IsString()
name!: string;
@IsString()
slug!: string;
@IsEmail()
adminEmail!: string;
@IsString()
adminPassword!: string;
@IsString()
adminFirstName!: string;
@IsString()
adminLastName!: string;
@IsOptional()
@IsObject()
settings?: Record<string, any>;
}

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class TenantListQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
status?: 'active' | 'inactive' | 'all';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsBoolean, IsObject } from 'class-validator';
export class UpdateTenantDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsObject()
settings?: Record<string, any>;
}

View File

@@ -0,0 +1,59 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
} from '@nestjs/common';
import { TenantsService } from './tenants.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { TenantListQueryDto } from './dto/list-query.dto';
@Controller('tenants')
export class TenantsController {
constructor(private readonly service: TenantsService) {}
@Get()
findAll(@Query() query: TenantListQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Post()
create(@Body() dto: CreateTenantDto) {
return this.service.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(id);
}
@Patch(':id/activate')
activate(@Param('id') id: string) {
return this.service.activate(id);
}
@Patch(':id/deactivate')
deactivate(@Param('id') id: string) {
return this.service.deactivate(id);
}
@Get(':id/users')
getTenantUsers(@Param('id') tenantId: string) {
return this.service.getTenantUsers(tenantId);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
controllers: [TenantsController],
providers: [TenantsService],
})
export class TenantsModule {}

View File

@@ -0,0 +1,323 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { TenantPrismaService } from '../prisma/tenant-prisma.service';
import { AdminPrismaService } from '../prisma/admin-prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { TenantListQueryDto } from './dto/list-query.dto';
import { DEFAULT_ROLE_PERMISSIONS } from '@fiberops/shared';
import * as bcrypt from 'bcrypt';
@Injectable()
export class TenantsService {
constructor(
private readonly tenantDb: TenantPrismaService,
private readonly adminDb: AdminPrismaService,
) {}
async findAll(query: TenantListQueryDto) {
const { search, status, page = 1, limit = 20 } = query;
const skip = (page - 1) * limit;
const where: any = { deletedAt: null };
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
];
}
if (status === 'active') where.isActive = true;
if (status === 'inactive') where.isActive = false;
const [tenants, total] = await Promise.all([
this.tenantDb.tenant.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
_count: { select: { users: { where: { deletedAt: null } }, clients: { where: { deletedAt: null } }, subscriptions: { where: { deletedAt: null } } } },
},
}),
this.tenantDb.tenant.count({ where }),
]);
return {
items: tenants.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
settings: t.settings,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
_count: t._count,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: string) {
const tenant = await this.tenantDb.tenant.findUnique({
where: { id, deletedAt: null },
include: {
users: {
where: { deletedAt: null },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
isActive: true,
roles: { select: { role: true } },
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
},
},
_count: {
select: {
clients: { where: { deletedAt: null } },
subscriptions: { where: { deletedAt: null } },
invoices: { where: { deletedAt: null } },
payments: { where: { deletedAt: null } },
tickets: { where: { deletedAt: null } },
},
},
},
});
if (!tenant) throw new NotFoundException('Tenant not found');
// Revenue aggregation
const revenueAgg = await this.tenantDb.payment.aggregate({
_sum: { amount: true },
where: { tenantId: id, deletedAt: null },
});
return {
...tenant,
totalRevenue: revenueAgg._sum.amount || 0,
};
}
async create(dto: CreateTenantDto) {
const existing = await this.tenantDb.tenant.findUnique({
where: { slug: dto.slug },
});
if (existing) throw new ConflictException('Tenant slug already taken');
const existingEmail = await this.tenantDb.user.findFirst({
where: { email: dto.adminEmail },
});
if (existingEmail) throw new ConflictException('Admin email already in use');
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
const tenant = await this.tenantDb.tenant.create({
data: {
name: dto.name,
slug: dto.slug,
settings: dto.settings || {
companyName: dto.name,
currency: 'PHP',
timezone: 'Asia/Manila',
},
users: {
create: {
email: dto.adminEmail,
password: hashedPassword,
firstName: dto.adminFirstName,
lastName: dto.adminLastName,
mustChangePassword: true,
roles: { create: { role: 'tenant_admin' } },
},
},
},
include: {
users: { select: { id: true, email: true, firstName: true, lastName: true } },
},
});
const adminUser = tenant.users[0];
await this.seedTenantDefaults(tenant.id, adminUser.id);
return tenant;
}
async update(id: string, dto: UpdateTenantDto) {
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
if (!tenant) throw new NotFoundException('Tenant not found');
if (dto.slug && dto.slug !== tenant.slug) {
const existing = await this.tenantDb.tenant.findUnique({ where: { slug: dto.slug } });
if (existing) throw new ConflictException('Slug already taken');
}
return this.tenantDb.tenant.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.slug && { slug: dto.slug }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
...(dto.settings && { settings: dto.settings }),
},
});
}
async remove(id: string) {
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
if (!tenant) throw new NotFoundException('Tenant not found');
return this.tenantDb.tenant.update({
where: { id },
data: { deletedAt: new Date() },
});
}
async activate(id: string) {
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
if (!tenant) throw new NotFoundException('Tenant not found');
return this.tenantDb.tenant.update({
where: { id },
data: { isActive: true },
});
}
async deactivate(id: string) {
const tenant = await this.tenantDb.tenant.findUnique({ where: { id, deletedAt: null } });
if (!tenant) throw new NotFoundException('Tenant not found');
return this.tenantDb.tenant.update({
where: { id },
data: { isActive: false },
});
}
async getTenantUsers(tenantId: string) {
const tenant = await this.tenantDb.tenant.findUnique({ where: { id: tenantId, deletedAt: null } });
if (!tenant) throw new NotFoundException('Tenant not found');
return this.tenantDb.user.findMany({
where: { tenantId, deletedAt: null },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
isActive: true,
createdAt: true,
roles: { select: { role: true } },
tenantRoles: { include: { tenantRole: { select: { name: true, slug: true } } } },
},
orderBy: { createdAt: 'desc' },
});
}
private async seedTenantDefaults(tenantId: string, adminUserId: string) {
const ROLE_DEFS: { name: string; slug: string; description: string }[] = [
{ name: 'Tenant Admin', slug: 'tenant_admin', description: 'Full access to all modules' },
{ name: 'Manager', slug: 'manager', description: 'Operational management with approval rights' },
{ name: 'Technician', slug: 'technician', description: 'Field operations: clients, tickets, payments' },
{ name: 'Collector', slug: 'collector', description: 'Payment collection and client viewing' },
];
// Create roles with permissions
const roleMap: Record<string, string> = {};
for (const def of ROLE_DEFS) {
const perms = DEFAULT_ROLE_PERMISSIONS[def.slug] || [];
const role = await this.tenantDb.tenantRole.create({
data: {
tenantId,
name: def.name,
slug: def.slug,
description: def.description,
isSystem: true,
permissions: {
create: perms.map((p) => ({
module: p.module,
canView: p.canView,
canCreate: p.canCreate,
canUpdate: p.canUpdate,
canArchive: p.canArchive,
canApprove: p.canApprove,
canExport: p.canExport,
})),
},
},
});
roleMap[def.slug] = role.id;
}
// Assign admin user to tenant_admin role
await this.tenantDb.userTenantRole.create({
data: { userId: adminUserId, tenantRoleId: roleMap['tenant_admin'] },
});
// Areas
await Promise.all([
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 1 - Centro', description: 'Town center, commercial area' } }),
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 2 - Poblacion', description: 'Residential zone near market' } }),
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 3 - San Isidro', description: 'Agricultural and residential' } }),
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 4 - Riverside', description: 'River-side residential' } }),
this.tenantDb.area.create({ data: { tenantId, name: 'Barangay 5 - Hilltop', description: 'Elevated residential subdivision' } }),
]);
// Plans
await Promise.all([
this.tenantDb.plan.create({ data: { tenantId, name: 'Lite 15', description: 'Entry-level 15 Mbps', speedDown: 15, speedUp: 15, price: 699, billingCycle: 30 } }),
this.tenantDb.plan.create({ data: { tenantId, name: 'Basic 25', description: '25 Mbps residential', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 } }),
this.tenantDb.plan.create({ data: { tenantId, name: 'Standard 50', description: '50 Mbps residential', speedDown: 50, speedUp: 50, price: 1499, billingCycle: 30 } }),
this.tenantDb.plan.create({ data: { tenantId, name: 'Premium 100', description: '100 Mbps business', speedDown: 100, speedUp: 100, price: 2499, billingCycle: 30 } }),
this.tenantDb.plan.create({ data: { tenantId, name: 'Enterprise 200', description: '200 Mbps dedicated', speedDown: 200, speedUp: 200, price: 4999, billingCycle: 30 } }),
]);
// Chart of Accounts
const coaDefs = [
{ code: '1000', name: 'Assets', type: 'asset' },
{ code: '1010', name: 'Cash on Hand', type: 'asset' },
{ code: '1020', name: 'GCash Business', type: 'asset' },
{ code: '1030', name: 'Maya Business', type: 'asset' },
{ code: '1040', name: 'Bank Account', type: 'asset' },
{ code: '1100', name: 'Accounts Receivable', type: 'asset' },
{ code: '1200', name: 'Equipment', type: 'asset' },
{ code: '2000', name: 'Liabilities', type: 'liability' },
{ code: '2010', name: 'Accounts Payable', type: 'liability' },
{ code: '3000', name: 'Equity', type: 'equity' },
{ code: '3010', name: "Owner's Equity", type: 'equity' },
{ code: '3020', name: 'Retained Earnings', type: 'equity' },
{ code: '4000', name: 'Revenue', type: 'revenue' },
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue' },
{ code: '4020', name: 'Installation Fees', type: 'revenue' },
{ code: '5000', name: 'Expenses', type: 'expense' },
{ code: '5010', name: 'Utilities Expense', type: 'expense' },
{ code: '5020', name: 'Salaries Expense', type: 'expense' },
{ code: '5030', name: 'Maintenance Expense', type: 'expense' },
{ code: '5040', name: 'Transport Expense', type: 'expense' },
{ code: '5050', name: 'Supplies Expense', type: 'expense' },
{ code: '5060', name: 'Equipment Expense', type: 'expense' },
];
for (const a of coaDefs) {
await this.tenantDb.chartOfAccount.create({
data: { tenantId, code: a.code, name: a.name, type: a.type as any, isSystem: true },
});
}
// Company Accounts (linked to CoA)
const coa1010 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1010' } });
const coa1020 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1020' } });
const coa1030 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1030' } });
const coa1040 = await this.tenantDb.chartOfAccount.findFirst({ where: { tenantId, code: '1040' } });
await Promise.all([
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Cash on Hand', type: 'cash', balance: 0, isSystem: true, chartOfAccountId: coa1010?.id } }),
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'GCash Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1020?.id } }),
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'Maya Business', type: 'e_wallet', balance: 0, chartOfAccountId: coa1030?.id } }),
this.tenantDb.companyAccount.create({ data: { tenantId, name: 'BDO Savings', type: 'bank', balance: 0, chartOfAccountId: coa1040?.id } }),
]);
// Billing Settings
await this.tenantDb.billingSetting.create({
data: { tenantId, autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, invoicePrefix: 'INV' },
});
}
}