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
+38 -1
View File
@@ -73,7 +73,44 @@
"Bash(grep -v \"^$\")", "Bash(grep -v \"^$\")",
"Bash(npm info *)", "Bash(npm info *)",
"Bash(pnpm --filter @tower/search test)", "Bash(pnpm --filter @tower/search test)",
"Bash(pnpm --filter @tower/search build)" "Bash(pnpm --filter @tower/search build)",
"Bash(pnpm --filter @tower/worker test -- --testPathPattern approval)",
"Bash(pnpm --filter @tower/worker test -- approval)",
"Bash(xargs ls -la)",
"Bash(xargs ls)",
"Bash(pnpm --filter @tower/worker test -- --testPathPattern=index.processor)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('dependencies',{}\\), indent=2\\)\\)\")",
"Bash(pnpm --filter @tower/worker test index.processor)",
"Bash(pnpm --filter @tower/api test)",
"Bash(pnpm --filter @tower/api build)",
"Bash(pnpm --filter @tower/api test -- search.controller.spec.ts)",
"Bash(pnpm --filter @tower/api test -- search.service.spec.ts)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=groups.service)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=groups)",
"Bash(pnpm --filter @tower/api exec jest --testPathPattern=groups)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=routes.service)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=\"routes/routes.service\")",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=\"routes\")",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=routes)",
"Bash(pnpm --filter @tower/api exec jest --testPathPattern=routes)",
"Bash(pnpm --filter @tower/web test -- --testPathPattern=app/page)",
"Bash(pnpm --filter @tower/web test -- --testPathPattern 'app/page')",
"Bash(pnpm --filter @tower/web test -- --testPathPattern=search/page)",
"Bash(pnpm --filter @tower/web test -- apps/web/app/search/page.test.tsx)",
"Bash(pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager)",
"Bash(git -C /Users/maaz/Documents/insignia-work/tower status)",
"Bash(git -C /Users/maaz/Documents/insignia-work/tower show --stat HEAD)",
"Bash(pnpm -r build)",
"Bash(pnpm exec *)",
"Bash(pnpm add *)",
"Bash(mkdir -p /Users/maaz/Documents/insignia-work/tower/apps/web/app/api/accounts/\\\\[id\\\\]/qr)",
"Bash(pnpm jest *)",
"Bash(cd /Users/maaz/Documents/insignia-work/tower/apps/api && pnpm test --no-coverage 2>&1 | tail -15 && cd ../worker && pnpm test --no-coverage 2>&1 | tail -15 && cd ../web && pnpm test --no-coverage 2>&1 | tail -15)",
"Read(//Users/maaz/Documents/insignia-work/**)",
"Bash(psql postgresql://tower:tower_dev@localhost:5433/tower_dev -c \"SELECT id, name, platform, \\\\\"accountId\\\\\" FROM \\\\\"Group\\\\\" LIMIT 10;\")"
],
"additionalDirectories": [
"/Users/maaz/Documents/insignia-work/tower/apps/web/app/api/accounts/[id]"
] ]
} }
} }
+22 -1
View File
@@ -20,4 +20,25 @@ LOG_LEVEL=debug
# WhatsApp # WhatsApp
WHATSAPP_SESSION_PATH=./sessions WHATSAPP_SESSION_PATH=./sessions
TOWER_ADMIN_JIDS=
# TOWER Portal (used by worker command-handler to construct onboarding links)
TOWER_PORTAL_BASE_URL=http://localhost:3000
# Auth
BCRYPT_ROUNDS=10
JWT_EXPIRES_IN=7d
MEMBER_JWT_EXPIRES_IN=30d
# Default seed admin (only used in dev)
SEED_ADMIN_EMAIL=admin@tower.local
SEED_ADMIN_PASSWORD=tower_dev_password
# SMTP (optional — leave SMTP_HOST blank to skip email notifications).
# Defaults shown are for Ethereal (https://ethereal.email), a fake SMTP for testing.
# Generate fresh creds at https://ethereal.email/create and paste them below.
SMTP_HOST=smtp.ethereal.email
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=garrett.padberg@ethereal.email
SMTP_PASS=c93RRyQMb9WFysYZ6q
SMTP_FROM=TOWER <noreply@tower.local>
+51
View File
@@ -0,0 +1,51 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const groups = await prisma.group.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
select: {
id: true,
name: true,
platformId: true,
claimStatus: true,
accountId: true,
tenantId: true,
claimExpiresAt: true,
createdAt: true,
},
});
console.log(`Found ${groups.length} groups:`);
for (const g of groups) {
console.log(JSON.stringify(g, null, 2));
}
const accounts = await prisma.account.findMany({
where: { isBot: true },
select: { id: true, jid: true, status: true, displayName: true, createdAt: true },
});
console.log(`\nFound ${accounts.length} bot accounts:`);
for (const a of accounts) {
console.log(JSON.stringify(a, null, 2));
}
const audits = await prisma.auditEvent.findMany({
where: { action: { in: ['GROUP_PENDING_CLAIM', 'BOT_PAIRED', 'BOT_INITIATED'] } },
orderBy: { createdAt: 'desc' },
take: 10,
select: { action: true, resourceId: true, createdAt: true, payload: true, tenantId: true },
});
console.log(`\nFound ${audits.length} relevant audit events:`);
for (const a of audits) {
console.log(JSON.stringify(a, null, 2));
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+28
View File
@@ -0,0 +1,28 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const total = await prisma.group.count({ where: { claimStatus: 'PENDING_CLAIM' } });
const perTenant = await prisma.tenant.findMany({
where: { groups: { some: { claimStatus: 'PENDING_CLAIM' } } },
select: {
id: true,
name: true,
slug: true,
_count: { select: { groups: { where: { claimStatus: 'PENDING_CLAIM' } } } },
},
});
const sample = await prisma.group.findMany({
where: { claimStatus: 'PENDING_CLAIM' },
take: 3,
select: { id: true, name: true, platform: true, platformId: true, createdAt: true },
});
console.log('TOTAL PENDING_CLAIM:', total);
console.log('PER TENANT:', JSON.stringify(perTenant, null, 2));
console.log('SAMPLE:', JSON.stringify(sample, null, 2));
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const p = new PrismaClient();
(async () => {
const tenants = await p.tenant.findMany({ select: { id: true, slug: true, name: true } });
const admins = await p.admin.findMany({ select: { id: true, email: true, role: true, tenant: { select: { slug: true } } } });
console.log('TENANTS:', JSON.stringify(tenants, null, 2));
console.log('ADMINS:', JSON.stringify(admins, null, 2));
await p.$disconnect();
})();
+17 -1
View File
@@ -6,18 +6,31 @@
"dev": "nest start --watch", "dev": "nest start --watch",
"start": "node dist/main", "start": "node dist/main",
"test": "jest", "test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json",
"db:seed": "ts-node prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",
"@tower/config": "workspace:*", "@tower/config": "workspace:*",
"@tower/logger": "workspace:*", "@tower/logger": "workspace:*",
"@tower/search": "workspace:*", "@tower/search": "workspace:*",
"@tower/types": "workspace:*", "@tower/types": "workspace:*",
"bcryptjs": "^2.4.3",
"bullmq": "^5.0.0",
"ioredis": "^5.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0" "rxjs": "^7.8.0"
@@ -26,13 +39,16 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0", "@nestjs/testing": "^11.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/jest": "^29.0.0", "@types/jest": "^29.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"jest": "^29.0.0", "jest": "^29.0.0",
"prisma": "^6.0.0", "prisma": "^6.0.0",
"ts-jest": "^29.0.0", "ts-jest": "^29.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }
@@ -0,0 +1,101 @@
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('OWNER', 'ADMIN', 'VIEWER');
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('ADMIN', 'SYSTEM', 'ADAPTER');
-- CreateTable: Tenant (must come first — referenced by everything else)
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
-- Insert default tenant so existing rows can be backfilled
INSERT INTO "Tenant" ("id", "slug", "name", "settings", "updatedAt")
VALUES ('default', 'default', 'Default Tenant', '{}', CURRENT_TIMESTAMP);
-- Add tenantId columns as nullable first
ALTER TABLE "Account" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "Approval" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "ConsentRecord" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "Group" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "Message" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "SyncRoute" ADD COLUMN "tenantId" TEXT;
-- Backfill all existing rows to the default tenant
UPDATE "Account" SET "tenantId" = 'default';
UPDATE "Approval" SET "tenantId" = 'default';
UPDATE "ConsentRecord" SET "tenantId" = 'default';
UPDATE "Group" SET "tenantId" = 'default';
UPDATE "Message" SET "tenantId" = 'default';
UPDATE "SyncRoute" SET "tenantId" = 'default';
-- Now enforce NOT NULL
ALTER TABLE "Account" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "Approval" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "ConsentRecord" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "Group" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "Message" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "SyncRoute" ALTER COLUMN "tenantId" SET NOT NULL;
-- CreateTable: Admin (new — NOT NULL tenantId is fine, we'll seed default admin later)
CREATE TABLE "Admin" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'ADMIN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
);
-- CreateTable: AuditEvent (new — NOT NULL tenantId is fine)
CREATE TABLE "AuditEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"resourceType" TEXT NOT NULL,
"resourceId" TEXT NOT NULL,
"payload" JSONB NOT NULL DEFAULT '{}',
"traceId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Admin_tenantId_idx" ON "Admin"("tenantId");
CREATE UNIQUE INDEX "Admin_tenantId_email_key" ON "Admin"("tenantId", "email");
CREATE INDEX "AuditEvent_tenantId_createdAt_idx" ON "AuditEvent"("tenantId", "createdAt");
CREATE INDEX "AuditEvent_resourceType_resourceId_idx" ON "AuditEvent"("resourceType", "resourceId");
CREATE INDEX "Account_tenantId_idx" ON "Account"("tenantId");
CREATE INDEX "Approval_tenantId_idx" ON "Approval"("tenantId");
CREATE INDEX "ConsentRecord_tenantId_idx" ON "ConsentRecord"("tenantId");
CREATE INDEX "Group_tenantId_idx" ON "Group"("tenantId");
CREATE INDEX "Message_tenantId_idx" ON "Message"("tenantId");
CREATE INDEX "SyncRoute_tenantId_idx" ON "SyncRoute"("tenantId");
-- AddForeignKey
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Message" ADD CONSTRAINT "Message_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Approval" ADD CONSTRAINT "Approval_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SyncRoute" ADD CONSTRAINT "SyncRoute_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ConsentRecord" ADD CONSTRAINT "ConsentRecord_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Account" ADD CONSTRAINT "Account_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,201 @@
-- CreateEnum
CREATE TYPE "GroupClaimStatus" AS ENUM ('PENDING_CLAIM', 'CLAIMED', 'RELEASED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "ConsentScope" AS ENUM ('INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY');
-- CreateEnum
CREATE TYPE "ConsentStatus" AS ENUM ('GRANTED', 'REVOKED');
-- CreateEnum
CREATE TYPE "MemberOptOutReason" AS ENUM ('STOP_KEYWORD', 'SELF_PORTAL', 'ADMIN_ACTION');
-- AlterEnum
ALTER TYPE "AccountStatus" ADD VALUE 'PAIRING';
-- AlterEnum
ALTER TYPE "ActorType" ADD VALUE 'MEMBER';
-- DropForeignKey
ALTER TABLE "Account" DROP CONSTRAINT "Account_tenantId_fkey";
-- DropForeignKey
ALTER TABLE "Group" DROP CONSTRAINT "Group_tenantId_fkey";
-- DropIndex
DROP INDEX "Account_tenantId_idx";
-- DropIndex
DROP INDEX "ConsentRecord_groupId_memberJid_consentType_key";
-- DropIndex
DROP INDEX "ConsentRecord_tenantId_idx";
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "tenantId",
ADD COLUMN "isBot" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "pairingExpiresAt" TIMESTAMP(3),
ADD COLUMN "pairingToken" TEXT;
-- AlterTable
ALTER TABLE "ConsentRecord" DROP COLUMN "consentType",
DROP COLUMN "grantedAt",
DROP COLUMN "memberJid",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "effectiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "policyVersion" TEXT NOT NULL,
ADD COLUMN "proofEventId" TEXT NOT NULL,
ADD COLUMN "retentionDays" INTEGER NOT NULL DEFAULT 90,
ADD COLUMN "scopes" "ConsentScope"[],
ADD COLUMN "status" "ConsentStatus" NOT NULL DEFAULT 'GRANTED',
ADD COLUMN "userId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "claimExpiresAt" TIMESTAMP(3),
ADD COLUMN "claimStatus" "GroupClaimStatus" NOT NULL DEFAULT 'PENDING_CLAIM',
ADD COLUMN "claimedAt" TIMESTAMP(3),
ADD COLUMN "claimedByAdminId" TEXT,
ALTER COLUMN "tenantId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Message" ADD COLUMN "senderTowerUserId" TEXT;
-- CreateTable
CREATE TABLE "TenantBot" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TenantBot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TowerUser" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"phoneHash" TEXT NOT NULL,
"jid" TEXT NOT NULL,
"displayName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TowerUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TowerSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TowerSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberOptOut" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"groupId" TEXT,
"reason" "MemberOptOutReason" NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MemberOptOut_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TenantBot_accountId_idx" ON "TenantBot"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "TenantBot_tenantId_accountId_key" ON "TenantBot"("tenantId", "accountId");
-- CreateIndex
CREATE INDEX "TowerUser_phoneHash_idx" ON "TowerUser"("phoneHash");
-- CreateIndex
CREATE INDEX "TowerUser_tenantId_idx" ON "TowerUser"("tenantId");
-- CreateIndex
CREATE INDEX "TowerUser_jid_idx" ON "TowerUser"("jid");
-- CreateIndex
CREATE UNIQUE INDEX "TowerUser_tenantId_phoneHash_key" ON "TowerUser"("tenantId", "phoneHash");
-- CreateIndex
CREATE UNIQUE INDEX "TowerSession_tokenHash_key" ON "TowerSession"("tokenHash");
-- CreateIndex
CREATE INDEX "TowerSession_userId_idx" ON "TowerSession"("userId");
-- CreateIndex
CREATE INDEX "TowerSession_expiresAt_idx" ON "TowerSession"("expiresAt");
-- CreateIndex
CREATE INDEX "MemberOptOut_tenantId_userId_idx" ON "MemberOptOut"("tenantId", "userId");
-- CreateIndex
CREATE INDEX "MemberOptOut_groupId_idx" ON "MemberOptOut"("groupId");
-- CreateIndex
CREATE INDEX "MemberOptOut_userId_idx" ON "MemberOptOut"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_pairingToken_key" ON "Account"("pairingToken");
-- CreateIndex
CREATE INDEX "Account_isBot_idx" ON "Account"("isBot");
-- CreateIndex
CREATE INDEX "Account_status_idx" ON "Account"("status");
-- CreateIndex
CREATE INDEX "ConsentRecord_tenantId_groupId_userId_idx" ON "ConsentRecord"("tenantId", "groupId", "userId");
-- CreateIndex
CREATE INDEX "ConsentRecord_status_idx" ON "ConsentRecord"("status");
-- CreateIndex
CREATE INDEX "ConsentRecord_userId_idx" ON "ConsentRecord"("userId");
-- CreateIndex
CREATE INDEX "Group_claimStatus_idx" ON "Group"("claimStatus");
-- CreateIndex
CREATE INDEX "Message_senderTowerUserId_idx" ON "Message"("senderTowerUserId");
-- AddForeignKey
ALTER TABLE "TenantBot" ADD CONSTRAINT "TenantBot_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TenantBot" ADD CONSTRAINT "TenantBot_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_claimedByAdminId_fkey" FOREIGN KEY ("claimedByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderTowerUserId_fkey" FOREIGN KEY ("senderTowerUserId") REFERENCES "TowerUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TowerUser" ADD CONSTRAINT "TowerUser_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TowerSession" ADD CONSTRAINT "TowerSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentRecord" ADD CONSTRAINT "ConsentRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberOptOut" ADD CONSTRAINT "MemberOptOut_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberOptOut" ADD CONSTRAINT "MemberOptOut_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "OtpChallenge" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"jid" TEXT NOT NULL,
"phoneHash" TEXT NOT NULL,
"code" TEXT NOT NULL,
"scopes" "ConsentScope"[],
"retentionDays" INTEGER NOT NULL DEFAULT 90,
"policyVersion" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"sentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OtpChallenge_tenantId_jid_idx" ON "OtpChallenge"("tenantId", "jid");
-- CreateIndex
CREATE INDEX "OtpChallenge_expiresAt_idx" ON "OtpChallenge"("expiresAt");
-- CreateIndex
CREATE INDEX "OtpChallenge_sentAt_idx" ON "OtpChallenge"("sentAt");
@@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "isForwardingPaused" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "SuperAdmin" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SuperAdmin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SuperAdmin_email_key" ON "SuperAdmin"("email");
@@ -0,0 +1,32 @@
-- CreateEnum
CREATE TYPE "RuleMatchType" AS ENUM ('HASHTAG', 'PREFIX', 'REACTION_EMOJI');
-- CreateEnum
CREATE TYPE "RuleAction" AS ENUM ('FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT');
-- CreateTable
CREATE TABLE "TenantRule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"matchType" "RuleMatchType" NOT NULL,
"matchValue" TEXT NOT NULL,
"action" "RuleAction" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TenantRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TenantRule_tenantId_isActive_idx" ON "TenantRule"("tenantId", "isActive");
-- CreateIndex
CREATE INDEX "TenantRule_tenantId_matchType_idx" ON "TenantRule"("tenantId", "matchType");
-- CreateIndex
CREATE UNIQUE INDEX "TenantRule_tenantId_matchType_matchValue_key" ON "TenantRule"("tenantId", "matchType", "matchValue");
-- AddForeignKey
ALTER TABLE "TenantRule" ADD CONSTRAINT "TenantRule_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- CreateTable: GroupClaimToken
CREATE TABLE "GroupClaimToken" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"creatorJid" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GroupClaimToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable: GroupAccess
CREATE TABLE "GroupAccess" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"grantedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GroupAccess_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GroupClaimToken_token_key" ON "GroupClaimToken"("token");
CREATE INDEX "GroupClaimToken_expiresAt_idx" ON "GroupClaimToken"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "GroupAccess_groupId_tenantId_key" ON "GroupAccess"("groupId", "tenantId");
CREATE INDEX "GroupAccess_tenantId_idx" ON "GroupAccess"("tenantId");
-- AddForeignKey
ALTER TABLE "GroupClaimToken" ADD CONSTRAINT "GroupClaimToken_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GroupAccess" ADD CONSTRAINT "GroupAccess_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GroupAccess" ADD CONSTRAINT "GroupAccess_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+369 -51
View File
@@ -7,45 +7,211 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model Group { // ============================================================================
id String @id @default(cuid()) // Tenancy
platform String // ============================================================================
platformId String
name String
description String?
isActive Boolean @default(true)
accountId String?
account Account? @relation(fields: [accountId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
isActive Boolean @default(true)
isForwardingPaused Boolean @default(false)
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
admins Admin[]
tenantBots TenantBot[]
groups Group[]
messages Message[] messages Message[]
syncRoutesFrom SyncRoute[] @relation("sourceGroup") approvals Approval[]
syncRoutesTo SyncRoute[] @relation("targetGroup") syncRoutes SyncRoute[]
consentRecords ConsentRecord[] consentRecords ConsentRecord[]
memberOptOuts MemberOptOut[]
towerUsers TowerUser[]
auditEvents AuditEvent[]
rules TenantRule[]
groupAccesses GroupAccess[]
}
@@unique([platform, platformId]) enum AdminRole {
OWNER
ADMIN
VIEWER
}
model Admin {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
email String
passwordHash String
role AdminRole @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
claimedGroups Group[] @relation("claimer")
@@unique([tenantId, email])
@@index([tenantId])
}
model SuperAdmin {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ActorType {
ADMIN
SYSTEM
ADAPTER
MEMBER
}
model AuditEvent {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
actorType ActorType
actorId String?
action String
resourceType String
resourceId String
payload Json @default("{}")
traceId String?
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
@@index([resourceType, resourceId])
}
// ============================================================================
// WhatsApp accounts (Phase 2B: bots only, tenant-less, accessed via TenantBot)
// ============================================================================
enum AccountStatus {
ACTIVE
DISCONNECTED
BANNED
PAIRING
}
model Account {
id String @id @default(cuid())
// tenantId REMOVED in Phase 2B — bots are global, access scoped via TenantBot
platform String
jid String
sessionPath String
displayName String?
status AccountStatus @default(ACTIVE)
qrCode String?
isBot Boolean @default(true)
pairingToken String? @unique
pairingExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenants TenantBot[]
groups Group[]
@@unique([platform, jid])
@@index([isBot])
@@index([status])
}
// Many-to-many: which tenants may claim groups from which bot.
// Phase 2B ships with implicit "all tenants" (UI auto-grants on first claim),
// but the table is wired so multi-bot + restricted sharing works in later phases.
model TenantBot {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@unique([tenantId, accountId])
@@index([accountId]) @@index([accountId])
} }
// ============================================================================
// Groups + claim lifecycle
// ============================================================================
enum GroupClaimStatus {
PENDING_CLAIM
CLAIMED
RELEASED
EXPIRED
}
model Group {
id String @id @default(cuid())
// tenantId nullable: null while PENDING_CLAIM/EXPIRED, set once CLAIMED
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id])
platform String
platformId String
name String
description String?
isActive Boolean @default(true)
accountId String?
account Account? @relation(fields: [accountId], references: [id])
claimStatus GroupClaimStatus @default(PENDING_CLAIM)
claimedAt DateTime?
claimedByAdminId String?
claimedByAdmin Admin? @relation("claimer", fields: [claimedByAdminId], references: [id])
claimExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
syncRoutesTo SyncRoute[] @relation("targetGroup")
consents ConsentRecord[]
claimTokens GroupClaimToken[]
groupAccesses GroupAccess[]
@@unique([platform, platformId])
@@index([accountId])
@@index([tenantId])
@@index([claimStatus])
}
// ============================================================================
// Message ingest + approval
// ============================================================================
model Message { model Message {
id String @id @default(cuid()) id String @id @default(cuid())
platform String tenantId String
platformMsgId String tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String platform String
sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) platformMsgId String
senderJid String sourceGroupId String
senderName String? sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
content String senderJid String
mediaUrl String? senderName String?
tags String[] senderTowerUserId String?
status MessageStatus @default(PENDING) senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
createdAt DateTime @default(now()) content String
updatedAt DateTime @updatedAt mediaUrl String?
tags String[]
status MessageStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
approval Approval? approval Approval?
@@unique([platform, platformMsgId]) @@unique([platform, platformMsgId])
@@index([tenantId])
@@index([senderTowerUserId])
} }
enum MessageStatus { enum MessageStatus {
@@ -58,12 +224,16 @@ enum MessageStatus {
model Approval { model Approval {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
messageId String @unique messageId String @unique
message Message @relation(fields: [messageId], references: [id]) message Message @relation(fields: [messageId], references: [id])
adminId String adminId String
decision ApprovalDecision decision ApprovalDecision
notes String? notes String?
decidedAt DateTime @default(now()) decidedAt DateTime @default(now())
@@index([tenantId])
} }
enum ApprovalDecision { enum ApprovalDecision {
@@ -73,6 +243,8 @@ enum ApprovalDecision {
model SyncRoute { model SyncRoute {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String sourceGroupId String
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
targetGroupId String targetGroupId String
@@ -81,37 +253,183 @@ model SyncRoute {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([sourceGroupId, targetGroupId]) @@unique([sourceGroupId, targetGroupId])
@@index([tenantId])
}
// ============================================================================
// Group claiming + sharing
// ============================================================================
model GroupClaimToken {
id String @id @default(cuid())
groupId String
group Group @relation(fields: [groupId], references: [id])
token String @unique
creatorJid String
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
@@index([expiresAt])
}
model GroupAccess {
id String @id @default(cuid())
groupId String
group Group @relation(fields: [groupId], references: [id])
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
grantedBy String
createdAt DateTime @default(now())
@@unique([groupId, tenantId])
@@index([tenantId])
}
// ============================================================================
// Member onboarding (Phase 2B)
// ============================================================================
enum ConsentScope {
INGEST
ARCHIVE
REPLICATE
DISPLAY
}
enum ConsentStatus {
GRANTED
REVOKED
}
enum MemberOptOutReason {
STOP_KEYWORD
SELF_PORTAL
ADMIN_ACTION
}
// Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET).
model TowerUser {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
phoneHash String
jid String
displayName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
consents ConsentRecord[]
optOuts MemberOptOut[]
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@index([tenantId])
@@index([jid])
}
model TowerSession {
id String @id @default(cuid())
userId String
user TowerUser @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
} }
model ConsentRecord { model ConsentRecord {
id String @id @default(cuid()) id String @id @default(cuid())
groupId String tenantId String
group Group @relation(fields: [groupId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
memberJid String groupId String
consentType String group Group @relation(fields: [groupId], references: [id])
grantedAt DateTime @default(now()) userId String
revokedAt DateTime? user TowerUser @relation(fields: [userId], references: [id])
scopes ConsentScope[]
retentionDays Int @default(90)
policyVersion String
status ConsentStatus @default(GRANTED)
proofEventId String
effectiveAt DateTime @default(now())
revokedAt DateTime?
createdAt DateTime @default(now())
@@unique([groupId, memberJid, consentType]) @@index([tenantId, groupId, userId])
@@index([status])
@@index([userId])
} }
enum AccountStatus { model MemberOptOut {
ACTIVE id String @id @default(cuid())
DISCONNECTED tenantId String
BANNED tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
groupId String?
reason MemberOptOutReason
notes String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([groupId])
@@index([userId])
} }
model Account { model OtpChallenge {
id String @id @default(cuid()) id String @id @default(cuid())
platform String tenantId String
jid String jid String
sessionPath String phoneHash String
displayName String? code String
status AccountStatus @default(ACTIVE) scopes ConsentScope[]
qrCode String? retentionDays Int @default(90)
groups Group[] policyVersion String
createdAt DateTime @default(now()) groupId String
updatedAt DateTime @updatedAt expiresAt DateTime
consumedAt DateTime?
sentAt DateTime?
createdAt DateTime @default(now())
@@unique([platform, jid]) @@index([tenantId, jid])
@@index([expiresAt])
@@index([sentAt])
}
// ============================================================================
// Tenant Rules Engine
// ============================================================================
enum RuleMatchType {
HASHTAG
PREFIX
REACTION_EMOJI
}
enum RuleAction {
FLAG
AUTO_APPROVE
SKIP
REJECT
}
model TenantRule {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive])
@@index([tenantId, matchType])
} }
+59
View File
@@ -0,0 +1,59 @@
/* eslint-disable no-console */
import { PrismaClient, AdminRole } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
const DEFAULT_TENANT_SLUG = 'default';
const SEED_ADMIN_EMAIL = process.env['SEED_ADMIN_EMAIL'] ?? 'admin@tower.local';
const SEED_ADMIN_PASSWORD = process.env['SEED_ADMIN_PASSWORD'] ?? 'tower_dev_password';
const SUPER_ADMIN_EMAIL = process.env['SUPER_ADMIN_EMAIL'] ?? 'super@tower.local';
const SUPER_ADMIN_PASSWORD = process.env['SUPER_ADMIN_PASSWORD'] ?? 'super_dev_password';
const BCRYPT_ROUNDS = Number(process.env['BCRYPT_ROUNDS'] ?? '10');
async function main(): Promise<void> {
const tenant = await prisma.tenant.upsert({
where: { slug: DEFAULT_TENANT_SLUG },
update: {},
create: { slug: DEFAULT_TENANT_SLUG, name: 'Default Tenant' },
});
const passwordHash = await bcrypt.hash(SEED_ADMIN_PASSWORD, BCRYPT_ROUNDS);
const admin = await prisma.admin.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: SEED_ADMIN_EMAIL } },
update: { passwordHash, role: AdminRole.OWNER },
create: {
tenantId: tenant.id,
email: SEED_ADMIN_EMAIL,
passwordHash,
role: AdminRole.OWNER,
},
});
const superPasswordHash = await bcrypt.hash(SUPER_ADMIN_PASSWORD, BCRYPT_ROUNDS);
const superAdmin = await prisma.superAdmin.upsert({
where: { email: SUPER_ADMIN_EMAIL },
update: { passwordHash: superPasswordHash },
create: {
email: SUPER_ADMIN_EMAIL,
passwordHash: superPasswordHash,
name: 'Super Admin',
},
});
console.log('Seed complete:');
console.log(` Tenant: ${tenant.slug} (${tenant.id})`);
console.log(` Admin: ${admin.email} (${admin.id}) role=${admin.role}`);
console.log(` SuperAdmin: ${superAdmin.email} (${superAdmin.id})`);
console.log(` Password: ${SEED_ADMIN_PASSWORD} (dev only — change for production)`);
console.log(` Super pwd: ${SUPER_ADMIN_PASSWORD} (dev only — change for production)`);
}
main()
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+18 -2
View File
@@ -1,21 +1,37 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { AuditModule } from './modules/audit/audit.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { SearchModule } from './modules/search/search.module'; import { SearchModule } from './modules/search/search.module';
import { GroupsModule } from './modules/groups/groups.module'; import { GroupsModule } from './modules/groups/groups.module';
import { RoutesModule } from './modules/routes/routes.module'; import { RoutesModule } from './modules/routes/routes.module';
import { AccountsModule } from './modules/accounts/accounts.module'; import { BotModule } from './modules/bot/bot.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { MyModule } from './modules/my/my.module';
import { MessagesModule } from './modules/messages/messages.module';
import { RulesModule } from './modules/rules/rules.module';
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
import { TenantModule } from './modules/tenant/tenant.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, PrismaModule,
AuthModule,
AuditModule,
HealthModule, HealthModule,
SearchModule, SearchModule,
GroupsModule, GroupsModule,
RoutesModule, RoutesModule,
AccountsModule, BotModule,
OnboardingModule,
MyModule,
MessagesModule,
RulesModule,
SuperAdminModule,
TenantModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+13
View File
@@ -0,0 +1,13 @@
import { AdminRole } from '@tower/types';
export interface TenantContext {
tenantId: string;
adminId: string | null;
role: AdminRole | null;
}
export const emptyTenantContext: TenantContext = {
tenantId: '',
adminId: null,
role: null,
};
+17 -1
View File
@@ -1,9 +1,25 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory, Reflector } from '@nestjs/core';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { JwtAuthGuard } from './modules/auth/jwt-auth.guard';
import { validateEnv } from '@tower/config';
async function bootstrap() { async function bootstrap() {
validateEnv();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)));
const port = process.env['API_PORT'] ?? 3001; const port = process.env['API_PORT'] ?? 3001;
await app.listen(port); await app.listen(port);
console.log(`TOWER API running on port ${port}`); console.log(`TOWER API running on port ${port}`);
@@ -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 { Test, TestingModule } from '@nestjs/testing';
import { GroupsController } from './groups.controller'; import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service'; 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 = [ 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', () => { describe('GroupsController', () => {
let controller: GroupsController; let controller: GroupsController;
@@ -22,9 +36,29 @@ describe('GroupsController', () => {
controller = module.get<GroupsController>(GroupsController); controller = module.get<GroupsController>(GroupsController);
}); });
it('returns groups from service', async () => { it('list() delegates to service', async () => {
const result = await controller.list(); const result = await controller.list(ctx);
expect(result).toEqual(mockGroups); 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 { 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 { export class GroupsController {
constructor(private readonly groupsService: GroupsService) {} constructor(private readonly groupsService: GroupsService) {}
@Get() @Get('groups')
list() { list(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.list(); 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 { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
const mockGroups = [ const mockGroup = { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' };
{ 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 },
];
describe('GroupsService', () => { describe('GroupsService', () => {
let service: GroupsService; let service: GroupsService;
const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } }; const mockPrisma: any = {
group: {
beforeEach(() => { findMany: jest.fn().mockResolvedValue([mockGroup]),
jest.clearAllMocks(); 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 () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
GroupsService, GroupsService,
{ provide: PrismaService, useValue: mockPrisma }, { provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
], ],
}).compile(); }).compile();
service = module.get<GroupsService>(GroupsService); service = module.get<GroupsService>(GroupsService);
}); });
it('returns all groups ordered by name', async () => { describe('list', () => {
const result = await service.list(); it('returns groups for the given tenant including shared groups', async () => {
expect(result).toHaveLength(2); const result = await service.list('tnt-A');
expect(result[0].name).toBe('Alpha'); expect(result).toHaveLength(1);
expect(mockPrisma.group.findMany).toHaveBeenCalledWith({ expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
orderBy: { name: 'asc' }, expect.objectContaining({
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true }, 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 { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { randomBytes } from 'crypto';
export interface GroupSummary { export interface GroupSummary {
id: string; id: string;
@@ -8,16 +11,257 @@ export interface GroupSummary {
platformId: string; platformId: string;
isActive: boolean; isActive: boolean;
accountId: string | null; accountId: string | null;
tenantId: string | null;
} }
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
@Injectable() @Injectable()
export class GroupsService { 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({ return this.prisma.group.findMany({
where: {
OR: [
{ tenantId },
{ groupAccesses: { some: { tenantId } } },
],
},
orderBy: { name: 'asc' }, 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 { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/public.decorator';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@Public()
@Get() @Get()
check() { check() {
return { 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 { Test, TestingModule } from '@nestjs/testing';
import { RoutesController } from './routes.controller'; import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service'; 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 = { const mockRoute = {
id: 'rt_1', id: 'rt_1',
@@ -12,6 +15,7 @@ const mockRoute = {
const mockService = { const mockService = {
list: jest.fn().mockResolvedValue([mockRoute]), list: jest.fn().mockResolvedValue([mockRoute]),
create: 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), remove: jest.fn().mockResolvedValue(undefined),
}; };
@@ -27,25 +31,31 @@ describe('RoutesController', () => {
controller = module.get<RoutesController>(RoutesController); controller = module.get<RoutesController>(RoutesController);
}); });
it('list() delegates to service with no filter', async () => { it('list() delegates to service with tenantId and no filter', async () => {
const result = await controller.list(undefined); const result = await controller.list(ctx, undefined);
expect(result).toEqual([mockRoute]); expect(result).toEqual([mockRoute]);
expect(mockService.list).toHaveBeenCalledWith(undefined); expect(mockService.list).toHaveBeenCalledWith('tnt_1', undefined);
}); });
it('list() passes sourceGroupId filter to service', async () => { it('list() passes sourceGroupId filter to service along with tenantId', async () => {
await controller.list('grp_1'); await controller.list(ctx, 'grp_1');
expect(mockService.list).toHaveBeenCalledWith('grp_1'); expect(mockService.list).toHaveBeenCalledWith('tnt_1', 'grp_1');
}); });
it('create() extracts body fields and delegates to service', async () => { it('create() extracts body fields and delegates to service with tenantId', async () => {
const result = await controller.create({ sourceGroupId: 'grp_1', targetGroupId: 'grp_2' }); const result = await controller.create(ctx, { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' });
expect(result).toEqual(mockRoute); 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 () => { it('remove() delegates tenantId and id to service', async () => {
await controller.remove('rt_1'); await controller.remove(ctx, 'rt_1');
expect(mockService.remove).toHaveBeenCalledWith('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 { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { RoutesService } from './routes.service'; 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') @Controller('routes')
export class RoutesController { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}
@Get() @Get()
list(@Query('sourceGroupId') sourceGroupId?: string) { list(
return this.routesService.list(sourceGroupId); @CurrentTenantContext() ctx: TenantContext,
@Query('sourceGroupId') sourceGroupId?: string,
) {
return this.routesService.list(ctx.tenantId, sourceGroupId);
} }
@Post() @Post()
create(@Body() body: { sourceGroupId: string; targetGroupId: string }) { create(@CurrentTenantContext() ctx: TenantContext, @Body() body: CreateRouteDto) {
return this.routesService.create(body.sourceGroupId, body.targetGroupId); 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') @Delete(':id')
@HttpCode(204) @HttpCode(204)
remove(@Param('id') id: string) { async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.routesService.remove(id); await this.routesService.remove(ctx.tenantId, id);
} }
} }
@@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, NotFoundException } from '@nest
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
const mockRoute = { const mockRoute = {
id: 'rt_1', id: 'rt_1',
@@ -14,15 +15,32 @@ const mockRoute = {
targetGroup: { name: 'Beta' }, 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', () => { describe('RoutesService', () => {
let service: RoutesService; let service: RoutesService;
const mockPrisma = { const mockPrisma: any = {
$transaction: jest.fn((ops: any[]) => Promise.all(ops)),
syncRoute: { syncRoute: {
findMany: jest.fn().mockResolvedValue([mockRoute]), findMany: jest.fn().mockResolvedValue([mockRoute]),
findFirst: jest.fn().mockResolvedValue(mockRoute),
create: jest.fn().mockResolvedValue(mockRoute), create: jest.fn().mockResolvedValue(mockRoute),
delete: 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 () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -30,53 +48,51 @@ describe('RoutesService', () => {
providers: [ providers: [
RoutesService, RoutesService,
{ provide: PrismaService, useValue: mockPrisma }, { provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
], ],
}).compile(); }).compile();
service = module.get<RoutesService>(RoutesService); service = module.get<RoutesService>(RoutesService);
}); });
describe('list', () => { describe('list', () => {
it('returns all routes with group names', async () => { it('returns routes for tenant', async () => {
const result = await service.list(); const result = await service.list('tnt-1');
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].sourceGroup.name).toBe('Alpha'); expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith({ expect.objectContaining({ where: { tenantId: 'tnt-1' } }),
where: undefined, );
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
});
}); });
it('filters by sourceGroupId when provided', async () => { 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(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { sourceGroupId: 'grp_1' } }), expect.objectContaining({ where: { tenantId: 'tnt-1', sourceGroupId: 'grp_1' } }),
); );
}); });
}); });
describe('create', () => { describe('create', () => {
it('creates a route and returns it with group names', async () => { it('creates a route within the tenant and writes audit', async () => {
const result = await service.create('grp_1', 'grp_2'); const result = await service.create('tnt-1', 'grp_1', 'grp_2');
expect(result).toEqual(mockRoute); expect(result).toEqual(mockRoute);
expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({ expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({
data: { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' }, data: { tenantId: 'tnt-1', sourceGroupId: 'grp_1', targetGroupId: 'grp_2' },
include: { include: {
sourceGroup: { select: { name: true } }, sourceGroup: { select: { name: true } },
targetGroup: { 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 () => { 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 () => { 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 () => { it('throws ConflictException when route already exists (Prisma P2002)', async () => {
@@ -85,36 +101,87 @@ describe('RoutesService', () => {
clientVersion: '6.0.0', clientVersion: '6.0.0',
}); });
mockPrisma.syncRoute.create.mockRejectedValueOnce(p2002); 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 () => { 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 () => { it('throws BadRequestException when a group is not in this tenant', async () => {
const p2003 = new Prisma.PrismaClientKnownRequestError('Foreign key constraint', { mockPrisma.group.findFirst.mockResolvedValueOnce(null);
code: 'P2003', await expect(service.create('tnt-1', 'grp_1', 'grp_x')).rejects.toThrow(BadRequestException);
clientVersion: '6.0.0', });
}); });
mockPrisma.syncRoute.create.mockRejectedValueOnce(p2003);
await expect(service.create('grp_1', 'bad_grp')).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', () => { describe('remove', () => {
it('deletes a route by id', async () => { it('deletes a route and writes audit', async () => {
await service.remove('rt_1'); 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(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', { const p2025 = new Prisma.PrismaClientKnownRequestError('Record not found', {
code: 'P2025', code: 'P2025',
clientVersion: '6.0.0', clientVersion: '6.0.0',
}); });
mockPrisma.syncRoute.delete.mockRejectedValueOnce(p2025); 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 { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; 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 = { const routeInclude = {
sourceGroup: { select: { name: true } }, sourceGroup: { select: { name: true, tenantId: true } },
targetGroup: { select: { name: true } }, targetGroup: { select: { name: true, tenantId: true } },
} as const; } as const;
@Injectable() @Injectable()
export class RoutesService { 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({ return this.prisma.syncRoute.findMany({
where: sourceGroupId ? { sourceGroupId } : undefined, where: {
include: routeInclude, tenantId,
...(sourceGroupId ? { sourceGroupId } : {}),
},
include: {
sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } },
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async create(sourceGroupId: string, targetGroupId: string) { async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
if (!sourceGroupId || !targetGroupId) { if (!sourceGroupId || !targetGroupId) {
throw new BadRequestException('sourceGroupId and targetGroupId are required'); throw new BadRequestException('sourceGroupId and targetGroupId are required');
} }
if (sourceGroupId === targetGroupId) { if (sourceGroupId === targetGroupId) {
throw new BadRequestException('Source and target groups cannot be the same'); 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 { try {
return await this.prisma.syncRoute.create({ const route = await this.prisma.syncRoute.create({
data: { sourceGroupId, targetGroupId }, data: { tenantId, sourceGroupId, targetGroupId },
include: routeInclude, 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) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') { if (e.code === 'P2002') {
throw new ConflictException('A route between these groups already exists'); 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; 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 { try {
await this.prisma.syncRoute.delete({ where: { id } }); await this.prisma.syncRoute.delete({ where: { id } });
await this.audit.log({
tenantId,
action: AuditAction.ROUTE_DELETED,
resourceType: 'SyncRoute',
resourceId: id,
});
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException(`Route ${id} not found`); 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 { Test, TestingModule } from '@nestjs/testing';
import { SearchController } from './search.controller'; import { SearchController } from './search.controller';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import type { TenantContext } from '../../common/tenant-context';
const mockSearchService = { search: jest.fn() }; const mockSearchService = { search: jest.fn() };
const ctx: TenantContext = { tenantId: 'tnt_1', adminId: 'adm_1', role: 'OWNER' };
describe('SearchController', () => { describe('SearchController', () => {
let controller: SearchController; let controller: SearchController;
@@ -18,26 +21,28 @@ describe('SearchController', () => {
it('calls service with all parsed params', async () => { it('calls service with all parsed params', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 2, limit: 10, query: 'hello' }); mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 2, limit: 10, query: 'hello' });
await controller.search('hello', 'grp-1', 'important,event', '2', '10'); await controller.search(ctx, 'hello', 'grp-1', 'important,event', '2', '10');
expect(mockSearchService.search).toHaveBeenCalledWith('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 () => { it('defaults page to 1 and limit to 20 when not provided', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' }); mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
await controller.search(''); await controller.search(ctx, '');
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, undefined, 1, 20); expect(mockSearchService.search).toHaveBeenCalledWith('tnt_1', '', undefined, undefined, 1, 20);
}); });
it('returns the service result directly', async () => { it('returns the service result directly', async () => {
const expected = { hits: [{ id: 'msg-1' }], total: 1, page: 1, limit: 20, query: 'test' }; const expected = { hits: [{ id: 'msg-1' }], total: 1, page: 1, limit: 20, query: 'test' };
mockSearchService.search.mockResolvedValue(expected); mockSearchService.search.mockResolvedValue(expected);
const result = await controller.search('test'); const result = await controller.search(ctx, 'test');
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('splits tags on comma and trims whitespace', async () => { it('splits tags on comma and trims whitespace', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' }); mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
await controller.search('', undefined, ' important , event '); await controller.search(ctx, '', undefined, ' important , event ');
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, ['important', 'event'], 1, 20); expect(mockSearchService.search).toHaveBeenCalledWith('tnt_1', '', undefined, ['important', 'event'], 1, 20);
}); });
}); });
@@ -1,5 +1,7 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
@Controller('search') @Controller('search')
export class SearchController { export class SearchController {
@@ -7,6 +9,7 @@ export class SearchController {
@Get() @Get()
search( search(
@CurrentTenantContext() ctx: TenantContext,
@Query('q') q = '', @Query('q') q = '',
@Query('groupId') groupId?: string, @Query('groupId') groupId?: string,
@Query('tags') tags?: string, @Query('tags') tags?: string,
@@ -16,6 +19,6 @@ export class SearchController {
const tagList = tags const tagList = tags
? tags.split(',').map((t) => t.trim()).filter(Boolean) ? tags.split(',').map((t) => t.trim()).filter(Boolean)
: undefined; : 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); 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 () => { it('returns hits and total', async () => {
mockSearch.mockResolvedValue({ hits: [{ id: 'msg-1', content: 'hello' }], totalHits: 1 }); 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.hits).toHaveLength(1);
expect(result.total).toBe(1); expect(result.total).toBe(1);
expect(result.query).toBe('hello'); 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 }); mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('test'); await service.search('tnt-1', 'hello', 'grp-1');
expect(mockSearch).toHaveBeenCalledWith('test', expect.objectContaining({ filter: undefined }));
});
it('applies sourceGroupId filter', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1');
expect(mockSearch).toHaveBeenCalledWith( expect(mockSearch).toHaveBeenCalledWith(
'hello', '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 }); mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', undefined, ['#important']); await service.search('tnt-1', 'hello', undefined, ['#important']);
expect(mockSearch).toHaveBeenCalledWith( expect(mockSearch).toHaveBeenCalledWith(
'hello', '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 }); 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( expect(mockSearch).toHaveBeenCalledWith(
'hello', 'hello',
expect.objectContaining({ 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 () => { it('defaults page to 1 and hitsPerPage to 20', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 }); mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello'); await service.search('tnt-1', 'hello');
expect(mockSearch).toHaveBeenCalledWith( expect(mockSearch).toHaveBeenCalledWith(
'hello', 'hello',
expect.objectContaining({ page: 1, hitsPerPage: 20 }), 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 }); 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( expect(mockSearch).toHaveBeenCalledWith(
'hello', '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 }); 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( expect(mockSearch).toHaveBeenCalledWith(
'hello', 'hello',
expect.objectContaining({ page: 1, hitsPerPage: 100 }), expect.objectContaining({ page: 1, hitsPerPage: 100 }),
@@ -30,6 +30,7 @@ export class SearchService implements OnModuleInit {
} }
async search( async search(
tenantId: string,
query: string, query: string,
groupId?: string, groupId?: string,
tags?: 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 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 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 (groupId) filters.push(`sourceGroupId = "${SearchService.escapeFilterValue(groupId)}"`);
if (tags?.length) filters.push(...tags.map((t) => `tags = "${SearchService.escapeFilterValue(t)}"`)); if (tags?.length) filters.push(...tags.map((t) => `tags = "${SearchService.escapeFilterValue(t)}"`));
const result = await this.client.index<MeiliDocument>(MESSAGES_INDEX).search(query, { const result = await this.client.index<MeiliDocument>(MESSAGES_INDEX).search(query, {
filter: filters.length ? filters.join(' AND ') : undefined, filter: filters.join(' AND '),
page: safePage, page: safePage,
hitsPerPage: safeLimit, hitsPerPage: safeLimit,
sort: ['approvedAt:desc'], 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,
};
}
}
@@ -22,8 +22,14 @@ describe('PrismaService', () => {
}); });
it('creates and retrieves a Group, then cleans up', async () => { it('creates and retrieves a Group, then cleans up', async () => {
const tenant = await prisma.tenant.upsert({
where: { slug: 'default' },
update: {},
create: { id: 'tnt_test', slug: 'default', name: 'Default Tenant' },
});
const group = await prisma.group.create({ const group = await prisma.group.create({
data: { data: {
tenantId: tenant.id,
platform: 'whatsapp', platform: 'whatsapp',
platformId: `test-group-${Date.now()}@g.us`, platformId: `test-group-${Date.now()}@g.us`,
name: 'Test Group', name: 'Test Group',
+35
View File
@@ -0,0 +1,35 @@
import { Provider } from '@nestjs/common';
import { Queue } from 'bullmq';
import { ConfigService } from '@nestjs/config';
import { ForwardJobData, IndexJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection';
export const FORWARD_QUEUE = 'FORWARD_QUEUE';
export const INDEX_QUEUE = 'INDEX_QUEUE';
export function createForwardQueue(redisUrl: string): Queue<ForwardJobData> {
return new Queue<ForwardJobData>('tower-forward', {
connection: parseRedisUrl(redisUrl),
});
}
export function createIndexQueue(redisUrl: string): Queue<IndexJobData> {
return new Queue<IndexJobData>('tower-index', {
connection: parseRedisUrl(redisUrl),
});
}
export const forwardQueueProvider: Provider = {
provide: FORWARD_QUEUE,
useFactory: (config: ConfigService) =>
createForwardQueue(config.get<string>('REDIS_URL', 'redis://localhost:6379')),
inject: [ConfigService],
};
export const indexQueueProvider: Provider = {
provide: INDEX_QUEUE,
useFactory: (config: ConfigService) =>
createIndexQueue(config.get<string>('REDIS_URL', 'redis://localhost:6379')),
inject: [ConfigService],
};
+4
View File
@@ -0,0 +1,4 @@
export function parseRedisUrl(url: string) {
const { hostname, port } = new URL(url);
return { host: hostname, port: parseInt(port || '6379', 10), maxRetriesPerRequest: null };
}
+61
View File
@@ -0,0 +1,61 @@
import { cookies } from 'next/headers';
export const TOKEN_COOKIE = 'tower_token';
export const MEMBER_COOKIE = 'tower_member_token';
const MEMBER_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
export function getApiBaseUrl(): string {
return process.env['API_URL'] ?? 'http://localhost:3001';
}
export async function getToken(): Promise<string | undefined> {
const store = await cookies();
return store.get(TOKEN_COOKIE)?.value;
}
export async function getMemberToken(): Promise<string | undefined> {
const store = await cookies();
return store.get(MEMBER_COOKIE)?.value;
}
function withAuthHeader(headers: Headers, token: string | undefined): void {
headers.set('Accept', 'application/json');
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
}
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getToken();
const headers = new Headers(init.headers);
withAuthHeader(headers, token);
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return fetch(`${getApiBaseUrl()}${path}`, { ...init, headers, cache: 'no-store' });
}
export async function memberApiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getMemberToken();
const headers = new Headers(init.headers);
withAuthHeader(headers, token);
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return fetch(`${getApiBaseUrl()}${path}`, { ...init, headers, cache: 'no-store' });
}
export function buildMemberCookie(token: string): string {
return `${MEMBER_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${MEMBER_MAX_AGE_SECONDS}`;
}
export function clearMemberCookie(): string {
return `${MEMBER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
export function jsonResponse(body: unknown, status = 200, extraHeaders: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...extraHeaders },
});
}
+79
View File
@@ -0,0 +1,79 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { AuthProvider, useAuth } from './auth-context';
function Probe() {
const { admin, loading, error, logout } = useAuth();
return (
<div>
<div data-testid="loading">{String(loading)}</div>
<div data-testid="admin">{admin?.email ?? 'null'}</div>
<div data-testid="error">{error ?? 'null'}</div>
<button type="button" onClick={() => void logout()}>logout</button>
</div>
);
}
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('AuthProvider', () => {
it('exposes a loading state then sets admin from /api/auth/me', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ admin: { id: 'a-1', email: 'me@x.com', role: 'OWNER', tenantId: 't1', tenantSlug: 'default', tenantName: 'Default' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('admin')).toHaveTextContent('me@x.com'));
expect(screen.getByTestId('loading')).toHaveTextContent('false');
});
it('sets admin to null on 401', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ message: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
);
render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('admin')).toHaveTextContent('null'));
});
it('calls POST /api/auth/logout when logout is invoked', async () => {
fetchSpy
.mockResolvedValueOnce(
new Response(JSON.stringify({ admin: { id: 'a-1', email: 'me@x.com' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }));
render(
<AuthProvider>
<Probe />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('admin')).toHaveTextContent('me@x.com'));
await act(async () => {
screen.getByText('logout').click();
});
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ method: 'POST' })));
expect(screen.getByTestId('admin')).toHaveTextContent('null');
});
});
+74
View File
@@ -0,0 +1,74 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
export interface AuthAdmin {
id: string;
email: string;
name?: string | null;
role: 'OWNER' | 'ADMIN' | 'VIEWER';
tenantId: string;
tenantSlug: string;
tenantName?: string;
}
interface AuthState {
admin: AuthAdmin | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [admin, setAdmin] = useState<AuthAdmin | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (res.status === 401) {
setAdmin(null);
return;
}
if (!res.ok) {
setError('Unable to verify session');
setAdmin(null);
return;
}
const data = await res.json();
setAdmin(data.admin ?? null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Network error');
setAdmin(null);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
setAdmin(null);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<AuthContext.Provider value={{ admin, loading, error, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
return ctx;
}
+146
View File
@@ -0,0 +1,146 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from './super-admin-context';
import { useAuth } from './auth-context';
const NAV_LINKS = [
{ href: '/search', label: 'Search' },
{ href: '/groups', label: 'Groups & Routes' },
{ href: '/messages/pending', label: 'Pending messages' },
{ href: '/settings/rules', label: 'Rules' },
{ href: '/settings/bot', label: 'Bot' },
];
const SUPER_ADMIN_LINKS = [
{ href: '/admin', label: 'Dashboard' },
{ href: '/admin/tenants', label: 'Tenants' },
{ href: '/admin/bots', label: 'Bot Pool' },
];
const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
const ADMIN_PATHS = ['/admin'];
const MEMBER_PATHS = ['/my'];
export function Sidebar() {
const { admin, loading, logout } = useAuth();
const { admin: superAdmin, logout: superLogout } = useSuperAdmin();
const pathname = usePathname();
const router = useRouter();
const [pendingCount, setPendingCount] = useState<number | null>(null);
useEffect(() => {
fetch('/api/messages/pending/count')
.then((r) => r.ok ? r.json() : null)
.then((data) => setPendingCount(data?.count ?? null))
.catch(() => setPendingCount(null));
}, []);
useEffect(() => {
if (loading) return;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return;
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) return;
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) return;
if (!admin) {
router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}
}, [loading, admin, pathname, router]);
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4">
<span className="font-bold text-base">TOWER</span>
</nav>
);
}
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER Admin</span>
<div className="flex flex-col gap-1 flex-1">
{SUPER_ADMIN_LINKS.map((link) => (
<Link key={link.href} href={link.href} className="rounded px-3 py-2 text-sm hover:bg-gray-100">
{link.label}
</Link>
))}
</div>
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{superAdmin?.email}</div>
<div className="uppercase tracking-wide">Super Admin</div>
</div>
<button
type="button"
onClick={() => void superLogout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
</nav>
);
}
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
<Link href="/my" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Profile
</Link>
<Link href="/my/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Groups
</Link>
<Link href="/my/settings" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Settings
</Link>
</div>
<div className="border-t border-gray-200 pt-3 mt-3 text-xs text-gray-500 px-3">
Member portal
</div>
</nav>
);
}
return (
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded px-3 py-2 text-sm hover:bg-gray-100 flex items-center justify-between"
>
<span>{link.label}</span>
{link.href === '/messages/pending' && pendingCount !== null && pendingCount > 0 && (
<span className="bg-blue-600 text-white text-[11px] font-bold rounded-full w-5 h-5 flex items-center justify-center">
{pendingCount > 99 ? '99+' : pendingCount}
</span>
)}
</Link>
))}
</div>
{admin && (
<div className="border-t border-gray-200 pt-3 mt-3 flex flex-col gap-2">
<div className="px-3 text-xs text-gray-500">
<div className="font-medium text-gray-700 truncate">{admin.name ?? admin.email}</div>
<div className="truncate">{admin.tenantName}</div>
<div className="uppercase tracking-wide">{admin.role}</div>
</div>
<button
type="button"
onClick={() => void logout()}
className="text-left rounded px-3 py-2 text-sm hover:bg-gray-100"
>
Sign out
</button>
</div>
)}
</nav>
);
}
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
interface SuperAdmin {
id: string;
email: string;
name: string | null;
}
interface SuperAdminState {
admin: SuperAdmin | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const SuperAdminContext = createContext<SuperAdminState | null>(null);
export function SuperAdminProvider({ children }: { children: React.ReactNode }) {
const [admin, setAdmin] = useState<SuperAdmin | null>(null);
const [loading, setLoading] = useState(true);
const checkSession = useCallback(async () => {
try {
const res = await fetch('/api/auth/super/me', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAdmin(data);
} else {
setAdmin(null);
}
} catch {
setAdmin(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void checkSession(); }, [checkSession]);
const login = useCallback(async (email: string, password: string) => {
const res = await fetch('/api/auth/super/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Login failed' }));
throw new Error(err.message ?? 'Login failed');
}
const data = await res.json();
setAdmin(data.superAdmin);
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/super/logout', { method: 'POST', credentials: 'include' });
setAdmin(null);
}, []);
return (
<SuperAdminContext.Provider value={{ admin, loading, login, logout }}>
{children}
</SuperAdminContext.Provider>
);
}
export function useSuperAdmin(): SuperAdminState {
const ctx = useContext(SuperAdminContext);
if (!ctx) throw new Error('useSuperAdmin must be used within <SuperAdminProvider>');
return ctx;
}
-100
View File
@@ -1,100 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { AccountCard } from './AccountCard';
const activeAccount = {
id: 'acc_1',
jid: '111@s.whatsapp.net',
displayName: 'My Account',
status: 'ACTIVE',
platform: 'whatsapp',
};
const disconnectedAccount = {
id: 'acc_2',
jid: '222@s.whatsapp.net',
displayName: null,
status: 'DISCONNECTED',
platform: 'whatsapp',
};
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('AccountCard', () => {
it('shows displayName and Connected badge when ACTIVE', () => {
render(<AccountCard account={activeAccount} />);
expect(screen.getByText('My Account')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('falls back to jid when displayName is null', () => {
render(<AccountCard account={disconnectedAccount} />);
expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument();
});
it('shows Awaiting scan badge when DISCONNECTED', () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountCard account={disconnectedAccount} />);
expect(screen.getByText('Awaiting scan')).toBeInTheDocument();
});
it('does not fetch QR when account is ACTIVE', () => {
render(<AccountCard account={activeAccount} />);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountCard account={disconnectedAccount} />);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'),
);
});
it('shows QR image when qrDataUrl is returned', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountCard account={disconnectedAccount} />);
await waitFor(() => {
expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument();
});
expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute(
'src',
'data:image/png;base64,abc123',
);
});
it('shows waiting message when DISCONNECTED but no QR yet', async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountCard account={disconnectedAccount} />);
await waitFor(() => {
expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument();
});
});
});
-72
View File
@@ -1,72 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
interface Account {
id: string;
jid: string;
displayName: string | null;
status: string;
platform: string;
}
export function AccountCard({ account }: { account: Account }) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const isDisconnected = account.status === 'DISCONNECTED';
useEffect(() => {
if (!isDisconnected) {
setQrDataUrl(null);
return;
}
async function fetchQr() {
try {
const res = await fetch(`/api/accounts/${account.id}/qr`);
if (!res.ok) return;
const data = await res.json();
setQrDataUrl(data.qrDataUrl ?? null);
} catch {
// ignore fetch errors (e.g. network issues)
}
}
fetchQr();
const interval = setInterval(fetchQr, 5000);
return () => clearInterval(interval);
}, [account.id, isDisconnected]);
return (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-2">
<div>
<p className="font-medium">{account.displayName ?? account.jid}</p>
{account.displayName && (
<p className="text-xs text-gray-500">{account.jid}</p>
)}
</div>
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${
account.status === 'ACTIVE'
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'}
</span>
</div>
{isDisconnected && qrDataUrl && (
<div className="mt-3">
<p className="text-sm text-gray-600 mb-2">
Open WhatsApp Linked Devices Link a Device scan below
</p>
<img src={qrDataUrl} alt="WhatsApp QR Code" className="w-48 h-48" />
</div>
)}
{isDisconnected && !qrDataUrl && (
<p className="text-sm text-gray-500 mt-2">Waiting for QR code from worker...</p>
)}
</div>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AccountsList } from './AccountsList';
const mockAccounts = [
{ id: 'acc_1', jid: '111@s.whatsapp.net', displayName: 'Account One', status: 'ACTIVE', platform: 'whatsapp' },
{ id: 'acc_2', jid: '222@s.whatsapp.net', displayName: null, status: 'DISCONNECTED', platform: 'whatsapp' },
];
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('AccountsList', () => {
it('renders an AccountCard for each initial account', () => {
render(<AccountsList initialAccounts={mockAccounts} />);
expect(screen.getByText('Account One')).toBeInTheDocument();
expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument();
});
it('shows empty state when no accounts', () => {
render(<AccountsList initialAccounts={[]} />);
expect(screen.getByText(/no accounts yet/i)).toBeInTheDocument();
});
it('renders Add Account button', () => {
render(<AccountsList initialAccounts={[]} />);
expect(screen.getByRole('button', { name: /add account/i })).toBeInTheDocument();
});
it('renders display name input', () => {
render(<AccountsList initialAccounts={[]} />);
expect(screen.getByPlaceholderText(/display name/i)).toBeInTheDocument();
});
it('calls POST /api/accounts when Add Account is clicked', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: null, status: 'ACTIVE', platform: 'whatsapp' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountsList initialAccounts={[]} />);
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith('/api/accounts', expect.objectContaining({ method: 'POST' })),
);
});
it('adds new account to list after successful POST', async () => {
const newAccount = { id: 'acc_new', jid: 'pending_x@placeholder', displayName: 'New Device', status: 'ACTIVE', platform: 'whatsapp' };
fetchSpy.mockResolvedValue(
new Response(JSON.stringify(newAccount), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
);
render(<AccountsList initialAccounts={[]} />);
const input = screen.getByPlaceholderText(/display name/i);
fireEvent.change(input, { target: { value: 'New Device' } });
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() => expect(screen.getByText('New Device')).toBeInTheDocument());
});
it('sends displayName in POST body when entered', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: 'Test Name', status: 'ACTIVE', platform: 'whatsapp' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountsList initialAccounts={[]} />);
const input = screen.getByPlaceholderText(/display name/i);
fireEvent.change(input, { target: { value: 'Test Name' } });
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/accounts',
expect.objectContaining({
body: JSON.stringify({ displayName: 'Test Name' }),
}),
),
);
});
it('clears the input after successful account creation', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: null, status: 'ACTIVE', platform: 'whatsapp' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
),
);
render(<AccountsList initialAccounts={[]} />);
const input = screen.getByPlaceholderText(/display name/i) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'My Device' } });
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
await waitFor(() => expect(input.value).toBe(''));
});
});
-60
View File
@@ -1,60 +0,0 @@
'use client';
import { useState } from 'react';
import { AccountCard } from './AccountCard';
interface Account {
id: string;
jid: string;
displayName: string | null;
status: string;
platform: string;
}
export function AccountsList({ initialAccounts }: { initialAccounts: Account[] }) {
const [accounts, setAccounts] = useState<Account[]>(initialAccounts);
const [displayName, setDisplayName] = useState('');
async function handleAdd() {
try {
const res = await fetch('/api/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: displayName || undefined }),
});
if (!res.ok) return;
const account: Account = await res.json();
setAccounts((prev) => [...prev, account]);
setDisplayName('');
} catch {}
}
return (
<div>
<div className="flex gap-2 mb-6">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Display name (optional)"
className="flex-1 border rounded px-3 py-2 text-sm"
/>
<button
onClick={handleAdd}
className="bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700"
>
Add Account
</button>
</div>
{accounts.length === 0 ? (
<p className="text-gray-500">No accounts yet. Add one above to get started.</p>
) : (
<div className="flex flex-col gap-3">
{accounts.map((a) => (
<AccountCard key={a.id} account={a} />
))}
</div>
)}
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { AccountsList } from './AccountsList';
interface Account {
id: string;
jid: string;
displayName: string | null;
status: string;
platform: string;
}
export default async function AccountsPage() {
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
let accounts: Account[] = [];
try {
const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' });
if (res.ok) accounts = await res.json();
} catch {}
return (
<div className="max-w-2xl">
<h1 className="text-xl font-semibold mb-6">Accounts</h1>
<AccountsList initialAccounts={accounts} />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { useRouter } from 'next/navigation';
export default function BotsPage() {
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const [bots, setBots] = useState<any[]>([]);
const [initiating, setInitiating] = useState(false);
const [pairingInfo, setPairingInfo] = useState<{ token: string; expiresAt: string } | null>(null);
async function load() {
const res = await fetch('/api/admin/bots');
if (res.ok) setBots(await res.json());
}
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
void load();
}, [admin, loading, router]);
async function initiateBot() {
setInitiating(true);
try {
const res = await fetch('/api/admin/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (res.ok) {
const data = await res.json();
setPairingInfo(data);
}
} finally {
setInitiating(false);
void load();
}
}
async function removeBot(id: string) {
if (!confirm('Remove this bot? Only possible if no tenants are assigned.')) return;
const res = await fetch(`/api/admin/bots/${id}`, { method: 'DELETE' });
if (res.ok) {
void load();
} else {
const err = await res.json();
alert(err.message ?? 'Failed to remove bot');
}
}
function getQrUrl() {
if (!pairingInfo) return null;
return `/api/admin/bots/qr/${pairingInfo.token}`;
}
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Bot Pool</h1>
<button
onClick={initiateBot}
disabled={initiating}
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
{initiating ? 'Creating...' : 'Add Bot'}
</button>
</div>
{pairingInfo && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
<p className="text-sm font-medium mb-2">New bot created scan QR to pair</p>
<p className="text-xs text-gray-600 mb-2">Expires: {pairingInfo.expiresAt}</p>
<a
href={getQrUrl() ?? '#'}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 text-sm underline"
>
View QR Code
</a>
</div>
)}
<div className="bg-white rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">JID</th>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Tenants</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{bots.map((b: any) => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs">{b.jid?.slice(0, 30) ?? 'pending...'}</td>
<td className="px-4 py-3">{b.displayName ?? '—'}</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
b.status === 'ACTIVE' ? 'bg-green-100 text-green-700' :
b.status === 'PAIRING' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-500'
}`}>{b.status}</span>
</td>
<td className="px-4 py-3">{b.tenantCount}</td>
<td className="px-4 py-3 text-xs text-gray-500">{new Date(b.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3">
<button
onClick={() => removeBot(b.id)}
className="text-red-600 text-xs hover:underline"
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
{bots.length === 0 && <p className="p-4 text-gray-400">No bots in the pool.</p>}
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

Some files were not shown because too many files have changed in this diff Show More