diff --git a/apps/api/src/modules/digest/digest.controller.ts b/apps/api/src/modules/digest/digest.controller.ts new file mode 100644 index 0000000..91d68cd --- /dev/null +++ b/apps/api/src/modules/digest/digest.controller.ts @@ -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'); + } +} diff --git a/apps/api/src/modules/digest/digest.dto.ts b/apps/api/src/modules/digest/digest.dto.ts new file mode 100644 index 0000000..bb773a2 --- /dev/null +++ b/apps/api/src/modules/digest/digest.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/digest/digest.module.ts b/apps/api/src/modules/digest/digest.module.ts new file mode 100644 index 0000000..d9dad93 --- /dev/null +++ b/apps/api/src/modules/digest/digest.module.ts @@ -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 {} diff --git a/apps/api/src/modules/digest/digest.service.ts b/apps/api/src/modules/digest/digest.service.ts new file mode 100644 index 0000000..12c5aef --- /dev/null +++ b/apps/api/src/modules/digest/digest.service.ts @@ -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, + ) {} + + 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 { + 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' }; + } +} diff --git a/apps/api/src/modules/org/org-admin.decorator.ts b/apps/api/src/modules/org/org-admin.decorator.ts new file mode 100644 index 0000000..8cdd6cf --- /dev/null +++ b/apps/api/src/modules/org/org-admin.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/modules/org/org-admin.guard.ts b/apps/api/src/modules/org/org-admin.guard.ts new file mode 100644 index 0000000..bfa83a9 --- /dev/null +++ b/apps/api/src/modules/org/org-admin.guard.ts @@ -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(); + } + } +} diff --git a/apps/api/src/modules/org/org-auth.controller.ts b/apps/api/src/modules/org/org-auth.controller.ts new file mode 100644 index 0000000..9d00110 --- /dev/null +++ b/apps/api/src/modules/org/org-auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/org/org.controller.ts b/apps/api/src/modules/org/org.controller.ts new file mode 100644 index 0000000..2140029 --- /dev/null +++ b/apps/api/src/modules/org/org.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/org/org.module.ts b/apps/api/src/modules/org/org.module.ts new file mode 100644 index 0000000..2b03f29 --- /dev/null +++ b/apps/api/src/modules/org/org.module.ts @@ -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('JWT_SECRET') ?? '', + }), + }), + ], + controllers: [OrgAuthController, OrgController], + providers: [OrgService, OrgAdminGuard], + exports: [OrgAdminGuard], +}) +export class OrgModule {} diff --git a/apps/api/src/modules/org/org.service.ts b/apps/api/src/modules/org/org.service.ts new file mode 100644 index 0000000..d3ef881 --- /dev/null +++ b/apps/api/src/modules/org/org.service.ts @@ -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('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 } }); + } +} diff --git a/apps/api/src/modules/threads/threads.controller.ts b/apps/api/src/modules/threads/threads.controller.ts new file mode 100644 index 0000000..b7112b1 --- /dev/null +++ b/apps/api/src/modules/threads/threads.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/threads/threads.module.ts b/apps/api/src/modules/threads/threads.module.ts new file mode 100644 index 0000000..88624b5 --- /dev/null +++ b/apps/api/src/modules/threads/threads.module.ts @@ -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 {} diff --git a/apps/api/src/modules/threads/threads.service.ts b/apps/api/src/modules/threads/threads.service.ts new file mode 100644 index 0000000..b5cbf8d --- /dev/null +++ b/apps/api/src/modules/threads/threads.service.ts @@ -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(), + })), + }; + } +} diff --git a/apps/api/src/queues/digest.queue.ts b/apps/api/src/queues/digest.queue.ts new file mode 100644 index 0000000..153f99d --- /dev/null +++ b/apps/api/src/queues/digest.queue.ts @@ -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 { + return new Queue('tower.digest.generate.v1', { + connection: parseRedisUrl(redisUrl), + }); +} + +export const digestQueueProvider: Provider = { + provide: DIGEST_QUEUE, + useFactory: (config: ConfigService) => + createDigestQueue(config.get('REDIS_URL', 'redis://localhost:6379')), + inject: [ConfigService], +};