initial: standalone repo from monorepo split
This commit is contained in:
25
src/tenants/dto/create-tenant.dto.ts
Normal file
25
src/tenants/dto/create-tenant.dto.ts
Normal 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>;
|
||||
}
|
||||
24
src/tenants/dto/list-query.dto.ts
Normal file
24
src/tenants/dto/list-query.dto.ts
Normal 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;
|
||||
}
|
||||
19
src/tenants/dto/update-tenant.dto.ts
Normal file
19
src/tenants/dto/update-tenant.dto.ts
Normal 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>;
|
||||
}
|
||||
59
src/tenants/tenants.controller.ts
Normal file
59
src/tenants/tenants.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/tenants/tenants.module.ts
Normal file
9
src/tenants/tenants.module.ts
Normal 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 {}
|
||||
323
src/tenants/tenants.service.ts
Normal file
323
src/tenants/tenants.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user