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,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 } });
}
}