feat: add API modules for digest scheduling, org admin portal, and thread views

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 15:40:32 +05:30
parent 47f345c4bf
commit 435b0e7806
14 changed files with 605 additions and 0 deletions
@@ -0,0 +1,43 @@
import { Body, Controller, Delete, Get, Post, Put, UseGuards } from '@nestjs/common';
import { DigestService } from './digest.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { UpdateDigestConfigDto } from './digest.dto';
@Controller('admin/digest')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class DigestController {
constructor(private readonly digestService: DigestService) {}
@Get('config')
getConfig(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.getConfig(ctx.tenantId);
}
@Put('config')
upsertConfig(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: UpdateDigestConfigDto,
) {
return this.digestService.upsertConfig(ctx.tenantId, body, ctx.adminId ?? 'unknown');
}
@Delete('config')
disableConfig(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.disableConfig(ctx.tenantId);
}
@Get('history')
getHistory(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.getHistory(ctx.tenantId);
}
@Post('send-now')
sendNow(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.sendNow(ctx.tenantId, ctx.adminId ?? 'unknown');
}
}
+23
View File
@@ -0,0 +1,23 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateDigestConfigDto {
@IsString()
targetGroupJid: string;
@IsString()
targetAccountId: string;
@IsInt()
@Min(0)
@Max(23)
scheduleHour: number;
@IsInt()
@Min(0)
@Max(59)
scheduleMinute: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DigestController } from './digest.controller';
import { DigestService } from './digest.service';
import { digestQueueProvider } from '../../queues/digest.queue';
@Module({
controllers: [DigestController],
providers: [DigestService, digestQueueProvider],
})
export class DigestModule {}
@@ -0,0 +1,100 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Queue } from 'bullmq';
import { DigestJobData } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { DIGEST_QUEUE } from '../../queues/digest.queue';
import { UpdateDigestConfigDto } from './digest.dto';
function todayMidnightUtc(): Date {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
@Injectable()
export class DigestService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@Inject(DIGEST_QUEUE) private readonly digestQueue: Queue<DigestJobData>,
) {}
async getConfig(tenantId: string) {
return this.prisma.digestConfig.findUnique({ where: { tenantId } });
}
async upsertConfig(tenantId: string, dto: UpdateDigestConfigDto, adminId: string) {
const config = await this.prisma.digestConfig.upsert({
where: { tenantId },
create: {
tenantId,
targetGroupJid: dto.targetGroupJid,
targetAccountId: dto.targetAccountId,
scheduleHour: dto.scheduleHour,
scheduleMinute: dto.scheduleMinute,
isActive: dto.isActive ?? true,
},
update: {
targetGroupJid: dto.targetGroupJid,
targetAccountId: dto.targetAccountId,
scheduleHour: dto.scheduleHour,
scheduleMinute: dto.scheduleMinute,
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.DIGEST_SENT,
resourceType: 'DigestConfig',
resourceId: config.id,
payload: { targetGroupJid: dto.targetGroupJid, scheduleHour: dto.scheduleHour, scheduleMinute: dto.scheduleMinute },
});
return config;
}
async disableConfig(tenantId: string): Promise<void> {
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
if (!config) throw new NotFoundException('Digest config not found');
await this.prisma.digestConfig.update({ where: { tenantId }, data: { isActive: false } });
}
async getHistory(tenantId: string) {
const digests = await this.prisma.digest.findMany({
where: { tenantId },
orderBy: { digestDate: 'desc' },
take: 30,
});
return digests.map((d) => ({
id: d.id,
digestDate: d.digestDate.toISOString(),
messageCount: (d.messageIds as string[]).length,
sentAt: d.sentAt.toISOString(),
}));
}
async sendNow(tenantId: string, adminId: string): Promise<{ status: string }> {
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
if (!config) throw new NotFoundException('Digest config not found — configure via PUT /admin/digest/config first');
await this.digestQueue.add('digest', {
tenantId,
digestDate: todayMidnightUtc().toISOString(),
triggeredBy: 'manual',
}, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.DIGEST_SENT,
resourceType: 'Digest',
resourceId: tenantId,
payload: { triggeredBy: 'manual' },
});
return { status: 'queued' };
}
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { OrgAdminJwtPayload } from '@tower/types';
export const CurrentOrgAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): OrgAdminJwtPayload => {
return ctx.switchToHttp().getRequest().user;
},
);
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class OrgAdminGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) throw new UnauthorizedException();
const token = authHeader.slice(7);
try {
const payload = this.jwtService.verify(token);
if (payload.kind !== 'orgadmin') throw new UnauthorizedException('Not an org admin');
req.user = payload;
return true;
} catch {
throw new UnauthorizedException();
}
}
}
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { OrgService } from './org.service';
import { OrgAdminGuard } from './org-admin.guard';
import { Public } from '../auth/public.decorator';
@Controller('auth/org')
export class OrgAuthController {
constructor(private readonly orgService: OrgService) {}
@Public()
@Post('login')
login(@Body() body: { email: string; password: string }) {
return this.orgService.login(body.email, body.password);
}
@UseGuards(OrgAdminGuard)
@Get('me')
me(@Req() req: any) {
return this.orgService.me(req.user.sub);
}
}
@@ -0,0 +1,61 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { OrgService } from './org.service';
import { OrgAdminGuard } from './org-admin.guard';
import { CurrentOrgAdmin } from './org-admin.decorator';
import { OrgAdminJwtPayload } from '@tower/types';
@Controller('org')
@UseGuards(OrgAdminGuard)
export class OrgController {
constructor(private readonly orgService: OrgService) {}
@Get('dashboard')
getDashboard(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getDashboard(admin.organizationId);
}
@Get('tenants')
getTenants(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getTenants(admin.organizationId);
}
@Post('tenants')
createTenant(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Body() body: { name: string; slug: string },
) {
return this.orgService.createTenant(admin.organizationId, body, admin.role);
}
@Get('tenants/:id')
getTenant(@CurrentOrgAdmin() admin: OrgAdminJwtPayload, @Param('id') id: string) {
return this.orgService.getTenant(admin.organizationId, id);
}
@Get('rules')
getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getRules(admin.organizationId);
}
@Post('rules')
createRule(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Body() body: { matchType: string; matchValue: string; action: string; priority?: number },
) {
return this.orgService.createRule(admin.organizationId, body);
}
@Patch('rules/:id')
updateRule(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Param('id') id: string,
@Body() body: { matchValue?: string; action?: string; priority?: number; isActive?: boolean },
) {
return this.orgService.updateRule(admin.organizationId, id, body);
}
@Delete('rules/:id')
deleteRule(@CurrentOrgAdmin() admin: OrgAdminJwtPayload, @Param('id') id: string) {
return this.orgService.deleteRule(admin.organizationId, id);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OrgAuthController } from './org-auth.controller';
import { OrgController } from './org.controller';
import { OrgService } from './org.service';
import { OrgAdminGuard } from './org-admin.guard';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [
PrismaModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? '',
}),
}),
],
controllers: [OrgAuthController, OrgController],
providers: [OrgService, OrgAdminGuard],
exports: [OrgAdminGuard],
})
export class OrgModule {}
+165
View File
@@ -0,0 +1,165 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { OrgAdminJwtPayload } from '@tower/types';
@Injectable()
export class OrgService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
async login(email: string, password: string) {
const admin = await this.prisma.orgAdmin.findFirst({ where: { email } });
if (!admin) throw new UnauthorizedException('Invalid credentials');
const valid = await bcrypt.compare(password, admin.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid credentials');
const payload: OrgAdminJwtPayload = {
kind: 'orgadmin',
sub: admin.id,
organizationId: admin.organizationId,
email: admin.email,
role: admin.role as 'ORG_OWNER' | 'ORG_ADMIN',
};
const token = this.jwtService.sign(payload, {
secret: this.config.get<string>('JWT_SECRET'),
expiresIn: '7d',
});
return {
token,
orgAdmin: { id: admin.id, email: admin.email, name: admin.name, role: admin.role, organizationId: admin.organizationId },
};
}
async me(adminId: string) {
const admin = await this.prisma.orgAdmin.findUnique({
where: { id: adminId },
include: { organization: { select: { id: true, slug: true, name: true } } },
});
if (!admin) throw new UnauthorizedException('Org admin not found');
return { id: admin.id, email: admin.email, name: admin.name, role: admin.role, organization: admin.organization };
}
async getDashboard(organizationId: string) {
const tenants = await this.prisma.tenant.findMany({
where: { organizationId },
select: {
id: true,
name: true,
slug: true,
isActive: true,
_count: { select: { messages: true, towerUsers: true, admins: true } },
},
});
return {
chapterCount: tenants.length,
chapters: tenants.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
messageCount: t._count.messages,
memberCount: t._count.towerUsers,
adminCount: t._count.admins,
})),
};
}
async getTenants(organizationId: string) {
return this.prisma.tenant.findMany({
where: { organizationId },
select: {
id: true,
name: true,
slug: true,
isActive: true,
createdAt: true,
_count: { select: { messages: true, towerUsers: true } },
},
orderBy: { name: 'asc' },
});
}
async getTenant(organizationId: string, tenantId: string) {
const tenant = await this.prisma.tenant.findFirst({
where: { id: tenantId, organizationId },
include: {
admins: { select: { id: true, email: true, role: true, createdAt: true } },
_count: { select: { messages: true, towerUsers: true, groups: true } },
},
});
if (!tenant) throw new NotFoundException('Chapter not found');
return tenant;
}
async createTenant(organizationId: string, body: { name: string; slug: string }, role: string) {
if (role !== 'ORG_OWNER') throw new ForbiddenException('Only ORG_OWNER can create chapters');
const existing = await this.prisma.tenant.findUnique({ where: { slug: body.slug } });
if (existing) throw new ConflictException('Tenant slug already taken');
return this.prisma.tenant.create({
data: { name: body.name, slug: body.slug, organizationId },
});
}
async getRules(organizationId: string) {
return this.prisma.orgRule.findMany({
where: { organizationId },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
}
async createRule(organizationId: string, body: { matchType: string; matchValue: string; action: string; priority?: number }) {
const existing = await this.prisma.orgRule.findUnique({
where: { organizationId_matchType_matchValue: { organizationId, matchType: body.matchType as any, matchValue: body.matchValue } },
});
if (existing) throw new ConflictException('Rule already exists');
return this.prisma.orgRule.create({
data: {
organizationId,
matchType: body.matchType as any,
matchValue: body.matchValue,
action: body.action as any,
priority: body.priority ?? 0,
},
});
}
async updateRule(organizationId: string, ruleId: string, body: { matchValue?: string; action?: string; priority?: number; isActive?: boolean }) {
const rule = await this.prisma.orgRule.findFirst({ where: { id: ruleId, organizationId } });
if (!rule) throw new NotFoundException('Rule not found');
return this.prisma.orgRule.update({
where: { id: ruleId },
data: {
...(body.matchValue !== undefined && { matchValue: body.matchValue }),
...(body.action !== undefined && { action: body.action as any }),
...(body.priority !== undefined && { priority: body.priority }),
...(body.isActive !== undefined && { isActive: body.isActive }),
},
});
}
async deleteRule(organizationId: string, ruleId: string) {
const rule = await this.prisma.orgRule.findFirst({ where: { id: ruleId, organizationId } });
if (!rule) throw new NotFoundException('Rule not found');
await this.prisma.orgRule.delete({ where: { id: ruleId } });
}
}
@@ -0,0 +1,27 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ThreadsService } from './threads.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
@Controller('admin/threads')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class ThreadsController {
constructor(private readonly threads: ThreadsService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.threads.listThreads(ctx.tenantId);
}
@Get(':id')
get(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.threads.getThread(ctx.tenantId, id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ThreadsController } from './threads.controller';
import { ThreadsService } from './threads.service';
@Module({
controllers: [ThreadsController],
providers: [ThreadsService],
})
export class ThreadsModule {}
@@ -0,0 +1,70 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ThreadsService {
constructor(private readonly prisma: PrismaService) {}
async listThreads(tenantId: string) {
const threads = await this.prisma.thread.findMany({
where: { tenantId },
orderBy: { lastActivityAt: 'desc' },
include: {
_count: { select: { messages: true } },
sourceGroup: { select: { name: true, platformId: true } },
},
});
return threads.map((t) => ({
id: t.id,
topic: t.topic,
sourceGroupName: t.sourceGroup.name,
messageCount: t._count.messages,
lastActivityAt: t.lastActivityAt.toISOString(),
createdAt: t.createdAt.toISOString(),
}));
}
async getThread(tenantId: string, threadId: string) {
const thread = await this.prisma.thread.findUnique({
where: { id: threadId },
include: {
messages: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
content: true,
senderJid: true,
senderName: true,
tags: true,
status: true,
createdAt: true,
},
},
sourceGroup: { select: { name: true, platformId: true } },
},
});
if (!thread || thread.tenantId !== tenantId) {
throw new NotFoundException('Thread not found');
}
return {
id: thread.id,
topic: thread.topic,
sourceGroupName: thread.sourceGroup.name,
messageCount: thread.messageCount,
lastActivityAt: thread.lastActivityAt.toISOString(),
createdAt: thread.createdAt.toISOString(),
messages: thread.messages.map((m) => ({
id: m.id,
content: m.content,
senderJid: m.senderJid,
senderName: m.senderName,
tags: m.tags,
status: m.status,
createdAt: m.createdAt.toISOString(),
})),
};
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Provider } from '@nestjs/common';
import { Queue } from 'bullmq';
import { ConfigService } from '@nestjs/config';
import { DigestJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection';
export const DIGEST_QUEUE = 'DIGEST_QUEUE';
export function createDigestQueue(redisUrl: string): Queue<DigestJobData> {
return new Queue<DigestJobData>('tower.digest.generate.v1', {
connection: parseRedisUrl(redisUrl),
});
}
export const digestQueueProvider: Provider = {
provide: DIGEST_QUEUE,
useFactory: (config: ConfigService) =>
createDigestQueue(config.get<string>('REDIS_URL', 'redis://localhost:6379')),
inject: [ConfigService],
};