good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
@@ -1,50 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
const mockAccounts = [
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' },
];
const mockCreated = { id: 'acc_new', platform: 'whatsapp', jid: 'pending_x@placeholder', displayName: 'New', status: 'ACTIVE' };
const mockService = {
list: jest.fn().mockResolvedValue(mockAccounts),
getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }),
create: jest.fn().mockResolvedValue(mockCreated),
};
describe('AccountsController', () => {
let controller: AccountsController;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [AccountsController],
providers: [{ provide: AccountsService, useValue: mockService }],
}).compile();
controller = module.get<AccountsController>(AccountsController);
});
it('list() returns accounts from service', async () => {
const result = await controller.list();
expect(result).toEqual(mockAccounts);
expect(mockService.list).toHaveBeenCalled();
});
it('getQr() calls service with the account id', async () => {
const result = await controller.getQr('acc_1');
expect(mockService.getQr).toHaveBeenCalledWith('acc_1');
expect(result.qrDataUrl).toBe('data:image/png;base64,fake');
});
it('create() calls service with displayName from body', async () => {
const result = await controller.create({ displayName: 'New' });
expect(mockService.create).toHaveBeenCalledWith('New');
expect(result).toEqual(mockCreated);
});
it('create() calls service with undefined when no displayName', async () => {
await controller.create({});
expect(mockService.create).toHaveBeenCalledWith(undefined);
});
});
@@ -1,22 +0,0 @@
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
import { AccountsService } from './accounts.service';
@Controller('accounts')
export class AccountsController {
constructor(private readonly service: AccountsService) {}
@Get()
list() {
return this.service.list();
}
@Get(':id/qr')
getQr(@Param('id') id: string) {
return this.service.getQr(id);
}
@Post()
create(@Body() body: { displayName?: string }) {
return this.service.create(body.displayName);
}
}
@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
@Module({
imports: [ConfigModule],
controllers: [AccountsController],
providers: [AccountsService],
})
export class AccountsModule {}
@@ -1,121 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { AccountsService } from './accounts.service';
import { PrismaService } from '../../prisma/prisma.service';
import * as QRCode from 'qrcode';
jest.mock('qrcode', () => ({
toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'),
}));
const mockAccounts = [
{ id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' },
];
const mockCreatedAccount = {
id: 'acc_new',
platform: 'whatsapp',
jid: 'pending_uuid@placeholder',
displayName: 'My Number',
status: 'DISCONNECTED',
};
const mockPrisma = {
account: {
findMany: jest.fn().mockResolvedValue(mockAccounts),
findUnique: jest.fn(),
create: jest.fn().mockResolvedValue(mockCreatedAccount),
},
};
const mockConfig = {
get: jest.fn().mockImplementation((key: string, def: string) =>
key === 'WHATSAPP_SESSION_PATH' ? './sessions' : def,
),
};
describe('AccountsService', () => {
let service: AccountsService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
AccountsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<AccountsService>(AccountsService);
});
describe('list()', () => {
it('returns accounts from Prisma without qrCode field', async () => {
const result = await service.list();
expect(result).toEqual(mockAccounts);
expect(mockPrisma.account.findMany).toHaveBeenCalledWith(
expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }),
);
});
});
describe('getQr()', () => {
it('returns null qrDataUrl when account has no qrCode', async () => {
mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null });
const result = await service.getQr('acc_1');
expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null });
expect(QRCode.toDataURL).not.toHaveBeenCalled();
});
it('converts qrCode string to data URL when qrCode is present', async () => {
mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' });
const result = await service.getQr('acc_1');
expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string');
expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' });
});
it('returns not_found status when account does not exist', async () => {
mockPrisma.account.findUnique.mockResolvedValue(null);
const result = await service.getQr('nonexistent');
expect(result).toEqual({ status: 'not_found', qrDataUrl: null });
});
});
describe('create()', () => {
it('creates account with platform whatsapp and status DISCONNECTED', async () => {
await service.create('My Number');
expect(mockPrisma.account.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
platform: 'whatsapp',
status: 'DISCONNECTED',
displayName: 'My Number',
}),
}),
);
});
it('generates a unique sessionPath under WHATSAPP_SESSION_PATH', async () => {
await service.create();
const call = mockPrisma.account.create.mock.calls[0][0];
expect(call.data.sessionPath).toMatch(/^\.\/sessions\/.+/);
});
it('generates a placeholder jid prefixed with pending_', async () => {
await service.create();
const call = mockPrisma.account.create.mock.calls[0][0];
expect(call.data.jid).toMatch(/^pending_/);
});
it('sets displayName to null when not provided', async () => {
await service.create();
const call = mockPrisma.account.create.mock.calls[0][0];
expect(call.data.displayName).toBeNull();
});
it('returns the created account summary', async () => {
const result = await service.create('My Number');
expect(result).toEqual(mockCreatedAccount);
});
});
});
@@ -1,59 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import * as QRCode from 'qrcode';
export interface AccountSummary {
id: string;
platform: string;
jid: string;
displayName: string | null;
status: string;
}
export interface AccountQr {
status: string;
qrDataUrl: string | null;
}
@Injectable()
export class AccountsService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
list(): Promise<AccountSummary[]> {
return this.prisma.account.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, platform: true, jid: true, displayName: true, status: true },
});
}
async getQr(id: string): Promise<AccountQr> {
const account = await this.prisma.account.findUnique({
where: { id },
select: { status: true, qrCode: true },
});
if (!account) return { status: 'not_found', qrDataUrl: null };
if (!account.qrCode) return { status: account.status, qrDataUrl: null };
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
return { status: account.status, qrDataUrl };
}
async create(displayName?: string): Promise<AccountSummary> {
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
const uid = randomUUID();
return this.prisma.account.create({
data: {
platform: 'whatsapp',
jid: `pending_${uid}@placeholder`,
sessionPath: `${sessionBase}/${uid}`,
displayName: displayName ?? null,
status: 'DISCONNECTED',
},
select: { id: true, platform: true, jid: true, displayName: true, status: true },
});
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,61 @@
import { Test } from '@nestjs/testing';
import { ActorType } from '@prisma/client';
import { AuditService } from './audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditAction } from './audit.types';
describe('AuditService', () => {
let service: AuditService;
let prisma: { auditEvent: { create: jest.Mock } };
beforeEach(async () => {
prisma = { auditEvent: { create: jest.fn().mockResolvedValue({}) } };
const moduleRef = await Test.createTestingModule({
providers: [
AuditService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = moduleRef.get(AuditService);
});
it('writes an audit event with the explicit tenantId and admin actor by default', async () => {
await service.log({
tenantId: 'tnt-1',
actorId: 'adm-1',
action: AuditAction.ROUTE_CREATED,
resourceType: 'SyncRoute',
resourceId: 'r-1',
payload: { foo: 'bar' },
});
expect(prisma.auditEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tnt-1',
actorType: ActorType.ADMIN,
actorId: 'adm-1',
action: 'ROUTE_CREATED',
resourceType: 'SyncRoute',
resourceId: 'r-1',
payload: { foo: 'bar' },
}),
});
});
it('allows explicit tenantId override (e.g. system actor)', async () => {
await service.log({
tenantId: 'tnt-override',
actorType: ActorType.SYSTEM,
actorId: null,
action: AuditAction.AUTH_LOGIN,
resourceType: 'Admin',
resourceId: 'a-1',
});
expect(prisma.auditEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tnt-override',
actorType: ActorType.SYSTEM,
actorId: null,
}),
});
});
});
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ActorType } from '@prisma/client';
import { AuditActionValue } from './audit.types';
export interface AuditLogInput {
action: AuditActionValue;
resourceType: string;
resourceId: string;
actorType?: ActorType;
actorId?: string | null;
payload?: Record<string, unknown>;
traceId?: string | null;
tenantId: string;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(input: AuditLogInput): Promise<void> {
await this.prisma.auditEvent.create({
data: {
tenantId: input.tenantId,
actorType: input.actorType ?? ActorType.ADMIN,
actorId: input.actorId ?? null,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
payload: (input.payload ?? {}) as object,
traceId: input.traceId ?? null,
},
});
}
}
+42
View File
@@ -0,0 +1,42 @@
import { ActorType } from '@prisma/client';
export { ActorType };
// Initial set of audit actions — expand in later phases
export const AuditAction = {
AUTH_LOGIN: 'AUTH_LOGIN',
AUTH_LOGIN_FAILED: 'AUTH_LOGIN_FAILED',
AUTH_LOGOUT: 'AUTH_LOGOUT',
AUTH_SIGNUP: 'AUTH_SIGNUP',
ROUTE_CREATED: 'ROUTE_CREATED',
ROUTE_DELETED: 'ROUTE_DELETED',
ACCOUNT_CREATED: 'ACCOUNT_CREATED',
MESSAGE_INGESTED: 'MESSAGE_INGESTED',
MESSAGE_APPROVED: 'MESSAGE_APPROVED',
MESSAGE_FORWARDED: 'MESSAGE_FORWARDED',
MESSAGE_INDEXED: 'MESSAGE_INDEXED',
BOT_INITIATED: 'BOT_INITIATED',
BOT_PAIRED: 'BOT_PAIRED',
BOT_REVEALED: 'BOT_REVEALED',
BOT_REMOVED: 'BOT_REMOVED',
BOT_ACCESS_GRANTED: 'BOT_ACCESS_GRANTED',
GROUP_PENDING_CLAIM: 'GROUP_PENDING_CLAIM',
GROUP_CLAIMED: 'GROUP_CLAIMED',
GROUP_RELEASED: 'GROUP_RELEASED',
GROUP_EXPIRED: 'GROUP_EXPIRED',
GROUP_CLAIM_TOKEN_SENT: 'GROUP_CLAIM_TOKEN_SENT',
GROUP_CLAIMED_WITH_TOKEN: 'GROUP_CLAIMED_WITH_TOKEN',
GROUP_SHARED: 'GROUP_SHARED',
GROUP_UNSHARED: 'GROUP_UNSHARED',
GROUP_CLAIM_TOKEN_REGENERATED: 'GROUP_CLAIM_TOKEN_REGENERATED',
GROUP_BOT_REMOVED: 'GROUP_BOT_REMOVED',
GROUP_BOT_RE_ADDED: 'GROUP_BOT_RE_ADDED',
MEMBER_ONBOARDED: 'MEMBER_ONBOARDED',
MEMBER_OPT_OUT: 'MEMBER_OPT_OUT',
MEMBER_OPT_IN: 'MEMBER_OPT_IN',
MEMBER_DELETED: 'MEMBER_DELETED',
OTP_REQUESTED: 'OTP_REQUESTED',
OTP_VERIFIED: 'OTP_VERIFIED',
} as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
@@ -0,0 +1,39 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { SignupDto } from './dto/signup.dto';
import { Public } from './public.decorator';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentAdmin } from './current-admin.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Public()
@Post('signup')
@HttpCode(HttpStatus.OK)
async signup(@Body() dto: SignupDto) {
return this.authService.signup(dto);
}
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@CurrentAdmin() admin: any) {
return this.authService.logout(admin.sub, admin.tenantId);
}
@UseGuards(JwtAuthGuard)
@Get('me')
async me(@CurrentAdmin() admin: any) {
return this.authService.me(admin.sub, admin.tenantId);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';
import { AuditModule } from '../audit/audit.module';
import { BotModule } from '../bot/bot.module';
@Module({
imports: [
AuditModule,
forwardRef(() => BotModule),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? '',
signOptions: {
expiresIn: (config.get<string>('JWT_EXPIRES_IN') ?? '7d') as `${number}d` | `${number}h` | `${number}m` | `${number}s`,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
})
export class AuthModule {}
@@ -0,0 +1,254 @@
import { Test } from '@nestjs/testing';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { BotService } from '../bot/bot.service';
import { verifyPassword, hashPassword } from './password.util';
jest.mock('./password.util', () => ({
verifyPassword: jest.fn(),
hashPassword: jest.fn(),
}));
describe('AuthService', () => {
let service: AuthService;
let prisma: any;
let jwt: { signAsync: jest.Mock };
let audit: { log: jest.Mock };
let bot: { assignBotToTenant: jest.Mock };
beforeEach(async () => {
prisma = {
tenant: { findUnique: jest.fn(), create: jest.fn() },
admin: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn() },
$transaction: jest.fn(),
};
jwt = { signAsync: jest.fn().mockResolvedValue('signed-jwt-token') };
audit = { log: jest.fn().mockResolvedValue(undefined) };
bot = { assignBotToTenant: jest.fn().mockResolvedValue({ id: 'bot-1', status: 'ACTIVE' }) };
(verifyPassword as jest.Mock).mockReset();
(hashPassword as jest.Mock).mockReset().mockResolvedValue('hashed-pw');
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: prisma },
{ provide: JwtService, useValue: jwt },
{ provide: AuditService, useValue: audit },
{ provide: BotService, useValue: bot },
],
}).compile();
service = moduleRef.get(AuthService);
});
describe('login', () => {
it('returns token + admin on valid credentials', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
prisma.admin.findUnique.mockResolvedValue({
id: 'adm-1',
email: 'admin@tower.local',
passwordHash: 'hash',
role: 'OWNER',
tenantId: 'tnt-1',
});
(verifyPassword as jest.Mock).mockResolvedValue(true);
const res = await service.login({
tenantSlug: 'default',
email: 'admin@tower.local',
password: 'secret123',
});
expect(res.token).toBe('signed-jwt-token');
expect(res.admin).toEqual({
id: 'adm-1',
email: 'admin@tower.local',
role: 'OWNER',
tenantId: 'tnt-1',
tenantSlug: 'default',
});
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'AUTH_LOGIN', resourceId: 'adm-1' }),
);
});
it('rejects unknown tenant', async () => {
prisma.tenant.findUnique.mockResolvedValue(null);
await expect(
service.login({ tenantSlug: 'nope', email: 'a@b.c', password: 'secret123' }),
).rejects.toThrow(UnauthorizedException);
});
it('rejects unknown admin', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
prisma.admin.findUnique.mockResolvedValue(null);
await expect(
service.login({ tenantSlug: 'default', email: 'a@b.c', password: 'secret123' }),
).rejects.toThrow(UnauthorizedException);
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'AUTH_LOGIN_FAILED' }),
);
});
it('rejects bad password', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
prisma.admin.findUnique.mockResolvedValue({
id: 'adm-1',
email: 'a@b.c',
passwordHash: 'hash',
role: 'OWNER',
tenantId: 'tnt-1',
});
(verifyPassword as jest.Mock).mockResolvedValue(false);
await expect(
service.login({ tenantSlug: 'default', email: 'a@b.c', password: 'wrong1234' }),
).rejects.toThrow(UnauthorizedException);
});
it('logs in by email alone when no tenantSlug is given and the email is unique', async () => {
prisma.admin.findMany.mockResolvedValue([{ tenantId: 'tnt-2' }]);
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-2', slug: 'other' });
prisma.admin.findUnique.mockResolvedValue({
id: 'adm-2',
email: 'a@b.c',
passwordHash: 'hash',
role: 'OWNER',
tenantId: 'tnt-2',
});
(verifyPassword as jest.Mock).mockResolvedValue(true);
const res = await service.login({ email: 'a@b.c', password: 'secret123' });
expect(res.token).toBe('signed-jwt-token');
expect(res.admin.tenantSlug).toBe('other');
expect(res.admin.tenantId).toBe('tnt-2');
});
it('rejects when email belongs to multiple tenants', async () => {
prisma.admin.findMany.mockResolvedValue([{ tenantId: 'tnt-1' }, { tenantId: 'tnt-2' }]);
await expect(
service.login({ email: 'shared@x.com', password: 'secret123' }),
).rejects.toThrow(/multiple tenants/);
});
it('rejects when email matches no admin', async () => {
prisma.admin.findMany.mockResolvedValue([]);
await expect(
service.login({ email: 'nobody@x.com', password: 'secret123' }),
).rejects.toThrow(UnauthorizedException);
});
});
describe('me', () => {
it('returns admin profile', async () => {
prisma.admin.findFirst.mockResolvedValue({
id: 'adm-1',
email: 'a@b.c',
role: 'OWNER',
tenantId: 'tnt-1',
tenant: { slug: 'default' },
});
const res = await service.me('adm-1', 'tnt-1');
expect(res).toEqual({
admin: {
id: 'adm-1',
email: 'a@b.c',
role: 'OWNER',
tenantId: 'tnt-1',
tenantSlug: 'default',
},
});
});
it('throws when admin not found', async () => {
prisma.admin.findFirst.mockResolvedValue(null);
await expect(service.me('x', 'y')).rejects.toThrow(UnauthorizedException);
});
});
describe('logout', () => {
it('writes audit and returns ok', async () => {
const res = await service.logout('adm-1', 'tnt-1');
expect(res).toEqual({ ok: true });
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'AUTH_LOGOUT', resourceId: 'adm-1' }),
);
});
});
describe('signup', () => {
const baseReq = {
tenantName: 'Delhi Traders',
tenantSlug: 'delhi',
email: 'priya@delhi.test',
password: 'strongpass1',
};
it('creates tenant + owner admin atomically and returns token', async () => {
prisma.tenant.findUnique.mockResolvedValue(null);
prisma.$transaction.mockImplementation(async (cb: any) =>
cb({
tenant: {
create: jest.fn().mockResolvedValue({ id: 'tnt-new', slug: 'delhi', name: 'Delhi Traders' }),
},
admin: {
create: jest.fn().mockResolvedValue({
id: 'adm-new',
tenantId: 'tnt-new',
email: 'priya@delhi.test',
role: 'OWNER',
}),
},
tenantRule: { create: jest.fn().mockResolvedValue({}) },
}),
);
const res = await service.signup(baseReq);
expect(res.token).toBe('signed-jwt-token');
expect(res.admin).toEqual({
id: 'adm-new',
email: 'priya@delhi.test',
role: 'OWNER',
tenantId: 'tnt-new',
tenantSlug: 'delhi',
});
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'AUTH_SIGNUP',
tenantId: 'tnt-new',
resourceId: 'tnt-new',
}),
);
});
it('rejects a taken slug with 409', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-existing', slug: 'delhi' });
await expect(service.signup(baseReq)).rejects.toBeInstanceOf(ConflictException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('hashes the password before storing', async () => {
prisma.tenant.findUnique.mockResolvedValue(null);
prisma.$transaction.mockImplementation(async (cb: any) =>
cb({
tenant: { create: jest.fn().mockResolvedValue({ id: 'tnt-x', slug: 'x', name: 'X' }) },
admin: {
create: jest.fn().mockImplementation(async ({ data }: any) => ({
id: 'adm-x',
tenantId: 'tnt-x',
email: data.email,
role: data.role,
})),
},
tenantRule: { create: jest.fn().mockResolvedValue({}) },
}),
);
await service.signup({ ...baseReq, password: 'plaintext' });
expect(hashPassword).toHaveBeenCalledWith('plaintext');
});
});
});
+218
View File
@@ -0,0 +1,218 @@
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
AdminRole,
JwtPayload,
LoginRequest,
LoginResponse,
SignupRequest,
SignupResponse,
} from '@tower/types';
import { AdminRole as PrismaAdminRole } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { verifyPassword } from './password.util';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { hashPassword } from './password.util';
import { BotService } from '../bot/bot.service';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly audit: AuditService,
private readonly bot: BotService,
) {}
async login(req: LoginRequest): Promise<LoginResponse> {
let tenant: { id: string; slug: string } | null = null;
if (req.tenantSlug) {
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
if (!tenant) {
throw new UnauthorizedException('Invalid credentials');
}
} else {
// Look up by email across all tenants. Most users belong to one tenant.
const matches = await this.prisma.admin.findMany({
where: { email: req.email },
select: { tenantId: true },
});
if (matches.length === 0) {
throw new UnauthorizedException('Invalid credentials');
}
if (matches.length > 1) {
// Disambiguate by requiring the client to send tenantSlug.
throw new UnauthorizedException(
'Email is registered in multiple tenants — please specify tenantSlug',
);
}
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId } });
if (!found) {
throw new UnauthorizedException('Invalid credentials');
}
tenant = found;
}
const admin = await this.prisma.admin.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: req.email } },
});
if (!admin) {
await this.recordFailedLogin(req.email, 'no_admin', tenant.id);
throw new UnauthorizedException('Invalid credentials');
}
const ok = await verifyPassword(req.password, admin.passwordHash);
if (!ok) {
await this.recordFailedLogin(req.email, 'bad_password', tenant.id, admin.id);
throw new UnauthorizedException('Invalid credentials');
}
const payload: JwtPayload = {
kind: 'admin',
sub: admin.id,
tenantId: admin.tenantId,
role: admin.role as AdminRole,
email: admin.email,
};
const token = await this.jwt.signAsync(payload);
await this.audit.log({
tenantId: tenant.id,
actorId: admin.id,
action: AuditAction.AUTH_LOGIN,
resourceType: 'Admin',
resourceId: admin.id,
payload: { email: admin.email },
});
return {
token,
admin: {
id: admin.id,
email: admin.email,
role: admin.role as AdminRole,
tenantId: admin.tenantId,
tenantSlug: tenant.slug,
},
};
}
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
const admin = await this.prisma.admin.findFirst({
where: { id: adminId, tenantId },
include: { tenant: true },
});
if (!admin) throw new UnauthorizedException('Admin not found');
return {
admin: {
id: admin.id,
email: admin.email,
role: admin.role as AdminRole,
tenantId: admin.tenantId,
tenantSlug: admin.tenant.slug,
},
};
}
async logout(adminId: string, tenantId: string): Promise<{ ok: true }> {
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.AUTH_LOGOUT,
resourceType: 'Admin',
resourceId: adminId,
});
return { ok: true };
}
async signup(req: SignupRequest): Promise<SignupResponse> {
const existingSlug = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
if (existingSlug) {
throw new ConflictException('That tenant slug is already taken');
}
const passwordHash = await hashPassword(req.password);
const { tenant, admin } = await this.prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: { slug: req.tenantSlug, name: req.tenantName },
});
const admin = await tx.admin.create({
data: {
tenantId: tenant.id,
email: req.email,
passwordHash,
role: PrismaAdminRole.OWNER,
},
});
// Seed default rules: FLAG for #important and #event hashtags, PREFIX for /tower
const defaults: Array<{ matchType: any; matchValue: string; action: any; priority: number }> = [
{ matchType: 'HASHTAG' as const, matchValue: '#important', action: 'FLAG' as const, priority: 0 },
{ matchType: 'HASHTAG' as const, matchValue: '#event', action: 'FLAG' as const, priority: 1 },
{ matchType: 'PREFIX' as const, matchValue: '/tower', action: 'FLAG' as const, priority: 2 },
];
for (const rule of defaults) {
await tx.tenantRule.create({
data: { tenantId: tenant.id, ...rule },
}).catch(() => {
// Ignore duplicate errors; rules are best-effort during signup
});
}
return { tenant, admin };
});
const payload: JwtPayload = {
kind: 'admin',
sub: admin.id,
tenantId: tenant.id,
role: PrismaAdminRole.OWNER,
email: admin.email,
};
const token = await this.jwt.signAsync(payload);
await this.audit.log({
tenantId: tenant.id,
actorId: admin.id,
action: AuditAction.AUTH_SIGNUP,
resourceType: 'Tenant',
resourceId: tenant.id,
payload: { email: admin.email, tenantSlug: tenant.slug },
});
// Auto-assign the least-loaded bot
await this.bot.assignBotToTenant(tenant.id);
return {
token,
admin: {
id: admin.id,
email: admin.email,
role: PrismaAdminRole.OWNER,
tenantId: tenant.id,
tenantSlug: tenant.slug,
},
};
}
private async recordFailedLogin(
email: string,
reason: string,
tenantId?: string,
adminId?: string,
): Promise<void> {
if (!tenantId) return; // cannot audit without a tenant
await this.audit.log({
tenantId,
actorId: adminId ?? null,
action: AuditAction.AUTH_LOGIN_FAILED,
resourceType: 'Admin',
resourceId: adminId ?? email,
payload: { email, reason },
});
}
// Helper used by the seed script
static hashForSeed = hashPassword;
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '@tower/types';
export const CurrentAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload | null => {
const request = ctx.switchToHttp().getRequest();
return (request.user as JwtPayload | undefined) ?? null;
},
);
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { MemberJwtPayload } from '@tower/types';
export const CurrentMember = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): MemberJwtPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user as MemberJwtPayload;
},
);
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { TenantContext } from '../../common/tenant-context';
export const CurrentTenantContext = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): TenantContext => {
const request = ctx.switchToHttp().getRequest();
return request.tenantContext as TenantContext;
},
);
@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsOptional()
@IsString()
tenantSlug?: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
@@ -0,0 +1,24 @@
import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class SignupDto {
@IsString()
@MinLength(2)
@MaxLength(80)
tenantName!: string;
// Lowercase, alphanumeric + dashes, 2-40 chars, must start with a letter
@IsString()
@Matches(/^[a-z][a-z0-9-]{1,39}$/, {
message:
'tenantSlug must be 2-40 chars, start with a letter, and contain only lowercase letters, digits, and dashes',
})
tenantSlug!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
}
@@ -0,0 +1,48 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AdminJwtPayload, JwtPayload, MemberJwtPayload } from '@tower/types';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
// After JWT validation, attach a TenantContext to the request so that
// controllers, services, and the AuditService can read tenantId/adminId/role
// without re-parsing the token.
handleRequest<TUser = JwtPayload>(err: unknown, user: TUser, info: unknown, context: ExecutionContext): TUser {
if (err || !user) {
throw err ?? new UnauthorizedException(info instanceof Error ? info.message : 'Invalid or missing token');
}
const payload = user as unknown as JwtPayload;
const request = context.switchToHttp().getRequest();
if (payload.kind === 'admin') {
const admin = payload as AdminJwtPayload;
request.tenantContext = {
tenantId: admin.tenantId,
adminId: admin.sub,
role: admin.role,
};
} else {
const member = payload as MemberJwtPayload;
request.tenantContext = {
tenantId: member.tenantId,
adminId: null,
role: null,
};
}
return user;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '@tower/types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') ?? '',
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
return payload;
}
}
@@ -0,0 +1,8 @@
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { MemberAuthGuard } from './member-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';
export const MEMBER_AUTH_KEY = 'member-auth';
export const MemberAuth = () =>
applyDecorators(SetMetadata(MEMBER_AUTH_KEY, true), UseGuards(JwtAuthGuard, MemberAuthGuard));
@@ -0,0 +1,14 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { MemberJwtPayload } from '@tower/types';
@Injectable()
export class MemberAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user as MemberJwtPayload | undefined;
if (!user || user.kind !== 'member') {
throw new UnauthorizedException('Member authentication required');
}
return true;
}
}
@@ -0,0 +1,34 @@
jest.mock('bcryptjs', () => ({
__esModule: true,
hash: jest.fn(),
compare: jest.fn(),
}));
import * as bcrypt from 'bcryptjs';
import { hashPassword, verifyPassword } from './password.util';
const mockedBcrypt = bcrypt as unknown as { hash: jest.Mock; compare: jest.Mock };
describe('password util', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('hashes and verifies a password roundtrip', async () => {
mockedBcrypt.hash.mockResolvedValue('$2a$10$hashedvalue');
mockedBcrypt.compare.mockResolvedValue(true);
const hash = await hashPassword('secret', 4);
expect(hash).toBe('$2a$10$hashedvalue');
expect(mockedBcrypt.hash).toHaveBeenCalledWith('secret', 4);
const ok = await verifyPassword('secret', hash);
expect(ok).toBe(true);
});
it('rejects wrong password', async () => {
mockedBcrypt.compare.mockResolvedValue(false);
const ok = await verifyPassword('wrong', '$2a$10$hashedvalue');
expect(ok).toBe(false);
});
});
@@ -0,0 +1,9 @@
import * as bcrypt from 'bcryptjs';
export async function hashPassword(plain: string, rounds: number = 10): Promise<string> {
return bcrypt.hash(plain, rounds);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { AdminRole } from '@tower/types';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: AdminRole[]) => SetMetadata(ROLES_KEY, roles);
+23
View File
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AdminRole } from '@tower/types';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<AdminRole[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const request = context.switchToHttp().getRequest();
const role = request.tenantContext?.role as AdminRole | null | undefined;
if (!role || !required.includes(role)) {
throw new ForbiddenException(`Role ${role ?? 'none'} not in required: ${required.join(', ')}`);
}
return true;
}
}
@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { BotService } from './bot.service';
import { SuperAdminGuard } from '../super-admin/super-admin.guard';
import { IsOptional, IsString } from 'class-validator';
class InitiateBotDto {
@IsOptional() @IsString() displayName?: string;
}
@Controller('admin/bots')
@UseGuards(SuperAdminGuard)
export class BotAdminController {
constructor(private readonly service: BotService) {}
@Get()
list() {
return this.service.listAll();
}
@Post('initiate')
initiate(@Body() body: InitiateBotDto) {
return this.service.superInitiate(body.displayName);
}
@Get('qr/:token')
getQr(@Param('token') token: string) {
return this.service.superGetQr(token);
}
@Post(':id/assign')
assign(@Param('id') id: string, @Body() body: { tenantId: string }) {
return this.service.assignTenant(body.tenantId, id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.superRemove(id);
}
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { BotService } from './bot.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@Controller('admin/bot')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class BotController {
constructor(private readonly service: BotService) {}
@Get()
get(@CurrentTenantContext() ctx: TenantContext) {
return this.service.get(ctx.tenantId);
}
@Post('reveal')
reveal(@CurrentTenantContext() ctx: TenantContext) {
return this.service.reveal(ctx.tenantId, ctx.adminId ?? '');
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BotController } from './bot.controller';
import { BotAdminController } from './bot-admin.controller';
import { BotService } from './bot.service';
import { AuthModule } from '../auth/auth.module';
import { SuperAdminModule } from '../super-admin/super-admin.module';
@Module({
imports: [ConfigModule, forwardRef(() => AuthModule), SuperAdminModule],
controllers: [BotController, BotAdminController],
providers: [BotService],
exports: [BotService],
})
export class BotModule {}
@@ -0,0 +1,189 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BotService } from './bot.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { ConfigService } from '@nestjs/config';
describe('BotService', () => {
let service: BotService;
const mockPrisma: any = {
account: {
findFirst: jest.fn(),
create: jest.fn(),
findUnique: jest.fn(),
},
tenantBot: {
create: jest.fn(),
deleteMany: jest.fn(),
count: jest.fn(),
findUnique: jest.fn(),
},
};
const mockAudit = { log: jest.fn() };
const mockConfig = { get: jest.fn().mockReturnValue('./sessions') };
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BotService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<BotService>(BotService);
});
describe('initiate', () => {
it('creates Account + TenantBot and returns pairingToken', async () => {
mockPrisma.account.findFirst.mockResolvedValue(null);
const created = { id: 'acc-1', jid: 'pending_x@placeholder', displayName: null };
mockPrisma.account.create.mockResolvedValue(created);
mockPrisma.tenantBot.create.mockResolvedValue({});
const res = await service.initiate('tnt-1', 'adm-1', 'MyBot');
expect(res.pairingToken).toBeTruthy();
expect(res.expiresAt).toBeTruthy();
expect(mockPrisma.account.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isBot: true,
status: 'PAIRING',
displayName: 'MyBot',
}),
}),
);
expect(mockPrisma.tenantBot.create).toHaveBeenCalledWith({
data: { tenantId: 'tnt-1', accountId: 'acc-1', isActive: true },
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'BOT_INITIATED', resourceId: 'acc-1' }),
);
});
it('rejects if any account already exists', async () => {
mockPrisma.account.findFirst.mockResolvedValue({ id: 'acc-existing' });
await expect(service.initiate('tnt-1', 'adm-1')).rejects.toThrow(/already configured/);
});
});
describe('get', () => {
it('returns { bot: null, shared: false } when no bot paired', async () => {
mockPrisma.account.findFirst.mockResolvedValue(null);
expect(await service.get('tnt-1')).toEqual({ bot: null, shared: false });
});
it('hides jid when bot is not ACTIVE', async () => {
mockPrisma.account.findFirst.mockResolvedValue({
id: 'acc-1',
platform: 'whatsapp',
jid: 'pending_x@placeholder',
displayName: null,
status: 'PAIRING',
isBot: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.get('tnt-1');
expect(res.bot?.jid).toBeNull();
expect(res.bot?.status).toBe('PAIRING');
});
it('shows jid when bot is ACTIVE', async () => {
mockPrisma.account.findFirst.mockResolvedValue({
id: 'acc-1',
platform: 'whatsapp',
jid: '1234567890:12@s.whatsapp.net',
displayName: null,
status: 'ACTIVE',
isBot: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.get('tnt-1');
expect(res.bot?.jid).toBe('1234567890:12@s.whatsapp.net');
});
it('reports shared=true when caller has no own bot but another tenant does', async () => {
mockPrisma.account.findFirst
.mockResolvedValueOnce(null) // own bot lookup: none
.mockResolvedValueOnce({
id: 'acc-shared',
platform: 'whatsapp',
jid: 'shared:1@s.whatsapp.net',
displayName: null,
status: 'ACTIVE',
isBot: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.get('tnt-new');
expect(res.shared).toBe(true);
expect(res.bot).toBeNull();
expect(res.sharedBotId).toBe('acc-shared');
});
});
const fullAccount = (overrides: Partial<any> = {}) => ({
id: 'acc-1',
platform: 'whatsapp',
jid: 'shared:1@s.whatsapp.net',
displayName: null,
status: 'ACTIVE',
isBot: true,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
});
describe('attach', () => {
it('creates TenantBot link and writes audit', async () => {
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-shared' }));
mockPrisma.tenantBot.findUnique.mockResolvedValue(null);
const res = await service.attach('tnt-new', 'adm-new', 'acc-shared');
expect(res.bot.id).toBe('acc-shared');
expect(mockPrisma.tenantBot.create).toHaveBeenCalledWith({
data: { tenantId: 'tnt-new', accountId: 'acc-shared', isActive: true },
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'BOT_ACCESS_GRANTED' }),
);
});
it('is idempotent when TenantBot link already exists', async () => {
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-shared' }));
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-new', accountId: 'acc-shared' });
await service.attach('tnt-new', 'adm-new', 'acc-shared');
expect(mockPrisma.tenantBot.create).not.toHaveBeenCalled();
});
it('rejects banned bots', async () => {
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-banned', status: 'BANNED' }));
await expect(service.attach('tnt-new', 'adm-new', 'acc-banned')).rejects.toThrow(/banned/);
});
});
describe('reveal', () => {
it('throws when no active bot', async () => {
mockPrisma.account.findFirst.mockResolvedValue({ id: 'acc-1', status: 'PAIRING', jid: 'x' });
await expect(service.reveal('tnt-1', 'adm-1')).rejects.toThrow(/No active bot/);
});
it('returns jid and writes audit event', async () => {
mockPrisma.account.findFirst.mockResolvedValue({
id: 'acc-1',
status: 'ACTIVE',
jid: '1234567890:12@s.whatsapp.net',
});
const res = await service.reveal('tnt-1', 'adm-1');
expect(res.jid).toBe('1234567890:12@s.whatsapp.net');
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'BOT_REVEALED', payload: { jid: '1234567890:12@s.whatsapp.net' } }),
);
});
});
});
+304
View File
@@ -0,0 +1,304 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import * as QRCode from 'qrcode';
import type { BotInitiateResponse, BotQrResponse, BotRevealResponse, BotStatus, BotSummary } from '@tower/types';
const PAIRING_TTL_MS = 5 * 60 * 1000;
@Injectable()
export class BotService {
private readonly logger = new Logger(BotService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
async initiate(tenantId: string, adminId: string, displayName?: string): Promise<BotInitiateResponse> {
const existing = await this.prisma.account.findFirst({
where: { isBot: true, status: { in: ['PAIRING', 'ACTIVE', 'DISCONNECTED'] } },
});
if (existing) {
throw new ConflictException('A bot is already configured. Remove it before pairing a new one.');
}
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
const uid = randomUUID();
const pairingToken = randomUUID();
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
const account = await this.prisma.account.create({
data: {
platform: 'whatsapp',
jid: `pending_${uid}@placeholder`,
sessionPath: `${sessionBase}/${uid}`,
displayName: displayName ?? null,
status: 'PAIRING',
isBot: true,
pairingToken,
pairingExpiresAt: expiresAt,
},
});
await this.prisma.tenantBot.create({
data: { tenantId, accountId: account.id, isActive: true },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.BOT_INITIATED,
resourceType: 'Account',
resourceId: account.id,
payload: { displayName: account.displayName },
});
return {
pairingToken,
expiresAt: expiresAt.toISOString(),
qrDataUrl: null,
};
}
async getQr(tenantId: string, pairingToken: string): Promise<BotQrResponse> {
const account = await this.prisma.account.findFirst({
where: { pairingToken, tenants: { some: { tenantId } } },
});
if (!account) {
throw new NotFoundException('Pairing token not found for this tenant');
}
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
return {
status: account.status as BotStatus,
qrDataUrl: null,
pairingToken: account.pairingToken ?? '',
expiresAt: account.pairingExpiresAt.toISOString(),
};
}
if (!account.qrCode) {
return {
status: account.status as BotStatus,
qrDataUrl: null,
pairingToken: account.pairingToken ?? '',
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
};
}
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
return {
status: account.status as BotStatus,
qrDataUrl,
pairingToken: account.pairingToken ?? '',
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
};
}
async get(tenantId: string): Promise<{ bot: BotSummary | null; shared: boolean; sharedBotId?: string }> {
const own = await this.prisma.account.findFirst({
where: { tenants: { some: { tenantId } } },
orderBy: { createdAt: 'asc' },
});
if (own) {
return { bot: this.toSummary(own), shared: false };
}
// No own bot — is there a shared bot we could attach to?
const shared = await this.prisma.account.findFirst({
where: {
isBot: true,
status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] },
tenants: { some: {} },
},
orderBy: { createdAt: 'asc' },
});
if (shared) {
return { bot: null, shared: true, sharedBotId: shared.id };
}
return { bot: null, shared: false };
}
async attach(tenantId: string, adminId: string, accountId: string): Promise<{ bot: BotSummary }> {
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
if (account.status === 'BANNED') {
throw new ConflictException('Bot is banned and cannot be shared');
}
const existing = await this.prisma.tenantBot.findUnique({
where: { tenantId_accountId: { tenantId, accountId } },
});
if (!existing) {
await this.prisma.tenantBot.create({
data: { tenantId, accountId, isActive: true },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.BOT_ACCESS_GRANTED,
resourceType: 'Account',
resourceId: accountId,
payload: { reason: 'tenant attached to shared bot' },
});
}
return { bot: this.toSummary(account) };
}
private toSummary(account: any): BotSummary {
return {
id: account.id,
platform: account.platform,
jid: account.status === 'ACTIVE' ? account.jid : null,
displayName: account.displayName,
status: account.status as BotStatus,
isBot: account.isBot,
createdAt: account.createdAt.toISOString(),
updatedAt: account.updatedAt.toISOString(),
};
}
/**
* Find the least-loaded ACTIVE bot and assign it to the tenant.
* Returns null if no bot is available in the pool.
* Idempotent — skips if the tenant already has a TenantBot.
*/
async assignBotToTenant(tenantId: string): Promise<BotSummary | null> {
const existing = await this.prisma.tenantBot.findFirst({
where: { tenantId },
include: { account: true },
});
if (existing) {
return this.toSummary(existing.account);
}
const candidates = await this.prisma.account.findMany({
where: { isBot: true, status: 'ACTIVE' },
include: { _count: { select: { tenants: true } } },
});
if (candidates.length === 0) {
this.logger.warn({ tenantId }, 'No ACTIVE bot available to assign');
return null;
}
const best = candidates.reduce((a, b) =>
a._count.tenants <= b._count.tenants ? a : b,
);
await this.prisma.tenantBot.create({
data: { tenantId, accountId: best.id, isActive: true },
});
this.logger.log({ tenantId, accountId: best.id, tenantCount: best._count.tenants }, 'Bot auto-assigned');
return this.toSummary(best);
}
async reveal(tenantId: string, adminId: string): Promise<BotRevealResponse> {
const account = await this.prisma.account.findFirst({
where: { tenants: { some: { tenantId } } },
});
if (!account || account.status !== 'ACTIVE') {
throw new NotFoundException('No active bot to reveal');
}
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.BOT_REVEALED,
resourceType: 'Account',
resourceId: account.id,
payload: { jid: account.jid },
});
return { jid: account.jid, revealedAt: new Date().toISOString() };
}
// ---------------------------------------------------------------------------
// Super admin bot management
// ---------------------------------------------------------------------------
async listAll(): Promise<any[]> {
const bots = await this.prisma.account.findMany({
where: { isBot: true },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { tenants: true } } },
});
return bots.map((b) => ({
id: b.id,
jid: b.status === 'ACTIVE' ? b.jid : null,
displayName: b.displayName,
status: b.status,
platform: b.platform,
tenantCount: b._count.tenants,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}));
}
async superInitiate(displayName?: string): Promise<{ pairingToken: string; expiresAt: string }> {
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
const uid = randomUUID();
const pairingToken = randomUUID();
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
await this.prisma.account.create({
data: {
platform: 'whatsapp',
jid: `pending_${uid}@placeholder`,
sessionPath: `${sessionBase}/${uid}`,
displayName: displayName ?? null,
status: 'PAIRING',
isBot: true,
pairingToken,
pairingExpiresAt: expiresAt,
},
});
return { pairingToken, expiresAt: expiresAt.toISOString() };
}
async superGetQr(pairingToken: string): Promise<any> {
const account = await this.prisma.account.findFirst({
where: { pairingToken },
});
if (!account) throw new NotFoundException('Pairing token not found');
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt.toISOString() };
}
if (!account.qrCode) {
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
}
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
return { status: account.status, qrDataUrl, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
}
async assignTenant(tenantId: string, accountId: string): Promise<any> {
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
if (account.status !== 'ACTIVE') throw new ConflictException('Bot is not ACTIVE');
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
const existing = await this.prisma.tenantBot.findFirst({ where: { tenantId } });
if (existing) throw new ConflictException('Tenant already has a bot assigned');
await this.prisma.tenantBot.create({
data: { tenantId, accountId: account.id, isActive: true },
});
return { ok: true, accountId: account.id, jid: account.jid };
}
async superRemove(accountId: string): Promise<{ ok: true }> {
const account = await this.prisma.account.findUnique({
where: { id: accountId },
include: { _count: { select: { tenants: true } } },
});
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
if (account._count.tenants > 0) {
throw new ConflictException(`Cannot remove bot — ${account._count.tenants} tenant(s) still assigned. Reassign them first.`);
}
await this.prisma.account.delete({ where: { id: accountId } });
return { ok: true };
}
}
@@ -1,11 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';
import type { TenantContext } from '../../common/tenant-context';
const ctx: TenantContext = { tenantId: 'tnt-A', adminId: 'adm_1', role: 'OWNER' };
const mockGroups = [
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' },
];
const mockService = { list: jest.fn().mockResolvedValue(mockGroups) };
const mockService = {
list: jest.fn().mockResolvedValue(mockGroups),
listShared: jest.fn().mockResolvedValue([]),
listSharedByMe: jest.fn().mockResolvedValue([]),
getClaimTokenInfo: jest.fn(),
claimWithToken: jest.fn(),
share: jest.fn(),
unshare: jest.fn(),
regenerateToken: jest.fn(),
listUnclaimed: jest.fn().mockResolvedValue([]),
};
describe('GroupsController', () => {
let controller: GroupsController;
@@ -22,9 +36,29 @@ describe('GroupsController', () => {
controller = module.get<GroupsController>(GroupsController);
});
it('returns groups from service', async () => {
const result = await controller.list();
it('list() delegates to service', async () => {
const result = await controller.list(ctx);
expect(result).toEqual(mockGroups);
expect(mockService.list).toHaveBeenCalled();
expect(mockService.list).toHaveBeenCalledWith('tnt-A');
});
it('listShared() delegates to service', async () => {
await controller.listShared(ctx);
expect(mockService.listShared).toHaveBeenCalledWith('tnt-A');
});
it('claimWithToken() delegates to service', async () => {
await controller.claimWithToken(ctx, { token: 'abc123' });
expect(mockService.claimWithToken).toHaveBeenCalledWith('abc123', 'adm_1');
});
it('share() delegates to service', async () => {
await controller.share(ctx, 'grp_1', { targetTenantId: 'tnt-B' });
expect(mockService.share).toHaveBeenCalledWith('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
});
it('listUnclaimed() delegates to service', async () => {
await controller.listUnclaimed();
expect(mockService.listUnclaimed).toHaveBeenCalledWith();
});
});
@@ -1,12 +1,86 @@
import { Controller, Get } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { GroupsService } from './groups.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { IsString } from 'class-validator';
@Controller('groups')
class ClaimWithTokenDto {
@IsString() token!: string;
}
class ShareDto {
@IsString() targetTenantId!: string;
}
@Controller()
@UseGuards(JwtAuthGuard, RolesGuard)
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
@Get()
list() {
return this.groupsService.list();
@Get('groups')
list(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.list(ctx.tenantId);
}
@Get('groups/shared')
listShared(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.listShared(ctx.tenantId);
}
@Get('groups/shared-by-me')
listSharedByMe(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.listSharedByMe(ctx.tenantId);
}
@Get('admin/groups/claim-token-info')
getClaimTokenInfo(@Query('token') token: string) {
return this.groupsService.getClaimTokenInfo(token);
}
@Post('admin/groups/claim-with-token')
@Roles('OWNER')
claimWithToken(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: ClaimWithTokenDto,
) {
return this.groupsService.claimWithToken(body.token, ctx.adminId ?? '');
}
@Post('admin/groups/:id/share')
@Roles('OWNER')
share(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: ShareDto,
) {
return this.groupsService.share(ctx.tenantId, ctx.adminId ?? '', id, body.targetTenantId);
}
@Delete('admin/groups/:id/share/:targetTenantId')
@Roles('OWNER')
unshare(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('targetTenantId') targetTenantId: string,
) {
return this.groupsService.unshare(ctx.tenantId, id, targetTenantId);
}
@Post('admin/groups/:id/regenerate-token')
@Roles('OWNER')
regenerateToken(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.groupsService.regenerateToken(ctx.tenantId, ctx.adminId ?? '', id);
}
@Get('admin/groups/unclaimed')
@Roles('OWNER')
listUnclaimed() {
return this.groupsService.listUnclaimed();
}
}
@@ -1,37 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { GroupsService } from './groups.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
const mockGroups = [
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', platformId: '222@g.us', isActive: true, accountId: null },
];
const mockGroup = { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' };
describe('GroupsService', () => {
let service: GroupsService;
const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } };
beforeEach(() => {
jest.clearAllMocks();
});
const mockPrisma: any = {
group: {
findMany: jest.fn().mockResolvedValue([mockGroup]),
findUnique: jest.fn().mockResolvedValue(mockGroup),
findFirst: jest.fn(),
update: jest.fn().mockResolvedValue(mockGroup),
},
groupClaimToken: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
groupAccess: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
tenantBot: { findUnique: jest.fn(), count: jest.fn(), create: jest.fn() },
admin: { findUnique: jest.fn() },
tenant: { findMany: jest.fn() },
$transaction: jest.fn(),
};
const mockAudit = { log: jest.fn() };
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
GroupsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
],
}).compile();
service = module.get<GroupsService>(GroupsService);
});
it('returns all groups ordered by name', async () => {
const result = await service.list();
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Alpha');
expect(mockPrisma.group.findMany).toHaveBeenCalledWith({
orderBy: { name: 'asc' },
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
describe('list', () => {
it('returns groups for the given tenant including shared groups', async () => {
const result = await service.list('tnt-A');
expect(result).toHaveLength(1);
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ OR: expect.any(Array) }),
}),
);
});
});
describe('listUnclaimed', () => {
it('returns groups with no tenantId', async () => {
await service.listUnclaimed();
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: null } }),
);
});
});
describe('getClaimTokenInfo', () => {
it('throws NotFound for invalid token', async () => {
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce(null);
await expect(service.getClaimTokenInfo('bad')).rejects.toThrow(NotFoundException);
});
});
describe('claimWithToken', () => {
const mockToken = {
id: 'tok_1', groupId: 'grp_1', token: 'abc123', creatorJid: 'creator@jid',
expiresAt: new Date(Date.now() + 3600000), consumedAt: null,
};
beforeEach(() => {
mockPrisma.admin.findUnique.mockResolvedValue({ id: 'adm_1', tenantId: 'tnt-A' });
mockPrisma.groupClaimToken.findUnique.mockResolvedValue(mockToken);
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: null, accountId: 'acc_1' });
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-A', accountId: 'acc_1' });
mockPrisma.$transaction.mockResolvedValue([mockGroup]);
});
it('throws NotFound when admin does not exist', async () => {
mockPrisma.admin.findUnique.mockResolvedValueOnce(null);
await expect(service.claimWithToken('abc123', 'bad_admin')).rejects.toThrow(NotFoundException);
});
it('throws Conflict when token is consumed', async () => {
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, consumedAt: new Date() });
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
});
it('throws Conflict when token is expired', async () => {
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, expiresAt: new Date(Date.now() - 1000) });
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
});
it('throws Conflict when group is already claimed', async () => {
mockPrisma.group.findUnique.mockResolvedValueOnce({ id: 'grp_1', tenantId: 'tnt-B', accountId: 'acc_1' });
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
});
});
describe('share / unshare', () => {
const sharedAccess = { id: 'acc_1', groupId: 'grp_1', tenantId: 'tnt-B', grantedBy: 'adm_1' };
it('share creates a GroupAccess record', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
mockPrisma.groupAccess.create.mockResolvedValue(sharedAccess);
const result = await service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
expect(result).toEqual(sharedAccess);
});
it('share throws Conflict if already shared', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
await expect(service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B')).rejects.toThrow(ConflictException);
});
it('unshare deletes the GroupAccess record', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
mockPrisma.groupAccess.delete.mockResolvedValue(sharedAccess);
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).resolves.not.toThrow();
});
it('unshare throws NotFound if no share exists', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).rejects.toThrow(NotFoundException);
});
});
});
+248 -4
View File
@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { randomBytes } from 'crypto';
export interface GroupSummary {
id: string;
@@ -8,16 +11,257 @@ export interface GroupSummary {
platformId: string;
isActive: boolean;
accountId: string | null;
tenantId: string | null;
}
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
@Injectable()
export class GroupsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list(): Promise<GroupSummary[]> {
list(tenantId: string): Promise<GroupSummary[]> {
return this.prisma.group.findMany({
where: {
OR: [
{ tenantId },
{ groupAccesses: { some: { tenantId } } },
],
},
orderBy: { name: 'asc' },
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
});
}
async listUnclaimed(): Promise<GroupSummary[]> {
return this.prisma.group.findMany({
where: { tenantId: null },
orderBy: { name: 'asc' },
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
});
}
async listShared(tenantId: string): Promise<(GroupSummary & { sharedByTenantName: string })[]> {
const accesses = await this.prisma.groupAccess.findMany({
where: { tenantId },
include: {
group: {
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
},
},
});
const ownerTenantIds: string[] = [...new Set(accesses.map((a) => a.group.tenantId).filter((id): id is string => !!id))];
const tenants = ownerTenantIds.length > 0
? await this.prisma.tenant.findMany({
where: { id: { in: ownerTenantIds } },
select: { id: true, name: true },
})
: [];
const tenantMap = new Map(tenants.map((t) => [t.id, t.name]));
return accesses.map((a) => ({
...a.group,
sharedByTenantName: a.group.tenantId ? tenantMap.get(a.group.tenantId) ?? 'Unknown' : 'Unknown',
}));
}
async listSharedByMe(tenantId: string) {
const accesses = await this.prisma.groupAccess.findMany({
where: { group: { tenantId } },
include: {
group: { select: { id: true, name: true } },
tenant: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
});
// Group by group
const grouped = new Map<string, { groupId: string; groupName: string; sharedWith: { tenantId: string; tenantName: string; grantedAt: Date }[] }>();
for (const a of accesses) {
const key = a.group.id;
if (!grouped.has(key)) {
grouped.set(key, { groupId: a.group.id, groupName: a.group.name, sharedWith: [] });
}
grouped.get(key)!.sharedWith.push({
tenantId: a.tenantId,
tenantName: a.tenant.name,
grantedAt: a.createdAt,
});
}
return [...grouped.values()];
}
async getClaimTokenInfo(token: string) {
const record = await this.prisma.groupClaimToken.findUnique({
where: { token },
include: { group: { select: { name: true } } },
});
if (!record) throw new NotFoundException('Invalid token');
return {
groupName: record.group.name,
expiresAt: record.expiresAt.toISOString(),
isConsumed: record.consumedAt !== null,
isExpired: record.expiresAt < new Date(),
};
}
async claimWithToken(token: string, adminId: string): Promise<GroupSummary> {
const admin = await this.prisma.admin.findUnique({
where: { id: adminId },
select: { tenantId: true },
});
if (!admin) throw new NotFoundException('Admin not found');
const record = await this.prisma.groupClaimToken.findUnique({
where: { token },
});
if (!record) throw new NotFoundException('Invalid token');
if (record.consumedAt) throw new ConflictException('Token has already been used');
if (record.expiresAt < new Date()) throw new ConflictException('Token has expired');
const group = await this.prisma.group.findUnique({
where: { id: record.groupId },
});
if (!group) throw new NotFoundException('Group not found');
if (group.tenantId) throw new ConflictException('Group is already claimed');
// Account-binding: ensure the claiming tenant has a TenantBot link
if (group.accountId) {
const myLink = await this.prisma.tenantBot.findUnique({
where: { tenantId_accountId: { tenantId: admin.tenantId, accountId: group.accountId } },
});
if (!myLink) {
const anyLinks = await this.prisma.tenantBot.count({
where: { accountId: group.accountId },
});
if (anyLinks === 0) {
throw new ConflictException('Bot account has no tenant binding — cannot claim');
}
await this.prisma.tenantBot.create({
data: { tenantId: admin.tenantId, accountId: group.accountId, isActive: true },
});
await this.audit.log({
tenantId: admin.tenantId,
actorId: adminId,
action: AuditAction.BOT_ACCESS_GRANTED,
resourceType: 'Account',
resourceId: group.accountId,
payload: { reason: 'auto-grant on token claim', groupId: group.id },
});
}
}
const [updated] = await this.prisma.$transaction([
this.prisma.group.update({
where: { id: group.id },
data: { tenantId: admin.tenantId, claimStatus: 'CLAIMED' },
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
}),
this.prisma.groupClaimToken.update({
where: { id: record.id },
data: { consumedAt: new Date() },
}),
]);
await this.audit.log({
tenantId: admin.tenantId,
actorId: adminId,
action: AuditAction.GROUP_CLAIMED_WITH_TOKEN,
resourceType: 'Group',
resourceId: group.id,
payload: { groupName: group.name },
});
return updated;
}
async share(tenantId: string, adminId: string, groupId: string, targetTenantId: string) {
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
const existing = await this.prisma.groupAccess.findUnique({
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
});
if (existing) throw new ConflictException('Group already shared with this tenant');
const access = await this.prisma.groupAccess.create({
data: { groupId, tenantId: targetTenantId, grantedBy: adminId },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.GROUP_SHARED,
resourceType: 'Group',
resourceId: groupId,
payload: { targetTenantId, groupName: group.name },
});
return access;
}
async unshare(tenantId: string, groupId: string, targetTenantId: string) {
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
const existing = await this.prisma.groupAccess.findUnique({
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
});
if (!existing) throw new NotFoundException('Share not found');
await this.prisma.groupAccess.delete({
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
});
await this.audit.log({
tenantId,
action: AuditAction.GROUP_UNSHARED,
resourceType: 'Group',
resourceId: groupId,
payload: { targetTenantId },
});
}
async regenerateToken(tenantId: string, adminId: string, groupId: string) {
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
// Allow regenerate for owned groups OR unclaimed groups (support case)
if (group.tenantId && group.tenantId !== tenantId) throw new NotFoundException('Group not found');
const token = randomBytes(32).toString('hex');
const record = await this.prisma.groupClaimToken.create({
data: {
groupId,
token,
creatorJid: token, // placeholder — support will need to extract jid from group metadata
expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
},
});
await this.audit.log({
tenantId: group.tenantId ?? tenantId,
actorId: adminId,
action: AuditAction.GROUP_CLAIM_TOKEN_REGENERATED,
resourceType: 'Group',
resourceId: groupId,
payload: { tokenId: record.id },
});
return { token, expiresAt: record.expiresAt.toISOString() };
}
}
@@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/public.decorator';
@Controller('health')
export class HealthController {
@Public()
@Get()
check() {
return {
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { MessagesService } from './messages.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/messages')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Get('pending')
listPending(@CurrentTenantContext() ctx: TenantContext) {
return this.messagesService.listPending(ctx.tenantId);
}
@Get('pending/count')
pendingCount(@CurrentTenantContext() ctx: TenantContext) {
return this.messagesService.pendingCount(ctx.tenantId);
}
@Get(':id')
get(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.messagesService.get(ctx.tenantId, id);
}
@Post(':id/approve')
approve(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id);
}
@Post('reindex')
reindex(@CurrentTenantContext() ctx: TenantContext) {
return this.messagesService.reindexApproved(ctx.tenantId);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MessagesController } from './messages.controller';
import { MessagesService } from './messages.service';
import { forwardQueueProvider, indexQueueProvider, FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
@Module({
imports: [ConfigModule],
controllers: [MessagesController],
providers: [
MessagesService,
forwardQueueProvider,
indexQueueProvider,
],
exports: [FORWARD_QUEUE, INDEX_QUEUE],
})
export class MessagesModule {}
@@ -0,0 +1,154 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { MessagesService } from './messages.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
describe('MessagesService', () => {
let service: MessagesService;
const mockPrisma: any = {
message: { findMany: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
groupAccess: { findUnique: jest.fn() },
approval: { create: jest.fn() },
$transaction: jest.fn(),
};
const mockAudit = { log: jest.fn() };
const mockForwardQueue = { add: jest.fn().mockResolvedValue({}) };
const mockIndexQueue = { add: jest.fn().mockResolvedValue({}) };
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagesService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
{ provide: FORWARD_QUEUE, useValue: mockForwardQueue },
{ provide: INDEX_QUEUE, useValue: mockIndexQueue },
],
}).compile();
service = module.get<MessagesService>(MessagesService);
});
describe('listPending', () => {
it('returns PENDING messages with source group info', async () => {
mockPrisma.message.findMany.mockResolvedValue([
{
id: 'msg-1',
content: 'hello #important',
senderJid: '111@s.whatsapp.net',
senderName: 'Alice',
tags: ['#important'],
createdAt: new Date('2026-01-01T00:00:00Z'),
sourceGroupId: 'grp-1',
sourceGroup: { name: 'Notes', platformId: '111@g.us' },
},
]);
const res = await service.listPending('tnt-1');
expect(res).toHaveLength(1);
expect(res[0].sourceGroupName).toBe('Notes');
expect(mockPrisma.message.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: 'PENDING',
OR: [
{ tenantId: 'tnt-1' },
{ sourceGroup: { groupAccesses: { some: { tenantId: 'tnt-1' } } } },
],
},
}),
);
});
});
describe('approve', () => {
const baseMessage = {
id: 'msg-1',
tenantId: 'tnt-1',
content: 'hello #important',
senderJid: '111@s.whatsapp.net',
senderName: 'Alice',
platform: 'whatsapp',
tags: ['#important'],
status: 'PENDING',
sourceGroupId: 'grp-1',
approval: null,
sourceGroup: {
name: 'Notes',
accountId: 'acc-1',
syncRoutesFrom: [
{
targetGroup: { platformId: '222@g.us', accountId: 'acc-1' },
},
],
},
};
it('marks APPROVED, enqueues forward + index, writes audit', async () => {
mockPrisma.message.findUnique.mockResolvedValue(baseMessage);
mockPrisma.message.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.approval.create.mockResolvedValue({});
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
const res = await service.approve('tnt-1', 'adm-1', 'msg-1');
expect(res.status).toBe('APPROVED');
expect(res.routesForwarded).toBe(1);
expect(res.indexEnqueued).toBe(true);
expect(mockForwardQueue.add).toHaveBeenCalledWith(
'forward',
expect.objectContaining({ toGroupJid: '222@g.us', content: 'hello #important' }),
expect.objectContaining({ attempts: 3 }),
);
expect(mockIndexQueue.add).toHaveBeenCalledWith('index', expect.any(Object), expect.any(Object));
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MESSAGE_APPROVED' }),
);
});
it('rejects non-existent message', async () => {
mockPrisma.message.findUnique.mockResolvedValue(null);
await expect(service.approve('tnt-1', 'adm-1', 'missing')).rejects.toThrow(NotFoundException);
});
it('rejects message from a different tenant without group access', async () => {
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, tenantId: 'tnt-other' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(NotFoundException);
});
it('rejects already-approved message', async () => {
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, status: 'APPROVED' });
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(ConflictException);
});
it('rejects when message has an existing Approval row', async () => {
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, approval: { id: 'apr-1' } });
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(/already been approved/);
});
it('returns routesForwarded=0 when no routes configured', async () => {
mockPrisma.message.findUnique.mockResolvedValue({
...baseMessage,
sourceGroup: { ...baseMessage.sourceGroup, syncRoutesFrom: [] },
});
mockPrisma.message.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.approval.create.mockResolvedValue({});
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
const res = await service.approve('tnt-1', 'adm-1', 'msg-1');
expect(res.routesForwarded).toBe(0);
expect(mockForwardQueue.add).not.toHaveBeenCalled();
expect(mockIndexQueue.add).toHaveBeenCalled();
});
it('handles concurrent approval (updateMany.count=0) as conflict', async () => {
mockPrisma.message.findUnique.mockResolvedValue(baseMessage);
mockPrisma.message.updateMany.mockResolvedValue({ count: 0 });
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(/concurrent update/);
});
});
});
@@ -0,0 +1,254 @@
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
import { ForwardJobData, IndexJobData } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { createForwardQueue, createIndexQueue, FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
export interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
@Injectable()
export class MessagesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@Inject(FORWARD_QUEUE) private readonly forwardQueue: Queue<ForwardJobData>,
@Inject(INDEX_QUEUE) private readonly indexQueue: Queue<IndexJobData>,
) {}
async get(tenantId: string, id: string): Promise<any> {
const msg = await this.prisma.message.findUnique({
where: { id },
include: {
sourceGroup: true,
senderTowerUser: true,
approval: true,
},
});
if (!msg) throw new NotFoundException('Message not found');
if (msg.tenantId !== tenantId) {
const access = await this.prisma.groupAccess.findUnique({
where: { groupId_tenantId: { groupId: msg.sourceGroupId, tenantId } },
});
if (!access) throw new NotFoundException('Message not found');
}
return {
id: msg.id,
tenantId: msg.tenantId,
platform: msg.platform,
platformMsgId: msg.platformMsgId,
sourceGroupId: msg.sourceGroupId,
sourceGroup: msg.sourceGroup,
senderJid: msg.senderJid,
senderName: msg.senderName,
senderTowerUser: msg.senderTowerUser,
content: msg.content,
mediaUrl: msg.mediaUrl,
tags: msg.tags,
status: msg.status,
createdAt: msg.createdAt.toISOString(),
updatedAt: msg.updatedAt.toISOString(),
approval: msg.approval
? {
id: msg.approval.id,
adminId: msg.approval.adminId,
decision: msg.approval.decision,
notes: msg.approval.notes,
decidedAt: msg.approval.decidedAt.toISOString(),
}
: null,
};
}
async pendingCount(tenantId: string): Promise<{ count: number }> {
const count = await this.prisma.message.count({
where: {
status: 'PENDING',
OR: [
{ tenantId },
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
],
},
});
return { count };
}
async listPending(tenantId: string): Promise<PendingMessage[]> {
const rows = await this.prisma.message.findMany({
where: {
status: 'PENDING',
OR: [
{ tenantId },
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
],
},
orderBy: { createdAt: 'desc' },
include: {
sourceGroup: { select: { name: true, platformId: true } },
},
});
return rows.map((m: any) => ({
id: m.id,
content: m.content,
senderJid: m.senderJid,
senderName: m.senderName,
tags: m.tags ?? [],
createdAt: m.createdAt.toISOString(),
sourceGroupId: m.sourceGroupId,
sourceGroupName: m.sourceGroup?.name ?? '(unknown group)',
sourceGroupPlatformId: m.sourceGroup?.platformId ?? '',
}));
}
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
const message = await this.prisma.message.findUnique({
where: { id: messageId },
include: {
approval: true,
sourceGroup: {
include: {
syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } },
},
},
},
});
if (!message) throw new NotFoundException('Message not found');
if (message.tenantId !== tenantId) {
const access = await this.prisma.groupAccess.findUnique({
where: { groupId_tenantId: { groupId: message.sourceGroupId, tenantId } },
});
if (!access) throw new NotFoundException('Message not found');
}
if (message.status !== 'PENDING') {
throw new ConflictException(`Message is already ${message.status}`);
}
if (message.approval) {
throw new ConflictException('Message has already been approved');
}
let approved = false;
await this.prisma.$transaction(async (tx: any) => {
const updated = await tx.message.updateMany({
where: { id: message.id, status: 'PENDING' },
data: { status: 'APPROVED' },
});
if (updated.count === 0) return;
approved = true;
await tx.approval.create({
data: {
tenantId: message.tenantId,
messageId: message.id,
adminId,
decision: 'APPROVED',
},
});
});
if (!approved) {
throw new ConflictException('Message could not be approved (concurrent update)');
}
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
(r: any) => r.targetGroup != null,
);
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({
tenantId: message.tenantId,
messageId: message.id,
content: message.content,
sourceGroupName: message.sourceGroup.name,
senderName: message.senderName ?? undefined,
toGroupJid: route.targetGroup.platformId,
fromAccountId: route.targetGroup.accountId ?? message.sourceGroup.accountId ?? '',
}));
for (const job of forwardJobs) {
await this.forwardQueue.add('forward', job, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
const indexDoc: IndexJobData = {
tenantId: message.tenantId,
messageId: message.id,
content: message.content,
senderName: message.senderName ?? null,
sourceGroupId: message.sourceGroupId,
sourceGroupName: message.sourceGroup.name,
tags: message.tags ?? [],
platform: message.platform,
approvedAt: new Date().toISOString(),
};
await this.indexQueue.add('index', indexDoc, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.MESSAGE_APPROVED,
resourceType: 'Message',
resourceId: message.id,
payload: {
routesForwarded: forwardJobs.length,
contentPreview: message.content.slice(0, 80),
},
});
return {
id: message.id,
status: 'APPROVED',
routesForwarded: forwardJobs.length,
indexEnqueued: true,
};
}
async reindexApproved(tenantId: string): Promise<{ reindexed: number }> {
const messages = await this.prisma.message.findMany({
where: {
status: 'APPROVED',
OR: [
{ tenantId },
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
],
},
include: {
sourceGroup: { select: { name: true } },
approval: { select: { decidedAt: true } },
},
});
for (const msg of messages) {
const indexDoc: IndexJobData = {
tenantId: msg.tenantId,
messageId: msg.id,
content: msg.content,
senderName: msg.senderName ?? null,
sourceGroupId: msg.sourceGroupId,
sourceGroupName: msg.sourceGroup?.name ?? '(unknown)',
tags: msg.tags ?? [],
platform: msg.platform,
approvedAt: (msg.approval?.decidedAt ?? msg.updatedAt).toISOString(),
};
await this.indexQueue.add('index', indexDoc, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
}
return { reindexed: messages.length };
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { MyService } from './my.service';
import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types';
import { IsArray, IsInt, IsOptional, IsString, Min } from 'class-validator';
import { ConsentScope, MemberOptOutReason } from '@tower/types';
class OptOutDto {
@IsString() @IsOptional() groupId?: string;
@IsArray() @IsOptional() scopes?: ConsentScope[];
@IsString() @IsOptional() reason?: MemberOptOutReason;
@IsString() @IsOptional() notes?: string;
}
class OptInDto {
@IsString() groupId!: string;
@IsArray() scopes!: ConsentScope[];
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
}
@Controller('my')
@MemberAuth()
export class MyController {
constructor(private readonly service: MyService) {}
@Get('profile')
profile(@CurrentMember() member: MemberJwtPayload) {
return this.service.getProfile(member.sub, member.tenantId);
}
@Get('groups')
listGroups(@CurrentMember() member: MemberJwtPayload) {
return this.service.listGroups(member.sub, member.tenantId);
}
@Get('groups/:id')
getGroup(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.service.getGroup(member.sub, member.tenantId, id);
}
@Post('opt-out')
optOut(@CurrentMember() member: MemberJwtPayload, @Body() body: OptOutDto) {
return this.service.optOut(member.sub, member.tenantId, body);
}
@Post('opt-in')
optIn(@CurrentMember() member: MemberJwtPayload, @Body() body: OptInDto) {
return this.service.optIn(member.sub, member.tenantId, body);
}
@Delete('account')
deleteAccount(@CurrentMember() member: MemberJwtPayload) {
return this.service.deleteAccount(member.sub, member.tenantId);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MyController } from './my.controller';
import { MyService } from './my.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [MyController],
providers: [MyService],
})
export class MyModule {}
+136
View File
@@ -0,0 +1,136 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MyService } from './my.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { NotFoundException, BadRequestException } from '@nestjs/common';
describe('MyService', () => {
let service: MyService;
const mockPrisma: any = {
towerUser: { findFirst: jest.fn(), delete: jest.fn() },
consentRecord: {
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
create: jest.fn(),
deleteMany: jest.fn(),
},
group: { findFirst: jest.fn() },
memberOptOut: { create: jest.fn(), deleteMany: jest.fn() },
towerSession: { deleteMany: jest.fn() },
$transaction: jest.fn(),
};
const mockAudit = { log: jest.fn() };
beforeEach(async () => {
jest.clearAllMocks();
mockPrisma.$transaction.mockImplementation((cb: (tx: typeof mockPrisma) => Promise<unknown>) => cb(mockPrisma));
const module: TestingModule = await Test.createTestingModule({
providers: [
MyService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
],
}).compile();
service = module.get<MyService>(MyService);
});
describe('getProfile', () => {
it('returns member profile', async () => {
mockPrisma.towerUser.findFirst.mockResolvedValue({
id: 'u-1',
tenantId: 'tnt-1',
jid: '1234@s.whatsapp.net',
displayName: 'Alice',
createdAt: new Date('2026-01-01T00:00:00Z'),
});
const res = await service.getProfile('u-1', 'tnt-1');
expect(res.id).toBe('u-1');
expect(res.displayName).toBe('Alice');
});
it('throws when not found', async () => {
mockPrisma.towerUser.findFirst.mockResolvedValue(null);
await expect(service.getProfile('u-x', 'tnt-1')).rejects.toThrow(NotFoundException);
});
});
describe('listGroups', () => {
it('returns groups with consent metadata', async () => {
mockPrisma.consentRecord.findMany.mockResolvedValue([
{
group: { id: 'g-1', name: 'UP Parivar' },
tenantId: 'tnt-1',
groupId: 'g-1',
scopes: ['INGEST', 'DISPLAY'],
retentionDays: 90,
policyVersion: 'v1',
status: 'GRANTED',
effectiveAt: new Date('2026-01-01T00:00:00Z'),
},
]);
const res = await service.listGroups('u-1', 'tnt-1');
expect(res).toHaveLength(1);
expect(res[0].name).toBe('UP Parivar');
expect(res[0].scopes).toContain('INGEST');
});
});
describe('optOut', () => {
it('throws when no matching consent', async () => {
mockPrisma.consentRecord.findMany.mockResolvedValue([]);
await expect(service.optOut('u-1', 'tnt-1', { groupId: 'g-1' })).rejects.toThrow(NotFoundException);
});
it('revokes consent and creates MemberOptOut', async () => {
mockPrisma.consentRecord.findMany.mockResolvedValue([
{ id: 'c-1', groupId: 'g-1', scopes: ['INGEST', 'DISPLAY'] },
]);
const res = await service.optOut('u-1', 'tnt-1', { groupId: 'g-1', reason: 'SELF_PORTAL' });
expect(res.revoked).toBe(1);
expect(mockPrisma.consentRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'c-1' },
data: expect.objectContaining({ status: 'REVOKED' }),
}),
);
expect(mockPrisma.memberOptOut.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ reason: 'SELF_PORTAL' }) }),
);
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MEMBER_OPT_OUT' }),
);
});
});
describe('optIn', () => {
it('rejects empty scopes', async () => {
await expect(
service.optIn('u-1', 'tnt-1', { groupId: 'g-1', scopes: [] }),
).rejects.toThrow(BadRequestException);
});
it('creates a new consent record', async () => {
mockPrisma.group.findFirst.mockResolvedValue({ id: 'g-1', tenantId: 'tnt-1' });
mockPrisma.towerUser.findFirst.mockResolvedValue({ id: 'u-1', tenantId: 'tnt-1' });
mockPrisma.consentRecord.findFirst.mockResolvedValue(null);
mockPrisma.consentRecord.create.mockResolvedValue({ id: 'c-new' });
const res = await service.optIn('u-1', 'tnt-1', { groupId: 'g-1', scopes: ['INGEST'] });
expect(res.ok).toBe(true);
expect(res.consentId).toBe('c-new');
});
});
describe('deleteAccount', () => {
it('cascades deletes and writes audit', async () => {
const res = await service.deleteAccount('u-1', 'tnt-1');
expect(res.ok).toBe(true);
expect(mockPrisma.consentRecord.deleteMany).toHaveBeenCalled();
expect(mockPrisma.towerSession.deleteMany).toHaveBeenCalled();
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MEMBER_DELETED', resourceId: 'u-1' }),
);
});
});
});
+190
View File
@@ -0,0 +1,190 @@
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
@Injectable()
export class MyService {
private readonly logger = new Logger(MyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
return {
id: user.id,
tenantId: user.tenantId,
jid: user.jid,
displayName: user.displayName,
createdAt: user.createdAt.toISOString(),
};
}
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
const consents = await this.prisma.consentRecord.findMany({
where: { userId, tenantId },
include: { group: true },
});
return consents.map((c) => ({
id: c.group.id,
name: c.group.name,
tenantId: c.tenantId,
scopes: c.scopes as ConsentScope[],
retentionDays: c.retentionDays,
policyVersion: c.policyVersion,
consentStatus: c.status as ConsentStatus,
joinedAt: c.effectiveAt.toISOString(),
}));
}
async getGroup(userId: string, tenantId: string, groupId: string): Promise<MemberGroupSummary> {
const consent = await this.prisma.consentRecord.findFirst({
where: { userId, tenantId, groupId },
include: { group: true },
});
if (!consent) throw new NotFoundException('Not a member of this group');
return {
id: consent.group.id,
name: consent.group.name,
tenantId: consent.tenantId,
scopes: consent.scopes as ConsentScope[],
retentionDays: consent.retentionDays,
policyVersion: consent.policyVersion,
consentStatus: consent.status as ConsentStatus,
joinedAt: consent.effectiveAt.toISOString(),
};
}
async optOut(
userId: string,
tenantId: string,
body: OptOutRequest,
): Promise<{ ok: true; revoked: number }> {
const where = body.groupId
? { userId, tenantId, groupId: body.groupId }
: { userId, tenantId };
const consents = await this.prisma.consentRecord.findMany({ where });
if (consents.length === 0) {
throw new NotFoundException('No matching consent records');
}
const reason = body.reason ?? MemberOptOutReasonEnum.SELF_PORTAL;
await this.prisma.$transaction(async (tx) => {
for (const consent of consents) {
if (body.scopes && body.scopes.length > 0) {
const remaining = (consent.scopes as ConsentScope[]).filter((s) => !body.scopes!.includes(s));
if (remaining.length === 0) {
await tx.consentRecord.update({
where: { id: consent.id },
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
});
} else {
await tx.consentRecord.update({
where: { id: consent.id },
data: { scopes: remaining },
});
}
} else {
await tx.consentRecord.update({
where: { id: consent.id },
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
});
}
await tx.memberOptOut.create({
data: {
tenantId,
userId,
groupId: consent.groupId,
reason,
notes: body.notes ?? null,
},
});
}
});
await this.audit.log({
tenantId,
action: AuditAction.MEMBER_OPT_OUT,
resourceType: 'TowerUser',
resourceId: userId,
payload: { groupId: body.groupId, scopes: body.scopes, reason },
});
return { ok: true, revoked: consents.length };
}
async optIn(
userId: string,
tenantId: string,
body: OptInRequest,
): Promise<{ ok: true; consentId: string }> {
if (body.scopes.length === 0) {
throw new BadRequestException('At least one scope is required');
}
const group = await this.prisma.group.findFirst({ where: { id: body.groupId, tenantId } });
if (!group) throw new NotFoundException('Group not found in your tenant');
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new UnauthorizedException('User not found');
const existing = await this.prisma.consentRecord.findFirst({
where: { userId, tenantId, groupId: body.groupId },
});
let consent;
if (existing) {
consent = await this.prisma.consentRecord.update({
where: { id: existing.id },
data: {
scopes: body.scopes,
retentionDays: body.retentionDays ?? existing.retentionDays,
status: ConsentStatus.GRANTED,
revokedAt: null,
effectiveAt: new Date(),
},
});
} else {
consent = await this.prisma.consentRecord.create({
data: {
tenantId,
groupId: body.groupId,
userId,
scopes: body.scopes,
retentionDays: body.retentionDays ?? 90,
policyVersion: 'v1',
status: ConsentStatus.GRANTED,
proofEventId: 'self',
},
});
}
await this.audit.log({
tenantId,
action: AuditAction.MEMBER_OPT_IN,
resourceType: 'TowerUser',
resourceId: userId,
payload: { groupId: body.groupId, scopes: body.scopes },
});
return { ok: true, consentId: consent.id };
}
async deleteAccount(userId: string, tenantId: string): Promise<{ ok: true }> {
await this.prisma.$transaction(async (tx) => {
await tx.consentRecord.deleteMany({ where: { userId, tenantId } });
await tx.memberOptOut.deleteMany({ where: { userId, tenantId } });
await tx.towerSession.deleteMany({ where: { userId } });
await tx.towerUser.delete({ where: { id: userId } });
});
await this.audit.log({
tenantId,
action: AuditAction.MEMBER_DELETED,
resourceType: 'TowerUser',
resourceId: userId,
});
return { ok: true };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { OnboardingService } from './onboarding.service';
import { PublicOnboardingController } from './public-onboarding.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [ConfigModule, AuthModule],
controllers: [PublicOnboardingController],
providers: [OnboardingService],
exports: [OnboardingService],
})
export class OnboardingModule {}
@@ -0,0 +1,174 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OnboardingService } from './onboarding.service';
import { PrismaService } from '../../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { AuditService } from '../audit/audit.service';
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException, NotFoundException, ConflictException } from '@nestjs/common';
import { createHash } from 'crypto';
const PEPPER = 'pepper-secret-must-be-32-chars-min';
const TEST_PHONE = '+19198765432';
const TEST_PHONE_HASH = createHash('sha256').update(`${PEPPER}:${TEST_PHONE}`).digest('hex');
describe('OnboardingService', () => {
let service: OnboardingService;
const mockPrisma: any = {
group: { findUnique: jest.fn() },
tenant: { findUnique: jest.fn() },
otpChallenge: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
towerUser: { upsert: jest.fn() },
consentRecord: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
};
const mockJwt = { signAsync: jest.fn().mockResolvedValue('member-jwt'), verify: jest.fn() };
const mockAudit = { log: jest.fn() };
const mockConfig = { get: jest.fn().mockReturnValue('pepper-secret-must-be-32-chars-min') };
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
OnboardingService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: AuditService, useValue: mockAudit },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<OnboardingService>(OnboardingService);
});
function validToken(): string {
return Buffer.from(
JSON.stringify({ tenantId: 'tnt-1', groupId: 'grp-1', jid: '1234@s.whatsapp.net' }),
'utf8',
).toString('base64url');
}
describe('decodeOnboardingToken', () => {
it('rejects garbage', () => {
expect(() => service.decodeOnboardingToken('!!!')).toThrow(UnauthorizedException);
});
it('rejects missing fields', () => {
const tok = Buffer.from(JSON.stringify({ groupId: 'x' }), 'utf8').toString('base64url');
expect(() => service.decodeOnboardingToken(tok)).toThrow(UnauthorizedException);
});
it('decodes a valid token', () => {
const out = service.decodeOnboardingToken(validToken());
expect(out.tenantId).toBe('tnt-1');
expect(out.jid).toBe('1234@s.whatsapp.net');
});
});
describe('getOnboardInfo', () => {
it('throws when group is not claimed', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', name: 'Foo', tenantId: null });
await expect(service.getOnboardInfo(validToken())).rejects.toThrow(ConflictException);
});
it('returns group + tenant + policy info', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', name: 'UP Parivar', tenantId: 'tnt-1' });
mockPrisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', name: 'UP Parivar Dallas' });
const res = await service.getOnboardInfo(validToken());
expect(res.groupName).toBe('UP Parivar');
expect(res.tenantName).toBe('UP Parivar Dallas');
expect(res.defaultScopes).toContain('INGEST');
});
});
describe('requestOtp', () => {
it('creates a challenge with a 6-digit code', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1' });
mockPrisma.otpChallenge.create.mockResolvedValue({ id: 'ch-1' });
const res = await service.requestOtp(validToken(), '+19198765432');
expect(res.ok).toBe(true);
expect(res.challengeId).toBeTruthy();
expect(res.expiresInSeconds).toBe(300);
expect(mockPrisma.otpChallenge.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
jid: '1234@s.whatsapp.net',
policyVersion: 'v1',
}),
}),
);
});
it('rejects if group is not claimable', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: null });
await expect(service.requestOtp(validToken(), '+19198765432')).rejects.toThrow(ConflictException);
});
});
describe('verifyOtp', () => {
it('rejects unknown challenge', async () => {
mockPrisma.otpChallenge.findUnique.mockResolvedValue(null);
await expect(
service.verifyOtp(validToken(), 'ch-x', '+19198765432', '123456', [], undefined),
).rejects.toThrow(NotFoundException);
});
it('rejects consumed challenge', async () => {
mockPrisma.otpChallenge.findUnique.mockResolvedValue({
id: 'ch-1',
code: '123456',
consumedAt: new Date(),
expiresAt: new Date(Date.now() + 60000),
phoneHash: 'a',
});
await expect(
service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined),
).rejects.toThrow(UnauthorizedException);
});
it('rejects wrong code', async () => {
mockPrisma.otpChallenge.findUnique.mockResolvedValue({
id: 'ch-1',
code: '111111',
consumedAt: null,
expiresAt: new Date(Date.now() + 60000),
phoneHash: 'computed-hash',
});
await expect(
service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined),
).rejects.toThrow(/Invalid code/);
});
it('creates TowerUser, ConsentRecord, and member JWT on success', async () => {
mockPrisma.otpChallenge.findUnique.mockResolvedValue({
id: 'ch-1',
code: '123456',
consumedAt: null,
expiresAt: new Date(Date.now() + 60000),
phoneHash: TEST_PHONE_HASH,
tenantId: 'tnt-1',
jid: '1234@s.whatsapp.net',
groupId: 'grp-1',
});
mockPrisma.towerUser.upsert.mockResolvedValue({
id: 'user-1',
tenantId: 'tnt-1',
jid: '1234@s.whatsapp.net',
displayName: '1234@s.whatsapp.net',
});
mockPrisma.consentRecord.findFirst.mockResolvedValue(null);
mockPrisma.consentRecord.create.mockResolvedValue({
id: 'consent-1',
scopes: ['INGEST', 'DISPLAY'],
retentionDays: 90,
policyVersion: 'v1',
});
const res = await service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined);
expect(res.memberToken).toBe('member-jwt');
expect(res.user.id).toBe('user-1');
expect(res.consent.scopes).toContain('INGEST');
expect(mockPrisma.otpChallenge.update).toHaveBeenCalledWith({
where: { id: 'ch-1' },
data: { consumedAt: expect.any(Date) },
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MEMBER_ONBOARDED' }),
);
});
});
});
@@ -0,0 +1,230 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { ConsentScope, OnboardingTokenPayload, PublicOnboardInfo, RequestOtpResponse, VerifyOtpResponse } from '@tower/types';
import { ConsentStatus } from '@prisma/client';
const POLICY_VERSION = 'v1';
const OTP_TTL_MIN = 5;
const DEFAULT_SCOPES: ConsentScope[] = ['INGEST', 'DISPLAY'];
const DEFAULT_RETENTION_DAYS = 90;
function hashPhone(phone: string, pepper: string): string {
const normalized = phone.replace(/[^\d+]/g, '');
return createHash('sha256').update(`${pepper}:${normalized}`).digest('hex');
}
@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);
private readonly policyVersion = POLICY_VERSION;
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
decodeOnboardingToken(token: string): OnboardingTokenPayload {
// Phase 2B: token is base64url({groupId, jid, tenantId}) — no signature.
// The OTP step (sent via DM to the jid) is the real authentication.
try {
const json = Buffer.from(token, 'base64url').toString('utf8');
const parsed = JSON.parse(json) as OnboardingTokenPayload;
if (!parsed.groupId || !parsed.jid || !parsed.tenantId) {
throw new Error('Missing required fields');
}
return parsed;
} catch {
throw new UnauthorizedException('Invalid onboarding link');
}
}
async getOnboardInfo(token: string): Promise<PublicOnboardInfo> {
const payload = this.decodeOnboardingToken(token);
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
if (!group) throw new NotFoundException('Group not found');
if (!group.tenantId) {
throw new ConflictException('Group is not yet claimed by a tenant');
}
const tenant = await this.prisma.tenant.findUnique({ where: { id: payload.tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
return {
groupName: group.name,
tenantName: tenant.name,
policyVersion: this.policyVersion,
defaultScopes: DEFAULT_SCOPES,
defaultRetentionDays: DEFAULT_RETENTION_DAYS,
};
}
async requestOtp(token: string, phone: string): Promise<RequestOtpResponse> {
const payload = this.decodeOnboardingToken(token);
if (payload.jid !== this.normalizeJid(payload.jid)) {
// sanity
}
if (!phone || phone.length < 6) {
throw new BadRequestException('Invalid phone number');
}
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
if (!group || !group.tenantId) {
throw new ConflictException('Group is not claimable');
}
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
const challengeId = randomBytes(16).toString('hex');
const challenge = await this.prisma.otpChallenge.create({
data: {
id: challengeId,
tenantId: payload.tenantId,
jid: payload.jid,
phoneHash,
code,
scopes: DEFAULT_SCOPES,
retentionDays: DEFAULT_RETENTION_DAYS,
policyVersion: this.policyVersion,
groupId: payload.groupId,
expiresAt,
},
});
await this.audit.log({
tenantId: payload.tenantId,
action: AuditAction.OTP_REQUESTED,
resourceType: 'OtpChallenge',
resourceId: challenge.id,
payload: { jid: payload.jid },
});
return {
ok: true,
challengeId,
expiresInSeconds: OTP_TTL_MIN * 60,
};
}
async verifyOtp(
token: string,
challengeId: string,
phone: string,
code: string,
scopes: ConsentScope[],
retentionDays?: number,
): Promise<VerifyOtpResponse> {
const payload = this.decodeOnboardingToken(token);
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
if (!challenge) throw new NotFoundException('Challenge not found');
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Challenge expired');
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
const effectiveScopes = scopes.length > 0 ? scopes : DEFAULT_SCOPES;
const effectiveRetention = retentionDays ?? DEFAULT_RETENTION_DAYS;
const user = await this.prisma.towerUser.upsert({
where: { tenantId_phoneHash: { tenantId: payload.tenantId, phoneHash } },
update: { jid: payload.jid, displayName: payload.jid },
create: {
tenantId: payload.tenantId,
phoneHash,
jid: payload.jid,
displayName: payload.jid,
},
});
const existing = await this.prisma.consentRecord.findFirst({
where: { tenantId: payload.tenantId, groupId: payload.groupId, userId: user.id },
});
let consent;
if (existing) {
consent = await this.prisma.consentRecord.update({
where: { id: existing.id },
data: {
scopes: effectiveScopes,
retentionDays: effectiveRetention,
policyVersion: this.policyVersion,
status: ConsentStatus.GRANTED,
revokedAt: null,
effectiveAt: new Date(),
},
});
} else {
consent = await this.prisma.consentRecord.create({
data: {
tenantId: payload.tenantId,
groupId: payload.groupId,
userId: user.id,
scopes: effectiveScopes,
retentionDays: effectiveRetention,
policyVersion: this.policyVersion,
status: ConsentStatus.GRANTED,
proofEventId: 'pending',
},
});
}
await this.prisma.consentRecord.update({
where: { id: consent.id },
data: { proofEventId: consent.id },
});
await this.prisma.otpChallenge.update({
where: { id: challengeId },
data: { consumedAt: new Date() },
});
await this.audit.log({
tenantId: payload.tenantId,
action: AuditAction.MEMBER_ONBOARDED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: {
jid: payload.jid,
groupId: payload.groupId,
consentId: consent.id,
scopes: effectiveScopes,
},
});
const memberToken = await this.jwt.signAsync({
kind: 'member',
sub: user.id,
tenantId: user.tenantId,
jid: user.jid,
phoneHash: user.phoneHash,
} as const);
return {
memberToken,
user: {
id: user.id,
tenantId: user.tenantId,
jid: user.jid,
displayName: user.displayName,
},
consent: {
scopes: consent.scopes as ConsentScope[],
retentionDays: consent.retentionDays,
policyVersion: consent.policyVersion,
},
};
}
private normalizeJid(jid: string): string {
return jid.trim();
}
}
@@ -0,0 +1,49 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { OnboardingService } from './onboarding.service';
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
import { ConsentScope } from '@tower/types';
import { Public } from '../auth/public.decorator';
class RequestOtpDto {
@IsString() @MinLength(8) onboardingToken!: string;
@IsString() @MinLength(6) phone!: string;
}
class VerifyOtpDto {
@IsString() @MinLength(8) onboardingToken!: string;
@IsString() challengeId!: string;
@IsString() @MinLength(6) phone!: string;
@IsString() @MinLength(6) code!: string;
@IsArray() @IsOptional() scopes?: ConsentScope[];
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
}
@Controller('public')
export class PublicOnboardingController {
constructor(private readonly service: OnboardingService) {}
@Get('onboard/:token')
@Public()
getOnboardInfo(@Param('token') token: string) {
return this.service.getOnboardInfo(token);
}
@Post('auth/request-otp')
@Public()
requestOtp(@Body() body: RequestOtpDto) {
return this.service.requestOtp(body.onboardingToken, body.phone);
}
@Post('auth/verify-otp')
@Public()
verifyOtp(@Body() body: VerifyOtpDto) {
return this.service.verifyOtp(
body.onboardingToken,
body.challengeId,
body.phone,
body.code,
body.scopes ?? [],
body.retentionDays,
);
}
}
@@ -1,6 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
import type { TenantContext } from '../../common/tenant-context';
const ctx: TenantContext = { tenantId: 'tnt_1', adminId: 'adm_1', role: 'OWNER' };
const mockRoute = {
id: 'rt_1',
@@ -12,6 +15,7 @@ const mockRoute = {
const mockService = {
list: jest.fn().mockResolvedValue([mockRoute]),
create: jest.fn().mockResolvedValue(mockRoute),
createBatch: jest.fn().mockResolvedValue([mockRoute, { ...mockRoute, id: 'rt_2', targetGroupId: 'grp_3', targetGroup: { name: 'Gamma' } }]),
remove: jest.fn().mockResolvedValue(undefined),
};
@@ -27,25 +31,31 @@ describe('RoutesController', () => {
controller = module.get<RoutesController>(RoutesController);
});
it('list() delegates to service with no filter', async () => {
const result = await controller.list(undefined);
it('list() delegates to service with tenantId and no filter', async () => {
const result = await controller.list(ctx, undefined);
expect(result).toEqual([mockRoute]);
expect(mockService.list).toHaveBeenCalledWith(undefined);
expect(mockService.list).toHaveBeenCalledWith('tnt_1', undefined);
});
it('list() passes sourceGroupId filter to service', async () => {
await controller.list('grp_1');
expect(mockService.list).toHaveBeenCalledWith('grp_1');
it('list() passes sourceGroupId filter to service along with tenantId', async () => {
await controller.list(ctx, 'grp_1');
expect(mockService.list).toHaveBeenCalledWith('tnt_1', 'grp_1');
});
it('create() extracts body fields and delegates to service', async () => {
const result = await controller.create({ sourceGroupId: 'grp_1', targetGroupId: 'grp_2' });
it('create() extracts body fields and delegates to service with tenantId', async () => {
const result = await controller.create(ctx, { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' });
expect(result).toEqual(mockRoute);
expect(mockService.create).toHaveBeenCalledWith('grp_1', 'grp_2');
expect(mockService.create).toHaveBeenCalledWith('tnt_1', 'grp_1', 'grp_2');
});
it('remove() delegates id to service', async () => {
await controller.remove('rt_1');
expect(mockService.remove).toHaveBeenCalledWith('rt_1');
it('remove() delegates tenantId and id to service', async () => {
await controller.remove(ctx, 'rt_1');
expect(mockService.remove).toHaveBeenCalledWith('tnt_1', 'rt_1');
});
it('createBatch() extracts body fields and delegates to service with tenantId', async () => {
const result = await controller.createBatch(ctx, { sourceGroupId: 'grp_1', targetGroupIds: ['grp_2', 'grp_3'] });
expect(result).toHaveLength(2);
expect(mockService.createBatch).toHaveBeenCalledWith('tnt_1', 'grp_1', ['grp_2', 'grp_3']);
});
});
@@ -1,23 +1,44 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { RoutesService } from './routes.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { IsString } from 'class-validator';
class CreateRouteDto {
@IsString() sourceGroupId!: string;
@IsString() targetGroupId!: string;
}
class BatchCreateRouteDto {
@IsString() sourceGroupId!: string;
@IsString({ each: true }) targetGroupIds!: string[];
}
@Controller('routes')
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@Get()
list(@Query('sourceGroupId') sourceGroupId?: string) {
return this.routesService.list(sourceGroupId);
list(
@CurrentTenantContext() ctx: TenantContext,
@Query('sourceGroupId') sourceGroupId?: string,
) {
return this.routesService.list(ctx.tenantId, sourceGroupId);
}
@Post()
create(@Body() body: { sourceGroupId: string; targetGroupId: string }) {
return this.routesService.create(body.sourceGroupId, body.targetGroupId);
create(@CurrentTenantContext() ctx: TenantContext, @Body() body: CreateRouteDto) {
return this.routesService.create(ctx.tenantId, body.sourceGroupId, body.targetGroupId);
}
@Post('batch')
createBatch(@CurrentTenantContext() ctx: TenantContext, @Body() body: BatchCreateRouteDto) {
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id') id: string) {
return this.routesService.remove(id);
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
await this.routesService.remove(ctx.tenantId, id);
}
}
@@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, NotFoundException } from '@nest
import { Prisma } from '@prisma/client';
import { RoutesService } from './routes.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
const mockRoute = {
id: 'rt_1',
@@ -14,15 +15,32 @@ const mockRoute = {
targetGroup: { name: 'Beta' },
};
const mockRoute2 = {
id: 'rt_2',
sourceGroupId: 'grp_1',
targetGroupId: 'grp_3',
isActive: true,
createdAt: new Date(),
sourceGroup: { name: 'Alpha' },
targetGroup: { name: 'Gamma' },
};
describe('RoutesService', () => {
let service: RoutesService;
const mockPrisma = {
const mockPrisma: any = {
$transaction: jest.fn((ops: any[]) => Promise.all(ops)),
syncRoute: {
findMany: jest.fn().mockResolvedValue([mockRoute]),
findFirst: jest.fn().mockResolvedValue(mockRoute),
create: jest.fn().mockResolvedValue(mockRoute),
delete: jest.fn().mockResolvedValue(mockRoute),
},
group: {
findFirst: jest.fn().mockResolvedValue({ id: 'g' }),
findMany: jest.fn().mockResolvedValue([{ id: 'grp_2', name: 'Beta' }, { id: 'grp_3', name: 'Gamma' }]),
},
};
const mockAudit: any = { log: jest.fn().mockResolvedValue(undefined) };
beforeEach(async () => {
jest.clearAllMocks();
@@ -30,53 +48,51 @@ describe('RoutesService', () => {
providers: [
RoutesService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
],
}).compile();
service = module.get<RoutesService>(RoutesService);
});
describe('list', () => {
it('returns all routes with group names', async () => {
const result = await service.list();
it('returns routes for tenant', async () => {
const result = await service.list('tnt-1');
expect(result).toHaveLength(1);
expect(result[0].sourceGroup.name).toBe('Alpha');
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith({
where: undefined,
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
});
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: 'tnt-1' } }),
);
});
it('filters by sourceGroupId when provided', async () => {
await service.list('grp_1');
await service.list('tnt-1', 'grp_1');
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sourceGroupId: 'grp_1' } }),
expect.objectContaining({ where: { tenantId: 'tnt-1', sourceGroupId: 'grp_1' } }),
);
});
});
describe('create', () => {
it('creates a route and returns it with group names', async () => {
const result = await service.create('grp_1', 'grp_2');
it('creates a route within the tenant and writes audit', async () => {
const result = await service.create('tnt-1', 'grp_1', 'grp_2');
expect(result).toEqual(mockRoute);
expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({
data: { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' },
data: { tenantId: 'tnt-1', sourceGroupId: 'grp_1', targetGroupId: 'grp_2' },
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'ROUTE_CREATED', resourceId: 'rt_1' }),
);
});
it('throws BadRequestException when sourceGroupId is empty', async () => {
await expect(service.create('', 'grp_2')).rejects.toThrow(BadRequestException);
await expect(service.create('tnt-1', '', 'grp_2')).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when targetGroupId is empty', async () => {
await expect(service.create('grp_1', '')).rejects.toThrow(BadRequestException);
await expect(service.create('tnt-1', 'grp_1', '')).rejects.toThrow(BadRequestException);
});
it('throws ConflictException when route already exists (Prisma P2002)', async () => {
@@ -85,36 +101,87 @@ describe('RoutesService', () => {
clientVersion: '6.0.0',
});
mockPrisma.syncRoute.create.mockRejectedValueOnce(p2002);
await expect(service.create('grp_1', 'grp_2')).rejects.toThrow(ConflictException);
await expect(service.create('tnt-1', 'grp_1', 'grp_2')).rejects.toThrow(ConflictException);
});
it('throws BadRequestException when source and target are the same group', async () => {
await expect(service.create('grp_1', 'grp_1')).rejects.toThrow(BadRequestException);
await expect(service.create('tnt-1', 'grp_1', 'grp_1')).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when a group ID does not exist (Prisma P2003)', async () => {
const p2003 = new Prisma.PrismaClientKnownRequestError('Foreign key constraint', {
code: 'P2003',
clientVersion: '6.0.0',
});
mockPrisma.syncRoute.create.mockRejectedValueOnce(p2003);
await expect(service.create('grp_1', 'bad_grp')).rejects.toThrow(BadRequestException);
it('throws BadRequestException when a group is not in this tenant', async () => {
mockPrisma.group.findFirst.mockResolvedValueOnce(null);
await expect(service.create('tnt-1', 'grp_1', 'grp_x')).rejects.toThrow(BadRequestException);
});
});
describe('createBatch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPrisma.syncRoute.create
.mockResolvedValueOnce(mockRoute)
.mockResolvedValueOnce(mockRoute2);
mockPrisma.syncRoute.findMany.mockResolvedValue([]);
mockPrisma.group.findMany.mockResolvedValue([
{ id: 'grp_2', name: 'Beta' },
{ id: 'grp_3', name: 'Gamma' },
]);
});
it('creates multiple routes in batch', async () => {
const result = await service.createBatch('tnt-1', 'grp_1', ['grp_2', 'grp_3']);
expect(result).toHaveLength(2);
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'ROUTE_CREATED',
payload: expect.objectContaining({ count: 2 }),
}),
);
});
it('throws BadRequestException when targetGroupIds is empty', async () => {
await expect(service.createBatch('tnt-1', 'grp_1', [])).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when source is also a target', async () => {
await expect(service.createBatch('tnt-1', 'grp_1', ['grp_1'])).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when a target group is not in this tenant', async () => {
mockPrisma.group.findMany.mockResolvedValueOnce([{ id: 'grp_2', name: 'Beta' }]);
await expect(service.createBatch('tnt-1', 'grp_1', ['grp_2', 'grp_x'])).rejects.toThrow(BadRequestException);
});
it('throws ConflictException when any route already exists', async () => {
mockPrisma.syncRoute.findMany.mockResolvedValue([{ targetGroupId: 'grp_2' }]);
await expect(service.createBatch('tnt-1', 'grp_1', ['grp_2', 'grp_3'])).rejects.toThrow(ConflictException);
});
});
describe('remove', () => {
it('deletes a route by id', async () => {
await service.remove('rt_1');
it('deletes a route and writes audit', async () => {
await service.remove('tnt-1', 'rt_1');
expect(mockPrisma.syncRoute.findFirst).toHaveBeenCalledWith({
where: { id: 'rt_1', tenantId: 'tnt-1' },
});
expect(mockPrisma.syncRoute.delete).toHaveBeenCalledWith({ where: { id: 'rt_1' } });
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'ROUTE_DELETED', resourceId: 'rt_1' }),
);
});
it('throws NotFoundException when route does not exist (Prisma P2025)', async () => {
it('throws NotFoundException when route is not in this tenant', async () => {
mockPrisma.syncRoute.findFirst.mockResolvedValueOnce(null);
await expect(service.remove('tnt-1', 'bad_id')).rejects.toThrow(NotFoundException);
});
it('throws NotFoundException on Prisma P2025', async () => {
const p2025 = new Prisma.PrismaClientKnownRequestError('Record not found', {
code: 'P2025',
clientVersion: '6.0.0',
});
mockPrisma.syncRoute.delete.mockRejectedValueOnce(p2025);
await expect(service.remove('bad_id')).rejects.toThrow(NotFoundException);
await expect(service.remove('tnt-1', 'rt_1')).rejects.toThrow(NotFoundException);
});
});
});
+136 -14
View File
@@ -1,52 +1,174 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
export interface BatchCreateResult {
created: Array<{ id: string; sourceGroupId: string; targetGroupId: string; sourceGroup: { name: string }; targetGroup: { name: string } }>;
skipped: string[];
}
const routeInclude = {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
sourceGroup: { select: { name: true, tenantId: true } },
targetGroup: { select: { name: true, tenantId: true } },
} as const;
@Injectable()
export class RoutesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list(sourceGroupId?: string) {
list(tenantId: string, sourceGroupId?: string) {
return this.prisma.syncRoute.findMany({
where: sourceGroupId ? { sourceGroupId } : undefined,
include: routeInclude,
where: {
tenantId,
...(sourceGroupId ? { sourceGroupId } : {}),
},
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async create(sourceGroupId: string, targetGroupId: string) {
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
if (!sourceGroupId || !targetGroupId) {
throw new BadRequestException('sourceGroupId and targetGroupId are required');
}
if (sourceGroupId === targetGroupId) {
throw new BadRequestException('Source and target groups cannot be the same');
}
// Source must be owned by this tenant AND active; target can be owned OR shared AND active
const [source, target] = await Promise.all([
this.prisma.group.findFirst({ where: { id: sourceGroupId, tenantId, isActive: true }, select: { id: true } }),
this.prisma.group.findFirst({
where: {
id: targetGroupId,
isActive: true,
OR: [
{ tenantId },
{ groupAccesses: { some: { tenantId } } },
],
},
select: { id: true },
}),
]);
if (!source) throw new BadRequestException('Source group is not available or the bot has been removed from it');
if (!target) throw new BadRequestException('Target group is not available or the bot has been removed from it');
try {
return await this.prisma.syncRoute.create({
data: { sourceGroupId, targetGroupId },
include: routeInclude,
const route = await this.prisma.syncRoute.create({
data: { tenantId, sourceGroupId, targetGroupId },
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
});
await this.audit.log({
tenantId,
action: AuditAction.ROUTE_CREATED,
resourceType: 'SyncRoute',
resourceId: route.id,
payload: { sourceGroupId, targetGroupId },
});
return route;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
throw new ConflictException('A route between these groups already exists');
}
if (e.code === 'P2003') {
throw new BadRequestException('One or both group IDs do not exist');
}
}
throw e;
}
}
async remove(id: string) {
async createBatch(tenantId: string, sourceGroupId: string, targetGroupIds: string[]) {
if (!sourceGroupId || !targetGroupIds.length) {
throw new BadRequestException('sourceGroupId and at least one targetGroupId are required');
}
if (targetGroupIds.includes(sourceGroupId)) {
throw new BadRequestException('Source and target groups cannot be the same');
}
// Source group must exist in this tenant AND be active
const source = await this.prisma.group.findFirst({ where: { id: sourceGroupId, tenantId, isActive: true }, select: { id: true } });
if (!source) {
throw new BadRequestException('Source group is not available or the bot has been removed from it');
}
// All target groups must be owned OR shared AND active
const targets = await this.prisma.group.findMany({
where: {
id: { in: targetGroupIds },
isActive: true,
OR: [
{ tenantId },
{ groupAccesses: { some: { tenantId } } },
],
},
select: { id: true, name: true },
});
if (targets.length !== targetGroupIds.length) {
const found = new Set(targets.map((t) => t.id));
const missing = targetGroupIds.filter((id) => !found.has(id));
throw new BadRequestException(`Target groups not found or not shared: ${missing.join(', ')}`);
}
// Check for existing conflicts — reject the whole batch
const existing = await this.prisma.syncRoute.findMany({
where: { tenantId, sourceGroupId, targetGroupId: { in: targetGroupIds } },
select: { targetGroupId: true },
});
if (existing.length > 0) {
const names = existing.map((e) => {
const t = targets.find((t) => t.id === e.targetGroupId);
return t?.name ?? e.targetGroupId;
});
throw new ConflictException(`Routes already exist for: ${names.join(', ')}`);
}
const created = await this.prisma.$transaction(
targetGroupIds.map((targetGroupId) =>
this.prisma.syncRoute.create({
data: { tenantId, sourceGroupId, targetGroupId },
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
}),
),
);
await this.audit.log({
tenantId,
action: AuditAction.ROUTE_CREATED,
resourceType: 'SyncRoute',
resourceId: created.map((r) => r.id).join(','),
payload: { sourceGroupId, targetGroupIds, count: created.length },
});
return created;
}
async remove(tenantId: string, id: string) {
// Verify ownership before delete
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException(`Route ${id} not found`);
try {
await this.prisma.syncRoute.delete({ where: { id } });
await this.audit.log({
tenantId,
action: AuditAction.ROUTE_DELETED,
resourceType: 'SyncRoute',
resourceId: id,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException(`Route ${id} not found`);
@@ -0,0 +1,45 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
import { RulesService } from './rules.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 { CreateRuleRequest, UpdateRuleRequest } from '@tower/types';
@Controller('admin/rules')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class RulesController {
constructor(private readonly rulesService: RulesService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.rulesService.list(ctx.tenantId);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: CreateRuleRequest,
) {
return this.rulesService.create(ctx.tenantId, body);
}
@Put(':id')
update(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: UpdateRuleRequest,
) {
return this.rulesService.update(ctx.tenantId, id, body);
}
@Delete(':id')
remove(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.rulesService.remove(ctx.tenantId, id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RulesController } from './rules.controller';
import { RulesService } from './rules.service';
@Module({
controllers: [RulesController],
providers: [RulesService],
exports: [RulesService],
})
export class RulesModule {}
@@ -0,0 +1,80 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { RulesService } from './rules.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('RulesService', () => {
let service: RulesService;
const mockPrisma: any = {
tenantRule: { findMany: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn() },
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
RulesService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<RulesService>(RulesService);
});
describe('list', () => {
it('returns rules ordered by priority', async () => {
mockPrisma.tenantRule.findMany.mockResolvedValue([
{ id: 'r1', tenantId: 'tnt-1', matchType: 'HASHTAG', matchValue: '#important', action: 'FLAG', priority: 0, isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01') },
{ id: 'r2', tenantId: 'tnt-1', matchType: 'PREFIX', matchValue: '/tower', action: 'FLAG', priority: 1, isActive: true, createdAt: new Date('2026-01-02'), updatedAt: new Date('2026-01-02') },
]);
const res = await service.list('tnt-1');
expect(res).toHaveLength(2);
expect(res[0].matchValue).toBe('#important');
expect(mockPrisma.tenantRule.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { tenantId: 'tnt-1' } }));
});
});
describe('create', () => {
it('creates and returns a rule', async () => {
mockPrisma.tenantRule.findUnique.mockResolvedValue(null);
mockPrisma.tenantRule.create.mockResolvedValue({
id: 'r1', tenantId: 'tnt-1', matchType: 'HASHTAG', matchValue: '#event', action: 'FLAG', priority: 0, isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
});
const res = await service.create('tnt-1', { matchType: 'HASHTAG', matchValue: '#event', action: 'FLAG' });
expect(res.matchValue).toBe('#event');
});
it('rejects duplicate rule', async () => {
mockPrisma.tenantRule.findUnique.mockResolvedValue({ id: 'existing' });
await expect(service.create('tnt-1', { matchType: 'HASHTAG', matchValue: '#event', action: 'FLAG' })).rejects.toThrow(ConflictException);
});
});
describe('update', () => {
it('updates and returns a rule', async () => {
mockPrisma.tenantRule.findFirst.mockResolvedValue({ id: 'r1', tenantId: 'tnt-1' });
mockPrisma.tenantRule.update.mockResolvedValue({
id: 'r1', tenantId: 'tnt-1', matchType: 'HASHTAG', matchValue: '#event', action: 'AUTO_APPROVE', priority: 0, isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-02'),
});
const res = await service.update('tnt-1', 'r1', { action: 'AUTO_APPROVE' });
expect(res.action).toBe('AUTO_APPROVE');
});
it('throws on non-existent rule', async () => {
mockPrisma.tenantRule.findFirst.mockResolvedValue(null);
await expect(service.update('tnt-1', 'missing', { action: 'AUTO_APPROVE' })).rejects.toThrow(NotFoundException);
});
});
describe('remove', () => {
it('deletes a rule', async () => {
mockPrisma.tenantRule.findFirst.mockResolvedValue({ id: 'r1', tenantId: 'tnt-1' });
mockPrisma.tenantRule.delete.mockResolvedValue({});
await expect(service.remove('tnt-1', 'r1')).resolves.toBeUndefined();
});
it('throws on non-existent rule', async () => {
mockPrisma.tenantRule.findFirst.mockResolvedValue(null);
await expect(service.remove('tnt-1', 'missing')).rejects.toThrow(NotFoundException);
});
});
});
@@ -0,0 +1,90 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateRuleRequest, TenantRuleData, UpdateRuleRequest } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class RulesService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string): Promise<TenantRuleData[]> {
const rows = await this.prisma.tenantRule.findMany({
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => ({
id: r.id,
tenantId: r.tenantId,
matchType: r.matchType,
matchValue: r.matchValue,
action: r.action,
priority: r.priority,
isActive: r.isActive,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}));
}
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
const existing = await this.prisma.tenantRule.findUnique({
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } },
});
if (existing) {
throw new ConflictException('A rule with this matchType + matchValue already exists');
}
const row = await this.prisma.tenantRule.create({
data: {
tenantId,
matchType: req.matchType,
matchValue: req.matchValue,
action: req.action,
priority: req.priority ?? 0,
isActive: req.isActive ?? true,
},
});
return {
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
const rule = await this.prisma.tenantRule.findFirst({ where: { id, tenantId } });
if (!rule) throw new NotFoundException('Rule not found');
const data: any = {};
if (req.matchType !== undefined) data.matchType = req.matchType;
if (req.matchValue !== undefined) data.matchValue = req.matchValue;
if (req.action !== undefined) data.action = req.action;
if (req.priority !== undefined) data.priority = req.priority;
if (req.isActive !== undefined) data.isActive = req.isActive;
const row = await this.prisma.tenantRule.update({ where: { id }, data });
return {
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async remove(tenantId: string, id: string): Promise<void> {
const rule = await this.prisma.tenantRule.findFirst({ where: { id, tenantId } });
if (!rule) throw new NotFoundException('Rule not found');
await this.prisma.tenantRule.delete({ where: { id } });
}
}
@@ -1,9 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import type { TenantContext } from '../../common/tenant-context';
const mockSearchService = { search: jest.fn() };
const ctx: TenantContext = { tenantId: 'tnt_1', adminId: 'adm_1', role: 'OWNER' };
describe('SearchController', () => {
let controller: SearchController;
@@ -18,26 +21,28 @@ describe('SearchController', () => {
it('calls service with all parsed params', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 2, limit: 10, query: 'hello' });
await controller.search('hello', 'grp-1', 'important,event', '2', '10');
expect(mockSearchService.search).toHaveBeenCalledWith('hello', 'grp-1', ['important', 'event'], 2, 10);
await controller.search(ctx, 'hello', 'grp-1', 'important,event', '2', '10');
expect(mockSearchService.search).toHaveBeenCalledWith(
'tnt_1', 'hello', 'grp-1', ['important', 'event'], 2, 10,
);
});
it('defaults page to 1 and limit to 20 when not provided', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
await controller.search('');
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, undefined, 1, 20);
await controller.search(ctx, '');
expect(mockSearchService.search).toHaveBeenCalledWith('tnt_1', '', undefined, undefined, 1, 20);
});
it('returns the service result directly', async () => {
const expected = { hits: [{ id: 'msg-1' }], total: 1, page: 1, limit: 20, query: 'test' };
mockSearchService.search.mockResolvedValue(expected);
const result = await controller.search('test');
const result = await controller.search(ctx, 'test');
expect(result).toEqual(expected);
});
it('splits tags on comma and trims whitespace', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
await controller.search('', undefined, ' important , event ');
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, ['important', 'event'], 1, 20);
await controller.search(ctx, '', undefined, ' important , event ');
expect(mockSearchService.search).toHaveBeenCalledWith('tnt_1', '', undefined, ['important', 'event'], 1, 20);
});
});
@@ -1,5 +1,7 @@
import { Controller, Get, Query } from '@nestjs/common';
import { SearchService } from './search.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
@Controller('search')
export class SearchController {
@@ -7,6 +9,7 @@ export class SearchController {
@Get()
search(
@CurrentTenantContext() ctx: TenantContext,
@Query('q') q = '',
@Query('groupId') groupId?: string,
@Query('tags') tags?: string,
@@ -16,6 +19,6 @@ export class SearchController {
const tagList = tags
? tags.split(',').map((t) => t.trim()).filter(Boolean)
: undefined;
return this.searchService.search(q, groupId, tagList, Number(page), Number(limit));
return this.searchService.search(ctx.tenantId, q, groupId, tagList, Number(page), Number(limit));
}
}
@@ -34,70 +34,76 @@ describe('SearchService', () => {
expect(searchPkg.configureIndex).toHaveBeenCalledWith(mockClient);
});
it('always filters by tenantId', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('tnt-1', 'test');
expect(mockSearch).toHaveBeenCalledWith(
'test',
expect.objectContaining({ filter: 'tenantId = "tnt-1"' }),
);
});
it('returns hits and total', async () => {
mockSearch.mockResolvedValue({ hits: [{ id: 'msg-1', content: 'hello' }], totalHits: 1 });
const result = await service.search('hello');
const result = await service.search('tnt-1', 'hello');
expect(result.hits).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.query).toBe('hello');
});
it('searches with no filter when no groupId or tags', async () => {
it('applies sourceGroupId filter alongside tenant', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('test');
expect(mockSearch).toHaveBeenCalledWith('test', expect.objectContaining({ filter: undefined }));
});
it('applies sourceGroupId filter', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1');
await service.search('tnt-1', 'hello', 'grp-1');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'sourceGroupId = "grp-1"' }),
expect.objectContaining({ filter: 'tenantId = "tnt-1" AND sourceGroupId = "grp-1"' }),
);
});
it('applies tags filter', async () => {
it('applies tags filter alongside tenant', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', undefined, ['#important']);
await service.search('tnt-1', 'hello', undefined, ['#important']);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'tags = "#important"' }),
expect.objectContaining({ filter: 'tenantId = "tnt-1" AND tags = "#important"' }),
);
});
it('combines groupId and tags filters with AND', async () => {
it('combines groupId and tags filters with AND, all behind tenant', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1', ['#important', '#event']);
await service.search('tnt-1', 'hello', 'grp-1', ['#important', '#event']);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({
filter: 'sourceGroupId = "grp-1" AND tags = "#important" AND tags = "#event"',
filter:
'tenantId = "tnt-1" AND sourceGroupId = "grp-1" AND tags = "#important" AND tags = "#event"',
}),
);
});
it('defaults page to 1 and hitsPerPage to 20', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello');
await service.search('tnt-1', 'hello');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ page: 1, hitsPerPage: 20 }),
);
});
it('escapes double-quotes in groupId to prevent filter injection', async () => {
it('escapes double-quotes in filter values to prevent injection', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp"1"OR id EXISTS');
await service.search('tnt-1', 'hello', 'grp"1"OR id EXISTS');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'sourceGroupId = "grp\\"1\\"OR id EXISTS"' }),
expect.objectContaining({
filter: 'tenantId = "tnt-1" AND sourceGroupId = "grp\\"1\\"OR id EXISTS"',
}),
);
});
it('clamps page to minimum 1 and limit to maximum 100', async () => {
it('clamps page to min 1 and limit to max 100', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', undefined, undefined, 0, 999);
await service.search('tnt-1', 'hello', undefined, undefined, 0, 999);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ page: 1, hitsPerPage: 100 }),
@@ -30,6 +30,7 @@ export class SearchService implements OnModuleInit {
}
async search(
tenantId: string,
query: string,
groupId?: string,
tags?: string[],
@@ -39,12 +40,13 @@ export class SearchService implements OnModuleInit {
const safePage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1));
const safeLimit = Math.min(100, Math.max(1, Math.floor(Number.isFinite(limit) ? limit : 20)));
const filters: string[] = [];
// Always filter by tenant — non-negotiable for multi-tenant isolation
const filters: string[] = [`tenantId = "${SearchService.escapeFilterValue(tenantId)}"`];
if (groupId) filters.push(`sourceGroupId = "${SearchService.escapeFilterValue(groupId)}"`);
if (tags?.length) filters.push(...tags.map((t) => `tags = "${SearchService.escapeFilterValue(t)}"`));
const result = await this.client.index<MeiliDocument>(MESSAGES_INDEX).search(query, {
filter: filters.length ? filters.join(' AND ') : undefined,
filter: filters.join(' AND '),
page: safePage,
hitsPerPage: safeLimit,
sort: ['approvedAt:desc'],
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { SuperAdminService } from './super-admin.service';
import { SuperAdminGuard } from './super-admin.guard';
import { Public } from '../auth/public.decorator';
@Controller('auth/super')
export class SuperAdminController {
constructor(private readonly superAdminService: SuperAdminService) {}
@Public()
@Post('login')
login(@Body() body: { email: string; password: string }) {
return this.superAdminService.login(body.email, body.password);
}
@UseGuards(SuperAdminGuard)
@Get('me')
me(@Req() req: any) {
return this.superAdminService.me(req.user.sub);
}
}
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, Global, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class SuperAdminGuard 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 !== 'superadmin') throw new UnauthorizedException('Not a super admin');
req.user = payload;
return true;
} catch {
throw new UnauthorizedException();
}
}
}
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SuperAdminController } from './super-admin.controller';
import { SuperAdminService } from './super-admin.service';
import { SuperAdminGuard } from './super-admin.guard';
import { PrismaModule } from '../../prisma/prisma.module';
@Global()
@Module({
imports: [
PrismaModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? '',
}),
}),
],
controllers: [SuperAdminController],
providers: [SuperAdminService, SuperAdminGuard],
exports: [SuperAdminGuard, JwtModule],
})
export class SuperAdminModule {}
@@ -0,0 +1,35 @@
import { Injectable, 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';
@Injectable()
export class SuperAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
async login(email: string, password: string): Promise<{ token: string; superAdmin: { id: string; email: string; name: string | null } }> {
const admin = await this.prisma.superAdmin.findUnique({ 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 token = this.jwtService.sign(
{ kind: 'superadmin', sub: admin.id, email: admin.email },
{ secret: this.config.get<string>('JWT_SECRET'), expiresIn: '7d' },
);
return { token, superAdmin: { id: admin.id, email: admin.email, name: admin.name } };
}
async me(adminId: string): Promise<{ id: string; email: string; name: string | null }> {
const admin = await this.prisma.superAdmin.findUnique({ where: { id: adminId } });
if (!admin) throw new UnauthorizedException('Super admin not found');
return { id: admin.id, email: admin.email, name: admin.name };
}
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { TenantService } from './tenant.service';
import { SuperAdminGuard } from '../super-admin/super-admin.guard';
@Controller('admin/tenants')
@UseGuards(SuperAdminGuard)
export class TenantController {
constructor(private readonly tenantService: TenantService) {}
@Get()
list() {
return this.tenantService.list();
}
@Get(':id')
get(@Param('id') id: string) {
return this.tenantService.get(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() body: { isActive?: boolean; isForwardingPaused?: boolean }) {
return this.tenantService.update(id, body);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TenantController } from './tenant.controller';
import { TenantService } from './tenant.service';
import { PrismaModule } from '../../prisma/prisma.module';
import { SuperAdminModule } from '../super-admin/super-admin.module';
@Module({
imports: [PrismaModule, SuperAdminModule],
controllers: [TenantController],
providers: [TenantService],
})
export class TenantModule {}
@@ -0,0 +1,86 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class TenantService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<any[]> {
const tenants = await this.prisma.tenant.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { groups: true, admins: true, messages: true, tenantBots: true } },
tenantBots: { include: { account: { select: { jid: true, displayName: true, status: true } } } },
},
});
return tenants.map((t) => ({
id: t.id,
slug: t.slug,
name: t.name,
isActive: t.isActive,
isForwardingPaused: t.isForwardingPaused,
createdAt: t.createdAt.toISOString(),
stats: {
groups: t._count.groups,
admins: t._count.admins,
messages: t._count.messages,
bots: t._count.tenantBots,
},
bot: t.tenantBots[0]
? { jid: t.tenantBots[0].account.jid, displayName: t.tenantBots[0].account.displayName, status: t.tenantBots[0].account.status }
: null,
}));
}
async get(id: string): Promise<any> {
const t = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: { select: { groups: true, admins: true, messages: true, rules: true, syncRoutes: true } },
tenantBots: { include: { account: { select: { id: true, jid: true, displayName: true, status: true, createdAt: true } } } },
admins: { select: { id: true, email: true, role: true, createdAt: true } },
},
});
if (!t) throw new NotFoundException('Tenant not found');
return {
id: t.id,
slug: t.slug,
name: t.name,
isActive: t.isActive,
isForwardingPaused: t.isForwardingPaused,
createdAt: t.createdAt.toISOString(),
stats: {
groups: t._count.groups,
admins: t._count.admins,
messages: t._count.messages,
rules: t._count.rules,
routes: t._count.syncRoutes,
},
bot: t.tenantBots[0]
? { id: t.tenantBots[0].account.id, jid: t.tenantBots[0].account.jid, displayName: t.tenantBots[0].account.displayName, status: t.tenantBots[0].account.status, linkedSince: t.tenantBots[0].createdAt.toISOString() }
: null,
admins: t.admins,
};
}
async update(id: string, data: { isActive?: boolean; isForwardingPaused?: boolean }): Promise<any> {
const t = await this.prisma.tenant.findUnique({ where: { id } });
if (!t) throw new NotFoundException('Tenant not found');
const updated = await this.prisma.tenant.update({
where: { id },
data: {
...(data.isActive !== undefined && { isActive: data.isActive }),
...(data.isForwardingPaused !== undefined && { isForwardingPaused: data.isForwardingPaused }),
},
});
return {
id: updated.id,
slug: updated.slug,
name: updated.name,
isActive: updated.isActive,
isForwardingPaused: updated.isForwardingPaused,
};
}
}