Implement Phase 2 features: roles, assessment groups, budget import, Kanban
- Add hierarchical roles: SuperUser Admin (is_superadmin flag), Tenant Admin, Tenant User with separate /admin route and admin panel - Add Assessment Groups module for property type-based assessment rates (SFHs, Condos, Estate Lots with different regular/special rates) - Enhance Chart of Accounts: initial balance on create (with journal entry), archive/restore accounts, edit all fields including account number & fund type - Add Budget CSV import with downloadable template and account mapping - Add Capital Projects Kanban board with drag-and-drop between year columns, table/kanban view toggle, and PDF export via browser print - Update seed data with assessment groups, second test user, superadmin flag - Create repeatable reseed.sh script for clean database population - Fix AgingReportPage Mantine v7 Table prop compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,8 @@ export class AccountsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all accounts' })
|
||||
findAll(@Query('fundType') fundType?: string) {
|
||||
return this.accountsService.findAll(fundType);
|
||||
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
||||
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
||||
}
|
||||
|
||||
@Get('trial-balance')
|
||||
|
||||
@@ -7,12 +7,21 @@ import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
export class AccountsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll(fundType?: string) {
|
||||
let sql = 'SELECT * FROM accounts WHERE is_active = true';
|
||||
async findAll(fundType?: string, includeArchived?: boolean) {
|
||||
let sql = 'SELECT * FROM accounts';
|
||||
const params: any[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (!includeArchived) {
|
||||
conditions.push('is_active = true');
|
||||
}
|
||||
if (fundType) {
|
||||
sql += ' AND fund_type = $1';
|
||||
params.push(fundType);
|
||||
conditions.push(`fund_type = $${params.length}`);
|
||||
}
|
||||
|
||||
if (conditions.length) {
|
||||
sql += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
sql += ' ORDER BY account_number';
|
||||
return this.tenant.query(sql, params);
|
||||
@@ -47,7 +56,51 @@ export class AccountsService {
|
||||
dto.is1099Reportable || false,
|
||||
],
|
||||
);
|
||||
return rows[0];
|
||||
const account = rows[0];
|
||||
|
||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
|
||||
// Find or use the current fiscal period
|
||||
const periods = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[year, month],
|
||||
);
|
||||
if (periods.length) {
|
||||
const fiscalPeriodId = periods[0].id;
|
||||
const absAmount = Math.abs(dto.initialBalance);
|
||||
|
||||
// Determine debit/credit based on account type
|
||||
const isDebitNormal = ['asset', 'expense'].includes(dto.accountType);
|
||||
const debit = isDebitNormal ? absAmount : 0;
|
||||
const credit = isDebitNormal ? 0 : absAmount;
|
||||
|
||||
// Create the journal entry
|
||||
const jeRows = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
||||
RETURNING id`,
|
||||
[
|
||||
`Opening balance for ${dto.name}`,
|
||||
fiscalPeriodId,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
);
|
||||
|
||||
if (jeRows.length) {
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[jeRows[0].id, account.id, debit, credit, 'Opening balance'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateAccountDto) {
|
||||
@@ -62,6 +115,9 @@ export class AccountsService {
|
||||
|
||||
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
|
||||
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
|
||||
if (dto.accountNumber !== undefined) { sets.push(`account_number = $${idx++}`); params.push(dto.accountNumber); }
|
||||
if (dto.accountType !== undefined) { sets.push(`account_type = $${idx++}`); params.push(dto.accountType); }
|
||||
if (dto.fundType !== undefined) { sets.push(`fund_type = $${idx++}`); params.push(dto.fundType); }
|
||||
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||
|
||||
|
||||
@@ -32,4 +32,8 @@ export class CreateAccountDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is1099Reportable?: boolean;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
initialBalance?: number;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
|
||||
import { IsString, IsOptional, IsBoolean, IsIn, IsInt } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@@ -26,4 +26,14 @@ export class UpdateAccountDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
accountNumber?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsIn(['operating', 'reserve'])
|
||||
@IsOptional()
|
||||
fundType?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||
|
||||
@ApiTags('assessment-groups')
|
||||
@Controller('assessment-groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AssessmentGroupsController {
|
||||
constructor(private service: AssessmentGroupsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.service.findAll(); }
|
||||
|
||||
@Get('summary')
|
||||
getSummary() { return this.service.getSummary(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.service.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||
import { AssessmentGroupsController } from './assessment-groups.controller';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [AssessmentGroupsController],
|
||||
providers: [AssessmentGroupsService],
|
||||
exports: [AssessmentGroupsService],
|
||||
})
|
||||
export class AssessmentGroupsModule {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class AssessmentGroupsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT ag.*,
|
||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
|
||||
ag.regular_assessment * ag.unit_count as monthly_operating_income,
|
||||
ag.special_assessment * ag.unit_count as monthly_reserve_income,
|
||||
(ag.regular_assessment + ag.special_assessment) * ag.unit_count as total_monthly_income
|
||||
FROM assessment_groups ag
|
||||
ORDER BY ag.name
|
||||
`);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM assessment_groups WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Assessment group not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
|
||||
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
|
||||
if (dto.regularAssessment !== undefined) { sets.push(`regular_assessment = $${idx++}`); params.push(dto.regularAssessment); }
|
||||
if (dto.specialAssessment !== undefined) { sets.push(`special_assessment = $${idx++}`); params.push(dto.specialAssessment); }
|
||||
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
|
||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
sets.push('updated_at = NOW()');
|
||||
params.push(id);
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE assessment_groups SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async getSummary() {
|
||||
const rows = await this.tenant.query(`
|
||||
SELECT
|
||||
COUNT(*) as group_count,
|
||||
COALESCE(SUM(regular_assessment * unit_count), 0) as total_monthly_operating,
|
||||
COALESCE(SUM(special_assessment * unit_count), 0) as total_monthly_reserve,
|
||||
COALESCE(SUM((regular_assessment + special_assessment) * unit_count), 0) as total_monthly_income,
|
||||
COALESCE(SUM(unit_count), 0) as total_units
|
||||
FROM assessment_groups WHERE is_active = true
|
||||
`);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
45
backend/src/modules/auth/admin.controller.ts
Normal file
45
backend/src/modules/auth/admin.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AdminController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
const user = await this.usersService.findById(req.user.userId || req.user.sub);
|
||||
if (!user?.isSuperadmin) {
|
||||
throw new ForbiddenException('SuperUser Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
const users = await this.usersService.findAllUsers();
|
||||
return users.map(u => ({
|
||||
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
||||
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||
organizations: u.userOrganizations?.map(uo => ({
|
||||
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
||||
})) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
@Get('organizations')
|
||||
async listOrganizations(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.usersService.findAllOrganizations();
|
||||
}
|
||||
|
||||
@Post('users/:id/superadmin')
|
||||
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
||||
await this.requireSuperadmin(req);
|
||||
await this.usersService.setSuperadmin(id, body.isSuperadmin);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
@@ -21,7 +22,7 @@ import { UsersModule } from '../users/users.module';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
controllers: [AuthController, AdminController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
||||
@@ -109,6 +109,7 @@ export class AuthService {
|
||||
const payload: Record<string, any> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
};
|
||||
|
||||
if (defaultOrg) {
|
||||
@@ -124,6 +125,7 @@ export class AuthService {
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
|
||||
@@ -20,6 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
orgId: payload.orgId,
|
||||
orgSchema: payload.orgSchema,
|
||||
role: payload.role,
|
||||
isSuperadmin: payload.isSuperadmin || false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Put, Body, Param, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||
import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BudgetsService } from './budgets.service';
|
||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||
@@ -11,6 +12,38 @@ import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||
export class BudgetsController {
|
||||
constructor(private budgetsService: BudgetsService) {}
|
||||
|
||||
@Post(':year/import')
|
||||
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
||||
importBudget(
|
||||
@Param('year', ParseIntPipe) year: number,
|
||||
@Body() lines: any[],
|
||||
) {
|
||||
return this.budgetsService.importBudget(year, lines);
|
||||
}
|
||||
|
||||
@Get(':year/template')
|
||||
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
||||
async getTemplate(
|
||||
@Param('year', ParseIntPipe) year: number,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const csv = await this.budgetsService.getTemplate(year);
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
|
||||
});
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
@Get(':year/vs-actual')
|
||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||
budgetVsActual(
|
||||
@Param('year', ParseIntPipe) year: number,
|
||||
@Query('month') month?: string,
|
||||
) {
|
||||
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
|
||||
}
|
||||
|
||||
@Get(':year')
|
||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||
@@ -25,13 +58,4 @@ export class BudgetsController {
|
||||
) {
|
||||
return this.budgetsService.upsert(year, budgets);
|
||||
}
|
||||
|
||||
@Get(':year/vs-actual')
|
||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||
budgetVsActual(
|
||||
@Param('year', ParseIntPipe) year: number,
|
||||
@Query('month') month?: string,
|
||||
) {
|
||||
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,96 @@ import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||
export class BudgetsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async importBudget(year: number, lines: any[]) {
|
||||
const results = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
|
||||
if (!accountNumber) {
|
||||
errors.push(`Row ${i + 1}: missing account_number`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up account by account_number
|
||||
const accounts = await this.tenant.query(
|
||||
`SELECT id, fund_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||
[parseInt(accountNumber, 10)],
|
||||
);
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
errors.push(`Row ${i + 1}: account_number ${accountNumber} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const account = accounts[0];
|
||||
const fundType = line.fund_type || account.fund_type || 'operating';
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT (fiscal_year, account_id, fund_type)
|
||||
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, notes=$16
|
||||
RETURNING *`,
|
||||
[
|
||||
year,
|
||||
account.id,
|
||||
fundType,
|
||||
parseFloat(line.jan) || 0,
|
||||
parseFloat(line.feb) || 0,
|
||||
parseFloat(line.mar) || 0,
|
||||
parseFloat(line.apr) || 0,
|
||||
parseFloat(line.may) || 0,
|
||||
parseFloat(line.jun) || 0,
|
||||
parseFloat(line.jul) || 0,
|
||||
parseFloat(line.aug) || 0,
|
||||
parseFloat(line.sep) || 0,
|
||||
parseFloat(line.oct) || 0,
|
||||
parseFloat(line.nov) || 0,
|
||||
parseFloat(line.dec_amt || line.dec) || 0,
|
||||
line.notes || null,
|
||||
],
|
||||
);
|
||||
results.push(rows[0]);
|
||||
}
|
||||
|
||||
return { imported: results.length, errors };
|
||||
}
|
||||
|
||||
async getTemplate(year: number): Promise<string> {
|
||||
// Get all active income/expense accounts with existing budget data
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT a.account_number, a.name as account_name,
|
||||
COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb,
|
||||
COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr,
|
||||
COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun,
|
||||
COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug,
|
||||
COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct,
|
||||
COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec
|
||||
FROM accounts a
|
||||
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||
WHERE a.is_active = true
|
||||
AND a.account_type IN ('income', 'expense')
|
||||
ORDER BY a.account_number`,
|
||||
[year],
|
||||
);
|
||||
|
||||
const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec';
|
||||
const csvLines = rows.map((r: any) => {
|
||||
const name = String(r.account_name).includes(',')
|
||||
? `"${r.account_name}"`
|
||||
: r.account_name;
|
||||
return [
|
||||
r.account_number, name,
|
||||
r.jan, r.feb, r.mar, r.apr, r.may, r.jun,
|
||||
r.jul, r.aug, r.sep, r.oct, r.nov, r.dec,
|
||||
].join(',');
|
||||
});
|
||||
|
||||
return [header, ...csvLines].join('\n');
|
||||
}
|
||||
|
||||
async findByYear(year: number) {
|
||||
return this.tenant.query(
|
||||
`SELECT b.*, a.account_number, a.name as account_name, a.account_type
|
||||
|
||||
@@ -43,6 +43,9 @@ export class User {
|
||||
@Column({ name: 'oauth_provider_id', nullable: true })
|
||||
oauthProviderId: string;
|
||||
|
||||
@Column({ name: 'is_superadmin', default: false })
|
||||
isSuperadmin: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
|
||||
@@ -38,4 +38,25 @@ export class UsersService {
|
||||
async updateLastLogin(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { lastLoginAt: new Date() });
|
||||
}
|
||||
|
||||
async findAllUsers(): Promise<User[]> {
|
||||
return this.usersRepository.find({
|
||||
relations: ['userOrganizations', 'userOrganizations.organization'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAllOrganizations(): Promise<any[]> {
|
||||
const dataSource = this.usersRepository.manager.connection;
|
||||
return dataSource.query(`
|
||||
SELECT o.*,
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
|
||||
FROM shared.organizations o
|
||||
ORDER BY o.created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||
await this.usersRepository.update(userId, { isSuperadmin });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user