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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user