Compare commits
10 Commits
18edce7552
...
ff4d0f90e8
| Author | SHA1 | Date | |
|---|---|---|---|
| ff4d0f90e8 | |||
| 249d759e6a | |||
| 801c1d7121 | |||
| afff6fdbdf | |||
| 2f88e883b2 | |||
| 952a0e9b49 | |||
| e8aaae4188 | |||
| 759b49159e | |||
| 1dba77959d | |||
| 02dad1347c |
@@ -73,7 +73,44 @@
|
||||
"Bash(grep -v \"^$\")",
|
||||
"Bash(npm info *)",
|
||||
"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]"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
dist
|
||||
.next
|
||||
coverage
|
||||
.turbo
|
||||
sessions
|
||||
+22
-1
@@ -20,4 +20,25 @@ LOG_LEVEL=debug
|
||||
|
||||
# WhatsApp
|
||||
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>
|
||||
|
||||
@@ -5,4 +5,5 @@ dist
|
||||
coverage
|
||||
.env
|
||||
*.env.local
|
||||
.env.production
|
||||
sessions/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ─── Prune: extract only @tower/api + its workspace deps ───
|
||||
FROM node:22-alpine AS pruner
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
WORKDIR /app
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json tsconfig.base.json ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm exec turbo prune --scope=@tower/api --docker
|
||||
|
||||
# ─── Install ALL deps (layer cached by lockfile) ───
|
||||
FROM node:22-alpine AS installer
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ─── Build ───
|
||||
FROM installer AS builder
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY tsconfig.base.json ./
|
||||
RUN pnpm exec prisma generate --schema=apps/api/prisma/schema.prisma
|
||||
RUN pnpm turbo build --filter=@tower/api
|
||||
|
||||
# ─── Production runner ───
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=builder /app/packages/config/dist ./packages/config/dist
|
||||
COPY --from=builder /app/packages/logger/dist ./packages/logger/dist
|
||||
COPY --from=builder /app/packages/search/dist ./packages/search/dist
|
||||
COPY --from=builder /app/packages/types/dist ./packages/types/dist
|
||||
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
RUN pnpm exec prisma generate --schema=apps/api/prisma/schema.prisma
|
||||
|
||||
COPY apps/api/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "apps/api/dist/main"]
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
pnpm exec prisma migrate deploy --schema=apps/api/prisma/schema.prisma
|
||||
|
||||
echo "Starting TOWER API..."
|
||||
exec "$@"
|
||||
@@ -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();
|
||||
})();
|
||||
+19
-1
@@ -6,18 +6,32 @@
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:seed": "DOTENV_CONFIG_PATH=.env ts-node -r dotenv/config prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"@tower/config": "workspace:*",
|
||||
"@tower/logger": "workspace:*",
|
||||
"@tower/search": "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",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
@@ -25,12 +39,16 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"jest": "^29.0.0",
|
||||
"prisma": "^6.0.0",
|
||||
"ts-jest": "^29.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"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");
|
||||
|
||||
+18
@@ -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
@@ -7,45 +7,211 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
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
|
||||
// ============================================================================
|
||||
// Tenancy
|
||||
// ============================================================================
|
||||
|
||||
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[]
|
||||
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
|
||||
syncRoutesTo SyncRoute[] @relation("targetGroup")
|
||||
approvals Approval[]
|
||||
syncRoutes SyncRoute[]
|
||||
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])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 {
|
||||
id String @id @default(cuid())
|
||||
platform String
|
||||
platformMsgId String
|
||||
sourceGroupId String
|
||||
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||
senderJid String
|
||||
senderName String?
|
||||
content String
|
||||
mediaUrl String?
|
||||
tags String[]
|
||||
status MessageStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
platform String
|
||||
platformMsgId String
|
||||
sourceGroupId String
|
||||
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||
senderJid String
|
||||
senderName String?
|
||||
senderTowerUserId String?
|
||||
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
|
||||
content String
|
||||
mediaUrl String?
|
||||
tags String[]
|
||||
status MessageStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
approval Approval?
|
||||
|
||||
@@unique([platform, platformMsgId])
|
||||
@@index([tenantId])
|
||||
@@index([senderTowerUserId])
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
@@ -58,12 +224,16 @@ enum MessageStatus {
|
||||
|
||||
model Approval {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
messageId String @unique
|
||||
message Message @relation(fields: [messageId], references: [id])
|
||||
adminId String
|
||||
decision ApprovalDecision
|
||||
notes String?
|
||||
decidedAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
enum ApprovalDecision {
|
||||
@@ -73,6 +243,8 @@ enum ApprovalDecision {
|
||||
|
||||
model SyncRoute {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
sourceGroupId String
|
||||
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
|
||||
targetGroupId String
|
||||
@@ -81,37 +253,183 @@ model SyncRoute {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
memberJid String
|
||||
consentType String
|
||||
grantedAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
groupId String
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
userId String
|
||||
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 {
|
||||
ACTIVE
|
||||
DISCONNECTED
|
||||
BANNED
|
||||
model MemberOptOut {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
platform String
|
||||
jid String
|
||||
sessionPath String
|
||||
displayName String?
|
||||
status AccountStatus @default(ACTIVE)
|
||||
qrCode String?
|
||||
groups Group[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model OtpChallenge {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
jid String
|
||||
phoneHash String
|
||||
code String
|
||||
scopes ConsentScope[]
|
||||
retentionDays Int @default(90)
|
||||
policyVersion String
|
||||
groupId String
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,19 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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 { SearchModule } from './modules/search/search.module';
|
||||
import { GroupsModule } from './modules/groups/groups.module';
|
||||
import { RoutesModule } from './modules/routes/routes.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({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
HealthModule,
|
||||
SearchModule,
|
||||
GroupsModule,
|
||||
RoutesModule,
|
||||
BotModule,
|
||||
OnboardingModule,
|
||||
MyModule,
|
||||
MessagesModule,
|
||||
RulesModule,
|
||||
SuperAdminModule,
|
||||
TenantModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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
@@ -1,9 +1,25 @@
|
||||
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 { JwtAuthGuard } from './modules/auth/jwt-auth.guard';
|
||||
import { validateEnv } from '@tower/config';
|
||||
|
||||
async function bootstrap() {
|
||||
validateEnv();
|
||||
|
||||
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;
|
||||
await app.listen(port);
|
||||
console.log(`TOWER API running on port ${port}`);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import * as QRCode from 'qrcode';
|
||||
import type { BotInitiateResponse, BotQrResponse, BotRevealResponse, BotStatus, BotSummary } from '@tower/types';
|
||||
|
||||
const PAIRING_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class BotService {
|
||||
private readonly logger = new Logger(BotService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async initiate(tenantId: string, adminId: string, displayName?: string): Promise<BotInitiateResponse> {
|
||||
const existing = await this.prisma.account.findFirst({
|
||||
where: { isBot: true, status: { in: ['PAIRING', 'ACTIVE', 'DISCONNECTED'] } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('A bot is already configured. Remove it before pairing a new one.');
|
||||
}
|
||||
|
||||
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
|
||||
const uid = randomUUID();
|
||||
const pairingToken = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
|
||||
|
||||
const account = await this.prisma.account.create({
|
||||
data: {
|
||||
platform: 'whatsapp',
|
||||
jid: `pending_${uid}@placeholder`,
|
||||
sessionPath: `${sessionBase}/${uid}`,
|
||||
displayName: displayName ?? null,
|
||||
status: 'PAIRING',
|
||||
isBot: true,
|
||||
pairingToken,
|
||||
pairingExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.tenantBot.create({
|
||||
data: { tenantId, accountId: account.id, isActive: true },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.BOT_INITIATED,
|
||||
resourceType: 'Account',
|
||||
resourceId: account.id,
|
||||
payload: { displayName: account.displayName },
|
||||
});
|
||||
|
||||
return {
|
||||
pairingToken,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
qrDataUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getQr(tenantId: string, pairingToken: string): Promise<BotQrResponse> {
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: { pairingToken, tenants: { some: { tenantId } } },
|
||||
});
|
||||
if (!account) {
|
||||
throw new NotFoundException('Pairing token not found for this tenant');
|
||||
}
|
||||
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
|
||||
return {
|
||||
status: account.status as BotStatus,
|
||||
qrDataUrl: null,
|
||||
pairingToken: account.pairingToken ?? '',
|
||||
expiresAt: account.pairingExpiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
if (!account.qrCode) {
|
||||
return {
|
||||
status: account.status as BotStatus,
|
||||
qrDataUrl: null,
|
||||
pairingToken: account.pairingToken ?? '',
|
||||
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
|
||||
return {
|
||||
status: account.status as BotStatus,
|
||||
qrDataUrl,
|
||||
pairingToken: account.pairingToken ?? '',
|
||||
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async get(tenantId: string): Promise<{ bot: BotSummary | null; shared: boolean; sharedBotId?: string }> {
|
||||
const own = await this.prisma.account.findFirst({
|
||||
where: { tenants: { some: { tenantId } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (own) {
|
||||
return { bot: this.toSummary(own), shared: false };
|
||||
}
|
||||
// No own bot — is there a shared bot we could attach to?
|
||||
const shared = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
isBot: true,
|
||||
status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] },
|
||||
tenants: { some: {} },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (shared) {
|
||||
return { bot: null, shared: true, sharedBotId: shared.id };
|
||||
}
|
||||
return { bot: null, shared: false };
|
||||
}
|
||||
|
||||
async attach(tenantId: string, adminId: string, accountId: string): Promise<{ bot: BotSummary }> {
|
||||
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
|
||||
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
|
||||
if (account.status === 'BANNED') {
|
||||
throw new ConflictException('Bot is banned and cannot be shared');
|
||||
}
|
||||
const existing = await this.prisma.tenantBot.findUnique({
|
||||
where: { tenantId_accountId: { tenantId, accountId } },
|
||||
});
|
||||
if (!existing) {
|
||||
await this.prisma.tenantBot.create({
|
||||
data: { tenantId, accountId, isActive: true },
|
||||
});
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.BOT_ACCESS_GRANTED,
|
||||
resourceType: 'Account',
|
||||
resourceId: accountId,
|
||||
payload: { reason: 'tenant attached to shared bot' },
|
||||
});
|
||||
}
|
||||
return { bot: this.toSummary(account) };
|
||||
}
|
||||
|
||||
private toSummary(account: any): BotSummary {
|
||||
return {
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
jid: account.status === 'ACTIVE' ? account.jid : null,
|
||||
displayName: account.displayName,
|
||||
status: account.status as BotStatus,
|
||||
isBot: account.isBot,
|
||||
createdAt: account.createdAt.toISOString(),
|
||||
updatedAt: account.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the least-loaded ACTIVE bot and assign it to the tenant.
|
||||
* Returns null if no bot is available in the pool.
|
||||
* Idempotent — skips if the tenant already has a TenantBot.
|
||||
*/
|
||||
async assignBotToTenant(tenantId: string): Promise<BotSummary | null> {
|
||||
const existing = await this.prisma.tenantBot.findFirst({
|
||||
where: { tenantId },
|
||||
include: { account: true },
|
||||
});
|
||||
if (existing) {
|
||||
return this.toSummary(existing.account);
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.account.findMany({
|
||||
where: { isBot: true, status: 'ACTIVE' },
|
||||
include: { _count: { select: { tenants: true } } },
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
this.logger.warn({ tenantId }, 'No ACTIVE bot available to assign');
|
||||
return null;
|
||||
}
|
||||
|
||||
const best = candidates.reduce((a, b) =>
|
||||
a._count.tenants <= b._count.tenants ? a : b,
|
||||
);
|
||||
|
||||
await this.prisma.tenantBot.create({
|
||||
data: { tenantId, accountId: best.id, isActive: true },
|
||||
});
|
||||
this.logger.log({ tenantId, accountId: best.id, tenantCount: best._count.tenants }, 'Bot auto-assigned');
|
||||
|
||||
return this.toSummary(best);
|
||||
}
|
||||
|
||||
async reveal(tenantId: string, adminId: string): Promise<BotRevealResponse> {
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: { tenants: { some: { tenantId } } },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new NotFoundException('No active bot to reveal');
|
||||
}
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.BOT_REVEALED,
|
||||
resourceType: 'Account',
|
||||
resourceId: account.id,
|
||||
payload: { jid: account.jid },
|
||||
});
|
||||
return { jid: account.jid, revealedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Super admin bot management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async listAll(): Promise<any[]> {
|
||||
const bots = await this.prisma.account.findMany({
|
||||
where: { isBot: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { tenants: true } } },
|
||||
});
|
||||
return bots.map((b) => ({
|
||||
id: b.id,
|
||||
jid: b.status === 'ACTIVE' ? b.jid : null,
|
||||
displayName: b.displayName,
|
||||
status: b.status,
|
||||
platform: b.platform,
|
||||
tenantCount: b._count.tenants,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
updatedAt: b.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async superInitiate(displayName?: string): Promise<{ pairingToken: string; expiresAt: string }> {
|
||||
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
|
||||
const uid = randomUUID();
|
||||
const pairingToken = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
|
||||
|
||||
await this.prisma.account.create({
|
||||
data: {
|
||||
platform: 'whatsapp',
|
||||
jid: `pending_${uid}@placeholder`,
|
||||
sessionPath: `${sessionBase}/${uid}`,
|
||||
displayName: displayName ?? null,
|
||||
status: 'PAIRING',
|
||||
isBot: true,
|
||||
pairingToken,
|
||||
pairingExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { pairingToken, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
async superGetQr(pairingToken: string): Promise<any> {
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: { pairingToken },
|
||||
});
|
||||
if (!account) throw new NotFoundException('Pairing token not found');
|
||||
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
|
||||
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt.toISOString() };
|
||||
}
|
||||
if (!account.qrCode) {
|
||||
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
|
||||
}
|
||||
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
|
||||
return { status: account.status, qrDataUrl, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
|
||||
}
|
||||
|
||||
async assignTenant(tenantId: string, accountId: string): Promise<any> {
|
||||
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
|
||||
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
|
||||
if (account.status !== 'ACTIVE') throw new ConflictException('Bot is not ACTIVE');
|
||||
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
|
||||
const existing = await this.prisma.tenantBot.findFirst({ where: { tenantId } });
|
||||
if (existing) throw new ConflictException('Tenant already has a bot assigned');
|
||||
|
||||
await this.prisma.tenantBot.create({
|
||||
data: { tenantId, accountId: account.id, isActive: true },
|
||||
});
|
||||
|
||||
return { ok: true, accountId: account.id, jid: account.jid };
|
||||
}
|
||||
|
||||
async superRemove(accountId: string): Promise<{ ok: true }> {
|
||||
const account = await this.prisma.account.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { _count: { select: { tenants: true } } },
|
||||
});
|
||||
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
|
||||
if (account._count.tenants > 0) {
|
||||
throw new ConflictException(`Cannot remove bot — ${account._count.tenants} tenant(s) still assigned. Reassign them first.`);
|
||||
}
|
||||
|
||||
await this.prisma.account.delete({ where: { id: accountId } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GroupsController } from './groups.controller';
|
||||
import { GroupsService } from './groups.service';
|
||||
import type { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
const ctx: TenantContext = { tenantId: 'tnt-A', adminId: 'adm_1', role: 'OWNER' };
|
||||
|
||||
const mockGroups = [
|
||||
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
|
||||
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' },
|
||||
];
|
||||
const mockService = { list: jest.fn().mockResolvedValue(mockGroups) };
|
||||
|
||||
const mockService = {
|
||||
list: jest.fn().mockResolvedValue(mockGroups),
|
||||
listShared: jest.fn().mockResolvedValue([]),
|
||||
listSharedByMe: jest.fn().mockResolvedValue([]),
|
||||
getClaimTokenInfo: jest.fn(),
|
||||
claimWithToken: jest.fn(),
|
||||
share: jest.fn(),
|
||||
unshare: jest.fn(),
|
||||
regenerateToken: jest.fn(),
|
||||
listUnclaimed: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
describe('GroupsController', () => {
|
||||
let controller: GroupsController;
|
||||
@@ -22,9 +36,29 @@ describe('GroupsController', () => {
|
||||
controller = module.get<GroupsController>(GroupsController);
|
||||
});
|
||||
|
||||
it('returns groups from service', async () => {
|
||||
const result = await controller.list();
|
||||
it('list() delegates to service', async () => {
|
||||
const result = await controller.list(ctx);
|
||||
expect(result).toEqual(mockGroups);
|
||||
expect(mockService.list).toHaveBeenCalled();
|
||||
expect(mockService.list).toHaveBeenCalledWith('tnt-A');
|
||||
});
|
||||
|
||||
it('listShared() delegates to service', async () => {
|
||||
await controller.listShared(ctx);
|
||||
expect(mockService.listShared).toHaveBeenCalledWith('tnt-A');
|
||||
});
|
||||
|
||||
it('claimWithToken() delegates to service', async () => {
|
||||
await controller.claimWithToken(ctx, { token: 'abc123' });
|
||||
expect(mockService.claimWithToken).toHaveBeenCalledWith('abc123', 'adm_1');
|
||||
});
|
||||
|
||||
it('share() delegates to service', async () => {
|
||||
await controller.share(ctx, 'grp_1', { targetTenantId: 'tnt-B' });
|
||||
expect(mockService.share).toHaveBeenCalledWith('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
|
||||
});
|
||||
|
||||
it('listUnclaimed() delegates to service', async () => {
|
||||
await controller.listUnclaimed();
|
||||
expect(mockService.listUnclaimed).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,86 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
@Controller('groups')
|
||||
class ClaimWithTokenDto {
|
||||
@IsString() token!: string;
|
||||
}
|
||||
|
||||
class ShareDto {
|
||||
@IsString() targetTenantId!: string;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.groupsService.list();
|
||||
@Get('groups')
|
||||
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.groupsService.list(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get('groups/shared')
|
||||
listShared(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.groupsService.listShared(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get('groups/shared-by-me')
|
||||
listSharedByMe(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.groupsService.listSharedByMe(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get('admin/groups/claim-token-info')
|
||||
getClaimTokenInfo(@Query('token') token: string) {
|
||||
return this.groupsService.getClaimTokenInfo(token);
|
||||
}
|
||||
|
||||
@Post('admin/groups/claim-with-token')
|
||||
@Roles('OWNER')
|
||||
claimWithToken(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: ClaimWithTokenDto,
|
||||
) {
|
||||
return this.groupsService.claimWithToken(body.token, ctx.adminId ?? '');
|
||||
}
|
||||
|
||||
@Post('admin/groups/:id/share')
|
||||
@Roles('OWNER')
|
||||
share(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ShareDto,
|
||||
) {
|
||||
return this.groupsService.share(ctx.tenantId, ctx.adminId ?? '', id, body.targetTenantId);
|
||||
}
|
||||
|
||||
@Delete('admin/groups/:id/share/:targetTenantId')
|
||||
@Roles('OWNER')
|
||||
unshare(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Param('targetTenantId') targetTenantId: string,
|
||||
) {
|
||||
return this.groupsService.unshare(ctx.tenantId, id, targetTenantId);
|
||||
}
|
||||
|
||||
@Post('admin/groups/:id/regenerate-token')
|
||||
@Roles('OWNER')
|
||||
regenerateToken(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.groupsService.regenerateToken(ctx.tenantId, ctx.adminId ?? '', id);
|
||||
}
|
||||
|
||||
@Get('admin/groups/unclaimed')
|
||||
@Roles('OWNER')
|
||||
listUnclaimed() {
|
||||
return this.groupsService.listUnclaimed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,141 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { GroupsService } from './groups.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
|
||||
const mockGroups = [
|
||||
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
|
||||
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', platformId: '222@g.us', isActive: true, accountId: null },
|
||||
];
|
||||
const mockGroup = { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' };
|
||||
|
||||
describe('GroupsService', () => {
|
||||
let service: GroupsService;
|
||||
const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const mockPrisma: any = {
|
||||
group: {
|
||||
findMany: jest.fn().mockResolvedValue([mockGroup]),
|
||||
findUnique: jest.fn().mockResolvedValue(mockGroup),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(mockGroup),
|
||||
},
|
||||
groupClaimToken: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
groupAccess: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
tenantBot: { findUnique: jest.fn(), count: jest.fn(), create: jest.fn() },
|
||||
admin: { findUnique: jest.fn() },
|
||||
tenant: { findMany: jest.fn() },
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
const mockAudit = { log: jest.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GroupsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: AuditService, useValue: mockAudit },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<GroupsService>(GroupsService);
|
||||
});
|
||||
|
||||
it('returns all groups ordered by name', async () => {
|
||||
const result = await service.list();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Alpha');
|
||||
expect(mockPrisma.group.findMany).toHaveBeenCalledWith({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
|
||||
describe('list', () => {
|
||||
it('returns groups for the given tenant including shared groups', async () => {
|
||||
const result = await service.list('tnt-A');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ OR: expect.any(Array) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listUnclaimed', () => {
|
||||
it('returns groups with no tenantId', async () => {
|
||||
await service.listUnclaimed();
|
||||
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tenantId: null } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClaimTokenInfo', () => {
|
||||
it('throws NotFound for invalid token', async () => {
|
||||
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(service.getClaimTokenInfo('bad')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimWithToken', () => {
|
||||
const mockToken = {
|
||||
id: 'tok_1', groupId: 'grp_1', token: 'abc123', creatorJid: 'creator@jid',
|
||||
expiresAt: new Date(Date.now() + 3600000), consumedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma.admin.findUnique.mockResolvedValue({ id: 'adm_1', tenantId: 'tnt-A' });
|
||||
mockPrisma.groupClaimToken.findUnique.mockResolvedValue(mockToken);
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: null, accountId: 'acc_1' });
|
||||
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-A', accountId: 'acc_1' });
|
||||
mockPrisma.$transaction.mockResolvedValue([mockGroup]);
|
||||
});
|
||||
|
||||
it('throws NotFound when admin does not exist', async () => {
|
||||
mockPrisma.admin.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(service.claimWithToken('abc123', 'bad_admin')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws Conflict when token is consumed', async () => {
|
||||
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, consumedAt: new Date() });
|
||||
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('throws Conflict when token is expired', async () => {
|
||||
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, expiresAt: new Date(Date.now() - 1000) });
|
||||
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('throws Conflict when group is already claimed', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValueOnce({ id: 'grp_1', tenantId: 'tnt-B', accountId: 'acc_1' });
|
||||
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('share / unshare', () => {
|
||||
const sharedAccess = { id: 'acc_1', groupId: 'grp_1', tenantId: 'tnt-B', grantedBy: 'adm_1' };
|
||||
|
||||
it('share creates a GroupAccess record', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.groupAccess.create.mockResolvedValue(sharedAccess);
|
||||
const result = await service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
|
||||
expect(result).toEqual(sharedAccess);
|
||||
});
|
||||
|
||||
it('share throws Conflict if already shared', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
|
||||
await expect(service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('unshare deletes the GroupAccess record', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
|
||||
mockPrisma.groupAccess.delete.mockResolvedValue(sharedAccess);
|
||||
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('unshare throws NotFound if no share exists', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
|
||||
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export interface GroupSummary {
|
||||
id: string;
|
||||
@@ -8,16 +11,257 @@ export interface GroupSummary {
|
||||
platformId: string;
|
||||
isActive: boolean;
|
||||
accountId: string | null;
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
list(): Promise<GroupSummary[]> {
|
||||
list(tenantId: string): Promise<GroupSummary[]> {
|
||||
return this.prisma.group.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ groupAccesses: { some: { tenantId } } },
|
||||
],
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
|
||||
select: {
|
||||
id: true, name: true, platform: true, platformId: true,
|
||||
isActive: true, accountId: true, tenantId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listUnclaimed(): Promise<GroupSummary[]> {
|
||||
return this.prisma.group.findMany({
|
||||
where: { tenantId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true, name: true, platform: true, platformId: true,
|
||||
isActive: true, accountId: true, tenantId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listShared(tenantId: string): Promise<(GroupSummary & { sharedByTenantName: string })[]> {
|
||||
const accesses = await this.prisma.groupAccess.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true, name: true, platform: true, platformId: true,
|
||||
isActive: true, accountId: true, tenantId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const ownerTenantIds: string[] = [...new Set(accesses.map((a) => a.group.tenantId).filter((id): id is string => !!id))];
|
||||
const tenants = ownerTenantIds.length > 0
|
||||
? await this.prisma.tenant.findMany({
|
||||
where: { id: { in: ownerTenantIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const tenantMap = new Map(tenants.map((t) => [t.id, t.name]));
|
||||
return accesses.map((a) => ({
|
||||
...a.group,
|
||||
sharedByTenantName: a.group.tenantId ? tenantMap.get(a.group.tenantId) ?? 'Unknown' : 'Unknown',
|
||||
}));
|
||||
}
|
||||
|
||||
async listSharedByMe(tenantId: string) {
|
||||
const accesses = await this.prisma.groupAccess.findMany({
|
||||
where: { group: { tenantId } },
|
||||
include: {
|
||||
group: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
// Group by group
|
||||
const grouped = new Map<string, { groupId: string; groupName: string; sharedWith: { tenantId: string; tenantName: string; grantedAt: Date }[] }>();
|
||||
for (const a of accesses) {
|
||||
const key = a.group.id;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { groupId: a.group.id, groupName: a.group.name, sharedWith: [] });
|
||||
}
|
||||
grouped.get(key)!.sharedWith.push({
|
||||
tenantId: a.tenantId,
|
||||
tenantName: a.tenant.name,
|
||||
grantedAt: a.createdAt,
|
||||
});
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
|
||||
async getClaimTokenInfo(token: string) {
|
||||
const record = await this.prisma.groupClaimToken.findUnique({
|
||||
where: { token },
|
||||
include: { group: { select: { name: true } } },
|
||||
});
|
||||
if (!record) throw new NotFoundException('Invalid token');
|
||||
return {
|
||||
groupName: record.group.name,
|
||||
expiresAt: record.expiresAt.toISOString(),
|
||||
isConsumed: record.consumedAt !== null,
|
||||
isExpired: record.expiresAt < new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async claimWithToken(token: string, adminId: string): Promise<GroupSummary> {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: { tenantId: true },
|
||||
});
|
||||
if (!admin) throw new NotFoundException('Admin not found');
|
||||
|
||||
const record = await this.prisma.groupClaimToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
if (!record) throw new NotFoundException('Invalid token');
|
||||
if (record.consumedAt) throw new ConflictException('Token has already been used');
|
||||
if (record.expiresAt < new Date()) throw new ConflictException('Token has expired');
|
||||
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id: record.groupId },
|
||||
});
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
if (group.tenantId) throw new ConflictException('Group is already claimed');
|
||||
|
||||
// Account-binding: ensure the claiming tenant has a TenantBot link
|
||||
if (group.accountId) {
|
||||
const myLink = await this.prisma.tenantBot.findUnique({
|
||||
where: { tenantId_accountId: { tenantId: admin.tenantId, accountId: group.accountId } },
|
||||
});
|
||||
if (!myLink) {
|
||||
const anyLinks = await this.prisma.tenantBot.count({
|
||||
where: { accountId: group.accountId },
|
||||
});
|
||||
if (anyLinks === 0) {
|
||||
throw new ConflictException('Bot account has no tenant binding — cannot claim');
|
||||
}
|
||||
await this.prisma.tenantBot.create({
|
||||
data: { tenantId: admin.tenantId, accountId: group.accountId, isActive: true },
|
||||
});
|
||||
await this.audit.log({
|
||||
tenantId: admin.tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.BOT_ACCESS_GRANTED,
|
||||
resourceType: 'Account',
|
||||
resourceId: group.accountId,
|
||||
payload: { reason: 'auto-grant on token claim', groupId: group.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await this.prisma.$transaction([
|
||||
this.prisma.group.update({
|
||||
where: { id: group.id },
|
||||
data: { tenantId: admin.tenantId, claimStatus: 'CLAIMED' },
|
||||
select: {
|
||||
id: true, name: true, platform: true, platformId: true,
|
||||
isActive: true, accountId: true, tenantId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.groupClaimToken.update({
|
||||
where: { id: record.id },
|
||||
data: { consumedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.audit.log({
|
||||
tenantId: admin.tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.GROUP_CLAIMED_WITH_TOKEN,
|
||||
resourceType: 'Group',
|
||||
resourceId: group.id,
|
||||
payload: { groupName: group.name },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async share(tenantId: string, adminId: string, groupId: string, targetTenantId: string) {
|
||||
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
|
||||
|
||||
const existing = await this.prisma.groupAccess.findUnique({
|
||||
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
|
||||
});
|
||||
if (existing) throw new ConflictException('Group already shared with this tenant');
|
||||
|
||||
const access = await this.prisma.groupAccess.create({
|
||||
data: { groupId, tenantId: targetTenantId, grantedBy: adminId },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.GROUP_SHARED,
|
||||
resourceType: 'Group',
|
||||
resourceId: groupId,
|
||||
payload: { targetTenantId, groupName: group.name },
|
||||
});
|
||||
|
||||
return access;
|
||||
}
|
||||
|
||||
async unshare(tenantId: string, groupId: string, targetTenantId: string) {
|
||||
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
|
||||
|
||||
const existing = await this.prisma.groupAccess.findUnique({
|
||||
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('Share not found');
|
||||
|
||||
await this.prisma.groupAccess.delete({
|
||||
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.GROUP_UNSHARED,
|
||||
resourceType: 'Group',
|
||||
resourceId: groupId,
|
||||
payload: { targetTenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateToken(tenantId: string, adminId: string, groupId: string) {
|
||||
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
// Allow regenerate for owned groups OR unclaimed groups (support case)
|
||||
if (group.tenantId && group.tenantId !== tenantId) throw new NotFoundException('Group not found');
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const record = await this.prisma.groupClaimToken.create({
|
||||
data: {
|
||||
groupId,
|
||||
token,
|
||||
creatorJid: token, // placeholder — support will need to extract jid from group metadata
|
||||
expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId: group.tenantId ?? tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.GROUP_CLAIM_TOKEN_REGENERATED,
|
||||
resourceType: 'Group',
|
||||
resourceId: groupId,
|
||||
payload: { tokenId: record.id },
|
||||
});
|
||||
|
||||
return { token, expiresAt: record.expiresAt.toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
@Controller('admin/messages')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
@Get('pending')
|
||||
listPending(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.messagesService.listPending(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get('pending/count')
|
||||
pendingCount(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.messagesService.pendingCount(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.messagesService.get(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id);
|
||||
}
|
||||
|
||||
@Post('reindex')
|
||||
reindex(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.messagesService.reindexApproved(ctx.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MessagesController } from './messages.controller';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { forwardQueueProvider, indexQueueProvider, FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [MessagesController],
|
||||
providers: [
|
||||
MessagesService,
|
||||
forwardQueueProvider,
|
||||
indexQueueProvider,
|
||||
],
|
||||
exports: [FORWARD_QUEUE, INDEX_QUEUE],
|
||||
})
|
||||
export class MessagesModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
|
||||
|
||||
describe('MessagesService', () => {
|
||||
let service: MessagesService;
|
||||
const mockPrisma: any = {
|
||||
message: { findMany: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
groupAccess: { findUnique: jest.fn() },
|
||||
approval: { create: jest.fn() },
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
const mockAudit = { log: jest.fn() };
|
||||
const mockForwardQueue = { add: jest.fn().mockResolvedValue({}) };
|
||||
const mockIndexQueue = { add: jest.fn().mockResolvedValue({}) };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessagesService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: AuditService, useValue: mockAudit },
|
||||
{ provide: FORWARD_QUEUE, useValue: mockForwardQueue },
|
||||
{ provide: INDEX_QUEUE, useValue: mockIndexQueue },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<MessagesService>(MessagesService);
|
||||
});
|
||||
|
||||
describe('listPending', () => {
|
||||
it('returns PENDING messages with source group info', async () => {
|
||||
mockPrisma.message.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
content: 'hello #important',
|
||||
senderJid: '111@s.whatsapp.net',
|
||||
senderName: 'Alice',
|
||||
tags: ['#important'],
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
sourceGroupId: 'grp-1',
|
||||
sourceGroup: { name: 'Notes', platformId: '111@g.us' },
|
||||
},
|
||||
]);
|
||||
const res = await service.listPending('tnt-1');
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].sourceGroupName).toBe('Notes');
|
||||
expect(mockPrisma.message.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
OR: [
|
||||
{ tenantId: 'tnt-1' },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId: 'tnt-1' } } } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
const baseMessage = {
|
||||
id: 'msg-1',
|
||||
tenantId: 'tnt-1',
|
||||
content: 'hello #important',
|
||||
senderJid: '111@s.whatsapp.net',
|
||||
senderName: 'Alice',
|
||||
platform: 'whatsapp',
|
||||
tags: ['#important'],
|
||||
status: 'PENDING',
|
||||
sourceGroupId: 'grp-1',
|
||||
approval: null,
|
||||
sourceGroup: {
|
||||
name: 'Notes',
|
||||
accountId: 'acc-1',
|
||||
syncRoutesFrom: [
|
||||
{
|
||||
targetGroup: { platformId: '222@g.us', accountId: 'acc-1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('marks APPROVED, enqueues forward + index, writes audit', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue(baseMessage);
|
||||
mockPrisma.message.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrisma.approval.create.mockResolvedValue({});
|
||||
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
|
||||
|
||||
const res = await service.approve('tnt-1', 'adm-1', 'msg-1');
|
||||
expect(res.status).toBe('APPROVED');
|
||||
expect(res.routesForwarded).toBe(1);
|
||||
expect(res.indexEnqueued).toBe(true);
|
||||
expect(mockForwardQueue.add).toHaveBeenCalledWith(
|
||||
'forward',
|
||||
expect.objectContaining({ toGroupJid: '222@g.us', content: 'hello #important' }),
|
||||
expect.objectContaining({ attempts: 3 }),
|
||||
);
|
||||
expect(mockIndexQueue.add).toHaveBeenCalledWith('index', expect.any(Object), expect.any(Object));
|
||||
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'MESSAGE_APPROVED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-existent message', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue(null);
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects message from a different tenant without group access', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, tenantId: 'tnt-other' });
|
||||
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects already-approved message', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, status: 'APPROVED' });
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects when message has an existing Approval row', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, approval: { id: 'apr-1' } });
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(/already been approved/);
|
||||
});
|
||||
|
||||
it('returns routesForwarded=0 when no routes configured', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({
|
||||
...baseMessage,
|
||||
sourceGroup: { ...baseMessage.sourceGroup, syncRoutesFrom: [] },
|
||||
});
|
||||
mockPrisma.message.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrisma.approval.create.mockResolvedValue({});
|
||||
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
|
||||
|
||||
const res = await service.approve('tnt-1', 'adm-1', 'msg-1');
|
||||
expect(res.routesForwarded).toBe(0);
|
||||
expect(mockForwardQueue.add).not.toHaveBeenCalled();
|
||||
expect(mockIndexQueue.add).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles concurrent approval (updateMany.count=0) as conflict', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue(baseMessage);
|
||||
mockPrisma.message.updateMany.mockResolvedValue({ count: 0 });
|
||||
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
|
||||
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(/concurrent update/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue } from 'bullmq';
|
||||
import { ForwardJobData, IndexJobData } from '@tower/types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { createForwardQueue, createIndexQueue, FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
|
||||
|
||||
export interface PendingMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
senderJid: string;
|
||||
senderName: string | null;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
sourceGroupId: string;
|
||||
sourceGroupName: string;
|
||||
sourceGroupPlatformId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@Inject(FORWARD_QUEUE) private readonly forwardQueue: Queue<ForwardJobData>,
|
||||
@Inject(INDEX_QUEUE) private readonly indexQueue: Queue<IndexJobData>,
|
||||
) {}
|
||||
|
||||
async get(tenantId: string, id: string): Promise<any> {
|
||||
const msg = await this.prisma.message.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
sourceGroup: true,
|
||||
senderTowerUser: true,
|
||||
approval: true,
|
||||
},
|
||||
});
|
||||
if (!msg) throw new NotFoundException('Message not found');
|
||||
if (msg.tenantId !== tenantId) {
|
||||
const access = await this.prisma.groupAccess.findUnique({
|
||||
where: { groupId_tenantId: { groupId: msg.sourceGroupId, tenantId } },
|
||||
});
|
||||
if (!access) throw new NotFoundException('Message not found');
|
||||
}
|
||||
return {
|
||||
id: msg.id,
|
||||
tenantId: msg.tenantId,
|
||||
platform: msg.platform,
|
||||
platformMsgId: msg.platformMsgId,
|
||||
sourceGroupId: msg.sourceGroupId,
|
||||
sourceGroup: msg.sourceGroup,
|
||||
senderJid: msg.senderJid,
|
||||
senderName: msg.senderName,
|
||||
senderTowerUser: msg.senderTowerUser,
|
||||
content: msg.content,
|
||||
mediaUrl: msg.mediaUrl,
|
||||
tags: msg.tags,
|
||||
status: msg.status,
|
||||
createdAt: msg.createdAt.toISOString(),
|
||||
updatedAt: msg.updatedAt.toISOString(),
|
||||
approval: msg.approval
|
||||
? {
|
||||
id: msg.approval.id,
|
||||
adminId: msg.approval.adminId,
|
||||
decision: msg.approval.decision,
|
||||
notes: msg.approval.notes,
|
||||
decidedAt: msg.approval.decidedAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async pendingCount(tenantId: string): Promise<{ count: number }> {
|
||||
const count = await this.prisma.message.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return { count };
|
||||
}
|
||||
|
||||
async listPending(tenantId: string): Promise<PendingMessage[]> {
|
||||
const rows = await this.prisma.message.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
sourceGroup: { select: { name: true, platformId: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((m: any) => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
senderJid: m.senderJid,
|
||||
senderName: m.senderName,
|
||||
tags: m.tags ?? [],
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
sourceGroupId: m.sourceGroupId,
|
||||
sourceGroupName: m.sourceGroup?.name ?? '(unknown group)',
|
||||
sourceGroupPlatformId: m.sourceGroup?.platformId ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
approval: true,
|
||||
sourceGroup: {
|
||||
include: {
|
||||
syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!message) throw new NotFoundException('Message not found');
|
||||
if (message.tenantId !== tenantId) {
|
||||
const access = await this.prisma.groupAccess.findUnique({
|
||||
where: { groupId_tenantId: { groupId: message.sourceGroupId, tenantId } },
|
||||
});
|
||||
if (!access) throw new NotFoundException('Message not found');
|
||||
}
|
||||
if (message.status !== 'PENDING') {
|
||||
throw new ConflictException(`Message is already ${message.status}`);
|
||||
}
|
||||
if (message.approval) {
|
||||
throw new ConflictException('Message has already been approved');
|
||||
}
|
||||
|
||||
let approved = false;
|
||||
await this.prisma.$transaction(async (tx: any) => {
|
||||
const updated = await tx.message.updateMany({
|
||||
where: { id: message.id, status: 'PENDING' },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
if (updated.count === 0) return;
|
||||
approved = true;
|
||||
await tx.approval.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
adminId,
|
||||
decision: 'APPROVED',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!approved) {
|
||||
throw new ConflictException('Message could not be approved (concurrent update)');
|
||||
}
|
||||
|
||||
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
|
||||
(r: any) => r.targetGroup != null,
|
||||
);
|
||||
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
sourceGroupName: message.sourceGroup.name,
|
||||
senderName: message.senderName ?? undefined,
|
||||
toGroupJid: route.targetGroup.platformId,
|
||||
fromAccountId: route.targetGroup.accountId ?? message.sourceGroup.accountId ?? '',
|
||||
}));
|
||||
|
||||
for (const job of forwardJobs) {
|
||||
await this.forwardQueue.add('forward', job, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
});
|
||||
}
|
||||
|
||||
const indexDoc: IndexJobData = {
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
senderName: message.senderName ?? null,
|
||||
sourceGroupId: message.sourceGroupId,
|
||||
sourceGroupName: message.sourceGroup.name,
|
||||
tags: message.tags ?? [],
|
||||
platform: message.platform,
|
||||
approvedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.indexQueue.add('index', indexDoc, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.MESSAGE_APPROVED,
|
||||
resourceType: 'Message',
|
||||
resourceId: message.id,
|
||||
payload: {
|
||||
routesForwarded: forwardJobs.length,
|
||||
contentPreview: message.content.slice(0, 80),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
status: 'APPROVED',
|
||||
routesForwarded: forwardJobs.length,
|
||||
indexEnqueued: true,
|
||||
};
|
||||
}
|
||||
|
||||
async reindexApproved(tenantId: string): Promise<{ reindexed: number }> {
|
||||
const messages = await this.prisma.message.findMany({
|
||||
where: {
|
||||
status: 'APPROVED',
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
approval: { select: { decidedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const msg of messages) {
|
||||
const indexDoc: IndexJobData = {
|
||||
tenantId: msg.tenantId,
|
||||
messageId: msg.id,
|
||||
content: msg.content,
|
||||
senderName: msg.senderName ?? null,
|
||||
sourceGroupId: msg.sourceGroupId,
|
||||
sourceGroupName: msg.sourceGroup?.name ?? '(unknown)',
|
||||
tags: msg.tags ?? [],
|
||||
platform: msg.platform,
|
||||
approvedAt: (msg.approval?.decidedAt ?? msg.updatedAt).toISOString(),
|
||||
};
|
||||
await this.indexQueue.add('index', indexDoc, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
});
|
||||
}
|
||||
|
||||
return { reindexed: messages.length };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
|
||||
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class MyService {
|
||||
private readonly logger = new Logger(MyService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
|
||||
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
jid: user.jid,
|
||||
displayName: user.displayName,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
|
||||
const consents = await this.prisma.consentRecord.findMany({
|
||||
where: { userId, tenantId },
|
||||
include: { group: true },
|
||||
});
|
||||
return consents.map((c) => ({
|
||||
id: c.group.id,
|
||||
name: c.group.name,
|
||||
tenantId: c.tenantId,
|
||||
scopes: c.scopes as ConsentScope[],
|
||||
retentionDays: c.retentionDays,
|
||||
policyVersion: c.policyVersion,
|
||||
consentStatus: c.status as ConsentStatus,
|
||||
joinedAt: c.effectiveAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async getGroup(userId: string, tenantId: string, groupId: string): Promise<MemberGroupSummary> {
|
||||
const consent = await this.prisma.consentRecord.findFirst({
|
||||
where: { userId, tenantId, groupId },
|
||||
include: { group: true },
|
||||
});
|
||||
if (!consent) throw new NotFoundException('Not a member of this group');
|
||||
return {
|
||||
id: consent.group.id,
|
||||
name: consent.group.name,
|
||||
tenantId: consent.tenantId,
|
||||
scopes: consent.scopes as ConsentScope[],
|
||||
retentionDays: consent.retentionDays,
|
||||
policyVersion: consent.policyVersion,
|
||||
consentStatus: consent.status as ConsentStatus,
|
||||
joinedAt: consent.effectiveAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async optOut(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
body: OptOutRequest,
|
||||
): Promise<{ ok: true; revoked: number }> {
|
||||
const where = body.groupId
|
||||
? { userId, tenantId, groupId: body.groupId }
|
||||
: { userId, tenantId };
|
||||
const consents = await this.prisma.consentRecord.findMany({ where });
|
||||
if (consents.length === 0) {
|
||||
throw new NotFoundException('No matching consent records');
|
||||
}
|
||||
const reason = body.reason ?? MemberOptOutReasonEnum.SELF_PORTAL;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const consent of consents) {
|
||||
if (body.scopes && body.scopes.length > 0) {
|
||||
const remaining = (consent.scopes as ConsentScope[]).filter((s) => !body.scopes!.includes(s));
|
||||
if (remaining.length === 0) {
|
||||
await tx.consentRecord.update({
|
||||
where: { id: consent.id },
|
||||
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await tx.consentRecord.update({
|
||||
where: { id: consent.id },
|
||||
data: { scopes: remaining },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await tx.consentRecord.update({
|
||||
where: { id: consent.id },
|
||||
data: { status: ConsentStatus.REVOKED, revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
await tx.memberOptOut.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
groupId: consent.groupId,
|
||||
reason,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.MEMBER_OPT_OUT,
|
||||
resourceType: 'TowerUser',
|
||||
resourceId: userId,
|
||||
payload: { groupId: body.groupId, scopes: body.scopes, reason },
|
||||
});
|
||||
return { ok: true, revoked: consents.length };
|
||||
}
|
||||
|
||||
async optIn(
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
body: OptInRequest,
|
||||
): Promise<{ ok: true; consentId: string }> {
|
||||
if (body.scopes.length === 0) {
|
||||
throw new BadRequestException('At least one scope is required');
|
||||
}
|
||||
const group = await this.prisma.group.findFirst({ where: { id: body.groupId, tenantId } });
|
||||
if (!group) throw new NotFoundException('Group not found in your tenant');
|
||||
|
||||
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
const existing = await this.prisma.consentRecord.findFirst({
|
||||
where: { userId, tenantId, groupId: body.groupId },
|
||||
});
|
||||
|
||||
let consent;
|
||||
if (existing) {
|
||||
consent = await this.prisma.consentRecord.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
scopes: body.scopes,
|
||||
retentionDays: body.retentionDays ?? existing.retentionDays,
|
||||
status: ConsentStatus.GRANTED,
|
||||
revokedAt: null,
|
||||
effectiveAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
consent = await this.prisma.consentRecord.create({
|
||||
data: {
|
||||
tenantId,
|
||||
groupId: body.groupId,
|
||||
userId,
|
||||
scopes: body.scopes,
|
||||
retentionDays: body.retentionDays ?? 90,
|
||||
policyVersion: 'v1',
|
||||
status: ConsentStatus.GRANTED,
|
||||
proofEventId: 'self',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.MEMBER_OPT_IN,
|
||||
resourceType: 'TowerUser',
|
||||
resourceId: userId,
|
||||
payload: { groupId: body.groupId, scopes: body.scopes },
|
||||
});
|
||||
|
||||
return { ok: true, consentId: consent.id };
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string, tenantId: string): Promise<{ ok: true }> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.consentRecord.deleteMany({ where: { userId, tenantId } });
|
||||
await tx.memberOptOut.deleteMany({ where: { userId, tenantId } });
|
||||
await tx.towerSession.deleteMany({ where: { userId } });
|
||||
await tx.towerUser.delete({ where: { id: userId } });
|
||||
});
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.MEMBER_DELETED,
|
||||
resourceType: 'TowerUser',
|
||||
resourceId: userId,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { PublicOnboardingController } from './public-onboarding.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, AuthModule],
|
||||
controllers: [PublicOnboardingController],
|
||||
providers: [OnboardingService],
|
||||
exports: [OnboardingService],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UnauthorizedException, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const PEPPER = 'pepper-secret-must-be-32-chars-min';
|
||||
const TEST_PHONE = '+19198765432';
|
||||
const TEST_PHONE_HASH = createHash('sha256').update(`${PEPPER}:${TEST_PHONE}`).digest('hex');
|
||||
|
||||
describe('OnboardingService', () => {
|
||||
let service: OnboardingService;
|
||||
const mockPrisma: any = {
|
||||
group: { findUnique: jest.fn() },
|
||||
tenant: { findUnique: jest.fn() },
|
||||
otpChallenge: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
|
||||
towerUser: { upsert: jest.fn() },
|
||||
consentRecord: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||
};
|
||||
const mockJwt = { signAsync: jest.fn().mockResolvedValue('member-jwt'), verify: jest.fn() };
|
||||
const mockAudit = { log: jest.fn() };
|
||||
const mockConfig = { get: jest.fn().mockReturnValue('pepper-secret-must-be-32-chars-min') };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OnboardingService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: JwtService, useValue: mockJwt },
|
||||
{ provide: AuditService, useValue: mockAudit },
|
||||
{ provide: ConfigService, useValue: mockConfig },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<OnboardingService>(OnboardingService);
|
||||
});
|
||||
|
||||
function validToken(): string {
|
||||
return Buffer.from(
|
||||
JSON.stringify({ tenantId: 'tnt-1', groupId: 'grp-1', jid: '1234@s.whatsapp.net' }),
|
||||
'utf8',
|
||||
).toString('base64url');
|
||||
}
|
||||
|
||||
describe('decodeOnboardingToken', () => {
|
||||
it('rejects garbage', () => {
|
||||
expect(() => service.decodeOnboardingToken('!!!')).toThrow(UnauthorizedException);
|
||||
});
|
||||
it('rejects missing fields', () => {
|
||||
const tok = Buffer.from(JSON.stringify({ groupId: 'x' }), 'utf8').toString('base64url');
|
||||
expect(() => service.decodeOnboardingToken(tok)).toThrow(UnauthorizedException);
|
||||
});
|
||||
it('decodes a valid token', () => {
|
||||
const out = service.decodeOnboardingToken(validToken());
|
||||
expect(out.tenantId).toBe('tnt-1');
|
||||
expect(out.jid).toBe('1234@s.whatsapp.net');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOnboardInfo', () => {
|
||||
it('throws when group is not claimed', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', name: 'Foo', tenantId: null });
|
||||
await expect(service.getOnboardInfo(validToken())).rejects.toThrow(ConflictException);
|
||||
});
|
||||
it('returns group + tenant + policy info', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', name: 'UP Parivar', tenantId: 'tnt-1' });
|
||||
mockPrisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', name: 'UP Parivar Dallas' });
|
||||
const res = await service.getOnboardInfo(validToken());
|
||||
expect(res.groupName).toBe('UP Parivar');
|
||||
expect(res.tenantName).toBe('UP Parivar Dallas');
|
||||
expect(res.defaultScopes).toContain('INGEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestOtp', () => {
|
||||
it('creates a challenge with a 6-digit code', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1' });
|
||||
mockPrisma.otpChallenge.create.mockResolvedValue({ id: 'ch-1' });
|
||||
const res = await service.requestOtp(validToken(), '+19198765432');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.challengeId).toBeTruthy();
|
||||
expect(res.expiresInSeconds).toBe(300);
|
||||
expect(mockPrisma.otpChallenge.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
jid: '1234@s.whatsapp.net',
|
||||
policyVersion: 'v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects if group is not claimable', async () => {
|
||||
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: null });
|
||||
await expect(service.requestOtp(validToken(), '+19198765432')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyOtp', () => {
|
||||
it('rejects unknown challenge', async () => {
|
||||
mockPrisma.otpChallenge.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.verifyOtp(validToken(), 'ch-x', '+19198765432', '123456', [], undefined),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects consumed challenge', async () => {
|
||||
mockPrisma.otpChallenge.findUnique.mockResolvedValue({
|
||||
id: 'ch-1',
|
||||
code: '123456',
|
||||
consumedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
phoneHash: 'a',
|
||||
});
|
||||
await expect(
|
||||
service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects wrong code', async () => {
|
||||
mockPrisma.otpChallenge.findUnique.mockResolvedValue({
|
||||
id: 'ch-1',
|
||||
code: '111111',
|
||||
consumedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
phoneHash: 'computed-hash',
|
||||
});
|
||||
await expect(
|
||||
service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined),
|
||||
).rejects.toThrow(/Invalid code/);
|
||||
});
|
||||
|
||||
it('creates TowerUser, ConsentRecord, and member JWT on success', async () => {
|
||||
mockPrisma.otpChallenge.findUnique.mockResolvedValue({
|
||||
id: 'ch-1',
|
||||
code: '123456',
|
||||
consumedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
phoneHash: TEST_PHONE_HASH,
|
||||
tenantId: 'tnt-1',
|
||||
jid: '1234@s.whatsapp.net',
|
||||
groupId: 'grp-1',
|
||||
});
|
||||
mockPrisma.towerUser.upsert.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
tenantId: 'tnt-1',
|
||||
jid: '1234@s.whatsapp.net',
|
||||
displayName: '1234@s.whatsapp.net',
|
||||
});
|
||||
mockPrisma.consentRecord.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.consentRecord.create.mockResolvedValue({
|
||||
id: 'consent-1',
|
||||
scopes: ['INGEST', 'DISPLAY'],
|
||||
retentionDays: 90,
|
||||
policyVersion: 'v1',
|
||||
});
|
||||
|
||||
const res = await service.verifyOtp(validToken(), 'ch-1', '+19198765432', '123456', [], undefined);
|
||||
expect(res.memberToken).toBe('member-jwt');
|
||||
expect(res.user.id).toBe('user-1');
|
||||
expect(res.consent.scopes).toContain('INGEST');
|
||||
expect(mockPrisma.otpChallenge.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ch-1' },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'MEMBER_ONBOARDED' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { ConsentScope, OnboardingTokenPayload, PublicOnboardInfo, RequestOtpResponse, VerifyOtpResponse } from '@tower/types';
|
||||
import { ConsentStatus } from '@prisma/client';
|
||||
|
||||
const POLICY_VERSION = 'v1';
|
||||
const OTP_TTL_MIN = 5;
|
||||
const DEFAULT_SCOPES: ConsentScope[] = ['INGEST', 'DISPLAY'];
|
||||
const DEFAULT_RETENTION_DAYS = 90;
|
||||
|
||||
function hashPhone(phone: string, pepper: string): string {
|
||||
const normalized = phone.replace(/[^\d+]/g, '');
|
||||
return createHash('sha256').update(`${pepper}:${normalized}`).digest('hex');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OnboardingService {
|
||||
private readonly logger = new Logger(OnboardingService.name);
|
||||
private readonly policyVersion = POLICY_VERSION;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
decodeOnboardingToken(token: string): OnboardingTokenPayload {
|
||||
// Phase 2B: token is base64url({groupId, jid, tenantId}) — no signature.
|
||||
// The OTP step (sent via DM to the jid) is the real authentication.
|
||||
try {
|
||||
const json = Buffer.from(token, 'base64url').toString('utf8');
|
||||
const parsed = JSON.parse(json) as OnboardingTokenPayload;
|
||||
if (!parsed.groupId || !parsed.jid || !parsed.tenantId) {
|
||||
throw new Error('Missing required fields');
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid onboarding link');
|
||||
}
|
||||
}
|
||||
|
||||
async getOnboardInfo(token: string): Promise<PublicOnboardInfo> {
|
||||
const payload = this.decodeOnboardingToken(token);
|
||||
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
|
||||
if (!group) throw new NotFoundException('Group not found');
|
||||
if (!group.tenantId) {
|
||||
throw new ConflictException('Group is not yet claimed by a tenant');
|
||||
}
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: payload.tenantId } });
|
||||
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||
return {
|
||||
groupName: group.name,
|
||||
tenantName: tenant.name,
|
||||
policyVersion: this.policyVersion,
|
||||
defaultScopes: DEFAULT_SCOPES,
|
||||
defaultRetentionDays: DEFAULT_RETENTION_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
async requestOtp(token: string, phone: string): Promise<RequestOtpResponse> {
|
||||
const payload = this.decodeOnboardingToken(token);
|
||||
if (payload.jid !== this.normalizeJid(payload.jid)) {
|
||||
// sanity
|
||||
}
|
||||
if (!phone || phone.length < 6) {
|
||||
throw new BadRequestException('Invalid phone number');
|
||||
}
|
||||
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
|
||||
if (!group || !group.tenantId) {
|
||||
throw new ConflictException('Group is not claimable');
|
||||
}
|
||||
|
||||
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
|
||||
const phoneHash = hashPhone(phone, pepper);
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
|
||||
const challengeId = randomBytes(16).toString('hex');
|
||||
|
||||
const challenge = await this.prisma.otpChallenge.create({
|
||||
data: {
|
||||
id: challengeId,
|
||||
tenantId: payload.tenantId,
|
||||
jid: payload.jid,
|
||||
phoneHash,
|
||||
code,
|
||||
scopes: DEFAULT_SCOPES,
|
||||
retentionDays: DEFAULT_RETENTION_DAYS,
|
||||
policyVersion: this.policyVersion,
|
||||
groupId: payload.groupId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId: payload.tenantId,
|
||||
action: AuditAction.OTP_REQUESTED,
|
||||
resourceType: 'OtpChallenge',
|
||||
resourceId: challenge.id,
|
||||
payload: { jid: payload.jid },
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
challengeId,
|
||||
expiresInSeconds: OTP_TTL_MIN * 60,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOtp(
|
||||
token: string,
|
||||
challengeId: string,
|
||||
phone: string,
|
||||
code: string,
|
||||
scopes: ConsentScope[],
|
||||
retentionDays?: number,
|
||||
): Promise<VerifyOtpResponse> {
|
||||
const payload = this.decodeOnboardingToken(token);
|
||||
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
|
||||
const phoneHash = hashPhone(phone, pepper);
|
||||
|
||||
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
|
||||
if (!challenge) throw new NotFoundException('Challenge not found');
|
||||
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
|
||||
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Challenge expired');
|
||||
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
|
||||
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
|
||||
|
||||
const effectiveScopes = scopes.length > 0 ? scopes : DEFAULT_SCOPES;
|
||||
const effectiveRetention = retentionDays ?? DEFAULT_RETENTION_DAYS;
|
||||
|
||||
const user = await this.prisma.towerUser.upsert({
|
||||
where: { tenantId_phoneHash: { tenantId: payload.tenantId, phoneHash } },
|
||||
update: { jid: payload.jid, displayName: payload.jid },
|
||||
create: {
|
||||
tenantId: payload.tenantId,
|
||||
phoneHash,
|
||||
jid: payload.jid,
|
||||
displayName: payload.jid,
|
||||
},
|
||||
});
|
||||
|
||||
const existing = await this.prisma.consentRecord.findFirst({
|
||||
where: { tenantId: payload.tenantId, groupId: payload.groupId, userId: user.id },
|
||||
});
|
||||
|
||||
let consent;
|
||||
if (existing) {
|
||||
consent = await this.prisma.consentRecord.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
scopes: effectiveScopes,
|
||||
retentionDays: effectiveRetention,
|
||||
policyVersion: this.policyVersion,
|
||||
status: ConsentStatus.GRANTED,
|
||||
revokedAt: null,
|
||||
effectiveAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
consent = await this.prisma.consentRecord.create({
|
||||
data: {
|
||||
tenantId: payload.tenantId,
|
||||
groupId: payload.groupId,
|
||||
userId: user.id,
|
||||
scopes: effectiveScopes,
|
||||
retentionDays: effectiveRetention,
|
||||
policyVersion: this.policyVersion,
|
||||
status: ConsentStatus.GRANTED,
|
||||
proofEventId: 'pending',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.consentRecord.update({
|
||||
where: { id: consent.id },
|
||||
data: { proofEventId: consent.id },
|
||||
});
|
||||
|
||||
await this.prisma.otpChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId: payload.tenantId,
|
||||
action: AuditAction.MEMBER_ONBOARDED,
|
||||
resourceType: 'TowerUser',
|
||||
resourceId: user.id,
|
||||
payload: {
|
||||
jid: payload.jid,
|
||||
groupId: payload.groupId,
|
||||
consentId: consent.id,
|
||||
scopes: effectiveScopes,
|
||||
},
|
||||
});
|
||||
|
||||
const memberToken = await this.jwt.signAsync({
|
||||
kind: 'member',
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
jid: user.jid,
|
||||
phoneHash: user.phoneHash,
|
||||
} as const);
|
||||
|
||||
return {
|
||||
memberToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
jid: user.jid,
|
||||
displayName: user.displayName,
|
||||
},
|
||||
consent: {
|
||||
scopes: consent.scopes as ConsentScope[],
|
||||
retentionDays: consent.retentionDays,
|
||||
policyVersion: consent.policyVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeJid(jid: string): string {
|
||||
return jid.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||
import { ConsentScope } from '@tower/types';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
|
||||
class RequestOtpDto {
|
||||
@IsString() @MinLength(8) onboardingToken!: string;
|
||||
@IsString() @MinLength(6) phone!: string;
|
||||
}
|
||||
|
||||
class VerifyOtpDto {
|
||||
@IsString() @MinLength(8) onboardingToken!: string;
|
||||
@IsString() challengeId!: string;
|
||||
@IsString() @MinLength(6) phone!: string;
|
||||
@IsString() @MinLength(6) code!: string;
|
||||
@IsArray() @IsOptional() scopes?: ConsentScope[];
|
||||
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
|
||||
}
|
||||
|
||||
@Controller('public')
|
||||
export class PublicOnboardingController {
|
||||
constructor(private readonly service: OnboardingService) {}
|
||||
|
||||
@Get('onboard/:token')
|
||||
@Public()
|
||||
getOnboardInfo(@Param('token') token: string) {
|
||||
return this.service.getOnboardInfo(token);
|
||||
}
|
||||
|
||||
@Post('auth/request-otp')
|
||||
@Public()
|
||||
requestOtp(@Body() body: RequestOtpDto) {
|
||||
return this.service.requestOtp(body.onboardingToken, body.phone);
|
||||
}
|
||||
|
||||
@Post('auth/verify-otp')
|
||||
@Public()
|
||||
verifyOtp(@Body() body: VerifyOtpDto) {
|
||||
return this.service.verifyOtp(
|
||||
body.onboardingToken,
|
||||
body.challengeId,
|
||||
body.phone,
|
||||
body.code,
|
||||
body.scopes ?? [],
|
||||
body.retentionDays,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RoutesController } from './routes.controller';
|
||||
import { RoutesService } from './routes.service';
|
||||
import type { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
const ctx: TenantContext = { tenantId: 'tnt_1', adminId: 'adm_1', role: 'OWNER' };
|
||||
|
||||
const mockRoute = {
|
||||
id: 'rt_1',
|
||||
@@ -12,6 +15,7 @@ const mockRoute = {
|
||||
const mockService = {
|
||||
list: jest.fn().mockResolvedValue([mockRoute]),
|
||||
create: jest.fn().mockResolvedValue(mockRoute),
|
||||
createBatch: jest.fn().mockResolvedValue([mockRoute, { ...mockRoute, id: 'rt_2', targetGroupId: 'grp_3', targetGroup: { name: 'Gamma' } }]),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
@@ -27,25 +31,31 @@ describe('RoutesController', () => {
|
||||
controller = module.get<RoutesController>(RoutesController);
|
||||
});
|
||||
|
||||
it('list() delegates to service with no filter', async () => {
|
||||
const result = await controller.list(undefined);
|
||||
it('list() delegates to service with tenantId and no filter', async () => {
|
||||
const result = await controller.list(ctx, undefined);
|
||||
expect(result).toEqual([mockRoute]);
|
||||
expect(mockService.list).toHaveBeenCalledWith(undefined);
|
||||
expect(mockService.list).toHaveBeenCalledWith('tnt_1', undefined);
|
||||
});
|
||||
|
||||
it('list() passes sourceGroupId filter to service', async () => {
|
||||
await controller.list('grp_1');
|
||||
expect(mockService.list).toHaveBeenCalledWith('grp_1');
|
||||
it('list() passes sourceGroupId filter to service along with tenantId', async () => {
|
||||
await controller.list(ctx, 'grp_1');
|
||||
expect(mockService.list).toHaveBeenCalledWith('tnt_1', 'grp_1');
|
||||
});
|
||||
|
||||
it('create() extracts body fields and delegates to service', async () => {
|
||||
const result = await controller.create({ sourceGroupId: 'grp_1', targetGroupId: 'grp_2' });
|
||||
it('create() extracts body fields and delegates to service with tenantId', async () => {
|
||||
const result = await controller.create(ctx, { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' });
|
||||
expect(result).toEqual(mockRoute);
|
||||
expect(mockService.create).toHaveBeenCalledWith('grp_1', 'grp_2');
|
||||
expect(mockService.create).toHaveBeenCalledWith('tnt_1', 'grp_1', 'grp_2');
|
||||
});
|
||||
|
||||
it('remove() delegates id to service', async () => {
|
||||
await controller.remove('rt_1');
|
||||
expect(mockService.remove).toHaveBeenCalledWith('rt_1');
|
||||
it('remove() delegates tenantId and id to service', async () => {
|
||||
await controller.remove(ctx, 'rt_1');
|
||||
expect(mockService.remove).toHaveBeenCalledWith('tnt_1', 'rt_1');
|
||||
});
|
||||
|
||||
it('createBatch() extracts body fields and delegates to service with tenantId', async () => {
|
||||
const result = await controller.createBatch(ctx, { sourceGroupId: 'grp_1', targetGroupIds: ['grp_2', 'grp_3'] });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockService.createBatch).toHaveBeenCalledWith('tnt_1', 'grp_1', ['grp_2', 'grp_3']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
class CreateRouteDto {
|
||||
@IsString() sourceGroupId!: string;
|
||||
@IsString() targetGroupId!: string;
|
||||
}
|
||||
|
||||
class BatchCreateRouteDto {
|
||||
@IsString() sourceGroupId!: string;
|
||||
@IsString({ each: true }) targetGroupIds!: string[];
|
||||
}
|
||||
|
||||
@Controller('routes')
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('sourceGroupId') sourceGroupId?: string) {
|
||||
return this.routesService.list(sourceGroupId);
|
||||
list(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Query('sourceGroupId') sourceGroupId?: string,
|
||||
) {
|
||||
return this.routesService.list(ctx.tenantId, sourceGroupId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: { sourceGroupId: string; targetGroupId: string }) {
|
||||
return this.routesService.create(body.sourceGroupId, body.targetGroupId);
|
||||
create(@CurrentTenantContext() ctx: TenantContext, @Body() body: CreateRouteDto) {
|
||||
return this.routesService.create(ctx.tenantId, body.sourceGroupId, body.targetGroupId);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
createBatch(@CurrentTenantContext() ctx: TenantContext, @Body() body: BatchCreateRouteDto) {
|
||||
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.routesService.remove(id);
|
||||
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
await this.routesService.remove(ctx.tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, NotFoundException } from '@nest
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
|
||||
const mockRoute = {
|
||||
id: 'rt_1',
|
||||
@@ -14,15 +15,32 @@ const mockRoute = {
|
||||
targetGroup: { name: 'Beta' },
|
||||
};
|
||||
|
||||
const mockRoute2 = {
|
||||
id: 'rt_2',
|
||||
sourceGroupId: 'grp_1',
|
||||
targetGroupId: 'grp_3',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
sourceGroup: { name: 'Alpha' },
|
||||
targetGroup: { name: 'Gamma' },
|
||||
};
|
||||
|
||||
describe('RoutesService', () => {
|
||||
let service: RoutesService;
|
||||
const mockPrisma = {
|
||||
const mockPrisma: any = {
|
||||
$transaction: jest.fn((ops: any[]) => Promise.all(ops)),
|
||||
syncRoute: {
|
||||
findMany: jest.fn().mockResolvedValue([mockRoute]),
|
||||
findFirst: jest.fn().mockResolvedValue(mockRoute),
|
||||
create: jest.fn().mockResolvedValue(mockRoute),
|
||||
delete: jest.fn().mockResolvedValue(mockRoute),
|
||||
},
|
||||
group: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'g' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'grp_2', name: 'Beta' }, { id: 'grp_3', name: 'Gamma' }]),
|
||||
},
|
||||
};
|
||||
const mockAudit: any = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
@@ -30,53 +48,51 @@ describe('RoutesService', () => {
|
||||
providers: [
|
||||
RoutesService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: AuditService, useValue: mockAudit },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<RoutesService>(RoutesService);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all routes with group names', async () => {
|
||||
const result = await service.list();
|
||||
it('returns routes for tenant', async () => {
|
||||
const result = await service.list('tnt-1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sourceGroup.name).toBe('Alpha');
|
||||
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith({
|
||||
where: undefined,
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
targetGroup: { select: { name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tenantId: 'tnt-1' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by sourceGroupId when provided', async () => {
|
||||
await service.list('grp_1');
|
||||
await service.list('tnt-1', 'grp_1');
|
||||
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { sourceGroupId: 'grp_1' } }),
|
||||
expect.objectContaining({ where: { tenantId: 'tnt-1', sourceGroupId: 'grp_1' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a route and returns it with group names', async () => {
|
||||
const result = await service.create('grp_1', 'grp_2');
|
||||
it('creates a route within the tenant and writes audit', async () => {
|
||||
const result = await service.create('tnt-1', 'grp_1', 'grp_2');
|
||||
expect(result).toEqual(mockRoute);
|
||||
expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({
|
||||
data: { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' },
|
||||
data: { tenantId: 'tnt-1', sourceGroupId: 'grp_1', targetGroupId: 'grp_2' },
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
targetGroup: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'ROUTE_CREATED', resourceId: 'rt_1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when sourceGroupId is empty', async () => {
|
||||
await expect(service.create('', 'grp_2')).rejects.toThrow(BadRequestException);
|
||||
await expect(service.create('tnt-1', '', 'grp_2')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when targetGroupId is empty', async () => {
|
||||
await expect(service.create('grp_1', '')).rejects.toThrow(BadRequestException);
|
||||
await expect(service.create('tnt-1', 'grp_1', '')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when route already exists (Prisma P2002)', async () => {
|
||||
@@ -85,36 +101,87 @@ describe('RoutesService', () => {
|
||||
clientVersion: '6.0.0',
|
||||
});
|
||||
mockPrisma.syncRoute.create.mockRejectedValueOnce(p2002);
|
||||
await expect(service.create('grp_1', 'grp_2')).rejects.toThrow(ConflictException);
|
||||
await expect(service.create('tnt-1', 'grp_1', 'grp_2')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when source and target are the same group', async () => {
|
||||
await expect(service.create('grp_1', 'grp_1')).rejects.toThrow(BadRequestException);
|
||||
await expect(service.create('tnt-1', 'grp_1', 'grp_1')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when a group ID does not exist (Prisma P2003)', async () => {
|
||||
const p2003 = new Prisma.PrismaClientKnownRequestError('Foreign key constraint', {
|
||||
code: 'P2003',
|
||||
clientVersion: '6.0.0',
|
||||
});
|
||||
mockPrisma.syncRoute.create.mockRejectedValueOnce(p2003);
|
||||
await expect(service.create('grp_1', 'bad_grp')).rejects.toThrow(BadRequestException);
|
||||
it('throws BadRequestException when a group is not in this tenant', async () => {
|
||||
mockPrisma.group.findFirst.mockResolvedValueOnce(null);
|
||||
await expect(service.create('tnt-1', 'grp_1', 'grp_x')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBatch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPrisma.syncRoute.create
|
||||
.mockResolvedValueOnce(mockRoute)
|
||||
.mockResolvedValueOnce(mockRoute2);
|
||||
mockPrisma.syncRoute.findMany.mockResolvedValue([]);
|
||||
mockPrisma.group.findMany.mockResolvedValue([
|
||||
{ id: 'grp_2', name: 'Beta' },
|
||||
{ id: 'grp_3', name: 'Gamma' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates multiple routes in batch', async () => {
|
||||
const result = await service.createBatch('tnt-1', 'grp_1', ['grp_2', 'grp_3']);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'ROUTE_CREATED',
|
||||
payload: expect.objectContaining({ count: 2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when targetGroupIds is empty', async () => {
|
||||
await expect(service.createBatch('tnt-1', 'grp_1', [])).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when source is also a target', async () => {
|
||||
await expect(service.createBatch('tnt-1', 'grp_1', ['grp_1'])).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when a target group is not in this tenant', async () => {
|
||||
mockPrisma.group.findMany.mockResolvedValueOnce([{ id: 'grp_2', name: 'Beta' }]);
|
||||
await expect(service.createBatch('tnt-1', 'grp_1', ['grp_2', 'grp_x'])).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when any route already exists', async () => {
|
||||
mockPrisma.syncRoute.findMany.mockResolvedValue([{ targetGroupId: 'grp_2' }]);
|
||||
await expect(service.createBatch('tnt-1', 'grp_1', ['grp_2', 'grp_3'])).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes a route by id', async () => {
|
||||
await service.remove('rt_1');
|
||||
it('deletes a route and writes audit', async () => {
|
||||
await service.remove('tnt-1', 'rt_1');
|
||||
expect(mockPrisma.syncRoute.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: 'rt_1', tenantId: 'tnt-1' },
|
||||
});
|
||||
expect(mockPrisma.syncRoute.delete).toHaveBeenCalledWith({ where: { id: 'rt_1' } });
|
||||
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'ROUTE_DELETED', resourceId: 'rt_1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when route does not exist (Prisma P2025)', async () => {
|
||||
it('throws NotFoundException when route is not in this tenant', async () => {
|
||||
mockPrisma.syncRoute.findFirst.mockResolvedValueOnce(null);
|
||||
await expect(service.remove('tnt-1', 'bad_id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException on Prisma P2025', async () => {
|
||||
const p2025 = new Prisma.PrismaClientKnownRequestError('Record not found', {
|
||||
code: 'P2025',
|
||||
clientVersion: '6.0.0',
|
||||
});
|
||||
mockPrisma.syncRoute.delete.mockRejectedValueOnce(p2025);
|
||||
await expect(service.remove('bad_id')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('tnt-1', 'rt_1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,174 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
|
||||
export interface BatchCreateResult {
|
||||
created: Array<{ id: string; sourceGroupId: string; targetGroupId: string; sourceGroup: { name: string }; targetGroup: { name: string } }>;
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
const routeInclude = {
|
||||
sourceGroup: { select: { name: true } },
|
||||
targetGroup: { select: { name: true } },
|
||||
sourceGroup: { select: { name: true, tenantId: true } },
|
||||
targetGroup: { select: { name: true, tenantId: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
list(sourceGroupId?: string) {
|
||||
list(tenantId: string, sourceGroupId?: string) {
|
||||
return this.prisma.syncRoute.findMany({
|
||||
where: sourceGroupId ? { sourceGroupId } : undefined,
|
||||
include: routeInclude,
|
||||
where: {
|
||||
tenantId,
|
||||
...(sourceGroupId ? { sourceGroupId } : {}),
|
||||
},
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
targetGroup: { select: { name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(sourceGroupId: string, targetGroupId: string) {
|
||||
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
|
||||
if (!sourceGroupId || !targetGroupId) {
|
||||
throw new BadRequestException('sourceGroupId and targetGroupId are required');
|
||||
}
|
||||
if (sourceGroupId === targetGroupId) {
|
||||
throw new BadRequestException('Source and target groups cannot be the same');
|
||||
}
|
||||
|
||||
// Source must be owned by this tenant AND active; target can be owned OR shared AND active
|
||||
const [source, target] = await Promise.all([
|
||||
this.prisma.group.findFirst({ where: { id: sourceGroupId, tenantId, isActive: true }, select: { id: true } }),
|
||||
this.prisma.group.findFirst({
|
||||
where: {
|
||||
id: targetGroupId,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ groupAccesses: { some: { tenantId } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
if (!source) throw new BadRequestException('Source group is not available or the bot has been removed from it');
|
||||
if (!target) throw new BadRequestException('Target group is not available or the bot has been removed from it');
|
||||
|
||||
try {
|
||||
return await this.prisma.syncRoute.create({
|
||||
data: { sourceGroupId, targetGroupId },
|
||||
include: routeInclude,
|
||||
const route = await this.prisma.syncRoute.create({
|
||||
data: { tenantId, sourceGroupId, targetGroupId },
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
targetGroup: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.ROUTE_CREATED,
|
||||
resourceType: 'SyncRoute',
|
||||
resourceId: route.id,
|
||||
payload: { sourceGroupId, targetGroupId },
|
||||
});
|
||||
return route;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === 'P2002') {
|
||||
throw new ConflictException('A route between these groups already exists');
|
||||
}
|
||||
if (e.code === 'P2003') {
|
||||
throw new BadRequestException('One or both group IDs do not exist');
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
async createBatch(tenantId: string, sourceGroupId: string, targetGroupIds: string[]) {
|
||||
if (!sourceGroupId || !targetGroupIds.length) {
|
||||
throw new BadRequestException('sourceGroupId and at least one targetGroupId are required');
|
||||
}
|
||||
|
||||
if (targetGroupIds.includes(sourceGroupId)) {
|
||||
throw new BadRequestException('Source and target groups cannot be the same');
|
||||
}
|
||||
|
||||
// Source group must exist in this tenant AND be active
|
||||
const source = await this.prisma.group.findFirst({ where: { id: sourceGroupId, tenantId, isActive: true }, select: { id: true } });
|
||||
if (!source) {
|
||||
throw new BadRequestException('Source group is not available or the bot has been removed from it');
|
||||
}
|
||||
|
||||
// All target groups must be owned OR shared AND active
|
||||
const targets = await this.prisma.group.findMany({
|
||||
where: {
|
||||
id: { in: targetGroupIds },
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ groupAccesses: { some: { tenantId } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (targets.length !== targetGroupIds.length) {
|
||||
const found = new Set(targets.map((t) => t.id));
|
||||
const missing = targetGroupIds.filter((id) => !found.has(id));
|
||||
throw new BadRequestException(`Target groups not found or not shared: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check for existing conflicts — reject the whole batch
|
||||
const existing = await this.prisma.syncRoute.findMany({
|
||||
where: { tenantId, sourceGroupId, targetGroupId: { in: targetGroupIds } },
|
||||
select: { targetGroupId: true },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
const names = existing.map((e) => {
|
||||
const t = targets.find((t) => t.id === e.targetGroupId);
|
||||
return t?.name ?? e.targetGroupId;
|
||||
});
|
||||
throw new ConflictException(`Routes already exist for: ${names.join(', ')}`);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(
|
||||
targetGroupIds.map((targetGroupId) =>
|
||||
this.prisma.syncRoute.create({
|
||||
data: { tenantId, sourceGroupId, targetGroupId },
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
targetGroup: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.ROUTE_CREATED,
|
||||
resourceType: 'SyncRoute',
|
||||
resourceId: created.map((r) => r.id).join(','),
|
||||
payload: { sourceGroupId, targetGroupIds, count: created.length },
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
// Verify ownership before delete
|
||||
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) throw new NotFoundException(`Route ${id} not found`);
|
||||
|
||||
try {
|
||||
await this.prisma.syncRoute.delete({ where: { id } });
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
action: AuditAction.ROUTE_DELETED,
|
||||
resourceType: 'SyncRoute',
|
||||
resourceId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException(`Route ${id} not found`);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { RulesService } from './rules.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
import { CreateRuleRequest, UpdateRuleRequest } from '@tower/types';
|
||||
|
||||
@Controller('admin/rules')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class RulesController {
|
||||
constructor(private readonly rulesService: RulesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.rulesService.list(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: CreateRuleRequest,
|
||||
) {
|
||||
return this.rulesService.create(ctx.tenantId, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateRuleRequest,
|
||||
) {
|
||||
return this.rulesService.update(ctx.tenantId, id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.rulesService.remove(ctx.tenantId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RulesController } from './rules.controller';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RulesController],
|
||||
providers: [RulesService],
|
||||
exports: [RulesService],
|
||||
})
|
||||
export class RulesModule {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { RulesService } from './rules.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
describe('RulesService', () => {
|
||||
let service: RulesService;
|
||||
const mockPrisma: any = {
|
||||
tenantRule: { findMany: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn() },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RulesService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<RulesService>(RulesService);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns rules ordered by priority', async () => {
|
||||
mockPrisma.tenantRule.findMany.mockResolvedValue([
|
||||
{ id: 'r1', tenantId: 'tnt-1', matchType: 'HASHTAG', matchValue: '#important', action: 'FLAG', priority: 0, isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01') },
|
||||
{ id: 'r2', tenantId: 'tnt-1', matchType: 'PREFIX', matchValue: '/tower', action: 'FLAG', priority: 1, isActive: true, createdAt: new Date('2026-01-02'), updatedAt: new Date('2026-01-02') },
|
||||
]);
|
||||
const res = await service.list('tnt-1');
|
||||
expect(res).toHaveLength(2);
|
||||
expect(res[0].matchValue).toBe('#important');
|
||||
expect(mockPrisma.tenantRule.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { tenantId: 'tnt-1' } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns a rule', async () => {
|
||||
mockPrisma.tenantRule.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.tenantRule.create.mockResolvedValue({
|
||||
id: 'r1', tenantId: 'tnt-1', matchType: 'HASHTAG', matchValue: '#event', action: 'FLAG', priority: 0, isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
|
||||
});
|
||||
const res = await service.create('tnt-1', { matchType: 'HASHTAG', matchValue: '#event', action: 'FLAG' });
|
||||
expect(res.matchValue).toBe('#event');
|
||||
});
|
||||
|
||||
it('rejects duplicate rule', async () => {
|
||||
mockPrisma.tenantRule.findUnique.mockResolvedValue({ id: 'existing' });
|
||||
await expect(service.create('tnt-1', { matchType: 'HASHTAG', matchValue: '#event', action: 'FLAG' })).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns a rule', async () => {
|
||||
mockPrisma.tenantRule.findFirst.mockResolvedValue({ id: 'r1', tenantId: 'tnt-1' });
|
||||
mockPrisma.tenantRule.update.mockResolvedValue({
|
||||
id: 'r1', tenantId: 'tnt-1', matchType: 'HASHTAG', matchValue: '#event', action: 'AUTO_APPROVE', priority: 0, isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-02'),
|
||||
});
|
||||
const res = await service.update('tnt-1', 'r1', { action: 'AUTO_APPROVE' });
|
||||
expect(res.action).toBe('AUTO_APPROVE');
|
||||
});
|
||||
|
||||
it('throws on non-existent rule', async () => {
|
||||
mockPrisma.tenantRule.findFirst.mockResolvedValue(null);
|
||||
await expect(service.update('tnt-1', 'missing', { action: 'AUTO_APPROVE' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes a rule', async () => {
|
||||
mockPrisma.tenantRule.findFirst.mockResolvedValue({ id: 'r1', tenantId: 'tnt-1' });
|
||||
mockPrisma.tenantRule.delete.mockResolvedValue({});
|
||||
await expect(service.remove('tnt-1', 'r1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws on non-existent rule', async () => {
|
||||
mockPrisma.tenantRule.findFirst.mockResolvedValue(null);
|
||||
await expect(service.remove('tnt-1', 'missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateRuleRequest, TenantRuleData, UpdateRuleRequest } from '@tower/types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(tenantId: string): Promise<TenantRuleData[]> {
|
||||
const rows = await this.prisma.tenantRule.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
return rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
tenantId: r.tenantId,
|
||||
matchType: r.matchType,
|
||||
matchValue: r.matchValue,
|
||||
action: r.action,
|
||||
priority: r.priority,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
|
||||
const existing = await this.prisma.tenantRule.findUnique({
|
||||
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('A rule with this matchType + matchValue already exists');
|
||||
}
|
||||
|
||||
const row = await this.prisma.tenantRule.create({
|
||||
data: {
|
||||
tenantId,
|
||||
matchType: req.matchType,
|
||||
matchValue: req.matchValue,
|
||||
action: req.action,
|
||||
priority: req.priority ?? 0,
|
||||
isActive: req.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenantId,
|
||||
matchType: row.matchType as any,
|
||||
matchValue: row.matchValue,
|
||||
action: row.action as any,
|
||||
priority: row.priority,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
|
||||
const rule = await this.prisma.tenantRule.findFirst({ where: { id, tenantId } });
|
||||
if (!rule) throw new NotFoundException('Rule not found');
|
||||
|
||||
const data: any = {};
|
||||
if (req.matchType !== undefined) data.matchType = req.matchType;
|
||||
if (req.matchValue !== undefined) data.matchValue = req.matchValue;
|
||||
if (req.action !== undefined) data.action = req.action;
|
||||
if (req.priority !== undefined) data.priority = req.priority;
|
||||
if (req.isActive !== undefined) data.isActive = req.isActive;
|
||||
|
||||
const row = await this.prisma.tenantRule.update({ where: { id }, data });
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenantId,
|
||||
matchType: row.matchType as any,
|
||||
matchValue: row.matchValue,
|
||||
action: row.action as any,
|
||||
priority: row.priority,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
const rule = await this.prisma.tenantRule.findFirst({ where: { id, tenantId } });
|
||||
if (!rule) throw new NotFoundException('Rule not found');
|
||||
await this.prisma.tenantRule.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
import type { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
const mockSearchService = { search: jest.fn() };
|
||||
|
||||
const ctx: TenantContext = { tenantId: 'tnt_1', adminId: 'adm_1', role: 'OWNER' };
|
||||
|
||||
describe('SearchController', () => {
|
||||
let controller: SearchController;
|
||||
|
||||
@@ -18,26 +21,28 @@ describe('SearchController', () => {
|
||||
|
||||
it('calls service with all parsed params', async () => {
|
||||
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 2, limit: 10, query: 'hello' });
|
||||
await controller.search('hello', 'grp-1', 'important,event', '2', '10');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('hello', 'grp-1', ['important', 'event'], 2, 10);
|
||||
await controller.search(ctx, 'hello', 'grp-1', 'important,event', '2', '10');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith(
|
||||
'tnt_1', 'hello', 'grp-1', ['important', 'event'], 2, 10,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults page to 1 and limit to 20 when not provided', async () => {
|
||||
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
|
||||
await controller.search('');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, undefined, 1, 20);
|
||||
await controller.search(ctx, '');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('tnt_1', '', undefined, undefined, 1, 20);
|
||||
});
|
||||
|
||||
it('returns the service result directly', async () => {
|
||||
const expected = { hits: [{ id: 'msg-1' }], total: 1, page: 1, limit: 20, query: 'test' };
|
||||
mockSearchService.search.mockResolvedValue(expected);
|
||||
const result = await controller.search('test');
|
||||
const result = await controller.search(ctx, 'test');
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('splits tags on comma and trims whitespace', async () => {
|
||||
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
|
||||
await controller.search('', undefined, ' important , event ');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, ['important', 'event'], 1, 20);
|
||||
await controller.search(ctx, '', undefined, ' important , event ');
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('tnt_1', '', undefined, ['important', 'event'], 1, 20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { SearchService } from './search.service';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
@@ -7,6 +9,7 @@ export class SearchController {
|
||||
|
||||
@Get()
|
||||
search(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Query('q') q = '',
|
||||
@Query('groupId') groupId?: string,
|
||||
@Query('tags') tags?: string,
|
||||
@@ -16,6 +19,6 @@ export class SearchController {
|
||||
const tagList = tags
|
||||
? tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
return this.searchService.search(q, groupId, tagList, Number(page), Number(limit));
|
||||
return this.searchService.search(ctx.tenantId, q, groupId, tagList, Number(page), Number(limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,70 +34,76 @@ describe('SearchService', () => {
|
||||
expect(searchPkg.configureIndex).toHaveBeenCalledWith(mockClient);
|
||||
});
|
||||
|
||||
it('always filters by tenantId', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('tnt-1', 'test');
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.objectContaining({ filter: 'tenantId = "tnt-1"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns hits and total', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [{ id: 'msg-1', content: 'hello' }], totalHits: 1 });
|
||||
const result = await service.search('hello');
|
||||
const result = await service.search('tnt-1', 'hello');
|
||||
expect(result.hits).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.query).toBe('hello');
|
||||
});
|
||||
|
||||
it('searches with no filter when no groupId or tags', async () => {
|
||||
it('applies sourceGroupId filter alongside tenant', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('test');
|
||||
expect(mockSearch).toHaveBeenCalledWith('test', expect.objectContaining({ filter: undefined }));
|
||||
});
|
||||
|
||||
it('applies sourceGroupId filter', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('hello', 'grp-1');
|
||||
await service.search('tnt-1', 'hello', 'grp-1');
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({ filter: 'sourceGroupId = "grp-1"' }),
|
||||
expect.objectContaining({ filter: 'tenantId = "tnt-1" AND sourceGroupId = "grp-1"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies tags filter', async () => {
|
||||
it('applies tags filter alongside tenant', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('hello', undefined, ['#important']);
|
||||
await service.search('tnt-1', 'hello', undefined, ['#important']);
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({ filter: 'tags = "#important"' }),
|
||||
expect.objectContaining({ filter: 'tenantId = "tnt-1" AND tags = "#important"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('combines groupId and tags filters with AND', async () => {
|
||||
it('combines groupId and tags filters with AND, all behind tenant', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('hello', 'grp-1', ['#important', '#event']);
|
||||
await service.search('tnt-1', 'hello', 'grp-1', ['#important', '#event']);
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({
|
||||
filter: 'sourceGroupId = "grp-1" AND tags = "#important" AND tags = "#event"',
|
||||
filter:
|
||||
'tenantId = "tnt-1" AND sourceGroupId = "grp-1" AND tags = "#important" AND tags = "#event"',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults page to 1 and hitsPerPage to 20', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('hello');
|
||||
await service.search('tnt-1', 'hello');
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({ page: 1, hitsPerPage: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes double-quotes in groupId to prevent filter injection', async () => {
|
||||
it('escapes double-quotes in filter values to prevent injection', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('hello', 'grp"1"OR id EXISTS');
|
||||
await service.search('tnt-1', 'hello', 'grp"1"OR id EXISTS');
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({ filter: 'sourceGroupId = "grp\\"1\\"OR id EXISTS"' }),
|
||||
expect.objectContaining({
|
||||
filter: 'tenantId = "tnt-1" AND sourceGroupId = "grp\\"1\\"OR id EXISTS"',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps page to minimum 1 and limit to maximum 100', async () => {
|
||||
it('clamps page to min 1 and limit to max 100', async () => {
|
||||
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
|
||||
await service.search('hello', undefined, undefined, 0, 999);
|
||||
await service.search('tnt-1', 'hello', undefined, undefined, 0, 999);
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({ page: 1, hitsPerPage: 100 }),
|
||||
|
||||
@@ -30,6 +30,7 @@ export class SearchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async search(
|
||||
tenantId: string,
|
||||
query: string,
|
||||
groupId?: string,
|
||||
tags?: string[],
|
||||
@@ -39,12 +40,13 @@ export class SearchService implements OnModuleInit {
|
||||
const safePage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1));
|
||||
const safeLimit = Math.min(100, Math.max(1, Math.floor(Number.isFinite(limit) ? limit : 20)));
|
||||
|
||||
const filters: string[] = [];
|
||||
// Always filter by tenant — non-negotiable for multi-tenant isolation
|
||||
const filters: string[] = [`tenantId = "${SearchService.escapeFilterValue(tenantId)}"`];
|
||||
if (groupId) filters.push(`sourceGroupId = "${SearchService.escapeFilterValue(groupId)}"`);
|
||||
if (tags?.length) filters.push(...tags.map((t) => `tags = "${SearchService.escapeFilterValue(t)}"`));
|
||||
|
||||
const result = await this.client.index<MeiliDocument>(MESSAGES_INDEX).search(query, {
|
||||
filter: filters.length ? filters.join(' AND ') : undefined,
|
||||
filter: filters.join(' AND '),
|
||||
page: safePage,
|
||||
hitsPerPage: safeLimit,
|
||||
sort: ['approvedAt:desc'],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { SuperAdminService } from './super-admin.service';
|
||||
import { SuperAdminGuard } from './super-admin.guard';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
|
||||
@Controller('auth/super')
|
||||
export class SuperAdminController {
|
||||
constructor(private readonly superAdminService: SuperAdminService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
login(@Body() body: { email: string; password: string }) {
|
||||
return this.superAdminService.login(body.email, body.password);
|
||||
}
|
||||
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@Get('me')
|
||||
me(@Req() req: any) {
|
||||
return this.superAdminService.me(req.user.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, Global, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) throw new UnauthorizedException();
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = this.jwtService.verify(token);
|
||||
if (payload.kind !== 'superadmin') throw new UnauthorizedException('Not a super admin');
|
||||
req.user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { SuperAdminController } from './super-admin.controller';
|
||||
import { SuperAdminService } from './super-admin.service';
|
||||
import { SuperAdminGuard } from './super-admin.guard';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET') ?? '',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [SuperAdminController],
|
||||
providers: [SuperAdminService, SuperAdminGuard],
|
||||
exports: [SuperAdminGuard, JwtModule],
|
||||
})
|
||||
export class SuperAdminModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string): Promise<{ token: string; superAdmin: { id: string; email: string; name: string | null } }> {
|
||||
const admin = await this.prisma.superAdmin.findUnique({ where: { email } });
|
||||
if (!admin) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
const valid = await bcrypt.compare(password, admin.passwordHash);
|
||||
if (!valid) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
const token = this.jwtService.sign(
|
||||
{ kind: 'superadmin', sub: admin.id, email: admin.email },
|
||||
{ secret: this.config.get<string>('JWT_SECRET'), expiresIn: '7d' },
|
||||
);
|
||||
|
||||
return { token, superAdmin: { id: admin.id, email: admin.email, name: admin.name } };
|
||||
}
|
||||
|
||||
async me(adminId: string): Promise<{ id: string; email: string; name: string | null }> {
|
||||
const admin = await this.prisma.superAdmin.findUnique({ where: { id: adminId } });
|
||||
if (!admin) throw new UnauthorizedException('Super admin not found');
|
||||
return { id: admin.id, email: admin.email, name: admin.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { TenantService } from './tenant.service';
|
||||
import { SuperAdminGuard } from '../super-admin/super-admin.guard';
|
||||
|
||||
@Controller('admin/tenants')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
export class TenantController {
|
||||
constructor(private readonly tenantService: TenantService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.tenantService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.tenantService.get(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() body: { isActive?: boolean; isForwardingPaused?: boolean }) {
|
||||
return this.tenantService.update(id, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { TenantService } from './tenant.service';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { SuperAdminModule } from '../super-admin/super-admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, SuperAdminModule],
|
||||
controllers: [TenantController],
|
||||
providers: [TenantService],
|
||||
})
|
||||
export class TenantModule {}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TenantService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<any[]> {
|
||||
const tenants = await this.prisma.tenant.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { groups: true, admins: true, messages: true, tenantBots: true } },
|
||||
tenantBots: { include: { account: { select: { jid: true, displayName: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
return tenants.map((t) => ({
|
||||
id: t.id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
isActive: t.isActive,
|
||||
isForwardingPaused: t.isForwardingPaused,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
stats: {
|
||||
groups: t._count.groups,
|
||||
admins: t._count.admins,
|
||||
messages: t._count.messages,
|
||||
bots: t._count.tenantBots,
|
||||
},
|
||||
bot: t.tenantBots[0]
|
||||
? { jid: t.tenantBots[0].account.jid, displayName: t.tenantBots[0].account.displayName, status: t.tenantBots[0].account.status }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<any> {
|
||||
const t = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { groups: true, admins: true, messages: true, rules: true, syncRoutes: true } },
|
||||
tenantBots: { include: { account: { select: { id: true, jid: true, displayName: true, status: true, createdAt: true } } } },
|
||||
admins: { select: { id: true, email: true, role: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
if (!t) throw new NotFoundException('Tenant not found');
|
||||
return {
|
||||
id: t.id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
isActive: t.isActive,
|
||||
isForwardingPaused: t.isForwardingPaused,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
stats: {
|
||||
groups: t._count.groups,
|
||||
admins: t._count.admins,
|
||||
messages: t._count.messages,
|
||||
rules: t._count.rules,
|
||||
routes: t._count.syncRoutes,
|
||||
},
|
||||
bot: t.tenantBots[0]
|
||||
? { id: t.tenantBots[0].account.id, jid: t.tenantBots[0].account.jid, displayName: t.tenantBots[0].account.displayName, status: t.tenantBots[0].account.status, linkedSince: t.tenantBots[0].createdAt.toISOString() }
|
||||
: null,
|
||||
admins: t.admins,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, data: { isActive?: boolean; isForwardingPaused?: boolean }): Promise<any> {
|
||||
const t = await this.prisma.tenant.findUnique({ where: { id } });
|
||||
if (!t) throw new NotFoundException('Tenant not found');
|
||||
|
||||
const updated = await this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.isActive !== undefined && { isActive: data.isActive }),
|
||||
...(data.isForwardingPaused !== undefined && { isForwardingPaused: data.isForwardingPaused }),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
slug: updated.slug,
|
||||
name: updated.name,
|
||||
isActive: updated.isActive,
|
||||
isForwardingPaused: updated.isForwardingPaused,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,14 @@ describe('PrismaService', () => {
|
||||
});
|
||||
|
||||
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({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
platform: 'whatsapp',
|
||||
platformId: `test-group-${Date.now()}@g.us`,
|
||||
name: 'Test Group',
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, 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<{ pairingToken: string; expiresAt: string } | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrStatus, setQrStatus] = useState<string>('PAIRING');
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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]);
|
||||
|
||||
const pollQr = useCallback(async (token: string) => {
|
||||
const res = await fetch(`/api/admin/bots/qr/${token}`);
|
||||
if (!res.ok) { clearInterval(pollRef.current!); return; }
|
||||
const data = await res.json();
|
||||
setQrStatus(data.status);
|
||||
if (data.qrDataUrl) {
|
||||
setQrDataUrl(data.qrDataUrl);
|
||||
clearInterval(pollRef.current!);
|
||||
}
|
||||
if (data.status === 'ACTIVE') {
|
||||
clearInterval(pollRef.current!);
|
||||
setPairingInfo(null);
|
||||
void load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, []);
|
||||
|
||||
async function initiateBot() {
|
||||
setInitiating(true);
|
||||
setQrDataUrl(null);
|
||||
setQrStatus('PAIRING');
|
||||
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);
|
||||
pollRef.current = setInterval(() => void pollQr(data.pairingToken), 2000);
|
||||
void pollQr(data.pairingToken);
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
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 flex items-start gap-6">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{qrStatus === 'ACTIVE' ? 'Bot connected!' : 'New bot created — waiting for QR scan...'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">Status: {qrStatus}</p>
|
||||
<p className="text-xs text-gray-600">Expires: {pairingInfo.expiresAt}</p>
|
||||
</div>
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="QR Code" className="w-40 h-40 border-2 border-gray-300 rounded-lg" />
|
||||
) : (
|
||||
<div className="w-40 h-40 bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-xs text-gray-400">
|
||||
{qrStatus === 'ACTIVE' ? 'Connected' : 'Waiting for QR...'}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSuperAdmin } from '../../_lib/super-admin-context';
|
||||
|
||||
export default function SuperAdminLoginPage() {
|
||||
const { login } = useSuperAdmin();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.replace('/admin');
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Login failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto mt-24">
|
||||
<h1 className="text-xl font-bold mb-4">Super Admin Login</h1>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="bg-blue-600 text-white rounded px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSuperAdmin } from '../_lib/super-admin-context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { admin, loading } = useSuperAdmin();
|
||||
const router = useRouter();
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [bots, setBots] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!admin) { router.replace('/admin/login'); return; }
|
||||
fetch('/api/admin/tenants').then(r => r.ok && r.json()).then(d => setTenants(d ?? [])).catch(() => {});
|
||||
fetch('/api/admin/bots').then(r => r.ok && r.json()).then(d => setBots(d ?? [])).catch(() => {});
|
||||
}, [admin, loading, router]);
|
||||
|
||||
if (loading) return <p className="text-gray-500">Loading...</p>;
|
||||
if (!admin) return null;
|
||||
|
||||
const totalTenants = tenants.length;
|
||||
const activeTenants = tenants.filter((t: any) => t.isActive).length;
|
||||
const totalBots = bots.length;
|
||||
const totalMessages = tenants.reduce((s: number, t: any) => s + (t.stats?.messages ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Admin Dashboard</h1>
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Tenants</div>
|
||||
<div className="text-2xl font-bold mt-1">{totalTenants}</div>
|
||||
<div className="text-xs text-gray-400">{activeTenants} active</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Bot Accounts</div>
|
||||
<div className="text-2xl font-bold mt-1">{totalBots}</div>
|
||||
<div className="text-xs text-gray-400">in pool</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Messages</div>
|
||||
<div className="text-2xl font-bold mt-1">{totalMessages}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Avg tenants/bot</div>
|
||||
<div className="text-2xl font-bold mt-1">{totalBots ? Math.round(totalTenants / totalBots) : 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/admin/tenants" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Tenants</Link>
|
||||
<Link href="/admin/bots" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Bots</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSuperAdmin } from '../../../_lib/super-admin-context';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
export default function TenantDetailPage() {
|
||||
const { admin, loading } = useSuperAdmin();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [tenant, setTenant] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const res = await fetch(`/api/admin/tenants/${params.id}`);
|
||||
if (res.ok) setTenant(await res.json());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!admin) { router.replace('/admin/login'); return; }
|
||||
void load();
|
||||
}, [admin, loading, router, params.id]);
|
||||
|
||||
async function toggleActive() {
|
||||
setBusy(true);
|
||||
await fetch(`/api/admin/tenants/${params.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !tenant.isActive }),
|
||||
});
|
||||
await load();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function togglePaused() {
|
||||
setBusy(true);
|
||||
await fetch(`/api/admin/tenants/${params.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isForwardingPaused: !tenant.isForwardingPaused }),
|
||||
});
|
||||
await load();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-gray-500">Loading...</p>;
|
||||
if (!admin) return null;
|
||||
if (!tenant) return <p className="text-gray-500">Loading tenant...</p>;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-bold mb-2">{tenant.name}</h1>
|
||||
<p className="text-gray-500 text-sm mb-6">Slug: {tenant.slug}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase">Status</div>
|
||||
<button
|
||||
onClick={toggleActive}
|
||||
disabled={busy}
|
||||
className={`mt-1 text-sm font-medium px-3 py-1 rounded-full ${tenant.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
|
||||
>
|
||||
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase">Forwarding</div>
|
||||
<button
|
||||
onClick={togglePaused}
|
||||
disabled={busy}
|
||||
className={`mt-1 text-sm font-medium px-3 py-1 rounded-full ${tenant.isForwardingPaused ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
|
||||
>
|
||||
{tenant.isForwardingPaused ? 'Paused' : 'Active'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase">Groups</div>
|
||||
<div className="text-xl font-bold mt-1">{tenant.stats?.groups ?? 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase">Messages</div>
|
||||
<div className="text-xl font-bold mt-1">{tenant.stats?.messages ?? 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase">Rules</div>
|
||||
<div className="text-xl font-bold mt-1">{tenant.stats?.rules ?? 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<div className="text-xs text-gray-500 uppercase">Routes</div>
|
||||
<div className="text-xl font-bold mt-1">{tenant.stats?.routes ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tenant.bot && (
|
||||
<div className="bg-white rounded-xl border p-4 mb-6">
|
||||
<h2 className="font-semibold text-sm mb-2">Assigned Bot</h2>
|
||||
<div className="text-xs space-y-1">
|
||||
<p><span className="text-gray-500">JID:</span> <span className="font-mono">{tenant.bot.jid}</span></p>
|
||||
<p><span className="text-gray-500">Name:</span> {tenant.bot.displayName ?? '—'}</p>
|
||||
<p><span className="text-gray-500">Status:</span> {tenant.bot.status}</p>
|
||||
<p><span className="text-gray-500">Linked:</span> {tenant.bot.linkedSince}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tenant.admins && tenant.admins.length > 0 && (
|
||||
<div className="bg-white rounded-xl border p-4">
|
||||
<h2 className="font-semibold text-sm mb-2">Admins</h2>
|
||||
<div className="text-xs space-y-1">
|
||||
{tenant.admins.map((a: any) => (
|
||||
<p key={a.id}>{a.email} — <span className="uppercase">{a.role}</span></p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSuperAdmin } from '../../_lib/super-admin-context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function TenantsPage() {
|
||||
const { admin, loading } = useSuperAdmin();
|
||||
const router = useRouter();
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/api/admin/tenants');
|
||||
if (res.ok) setTenants(await res.json());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!admin) { router.replace('/admin/login'); return; }
|
||||
void load();
|
||||
}, [admin, loading, router]);
|
||||
|
||||
async function toggleActive(id: string, current: boolean) {
|
||||
await fetch(`/api/admin/tenants/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !current }),
|
||||
});
|
||||
void load();
|
||||
}
|
||||
|
||||
async function togglePaused(id: string, current: boolean) {
|
||||
await fetch(`/api/admin/tenants/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isForwardingPaused: !current }),
|
||||
});
|
||||
void load();
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-gray-500">Loading...</p>;
|
||||
if (!admin) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Tenants</h1>
|
||||
<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">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Slug</th>
|
||||
<th className="px-4 py-3 font-medium">Bot</th>
|
||||
<th className="px-4 py-3 font-medium">Groups</th>
|
||||
<th className="px-4 py-3 font-medium">Messages</th>
|
||||
<th className="px-4 py-3 font-medium">Active</th>
|
||||
<th className="px-4 py-3 font-medium">Paused</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{tenants.map((t: any) => (
|
||||
<tr key={t.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/tenants/${t.id}`} className="text-blue-600 hover:underline font-medium">
|
||||
{t.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{t.slug}</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{t.bot ? <span className="font-mono">{t.bot.jid?.slice(0, 20)}...</span> : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">{t.stats.groups}</td>
|
||||
<td className="px-4 py-3">{t.stats.messages}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleActive(t.id, t.isActive)}
|
||||
className={`text-xs font-medium px-2 py-1 rounded-full ${t.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}
|
||||
>
|
||||
{t.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => togglePaused(t.id, t.isForwardingPaused)}
|
||||
className={`text-xs font-medium px-2 py-1 rounded-full ${t.isForwardingPaused ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}
|
||||
>
|
||||
{t.isForwardingPaused ? 'Paused' : 'Active'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tenants.length === 0 && <p className="p-4 text-gray-400">No tenants yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
|
||||
const res = await fetch(`${getApiBaseUrl()}/admin/bots/${id}/assign`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await res.json();
|
||||
return jsonResponse(payload, res.status);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value);
|
||||
const res = await fetch(`${getApiBaseUrl()}/admin/bots/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user