From 249d759e6ac41ac42ccd0c85668c4ebfc2782d99 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 9 Jun 2026 02:02:40 +0530 Subject: [PATCH] good forst commit --- .claude/settings.local.json | 39 +- .env.example | 23 +- apps/api/check-groups.ts | 51 + apps/api/check-pending.ts | 28 + apps/api/list-users.ts | 9 + apps/api/package.json | 18 +- .../migration.sql | 101 + .../migration.sql | 201 ++ .../migration.sql | 28 + .../migration.sql | 18 + .../20260608_rules_engine/migration.sql | 32 + .../migration.sql | 38 + apps/api/prisma/schema.prisma | 420 ++- apps/api/prisma/seed.ts | 59 + apps/api/src/app.module.ts | 20 +- apps/api/src/common/tenant-context.ts | 13 + apps/api/src/main.ts | 18 +- .../accounts/accounts.controller.spec.ts | 50 - .../modules/accounts/accounts.controller.ts | 22 - .../src/modules/accounts/accounts.module.ts | 11 - .../modules/accounts/accounts.service.spec.ts | 121 - .../src/modules/accounts/accounts.service.ts | 59 - apps/api/src/modules/audit/audit.module.ts | 9 + .../src/modules/audit/audit.service.spec.ts | 61 + apps/api/src/modules/audit/audit.service.ts | 35 + apps/api/src/modules/audit/audit.types.ts | 42 + apps/api/src/modules/auth/auth.controller.ts | 39 + apps/api/src/modules/auth/auth.module.ts | 33 + .../api/src/modules/auth/auth.service.spec.ts | 254 ++ apps/api/src/modules/auth/auth.service.ts | 218 ++ .../modules/auth/current-admin.decorator.ts | 9 + .../modules/auth/current-member.decorator.ts | 9 + .../modules/auth/current-tenant.decorator.ts | 9 + apps/api/src/modules/auth/dto/login.dto.ts | 14 + apps/api/src/modules/auth/dto/signup.dto.ts | 24 + apps/api/src/modules/auth/jwt-auth.guard.ts | 48 + apps/api/src/modules/auth/jwt.strategy.ts | 20 + .../src/modules/auth/member-auth.decorator.ts | 8 + .../api/src/modules/auth/member-auth.guard.ts | 14 + .../src/modules/auth/password.util.spec.ts | 34 + apps/api/src/modules/auth/password.util.ts | 9 + apps/api/src/modules/auth/public.decorator.ts | 4 + apps/api/src/modules/auth/roles.decorator.ts | 5 + apps/api/src/modules/auth/roles.guard.ts | 23 + .../src/modules/bot/bot-admin.controller.ts | 39 + apps/api/src/modules/bot/bot.controller.ts | 24 + apps/api/src/modules/bot/bot.module.ts | 15 + apps/api/src/modules/bot/bot.service.spec.ts | 189 ++ apps/api/src/modules/bot/bot.service.ts | 304 +++ .../modules/groups/groups.controller.spec.ts | 44 +- .../src/modules/groups/groups.controller.ts | 84 +- .../src/modules/groups/groups.service.spec.ts | 136 +- apps/api/src/modules/groups/groups.service.ts | 252 +- .../src/modules/health/health.controller.ts | 2 + .../modules/messages/messages.controller.ts | 45 + .../src/modules/messages/messages.module.ts | 17 + .../modules/messages/messages.service.spec.ts | 154 ++ .../src/modules/messages/messages.service.ts | 254 ++ apps/api/src/modules/my/my.controller.ts | 56 + apps/api/src/modules/my/my.module.ts | 11 + apps/api/src/modules/my/my.service.spec.ts | 136 + apps/api/src/modules/my/my.service.ts | 190 ++ .../modules/onboarding/onboarding.module.ts | 13 + .../onboarding/onboarding.service.spec.ts | 174 ++ .../modules/onboarding/onboarding.service.ts | 230 ++ .../public-onboarding.controller.ts | 49 + .../modules/routes/routes.controller.spec.ts | 34 +- .../src/modules/routes/routes.controller.ts | 33 +- .../src/modules/routes/routes.service.spec.ts | 131 +- apps/api/src/modules/routes/routes.service.ts | 150 +- .../api/src/modules/rules/rules.controller.ts | 45 + apps/api/src/modules/rules/rules.module.ts | 10 + .../src/modules/rules/rules.service.spec.ts | 80 + apps/api/src/modules/rules/rules.service.ts | 90 + .../modules/search/search.controller.spec.ts | 19 +- .../src/modules/search/search.controller.ts | 5 +- .../src/modules/search/search.service.spec.ts | 50 +- apps/api/src/modules/search/search.service.ts | 6 +- .../super-admin/super-admin.controller.ts | 21 + .../modules/super-admin/super-admin.guard.ts | 23 + .../modules/super-admin/super-admin.module.ts | 25 + .../super-admin/super-admin.service.ts | 35 + .../src/modules/tenant/tenant.controller.ts | 24 + apps/api/src/modules/tenant/tenant.module.ts | 12 + apps/api/src/modules/tenant/tenant.service.ts | 86 + apps/api/src/prisma/prisma.service.spec.ts | 6 + apps/api/src/queues/forward.queue.ts | 35 + apps/api/src/queues/redis-connection.ts | 4 + apps/web/app/_lib/api.ts | 61 + apps/web/app/_lib/auth-context.test.tsx | 79 + apps/web/app/_lib/auth-context.tsx | 74 + apps/web/app/_lib/sidebar.tsx | 146 + apps/web/app/_lib/super-admin-context.tsx | 72 + apps/web/app/accounts/AccountCard.test.tsx | 100 - apps/web/app/accounts/AccountCard.tsx | 72 - apps/web/app/accounts/AccountsList.test.tsx | 104 - apps/web/app/accounts/AccountsList.tsx | 60 - apps/web/app/accounts/page.tsx | 25 - apps/web/app/admin/bots/page.tsx | 132 + apps/web/app/admin/layout.tsx | 3 + apps/web/app/admin/login/page.tsx | 60 + apps/web/app/admin/page.tsx | 58 + apps/web/app/admin/tenants/[id]/page.tsx | 119 + apps/web/app/admin/tenants/page.tsx | 99 + apps/web/app/api/accounts/[id]/qr/route.ts | 7 - apps/web/app/api/accounts/route.ts | 16 - .../app/api/admin/bots/[id]/assign/route.ts | 21 + apps/web/app/api/admin/bots/[id]/route.ts | 18 + .../app/api/admin/bots/qr/[token]/route.ts | 17 + apps/web/app/api/admin/bots/route.ts | 33 + apps/web/app/api/admin/tenants/[id]/route.ts | 35 + apps/web/app/api/admin/tenants/route.ts | 16 + apps/web/app/api/auth/login/route.ts | 19 + apps/web/app/api/auth/logout/route.ts | 12 + apps/web/app/api/auth/me/route.ts | 9 + apps/web/app/api/auth/signup/route.ts | 19 + apps/web/app/api/auth/super/login/route.ts | 19 + apps/web/app/api/auth/super/logout/route.ts | 9 + apps/web/app/api/auth/super/me/route.ts | 16 + apps/web/app/api/bot/[id]/route.ts | 13 + apps/web/app/api/bot/attach/route.ts | 15 + apps/web/app/api/bot/initiate/route.ts | 20 + apps/web/app/api/bot/qr/[token]/route.ts | 13 + apps/web/app/api/bot/reveal/route.ts | 17 + apps/web/app/api/bot/route.ts | 10 + apps/web/app/api/groups/[id]/share/route.ts | 17 + .../[id]/unshare/[targetTenantId]/route.ts | 14 + .../app/api/groups/claim-token-info/route.ts | 12 + .../app/api/groups/claim-with-token/route.ts | 13 + apps/web/app/api/groups/shared-by-me/route.ts | 9 + apps/web/app/api/groups/shared/route.ts | 9 + .../app/api/messages/[id]/approve/route.ts | 19 + apps/web/app/api/messages/[id]/route.ts | 13 + .../app/api/messages/pending/count/route.ts | 9 + apps/web/app/api/messages/pending/route.ts | 9 + apps/web/app/api/my/account/route.ts | 15 + apps/web/app/api/my/groups/[id]/route.ts | 13 + apps/web/app/api/my/groups/route.ts | 9 + apps/web/app/api/my/logout/route.ts | 7 + apps/web/app/api/my/opt-in/route.ts | 10 + apps/web/app/api/my/opt-out/route.ts | 10 + apps/web/app/api/my/profile/route.ts | 9 + apps/web/app/api/onboard/verify-otp/route.ts | 30 + apps/web/app/api/routes/[id]/route.ts | 11 +- apps/web/app/api/routes/batch/route.ts | 13 + apps/web/app/api/routes/route.ts | 23 +- apps/web/app/api/rules/[id]/route.ts | 28 + apps/web/app/api/rules/route.ts | 19 + apps/web/app/claim-group/page.tsx | 120 + apps/web/app/groups/GroupsTabs.tsx | 28 + apps/web/app/groups/RouteManager.test.tsx | 62 +- apps/web/app/groups/RouteManager.tsx | 168 +- apps/web/app/groups/page.tsx | 91 +- apps/web/app/layout.tsx | 22 +- apps/web/app/login/page.test.tsx | 88 + apps/web/app/login/page.tsx | 113 + apps/web/app/messages/[id]/page.tsx | 158 ++ apps/web/app/messages/pending/page.tsx | 105 + apps/web/app/my/groups/GroupOptOutButton.tsx | 44 + apps/web/app/my/groups/[id]/page.tsx | 88 + apps/web/app/my/groups/page.tsx | 89 + apps/web/app/my/page.tsx | 74 + .../app/my/settings/DeleteAccountButton.tsx | 44 + .../app/my/settings/MemberLogoutButton.tsx | 24 + apps/web/app/my/settings/page.tsx | 36 + apps/web/app/onboard/OnboardingForm.tsx | 163 ++ apps/web/app/onboard/page.tsx | 66 + apps/web/app/search/page.tsx | 42 +- apps/web/app/settings/bot/BotSettingsCard.tsx | 87 + apps/web/app/settings/bot/page.tsx | 41 + apps/web/app/settings/rules/RuleManager.tsx | 189 ++ apps/web/app/settings/rules/page.tsx | 36 + apps/web/app/signup/SignupForm.tsx | 142 + apps/web/app/signup/page.tsx | 24 + apps/web/next-env.d.ts | 2 +- apps/web/tsconfig.tsbuildinfo | 1 + apps/worker/package.json | 2 + apps/worker/src/core/approval.test.ts | 158 +- apps/worker/src/core/approval.ts | 128 +- apps/worker/src/core/approve-message.ts | 86 + apps/worker/src/email/email.service.spec.ts | 46 + apps/worker/src/email/email.service.ts | 86 + apps/worker/src/main.ts | 138 +- .../src/queues/forward.processor.test.ts | 1 + .../worker/src/queues/index.processor.test.ts | 4 +- apps/worker/src/queues/index.processor.ts | 1 + .../src/queues/ingest.processor.test.ts | 73 +- apps/worker/src/queues/ingest.processor.ts | 123 +- .../src/whatsapp/command-handler.test.ts | 124 + apps/worker/src/whatsapp/command-handler.ts | 232 ++ apps/worker/src/whatsapp/group-sync.test.ts | 255 +- apps/worker/src/whatsapp/group-sync.ts | 129 + apps/worker/src/whatsapp/match-rules.test.ts | 103 + apps/worker/src/whatsapp/match-rules.ts | 109 + apps/worker/src/whatsapp/normalizer.ts | 8 +- apps/worker/src/whatsapp/otp-sender.ts | 57 + apps/worker/src/whatsapp/session.ts | 5 +- backups/phase2b-pre-20260604T160707Z.sql | 319 +++ .../plans/2026-05-28-admin-dashboard.md | 1198 +++++++++ .../plans/2026-05-29-whatsapp-qr-dashboard.md | 2395 +++++++++++++++++ .../2026-06-02-phase1-tenants-auth-audit.md | 203 ++ .../2026-06-04-phase2b-bot-shared-hidden.md | 268 ++ .../plans/Insignia_TOWER_Architecture.pdf | Bin 0 -> 173050 bytes packages/config/src/index.test.ts | 14 +- packages/config/src/index.ts | 5 +- packages/search/src/index.test.ts | 5 +- packages/search/src/index.ts | 12 +- packages/types/src/auth.ts | 61 + packages/types/src/bot.ts | 35 + packages/types/src/index.ts | 4 + packages/types/src/message.ts | 4 + packages/types/src/onboarding.ts | 86 + packages/types/src/rule.ts | 31 + pnpm-lock.yaml | 372 ++- scripts/backup-before-phase2b.sh | 57 + 215 files changed, 15425 insertions(+), 1240 deletions(-) create mode 100644 apps/api/check-groups.ts create mode 100644 apps/api/check-pending.ts create mode 100644 apps/api/list-users.ts create mode 100644 apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql create mode 100644 apps/api/prisma/migrations/20260604160800_phase2b_bot_claim/migration.sql create mode 100644 apps/api/prisma/migrations/20260604170000_phase2b_otp_challenge/migration.sql create mode 100644 apps/api/prisma/migrations/20260608194802_add_superadmin_and_tenant_flags/migration.sql create mode 100644 apps/api/prisma/migrations/20260608_rules_engine/migration.sql create mode 100644 apps/api/prisma/migrations/20260608_token_claim_group_access/migration.sql create mode 100644 apps/api/prisma/seed.ts create mode 100644 apps/api/src/common/tenant-context.ts delete mode 100644 apps/api/src/modules/accounts/accounts.controller.spec.ts delete mode 100644 apps/api/src/modules/accounts/accounts.controller.ts delete mode 100644 apps/api/src/modules/accounts/accounts.module.ts delete mode 100644 apps/api/src/modules/accounts/accounts.service.spec.ts delete mode 100644 apps/api/src/modules/accounts/accounts.service.ts create mode 100644 apps/api/src/modules/audit/audit.module.ts create mode 100644 apps/api/src/modules/audit/audit.service.spec.ts create mode 100644 apps/api/src/modules/audit/audit.service.ts create mode 100644 apps/api/src/modules/audit/audit.types.ts create mode 100644 apps/api/src/modules/auth/auth.controller.ts create mode 100644 apps/api/src/modules/auth/auth.module.ts create mode 100644 apps/api/src/modules/auth/auth.service.spec.ts create mode 100644 apps/api/src/modules/auth/auth.service.ts create mode 100644 apps/api/src/modules/auth/current-admin.decorator.ts create mode 100644 apps/api/src/modules/auth/current-member.decorator.ts create mode 100644 apps/api/src/modules/auth/current-tenant.decorator.ts create mode 100644 apps/api/src/modules/auth/dto/login.dto.ts create mode 100644 apps/api/src/modules/auth/dto/signup.dto.ts create mode 100644 apps/api/src/modules/auth/jwt-auth.guard.ts create mode 100644 apps/api/src/modules/auth/jwt.strategy.ts create mode 100644 apps/api/src/modules/auth/member-auth.decorator.ts create mode 100644 apps/api/src/modules/auth/member-auth.guard.ts create mode 100644 apps/api/src/modules/auth/password.util.spec.ts create mode 100644 apps/api/src/modules/auth/password.util.ts create mode 100644 apps/api/src/modules/auth/public.decorator.ts create mode 100644 apps/api/src/modules/auth/roles.decorator.ts create mode 100644 apps/api/src/modules/auth/roles.guard.ts create mode 100644 apps/api/src/modules/bot/bot-admin.controller.ts create mode 100644 apps/api/src/modules/bot/bot.controller.ts create mode 100644 apps/api/src/modules/bot/bot.module.ts create mode 100644 apps/api/src/modules/bot/bot.service.spec.ts create mode 100644 apps/api/src/modules/bot/bot.service.ts create mode 100644 apps/api/src/modules/messages/messages.controller.ts create mode 100644 apps/api/src/modules/messages/messages.module.ts create mode 100644 apps/api/src/modules/messages/messages.service.spec.ts create mode 100644 apps/api/src/modules/messages/messages.service.ts create mode 100644 apps/api/src/modules/my/my.controller.ts create mode 100644 apps/api/src/modules/my/my.module.ts create mode 100644 apps/api/src/modules/my/my.service.spec.ts create mode 100644 apps/api/src/modules/my/my.service.ts create mode 100644 apps/api/src/modules/onboarding/onboarding.module.ts create mode 100644 apps/api/src/modules/onboarding/onboarding.service.spec.ts create mode 100644 apps/api/src/modules/onboarding/onboarding.service.ts create mode 100644 apps/api/src/modules/onboarding/public-onboarding.controller.ts create mode 100644 apps/api/src/modules/rules/rules.controller.ts create mode 100644 apps/api/src/modules/rules/rules.module.ts create mode 100644 apps/api/src/modules/rules/rules.service.spec.ts create mode 100644 apps/api/src/modules/rules/rules.service.ts create mode 100644 apps/api/src/modules/super-admin/super-admin.controller.ts create mode 100644 apps/api/src/modules/super-admin/super-admin.guard.ts create mode 100644 apps/api/src/modules/super-admin/super-admin.module.ts create mode 100644 apps/api/src/modules/super-admin/super-admin.service.ts create mode 100644 apps/api/src/modules/tenant/tenant.controller.ts create mode 100644 apps/api/src/modules/tenant/tenant.module.ts create mode 100644 apps/api/src/modules/tenant/tenant.service.ts create mode 100644 apps/api/src/queues/forward.queue.ts create mode 100644 apps/api/src/queues/redis-connection.ts create mode 100644 apps/web/app/_lib/api.ts create mode 100644 apps/web/app/_lib/auth-context.test.tsx create mode 100644 apps/web/app/_lib/auth-context.tsx create mode 100644 apps/web/app/_lib/sidebar.tsx create mode 100644 apps/web/app/_lib/super-admin-context.tsx delete mode 100644 apps/web/app/accounts/AccountCard.test.tsx delete mode 100644 apps/web/app/accounts/AccountCard.tsx delete mode 100644 apps/web/app/accounts/AccountsList.test.tsx delete mode 100644 apps/web/app/accounts/AccountsList.tsx delete mode 100644 apps/web/app/accounts/page.tsx create mode 100644 apps/web/app/admin/bots/page.tsx create mode 100644 apps/web/app/admin/layout.tsx create mode 100644 apps/web/app/admin/login/page.tsx create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/tenants/[id]/page.tsx create mode 100644 apps/web/app/admin/tenants/page.tsx delete mode 100644 apps/web/app/api/accounts/[id]/qr/route.ts delete mode 100644 apps/web/app/api/accounts/route.ts create mode 100644 apps/web/app/api/admin/bots/[id]/assign/route.ts create mode 100644 apps/web/app/api/admin/bots/[id]/route.ts create mode 100644 apps/web/app/api/admin/bots/qr/[token]/route.ts create mode 100644 apps/web/app/api/admin/bots/route.ts create mode 100644 apps/web/app/api/admin/tenants/[id]/route.ts create mode 100644 apps/web/app/api/admin/tenants/route.ts create mode 100644 apps/web/app/api/auth/login/route.ts create mode 100644 apps/web/app/api/auth/logout/route.ts create mode 100644 apps/web/app/api/auth/me/route.ts create mode 100644 apps/web/app/api/auth/signup/route.ts create mode 100644 apps/web/app/api/auth/super/login/route.ts create mode 100644 apps/web/app/api/auth/super/logout/route.ts create mode 100644 apps/web/app/api/auth/super/me/route.ts create mode 100644 apps/web/app/api/bot/[id]/route.ts create mode 100644 apps/web/app/api/bot/attach/route.ts create mode 100644 apps/web/app/api/bot/initiate/route.ts create mode 100644 apps/web/app/api/bot/qr/[token]/route.ts create mode 100644 apps/web/app/api/bot/reveal/route.ts create mode 100644 apps/web/app/api/bot/route.ts create mode 100644 apps/web/app/api/groups/[id]/share/route.ts create mode 100644 apps/web/app/api/groups/[id]/unshare/[targetTenantId]/route.ts create mode 100644 apps/web/app/api/groups/claim-token-info/route.ts create mode 100644 apps/web/app/api/groups/claim-with-token/route.ts create mode 100644 apps/web/app/api/groups/shared-by-me/route.ts create mode 100644 apps/web/app/api/groups/shared/route.ts create mode 100644 apps/web/app/api/messages/[id]/approve/route.ts create mode 100644 apps/web/app/api/messages/[id]/route.ts create mode 100644 apps/web/app/api/messages/pending/count/route.ts create mode 100644 apps/web/app/api/messages/pending/route.ts create mode 100644 apps/web/app/api/my/account/route.ts create mode 100644 apps/web/app/api/my/groups/[id]/route.ts create mode 100644 apps/web/app/api/my/groups/route.ts create mode 100644 apps/web/app/api/my/logout/route.ts create mode 100644 apps/web/app/api/my/opt-in/route.ts create mode 100644 apps/web/app/api/my/opt-out/route.ts create mode 100644 apps/web/app/api/my/profile/route.ts create mode 100644 apps/web/app/api/onboard/verify-otp/route.ts create mode 100644 apps/web/app/api/routes/batch/route.ts create mode 100644 apps/web/app/api/rules/[id]/route.ts create mode 100644 apps/web/app/api/rules/route.ts create mode 100644 apps/web/app/claim-group/page.tsx create mode 100644 apps/web/app/groups/GroupsTabs.tsx create mode 100644 apps/web/app/login/page.test.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/app/messages/[id]/page.tsx create mode 100644 apps/web/app/messages/pending/page.tsx create mode 100644 apps/web/app/my/groups/GroupOptOutButton.tsx create mode 100644 apps/web/app/my/groups/[id]/page.tsx create mode 100644 apps/web/app/my/groups/page.tsx create mode 100644 apps/web/app/my/page.tsx create mode 100644 apps/web/app/my/settings/DeleteAccountButton.tsx create mode 100644 apps/web/app/my/settings/MemberLogoutButton.tsx create mode 100644 apps/web/app/my/settings/page.tsx create mode 100644 apps/web/app/onboard/OnboardingForm.tsx create mode 100644 apps/web/app/onboard/page.tsx create mode 100644 apps/web/app/settings/bot/BotSettingsCard.tsx create mode 100644 apps/web/app/settings/bot/page.tsx create mode 100644 apps/web/app/settings/rules/RuleManager.tsx create mode 100644 apps/web/app/settings/rules/page.tsx create mode 100644 apps/web/app/signup/SignupForm.tsx create mode 100644 apps/web/app/signup/page.tsx create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 apps/worker/src/core/approve-message.ts create mode 100644 apps/worker/src/email/email.service.spec.ts create mode 100644 apps/worker/src/email/email.service.ts create mode 100644 apps/worker/src/whatsapp/command-handler.test.ts create mode 100644 apps/worker/src/whatsapp/command-handler.ts create mode 100644 apps/worker/src/whatsapp/match-rules.test.ts create mode 100644 apps/worker/src/whatsapp/match-rules.ts create mode 100644 apps/worker/src/whatsapp/otp-sender.ts create mode 100644 backups/phase2b-pre-20260604T160707Z.sql create mode 100644 docs/superpowers/plans/2026-05-28-admin-dashboard.md create mode 100644 docs/superpowers/plans/2026-05-29-whatsapp-qr-dashboard.md create mode 100644 docs/superpowers/plans/2026-06-02-phase1-tenants-auth-audit.md create mode 100644 docs/superpowers/plans/2026-06-04-phase2b-bot-shared-hidden.md create mode 100644 docs/superpowers/plans/Insignia_TOWER_Architecture.pdf create mode 100644 packages/types/src/auth.ts create mode 100644 packages/types/src/bot.ts create mode 100644 packages/types/src/onboarding.ts create mode 100644 packages/types/src/rule.ts create mode 100755 scripts/backup-before-phase2b.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 61369e3..c811a8f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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]" ] } } diff --git a/.env.example b/.env.example index 8f9b6fc..7ee94df 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/check-groups.ts b/apps/api/check-groups.ts new file mode 100644 index 0000000..de051b6 --- /dev/null +++ b/apps/api/check-groups.ts @@ -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()); diff --git a/apps/api/check-pending.ts b/apps/api/check-pending.ts new file mode 100644 index 0000000..5ae43cf --- /dev/null +++ b/apps/api/check-pending.ts @@ -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()); diff --git a/apps/api/list-users.ts b/apps/api/list-users.ts new file mode 100644 index 0000000..8b01a0b --- /dev/null +++ b/apps/api/list-users.ts @@ -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(); +})(); diff --git a/apps/api/package.json b/apps/api/package.json index f6dc5aa..680a925 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,18 +6,31 @@ "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": "ts-node 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" @@ -26,13 +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" } } diff --git a/apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql b/apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql new file mode 100644 index 0000000..0ba0440 --- /dev/null +++ b/apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260604160800_phase2b_bot_claim/migration.sql b/apps/api/prisma/migrations/20260604160800_phase2b_bot_claim/migration.sql new file mode 100644 index 0000000..19f2bd4 --- /dev/null +++ b/apps/api/prisma/migrations/20260604160800_phase2b_bot_claim/migration.sql @@ -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; + diff --git a/apps/api/prisma/migrations/20260604170000_phase2b_otp_challenge/migration.sql b/apps/api/prisma/migrations/20260604170000_phase2b_otp_challenge/migration.sql new file mode 100644 index 0000000..ab4b45c --- /dev/null +++ b/apps/api/prisma/migrations/20260604170000_phase2b_otp_challenge/migration.sql @@ -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"); + diff --git a/apps/api/prisma/migrations/20260608194802_add_superadmin_and_tenant_flags/migration.sql b/apps/api/prisma/migrations/20260608194802_add_superadmin_and_tenant_flags/migration.sql new file mode 100644 index 0000000..2a870e9 --- /dev/null +++ b/apps/api/prisma/migrations/20260608194802_add_superadmin_and_tenant_flags/migration.sql @@ -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"); diff --git a/apps/api/prisma/migrations/20260608_rules_engine/migration.sql b/apps/api/prisma/migrations/20260608_rules_engine/migration.sql new file mode 100644 index 0000000..91a9028 --- /dev/null +++ b/apps/api/prisma/migrations/20260608_rules_engine/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260608_token_claim_group_access/migration.sql b/apps/api/prisma/migrations/20260608_token_claim_group_access/migration.sql new file mode 100644 index 0000000..3143248 --- /dev/null +++ b/apps/api/prisma/migrations/20260608_token_claim_group_access/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7188a46..8475200 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..700ce96 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -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 { + 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(); + }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d2e36d8..3b78eb3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,21 +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 { AccountsModule } from './modules/accounts/accounts.module'; +import { BotModule } from './modules/bot/bot.module'; +import { OnboardingModule } from './modules/onboarding/onboarding.module'; +import { MyModule } from './modules/my/my.module'; +import { MessagesModule } from './modules/messages/messages.module'; +import { RulesModule } from './modules/rules/rules.module'; +import { SuperAdminModule } from './modules/super-admin/super-admin.module'; +import { TenantModule } from './modules/tenant/tenant.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, + AuthModule, + AuditModule, HealthModule, SearchModule, GroupsModule, RoutesModule, - AccountsModule, + BotModule, + OnboardingModule, + MyModule, + MessagesModule, + RulesModule, + SuperAdminModule, + TenantModule, ], }) export class AppModule {} diff --git a/apps/api/src/common/tenant-context.ts b/apps/api/src/common/tenant-context.ts new file mode 100644 index 0000000..da37828 --- /dev/null +++ b/apps/api/src/common/tenant-context.ts @@ -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, +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index dd42ec7..bb3ef4f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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}`); diff --git a/apps/api/src/modules/accounts/accounts.controller.spec.ts b/apps/api/src/modules/accounts/accounts.controller.spec.ts deleted file mode 100644 index 3fe9cdd..0000000 --- a/apps/api/src/modules/accounts/accounts.controller.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AccountsController } from './accounts.controller'; -import { AccountsService } from './accounts.service'; - -const mockAccounts = [ - { id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' }, -]; -const mockCreated = { id: 'acc_new', platform: 'whatsapp', jid: 'pending_x@placeholder', displayName: 'New', status: 'ACTIVE' }; - -const mockService = { - list: jest.fn().mockResolvedValue(mockAccounts), - getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }), - create: jest.fn().mockResolvedValue(mockCreated), -}; - -describe('AccountsController', () => { - let controller: AccountsController; - - beforeEach(async () => { - jest.clearAllMocks(); - const module: TestingModule = await Test.createTestingModule({ - controllers: [AccountsController], - providers: [{ provide: AccountsService, useValue: mockService }], - }).compile(); - controller = module.get(AccountsController); - }); - - it('list() returns accounts from service', async () => { - const result = await controller.list(); - expect(result).toEqual(mockAccounts); - expect(mockService.list).toHaveBeenCalled(); - }); - - it('getQr() calls service with the account id', async () => { - const result = await controller.getQr('acc_1'); - expect(mockService.getQr).toHaveBeenCalledWith('acc_1'); - expect(result.qrDataUrl).toBe('data:image/png;base64,fake'); - }); - - it('create() calls service with displayName from body', async () => { - const result = await controller.create({ displayName: 'New' }); - expect(mockService.create).toHaveBeenCalledWith('New'); - expect(result).toEqual(mockCreated); - }); - - it('create() calls service with undefined when no displayName', async () => { - await controller.create({}); - expect(mockService.create).toHaveBeenCalledWith(undefined); - }); -}); diff --git a/apps/api/src/modules/accounts/accounts.controller.ts b/apps/api/src/modules/accounts/accounts.controller.ts deleted file mode 100644 index 09ea552..0000000 --- a/apps/api/src/modules/accounts/accounts.controller.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Controller, Get, Param, Post, Body } from '@nestjs/common'; -import { AccountsService } from './accounts.service'; - -@Controller('accounts') -export class AccountsController { - constructor(private readonly service: AccountsService) {} - - @Get() - list() { - return this.service.list(); - } - - @Get(':id/qr') - getQr(@Param('id') id: string) { - return this.service.getQr(id); - } - - @Post() - create(@Body() body: { displayName?: string }) { - return this.service.create(body.displayName); - } -} diff --git a/apps/api/src/modules/accounts/accounts.module.ts b/apps/api/src/modules/accounts/accounts.module.ts deleted file mode 100644 index 1018a20..0000000 --- a/apps/api/src/modules/accounts/accounts.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AccountsController } from './accounts.controller'; -import { AccountsService } from './accounts.service'; - -@Module({ - imports: [ConfigModule], - controllers: [AccountsController], - providers: [AccountsService], -}) -export class AccountsModule {} diff --git a/apps/api/src/modules/accounts/accounts.service.spec.ts b/apps/api/src/modules/accounts/accounts.service.spec.ts deleted file mode 100644 index 7116d68..0000000 --- a/apps/api/src/modules/accounts/accounts.service.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { AccountsService } from './accounts.service'; -import { PrismaService } from '../../prisma/prisma.service'; -import * as QRCode from 'qrcode'; - -jest.mock('qrcode', () => ({ - toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'), -})); - -const mockAccounts = [ - { id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' }, -]; - -const mockCreatedAccount = { - id: 'acc_new', - platform: 'whatsapp', - jid: 'pending_uuid@placeholder', - displayName: 'My Number', - status: 'DISCONNECTED', -}; - -const mockPrisma = { - account: { - findMany: jest.fn().mockResolvedValue(mockAccounts), - findUnique: jest.fn(), - create: jest.fn().mockResolvedValue(mockCreatedAccount), - }, -}; - -const mockConfig = { - get: jest.fn().mockImplementation((key: string, def: string) => - key === 'WHATSAPP_SESSION_PATH' ? './sessions' : def, - ), -}; - -describe('AccountsService', () => { - let service: AccountsService; - - beforeEach(async () => { - jest.clearAllMocks(); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AccountsService, - { provide: PrismaService, useValue: mockPrisma }, - { provide: ConfigService, useValue: mockConfig }, - ], - }).compile(); - service = module.get(AccountsService); - }); - - describe('list()', () => { - it('returns accounts from Prisma without qrCode field', async () => { - const result = await service.list(); - expect(result).toEqual(mockAccounts); - expect(mockPrisma.account.findMany).toHaveBeenCalledWith( - expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }), - ); - }); - }); - - describe('getQr()', () => { - it('returns null qrDataUrl when account has no qrCode', async () => { - mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null }); - const result = await service.getQr('acc_1'); - expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null }); - expect(QRCode.toDataURL).not.toHaveBeenCalled(); - }); - - it('converts qrCode string to data URL when qrCode is present', async () => { - mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' }); - const result = await service.getQr('acc_1'); - expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string'); - expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' }); - }); - - it('returns not_found status when account does not exist', async () => { - mockPrisma.account.findUnique.mockResolvedValue(null); - const result = await service.getQr('nonexistent'); - expect(result).toEqual({ status: 'not_found', qrDataUrl: null }); - }); - }); - - describe('create()', () => { - it('creates account with platform whatsapp and status DISCONNECTED', async () => { - await service.create('My Number'); - expect(mockPrisma.account.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - platform: 'whatsapp', - status: 'DISCONNECTED', - displayName: 'My Number', - }), - }), - ); - }); - - it('generates a unique sessionPath under WHATSAPP_SESSION_PATH', async () => { - await service.create(); - const call = mockPrisma.account.create.mock.calls[0][0]; - expect(call.data.sessionPath).toMatch(/^\.\/sessions\/.+/); - }); - - it('generates a placeholder jid prefixed with pending_', async () => { - await service.create(); - const call = mockPrisma.account.create.mock.calls[0][0]; - expect(call.data.jid).toMatch(/^pending_/); - }); - - it('sets displayName to null when not provided', async () => { - await service.create(); - const call = mockPrisma.account.create.mock.calls[0][0]; - expect(call.data.displayName).toBeNull(); - }); - - it('returns the created account summary', async () => { - const result = await service.create('My Number'); - expect(result).toEqual(mockCreatedAccount); - }); - }); -}); diff --git a/apps/api/src/modules/accounts/accounts.service.ts b/apps/api/src/modules/accounts/accounts.service.ts deleted file mode 100644 index 868d4a2..0000000 --- a/apps/api/src/modules/accounts/accounts.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { randomUUID } from 'crypto'; -import { PrismaService } from '../../prisma/prisma.service'; -import * as QRCode from 'qrcode'; - -export interface AccountSummary { - id: string; - platform: string; - jid: string; - displayName: string | null; - status: string; -} - -export interface AccountQr { - status: string; - qrDataUrl: string | null; -} - -@Injectable() -export class AccountsService { - constructor( - private readonly prisma: PrismaService, - private readonly config: ConfigService, - ) {} - - list(): Promise { - return this.prisma.account.findMany({ - orderBy: { createdAt: 'asc' }, - select: { id: true, platform: true, jid: true, displayName: true, status: true }, - }); - } - - async getQr(id: string): Promise { - const account = await this.prisma.account.findUnique({ - where: { id }, - select: { status: true, qrCode: true }, - }); - if (!account) return { status: 'not_found', qrDataUrl: null }; - if (!account.qrCode) return { status: account.status, qrDataUrl: null }; - const qrDataUrl = await QRCode.toDataURL(account.qrCode); - return { status: account.status, qrDataUrl }; - } - - async create(displayName?: string): Promise { - const sessionBase = this.config.get('WHATSAPP_SESSION_PATH', './sessions'); - const uid = randomUUID(); - return this.prisma.account.create({ - data: { - platform: 'whatsapp', - jid: `pending_${uid}@placeholder`, - sessionPath: `${sessionBase}/${uid}`, - displayName: displayName ?? null, - status: 'DISCONNECTED', - }, - select: { id: true, platform: true, jid: true, displayName: true, status: true }, - }); - } -} diff --git a/apps/api/src/modules/audit/audit.module.ts b/apps/api/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..8f9397d --- /dev/null +++ b/apps/api/src/modules/audit/audit.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; + +@Global() +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/api/src/modules/audit/audit.service.spec.ts b/apps/api/src/modules/audit/audit.service.spec.ts new file mode 100644 index 0000000..56109ae --- /dev/null +++ b/apps/api/src/modules/audit/audit.service.spec.ts @@ -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, + }), + }); + }); +}); diff --git a/apps/api/src/modules/audit/audit.service.ts b/apps/api/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..44ff894 --- /dev/null +++ b/apps/api/src/modules/audit/audit.service.ts @@ -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; + traceId?: string | null; + tenantId: string; +} + +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + async log(input: AuditLogInput): Promise { + 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, + }, + }); + } +} diff --git a/apps/api/src/modules/audit/audit.types.ts b/apps/api/src/modules/audit/audit.types.ts new file mode 100644 index 0000000..1f52426 --- /dev/null +++ b/apps/api/src/modules/audit/audit.types.ts @@ -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]; diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..a5468cb --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..841feda --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -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('JWT_SECRET') ?? '', + signOptions: { + expiresIn: (config.get('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 {} diff --git a/apps/api/src/modules/auth/auth.service.spec.ts b/apps/api/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..ba5ee7d --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..0c74f3f --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/modules/auth/current-admin.decorator.ts b/apps/api/src/modules/auth/current-admin.decorator.ts new file mode 100644 index 0000000..036ac2c --- /dev/null +++ b/apps/api/src/modules/auth/current-admin.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/modules/auth/current-member.decorator.ts b/apps/api/src/modules/auth/current-member.decorator.ts new file mode 100644 index 0000000..9c28a55 --- /dev/null +++ b/apps/api/src/modules/auth/current-member.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/modules/auth/current-tenant.decorator.ts b/apps/api/src/modules/auth/current-tenant.decorator.ts new file mode 100644 index 0000000..944b282 --- /dev/null +++ b/apps/api/src/modules/auth/current-tenant.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/modules/auth/dto/login.dto.ts b/apps/api/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..f903384 --- /dev/null +++ b/apps/api/src/modules/auth/dto/login.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/dto/signup.dto.ts b/apps/api/src/modules/auth/dto/signup.dto.ts new file mode 100644 index 0000000..6dc73b2 --- /dev/null +++ b/apps/api/src/modules/auth/dto/signup.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/jwt-auth.guard.ts b/apps/api/src/modules/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..e1a4c2b --- /dev/null +++ b/apps/api/src/modules/auth/jwt-auth.guard.ts @@ -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(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(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; + } +} diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..ff1c9c9 --- /dev/null +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -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('JWT_SECRET') ?? '', + }); + } + + async validate(payload: JwtPayload): Promise { + return payload; + } +} diff --git a/apps/api/src/modules/auth/member-auth.decorator.ts b/apps/api/src/modules/auth/member-auth.decorator.ts new file mode 100644 index 0000000..d639f53 --- /dev/null +++ b/apps/api/src/modules/auth/member-auth.decorator.ts @@ -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)); diff --git a/apps/api/src/modules/auth/member-auth.guard.ts b/apps/api/src/modules/auth/member-auth.guard.ts new file mode 100644 index 0000000..7010a77 --- /dev/null +++ b/apps/api/src/modules/auth/member-auth.guard.ts @@ -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; + } +} diff --git a/apps/api/src/modules/auth/password.util.spec.ts b/apps/api/src/modules/auth/password.util.spec.ts new file mode 100644 index 0000000..3631f7c --- /dev/null +++ b/apps/api/src/modules/auth/password.util.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/auth/password.util.ts b/apps/api/src/modules/auth/password.util.ts new file mode 100644 index 0000000..c50fab3 --- /dev/null +++ b/apps/api/src/modules/auth/password.util.ts @@ -0,0 +1,9 @@ +import * as bcrypt from 'bcryptjs'; + +export async function hashPassword(plain: string, rounds: number = 10): Promise { + return bcrypt.hash(plain, rounds); +} + +export async function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/apps/api/src/modules/auth/public.decorator.ts b/apps/api/src/modules/auth/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/api/src/modules/auth/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/modules/auth/roles.decorator.ts b/apps/api/src/modules/auth/roles.decorator.ts new file mode 100644 index 0000000..7a6a203 --- /dev/null +++ b/apps/api/src/modules/auth/roles.decorator.ts @@ -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); diff --git a/apps/api/src/modules/auth/roles.guard.ts b/apps/api/src/modules/auth/roles.guard.ts new file mode 100644 index 0000000..a3411f6 --- /dev/null +++ b/apps/api/src/modules/auth/roles.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/modules/bot/bot-admin.controller.ts b/apps/api/src/modules/bot/bot-admin.controller.ts new file mode 100644 index 0000000..27751dc --- /dev/null +++ b/apps/api/src/modules/bot/bot-admin.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/bot/bot.controller.ts b/apps/api/src/modules/bot/bot.controller.ts new file mode 100644 index 0000000..f0271b1 --- /dev/null +++ b/apps/api/src/modules/bot/bot.controller.ts @@ -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 ?? ''); + } +} diff --git a/apps/api/src/modules/bot/bot.module.ts b/apps/api/src/modules/bot/bot.module.ts new file mode 100644 index 0000000..63ea263 --- /dev/null +++ b/apps/api/src/modules/bot/bot.module.ts @@ -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 {} diff --git a/apps/api/src/modules/bot/bot.service.spec.ts b/apps/api/src/modules/bot/bot.service.spec.ts new file mode 100644 index 0000000..8015d78 --- /dev/null +++ b/apps/api/src/modules/bot/bot.service.spec.ts @@ -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); + }); + + 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 = {}) => ({ + 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' } }), + ); + }); + }); +}); diff --git a/apps/api/src/modules/bot/bot.service.ts b/apps/api/src/modules/bot/bot.service.ts new file mode 100644 index 0000000..4c3265f --- /dev/null +++ b/apps/api/src/modules/bot/bot.service.ts @@ -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 { + 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('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 { + 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 { + 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 { + 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 { + 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('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 { + 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 { + 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 }; + } +} diff --git a/apps/api/src/modules/groups/groups.controller.spec.ts b/apps/api/src/modules/groups/groups.controller.spec.ts index 6e3770a..eea170a 100644 --- a/apps/api/src/modules/groups/groups.controller.spec.ts +++ b/apps/api/src/modules/groups/groups.controller.spec.ts @@ -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); }); - 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(); }); }); diff --git a/apps/api/src/modules/groups/groups.controller.ts b/apps/api/src/modules/groups/groups.controller.ts index 0e16891..7d3144d 100644 --- a/apps/api/src/modules/groups/groups.controller.ts +++ b/apps/api/src/modules/groups/groups.controller.ts @@ -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(); } } diff --git a/apps/api/src/modules/groups/groups.service.spec.ts b/apps/api/src/modules/groups/groups.service.spec.ts index 3fda63e..b0f99f3 100644 --- a/apps/api/src/modules/groups/groups.service.spec.ts +++ b/apps/api/src/modules/groups/groups.service.spec.ts @@ -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); }); - 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); }); }); }); diff --git a/apps/api/src/modules/groups/groups.service.ts b/apps/api/src/modules/groups/groups.service.ts index 5069987..d2c89ac 100644 --- a/apps/api/src/modules/groups/groups.service.ts +++ b/apps/api/src/modules/groups/groups.service.ts @@ -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 { + list(tenantId: string): Promise { 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 { + 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(); + 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 { + 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() }; + } } diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts index 73f6be0..fd25e9f 100644 --- a/apps/api/src/modules/health/health.controller.ts +++ b/apps/api/src/modules/health/health.controller.ts @@ -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 { diff --git a/apps/api/src/modules/messages/messages.controller.ts b/apps/api/src/modules/messages/messages.controller.ts new file mode 100644 index 0000000..371461a --- /dev/null +++ b/apps/api/src/modules/messages/messages.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/messages/messages.module.ts b/apps/api/src/modules/messages/messages.module.ts new file mode 100644 index 0000000..ce36158 --- /dev/null +++ b/apps/api/src/modules/messages/messages.module.ts @@ -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 {} diff --git a/apps/api/src/modules/messages/messages.service.spec.ts b/apps/api/src/modules/messages/messages.service.spec.ts new file mode 100644 index 0000000..016e275 --- /dev/null +++ b/apps/api/src/modules/messages/messages.service.spec.ts @@ -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); + }); + + 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/); + }); + }); +}); diff --git a/apps/api/src/modules/messages/messages.service.ts b/apps/api/src/modules/messages/messages.service.ts new file mode 100644 index 0000000..c4727c3 --- /dev/null +++ b/apps/api/src/modules/messages/messages.service.ts @@ -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, + @Inject(INDEX_QUEUE) private readonly indexQueue: Queue, + ) {} + + async get(tenantId: string, id: string): Promise { + 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 { + 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 }; + } +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts new file mode 100644 index 0000000..7373889 --- /dev/null +++ b/apps/api/src/modules/my/my.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/my/my.module.ts b/apps/api/src/modules/my/my.module.ts new file mode 100644 index 0000000..1c6bd75 --- /dev/null +++ b/apps/api/src/modules/my/my.module.ts @@ -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 {} diff --git a/apps/api/src/modules/my/my.service.spec.ts b/apps/api/src/modules/my/my.service.spec.ts new file mode 100644 index 0000000..e321ddb --- /dev/null +++ b/apps/api/src/modules/my/my.service.spec.ts @@ -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) => cb(mockPrisma)); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MyService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: AuditService, useValue: mockAudit }, + ], + }).compile(); + service = module.get(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' }), + ); + }); + }); +}); diff --git a/apps/api/src/modules/my/my.service.ts b/apps/api/src/modules/my/my.service.ts new file mode 100644 index 0000000..8dbf9c4 --- /dev/null +++ b/apps/api/src/modules/my/my.service.ts @@ -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 { + 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 { + 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 { + 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 }; + } +} diff --git a/apps/api/src/modules/onboarding/onboarding.module.ts b/apps/api/src/modules/onboarding/onboarding.module.ts new file mode 100644 index 0000000..08ab23c --- /dev/null +++ b/apps/api/src/modules/onboarding/onboarding.module.ts @@ -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 {} diff --git a/apps/api/src/modules/onboarding/onboarding.service.spec.ts b/apps/api/src/modules/onboarding/onboarding.service.spec.ts new file mode 100644 index 0000000..ce94b94 --- /dev/null +++ b/apps/api/src/modules/onboarding/onboarding.service.spec.ts @@ -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); + }); + + 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' }), + ); + }); + }); +}); diff --git a/apps/api/src/modules/onboarding/onboarding.service.ts b/apps/api/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 0000000..fc2f443 --- /dev/null +++ b/apps/api/src/modules/onboarding/onboarding.service.ts @@ -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 { + 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 { + 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('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 { + const payload = this.decodeOnboardingToken(token); + const pepper = this.config.get('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(); + } +} diff --git a/apps/api/src/modules/onboarding/public-onboarding.controller.ts b/apps/api/src/modules/onboarding/public-onboarding.controller.ts new file mode 100644 index 0000000..dcff222 --- /dev/null +++ b/apps/api/src/modules/onboarding/public-onboarding.controller.ts @@ -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, + ); + } +} diff --git a/apps/api/src/modules/routes/routes.controller.spec.ts b/apps/api/src/modules/routes/routes.controller.spec.ts index 20d4993..516f7ec 100644 --- a/apps/api/src/modules/routes/routes.controller.spec.ts +++ b/apps/api/src/modules/routes/routes.controller.spec.ts @@ -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); }); - 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']); }); }); diff --git a/apps/api/src/modules/routes/routes.controller.ts b/apps/api/src/modules/routes/routes.controller.ts index 6c4e4e0..678d7bf 100644 --- a/apps/api/src/modules/routes/routes.controller.ts +++ b/apps/api/src/modules/routes/routes.controller.ts @@ -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); } } diff --git a/apps/api/src/modules/routes/routes.service.spec.ts b/apps/api/src/modules/routes/routes.service.spec.ts index 79374a9..c24db52 100644 --- a/apps/api/src/modules/routes/routes.service.spec.ts +++ b/apps/api/src/modules/routes/routes.service.spec.ts @@ -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); }); 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); }); }); }); diff --git a/apps/api/src/modules/routes/routes.service.ts b/apps/api/src/modules/routes/routes.service.ts index b1fd9cc..4aecd49 100644 --- a/apps/api/src/modules/routes/routes.service.ts +++ b/apps/api/src/modules/routes/routes.service.ts @@ -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`); diff --git a/apps/api/src/modules/rules/rules.controller.ts b/apps/api/src/modules/rules/rules.controller.ts new file mode 100644 index 0000000..4b2e481 --- /dev/null +++ b/apps/api/src/modules/rules/rules.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/rules/rules.module.ts b/apps/api/src/modules/rules/rules.module.ts new file mode 100644 index 0000000..00736ad --- /dev/null +++ b/apps/api/src/modules/rules/rules.module.ts @@ -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 {} diff --git a/apps/api/src/modules/rules/rules.service.spec.ts b/apps/api/src/modules/rules/rules.service.spec.ts new file mode 100644 index 0000000..78a6daa --- /dev/null +++ b/apps/api/src/modules/rules/rules.service.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/apps/api/src/modules/rules/rules.service.ts b/apps/api/src/modules/rules/rules.service.ts new file mode 100644 index 0000000..d0d2d39 --- /dev/null +++ b/apps/api/src/modules/rules/rules.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 } }); + } +} diff --git a/apps/api/src/modules/search/search.controller.spec.ts b/apps/api/src/modules/search/search.controller.spec.ts index 9c7bf80..7b86d47 100644 --- a/apps/api/src/modules/search/search.controller.spec.ts +++ b/apps/api/src/modules/search/search.controller.spec.ts @@ -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); }); }); diff --git a/apps/api/src/modules/search/search.controller.ts b/apps/api/src/modules/search/search.controller.ts index 84f78fa..0537cd7 100644 --- a/apps/api/src/modules/search/search.controller.ts +++ b/apps/api/src/modules/search/search.controller.ts @@ -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)); } } diff --git a/apps/api/src/modules/search/search.service.spec.ts b/apps/api/src/modules/search/search.service.spec.ts index f932582..28429d8 100644 --- a/apps/api/src/modules/search/search.service.spec.ts +++ b/apps/api/src/modules/search/search.service.spec.ts @@ -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 }), diff --git a/apps/api/src/modules/search/search.service.ts b/apps/api/src/modules/search/search.service.ts index 50002b0..01b68b5 100644 --- a/apps/api/src/modules/search/search.service.ts +++ b/apps/api/src/modules/search/search.service.ts @@ -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(MESSAGES_INDEX).search(query, { - filter: filters.length ? filters.join(' AND ') : undefined, + filter: filters.join(' AND '), page: safePage, hitsPerPage: safeLimit, sort: ['approvedAt:desc'], diff --git a/apps/api/src/modules/super-admin/super-admin.controller.ts b/apps/api/src/modules/super-admin/super-admin.controller.ts new file mode 100644 index 0000000..301ecf0 --- /dev/null +++ b/apps/api/src/modules/super-admin/super-admin.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/super-admin/super-admin.guard.ts b/apps/api/src/modules/super-admin/super-admin.guard.ts new file mode 100644 index 0000000..a9a1351 --- /dev/null +++ b/apps/api/src/modules/super-admin/super-admin.guard.ts @@ -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(); + } + } +} diff --git a/apps/api/src/modules/super-admin/super-admin.module.ts b/apps/api/src/modules/super-admin/super-admin.module.ts new file mode 100644 index 0000000..bb34ee8 --- /dev/null +++ b/apps/api/src/modules/super-admin/super-admin.module.ts @@ -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('JWT_SECRET') ?? '', + }), + }), + ], + controllers: [SuperAdminController], + providers: [SuperAdminService, SuperAdminGuard], + exports: [SuperAdminGuard, JwtModule], +}) +export class SuperAdminModule {} diff --git a/apps/api/src/modules/super-admin/super-admin.service.ts b/apps/api/src/modules/super-admin/super-admin.service.ts new file mode 100644 index 0000000..9e57ba1 --- /dev/null +++ b/apps/api/src/modules/super-admin/super-admin.service.ts @@ -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('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 }; + } +} diff --git a/apps/api/src/modules/tenant/tenant.controller.ts b/apps/api/src/modules/tenant/tenant.controller.ts new file mode 100644 index 0000000..2c4cfaa --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/tenant/tenant.module.ts b/apps/api/src/modules/tenant/tenant.module.ts new file mode 100644 index 0000000..1eaa0c6 --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.module.ts @@ -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 {} diff --git a/apps/api/src/modules/tenant/tenant.service.ts b/apps/api/src/modules/tenant/tenant.service.ts new file mode 100644 index 0000000..91885fb --- /dev/null +++ b/apps/api/src/modules/tenant/tenant.service.ts @@ -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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/prisma/prisma.service.spec.ts b/apps/api/src/prisma/prisma.service.spec.ts index cd752c1..6ecd8e2 100644 --- a/apps/api/src/prisma/prisma.service.spec.ts +++ b/apps/api/src/prisma/prisma.service.spec.ts @@ -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', diff --git a/apps/api/src/queues/forward.queue.ts b/apps/api/src/queues/forward.queue.ts new file mode 100644 index 0000000..8e43455 --- /dev/null +++ b/apps/api/src/queues/forward.queue.ts @@ -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 { + return new Queue('tower-forward', { + connection: parseRedisUrl(redisUrl), + }); +} + +export function createIndexQueue(redisUrl: string): Queue { + return new Queue('tower-index', { + connection: parseRedisUrl(redisUrl), + }); +} + +export const forwardQueueProvider: Provider = { + provide: FORWARD_QUEUE, + useFactory: (config: ConfigService) => + createForwardQueue(config.get('REDIS_URL', 'redis://localhost:6379')), + inject: [ConfigService], +}; + +export const indexQueueProvider: Provider = { + provide: INDEX_QUEUE, + useFactory: (config: ConfigService) => + createIndexQueue(config.get('REDIS_URL', 'redis://localhost:6379')), + inject: [ConfigService], +}; + diff --git a/apps/api/src/queues/redis-connection.ts b/apps/api/src/queues/redis-connection.ts new file mode 100644 index 0000000..05d70b4 --- /dev/null +++ b/apps/api/src/queues/redis-connection.ts @@ -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 }; +} diff --git a/apps/web/app/_lib/api.ts b/apps/web/app/_lib/api.ts new file mode 100644 index 0000000..62fb540 --- /dev/null +++ b/apps/web/app/_lib/api.ts @@ -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 { + const store = await cookies(); + return store.get(TOKEN_COOKIE)?.value; +} + +export async function getMemberToken(): Promise { + 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 { + 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 { + 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 = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...extraHeaders }, + }); +} diff --git a/apps/web/app/_lib/auth-context.test.tsx b/apps/web/app/_lib/auth-context.test.tsx new file mode 100644 index 0000000..3e13df3 --- /dev/null +++ b/apps/web/app/_lib/auth-context.test.tsx @@ -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 ( +
+
{String(loading)}
+
{admin?.email ?? 'null'}
+
{error ?? 'null'}
+ +
+ ); +} + +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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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'); + }); +}); diff --git a/apps/web/app/_lib/auth-context.tsx b/apps/web/app/_lib/auth-context.tsx new file mode 100644 index 0000000..9b52aee --- /dev/null +++ b/apps/web/app/_lib/auth-context.tsx @@ -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; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [admin, setAdmin] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + {children} + + ); +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within '); + return ctx; +} diff --git a/apps/web/app/_lib/sidebar.tsx b/apps/web/app/_lib/sidebar.tsx new file mode 100644 index 0000000..365b4f1 --- /dev/null +++ b/apps/web/app/_lib/sidebar.tsx @@ -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(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 ( + + ); + } + + if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) { + return ( + + ); + } + + if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/app/_lib/super-admin-context.tsx b/apps/web/app/_lib/super-admin-context.tsx new file mode 100644 index 0000000..68f1262 --- /dev/null +++ b/apps/web/app/_lib/super-admin-context.tsx @@ -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; + logout: () => Promise; +} + +const SuperAdminContext = createContext(null); + +export function SuperAdminProvider({ children }: { children: React.ReactNode }) { + const [admin, setAdmin] = useState(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 ( + + {children} + + ); +} + +export function useSuperAdmin(): SuperAdminState { + const ctx = useContext(SuperAdminContext); + if (!ctx) throw new Error('useSuperAdmin must be used within '); + return ctx; +} diff --git a/apps/web/app/accounts/AccountCard.test.tsx b/apps/web/app/accounts/AccountCard.test.tsx deleted file mode 100644 index 7dd6fba..0000000 --- a/apps/web/app/accounts/AccountCard.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { AccountCard } from './AccountCard'; - -const activeAccount = { - id: 'acc_1', - jid: '111@s.whatsapp.net', - displayName: 'My Account', - status: 'ACTIVE', - platform: 'whatsapp', -}; - -const disconnectedAccount = { - id: 'acc_2', - jid: '222@s.whatsapp.net', - displayName: null, - status: 'DISCONNECTED', - platform: 'whatsapp', -}; - -let fetchSpy: jest.SpyInstance; - -beforeEach(() => { - fetchSpy = jest.spyOn(global, 'fetch'); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('AccountCard', () => { - it('shows displayName and Connected badge when ACTIVE', () => { - render(); - expect(screen.getByText('My Account')).toBeInTheDocument(); - expect(screen.getByText('Connected')).toBeInTheDocument(); - }); - - it('falls back to jid when displayName is null', () => { - render(); - expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument(); - }); - - it('shows Awaiting scan badge when DISCONNECTED', () => { - fetchSpy.mockResolvedValue( - new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - render(); - expect(screen.getByText('Awaiting scan')).toBeInTheDocument(); - }); - - it('does not fetch QR when account is ACTIVE', () => { - render(); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => { - fetchSpy.mockResolvedValue( - new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - render(); - await waitFor(() => - expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'), - ); - }); - - it('shows QR image when qrDataUrl is returned', async () => { - fetchSpy.mockResolvedValue( - new Response( - JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - render(); - await waitFor(() => { - expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument(); - }); - expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute( - 'src', - 'data:image/png;base64,abc123', - ); - }); - - it('shows waiting message when DISCONNECTED but no QR yet', async () => { - fetchSpy.mockResolvedValue( - new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - render(); - await waitFor(() => { - expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/apps/web/app/accounts/AccountCard.tsx b/apps/web/app/accounts/AccountCard.tsx deleted file mode 100644 index b68b215..0000000 --- a/apps/web/app/accounts/AccountCard.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; - -interface Account { - id: string; - jid: string; - displayName: string | null; - status: string; - platform: string; -} - -export function AccountCard({ account }: { account: Account }) { - const [qrDataUrl, setQrDataUrl] = useState(null); - const isDisconnected = account.status === 'DISCONNECTED'; - - useEffect(() => { - if (!isDisconnected) { - setQrDataUrl(null); - return; - } - - async function fetchQr() { - try { - const res = await fetch(`/api/accounts/${account.id}/qr`); - if (!res.ok) return; - const data = await res.json(); - setQrDataUrl(data.qrDataUrl ?? null); - } catch { - // ignore fetch errors (e.g. network issues) - } - } - - fetchQr(); - const interval = setInterval(fetchQr, 5000); - return () => clearInterval(interval); - }, [account.id, isDisconnected]); - - return ( -
-
-
-

{account.displayName ?? account.jid}

- {account.displayName && ( -

{account.jid}

- )} -
- - {account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'} - -
- - {isDisconnected && qrDataUrl && ( -
-

- Open WhatsApp → Linked Devices → Link a Device → scan below -

- WhatsApp QR Code -
- )} - - {isDisconnected && !qrDataUrl && ( -

Waiting for QR code from worker...

- )} -
- ); -} diff --git a/apps/web/app/accounts/AccountsList.test.tsx b/apps/web/app/accounts/AccountsList.test.tsx deleted file mode 100644 index 5cab37c..0000000 --- a/apps/web/app/accounts/AccountsList.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { AccountsList } from './AccountsList'; - -const mockAccounts = [ - { id: 'acc_1', jid: '111@s.whatsapp.net', displayName: 'Account One', status: 'ACTIVE', platform: 'whatsapp' }, - { id: 'acc_2', jid: '222@s.whatsapp.net', displayName: null, status: 'DISCONNECTED', platform: 'whatsapp' }, -]; - -let fetchSpy: jest.SpyInstance; - -beforeEach(() => { - fetchSpy = jest.spyOn(global, 'fetch'); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('AccountsList', () => { - it('renders an AccountCard for each initial account', () => { - render(); - expect(screen.getByText('Account One')).toBeInTheDocument(); - expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument(); - }); - - it('shows empty state when no accounts', () => { - render(); - expect(screen.getByText(/no accounts yet/i)).toBeInTheDocument(); - }); - - it('renders Add Account button', () => { - render(); - expect(screen.getByRole('button', { name: /add account/i })).toBeInTheDocument(); - }); - - it('renders display name input', () => { - render(); - expect(screen.getByPlaceholderText(/display name/i)).toBeInTheDocument(); - }); - - it('calls POST /api/accounts when Add Account is clicked', async () => { - fetchSpy.mockResolvedValue( - new Response( - JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: null, status: 'ACTIVE', platform: 'whatsapp' }), - { status: 201, headers: { 'Content-Type': 'application/json' } }, - ), - ); - render(); - fireEvent.click(screen.getByRole('button', { name: /add account/i })); - await waitFor(() => - expect(fetchSpy).toHaveBeenCalledWith('/api/accounts', expect.objectContaining({ method: 'POST' })), - ); - }); - - it('adds new account to list after successful POST', async () => { - const newAccount = { id: 'acc_new', jid: 'pending_x@placeholder', displayName: 'New Device', status: 'ACTIVE', platform: 'whatsapp' }; - fetchSpy.mockResolvedValue( - new Response(JSON.stringify(newAccount), { - status: 201, - headers: { 'Content-Type': 'application/json' }, - }), - ); - render(); - const input = screen.getByPlaceholderText(/display name/i); - fireEvent.change(input, { target: { value: 'New Device' } }); - fireEvent.click(screen.getByRole('button', { name: /add account/i })); - await waitFor(() => expect(screen.getByText('New Device')).toBeInTheDocument()); - }); - - it('sends displayName in POST body when entered', async () => { - fetchSpy.mockResolvedValue( - new Response( - JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: 'Test Name', status: 'ACTIVE', platform: 'whatsapp' }), - { status: 201, headers: { 'Content-Type': 'application/json' } }, - ), - ); - render(); - const input = screen.getByPlaceholderText(/display name/i); - fireEvent.change(input, { target: { value: 'Test Name' } }); - fireEvent.click(screen.getByRole('button', { name: /add account/i })); - await waitFor(() => - expect(fetchSpy).toHaveBeenCalledWith( - '/api/accounts', - expect.objectContaining({ - body: JSON.stringify({ displayName: 'Test Name' }), - }), - ), - ); - }); - - it('clears the input after successful account creation', async () => { - fetchSpy.mockResolvedValue( - new Response( - JSON.stringify({ id: 'acc_new', jid: 'pending_x@placeholder', displayName: null, status: 'ACTIVE', platform: 'whatsapp' }), - { status: 201, headers: { 'Content-Type': 'application/json' } }, - ), - ); - render(); - const input = screen.getByPlaceholderText(/display name/i) as HTMLInputElement; - fireEvent.change(input, { target: { value: 'My Device' } }); - fireEvent.click(screen.getByRole('button', { name: /add account/i })); - await waitFor(() => expect(input.value).toBe('')); - }); -}); diff --git a/apps/web/app/accounts/AccountsList.tsx b/apps/web/app/accounts/AccountsList.tsx deleted file mode 100644 index 4b638d7..0000000 --- a/apps/web/app/accounts/AccountsList.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; -import { useState } from 'react'; -import { AccountCard } from './AccountCard'; - -interface Account { - id: string; - jid: string; - displayName: string | null; - status: string; - platform: string; -} - -export function AccountsList({ initialAccounts }: { initialAccounts: Account[] }) { - const [accounts, setAccounts] = useState(initialAccounts); - const [displayName, setDisplayName] = useState(''); - - async function handleAdd() { - try { - const res = await fetch('/api/accounts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ displayName: displayName || undefined }), - }); - if (!res.ok) return; - const account: Account = await res.json(); - setAccounts((prev) => [...prev, account]); - setDisplayName(''); - } catch {} - } - - return ( -
-
- setDisplayName(e.target.value)} - placeholder="Display name (optional)" - className="flex-1 border rounded px-3 py-2 text-sm" - /> - -
- - {accounts.length === 0 ? ( -

No accounts yet. Add one above to get started.

- ) : ( -
- {accounts.map((a) => ( - - ))} -
- )} -
- ); -} diff --git a/apps/web/app/accounts/page.tsx b/apps/web/app/accounts/page.tsx deleted file mode 100644 index d076132..0000000 --- a/apps/web/app/accounts/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { AccountsList } from './AccountsList'; - -interface Account { - id: string; - jid: string; - displayName: string | null; - status: string; - platform: string; -} - -export default async function AccountsPage() { - const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; - let accounts: Account[] = []; - try { - const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' }); - if (res.ok) accounts = await res.json(); - } catch {} - - return ( -
-

Accounts

- -
- ); -} diff --git a/apps/web/app/admin/bots/page.tsx b/apps/web/app/admin/bots/page.tsx new file mode 100644 index 0000000..486f2ef --- /dev/null +++ b/apps/web/app/admin/bots/page.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSuperAdmin } from '../../_lib/super-admin-context'; +import { useRouter } from 'next/navigation'; + +export default function BotsPage() { + const { admin, loading } = useSuperAdmin(); + const router = useRouter(); + const [bots, setBots] = useState([]); + const [initiating, setInitiating] = useState(false); + const [pairingInfo, setPairingInfo] = useState<{ token: string; expiresAt: string } | null>(null); + + async function load() { + const res = await fetch('/api/admin/bots'); + if (res.ok) setBots(await res.json()); + } + + useEffect(() => { + if (loading) return; + if (!admin) { router.replace('/admin/login'); return; } + void load(); + }, [admin, loading, router]); + + async function initiateBot() { + setInitiating(true); + try { + const res = await fetch('/api/admin/bots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (res.ok) { + const data = await res.json(); + setPairingInfo(data); + } + } finally { + setInitiating(false); + void load(); + } + } + + async function removeBot(id: string) { + if (!confirm('Remove this bot? Only possible if no tenants are assigned.')) return; + const res = await fetch(`/api/admin/bots/${id}`, { method: 'DELETE' }); + if (res.ok) { + void load(); + } else { + const err = await res.json(); + alert(err.message ?? 'Failed to remove bot'); + } + } + + function getQrUrl() { + if (!pairingInfo) return null; + return `/api/admin/bots/qr/${pairingInfo.token}`; + } + + if (loading) return

Loading...

; + if (!admin) return null; + + return ( +
+
+

Bot Pool

+ +
+ + {pairingInfo && ( +
+

New bot created — scan QR to pair

+

Expires: {pairingInfo.expiresAt}

+ + View QR Code + +
+ )} + +
+ + + + + + + + + + + + + {bots.map((b: any) => ( + + + + + + + + + ))} + +
JIDNameStatusTenantsCreated
{b.jid?.slice(0, 30) ?? 'pending...'}{b.displayName ?? '—'} + {b.status} + {b.tenantCount}{new Date(b.createdAt).toLocaleDateString()} + +
+ {bots.length === 0 &&

No bots in the pool.

} +
+
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..d571fab --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,3 @@ +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/admin/login/page.tsx b/apps/web/app/admin/login/page.tsx new file mode 100644 index 0000000..45f9028 --- /dev/null +++ b/apps/web/app/admin/login/page.tsx @@ -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 ( +
+

Super Admin Login

+
+ setEmail(e.target.value)} + required + className="border rounded px-3 py-2 text-sm" + /> + setPassword(e.target.value)} + required + className="border rounded px-3 py-2 text-sm" + /> + {error &&

{error}

} + +
+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..aa63bce --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -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([]); + const [bots, setBots] = useState([]); + + 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

Loading...

; + 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 ( +
+

Admin Dashboard

+
+
+
Tenants
+
{totalTenants}
+
{activeTenants} active
+
+
+
Bot Accounts
+
{totalBots}
+
in pool
+
+
+
Messages
+
{totalMessages}
+
+
+
Avg tenants/bot
+
{totalBots ? Math.round(totalTenants / totalBots) : 0}
+
+
+
+ Manage Tenants + Manage Bots +
+
+ ); +} diff --git a/apps/web/app/admin/tenants/[id]/page.tsx b/apps/web/app/admin/tenants/[id]/page.tsx new file mode 100644 index 0000000..417a8ae --- /dev/null +++ b/apps/web/app/admin/tenants/[id]/page.tsx @@ -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(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

Loading...

; + if (!admin) return null; + if (!tenant) return

Loading tenant...

; + + return ( +
+

{tenant.name}

+

Slug: {tenant.slug}

+ +
+
+
Status
+ +
+
+
Forwarding
+ +
+
+
Groups
+
{tenant.stats?.groups ?? 0}
+
+
+
Messages
+
{tenant.stats?.messages ?? 0}
+
+
+
Rules
+
{tenant.stats?.rules ?? 0}
+
+
+
Routes
+
{tenant.stats?.routes ?? 0}
+
+
+ + {tenant.bot && ( +
+

Assigned Bot

+
+

JID: {tenant.bot.jid}

+

Name: {tenant.bot.displayName ?? '—'}

+

Status: {tenant.bot.status}

+

Linked: {tenant.bot.linkedSince}

+
+
+ )} + + {tenant.admins && tenant.admins.length > 0 && ( +
+

Admins

+
+ {tenant.admins.map((a: any) => ( +

{a.email} — {a.role}

+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/admin/tenants/page.tsx b/apps/web/app/admin/tenants/page.tsx new file mode 100644 index 0000000..8ec1fd9 --- /dev/null +++ b/apps/web/app/admin/tenants/page.tsx @@ -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([]); + + 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

Loading...

; + if (!admin) return null; + + return ( +
+

Tenants

+
+ + + + + + + + + + + + + + {tenants.map((t: any) => ( + + + + + + + + + + ))} + +
NameSlugBotGroupsMessagesActivePaused
+ + {t.name} + + {t.slug} + {t.bot ? {t.bot.jid?.slice(0, 20)}... : } + {t.stats.groups}{t.stats.messages} + + + +
+ {tenants.length === 0 &&

No tenants yet.

} +
+
+ ); +} diff --git a/apps/web/app/api/accounts/[id]/qr/route.ts b/apps/web/app/api/accounts/[id]/qr/route.ts deleted file mode 100644 index 6dabbf5..0000000 --- a/apps/web/app/api/accounts/[id]/qr/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -const API_URL = process.env.API_URL ?? 'http://localhost:3001'; - -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const res = await fetch(`${API_URL}/accounts/${id}/qr`, { cache: 'no-store' }); - return Response.json(await res.json(), { status: res.status }); -} diff --git a/apps/web/app/api/accounts/route.ts b/apps/web/app/api/accounts/route.ts deleted file mode 100644 index dbbd90c..0000000 --- a/apps/web/app/api/accounts/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -const API_URL = process.env.API_URL ?? 'http://localhost:3001'; - -export async function GET() { - const res = await fetch(`${API_URL}/accounts`, { cache: 'no-store' }); - return Response.json(await res.json(), { status: res.status }); -} - -export async function POST(req: Request) { - const body = await req.json(); - const res = await fetch(`${API_URL}/accounts`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - return Response.json(await res.json(), { status: res.status }); -} diff --git a/apps/web/app/api/admin/bots/[id]/assign/route.ts b/apps/web/app/api/admin/bots/[id]/assign/route.ts new file mode 100644 index 0000000..5b34344 --- /dev/null +++ b/apps/web/app/api/admin/bots/[id]/assign/route.ts @@ -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 { + 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); +} diff --git a/apps/web/app/api/admin/bots/[id]/route.ts b/apps/web/app/api/admin/bots/[id]/route.ts new file mode 100644 index 0000000..160e39f --- /dev/null +++ b/apps/web/app/api/admin/bots/[id]/route.ts @@ -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 { + 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); +} diff --git a/apps/web/app/api/admin/bots/qr/[token]/route.ts b/apps/web/app/api/admin/bots/qr/[token]/route.ts new file mode 100644 index 0000000..545821d --- /dev/null +++ b/apps/web/app/api/admin/bots/qr/[token]/route.ts @@ -0,0 +1,17 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }): Promise { + const { token } = await params; + const tokenCookie = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value); + const res = await fetch(`${getApiBaseUrl()}/admin/bots/qr/${token}`, { + headers: { + Accept: 'application/json', + ...(tokenCookie ? { Authorization: `Bearer ${tokenCookie}` } : {}), + }, + cache: 'no-store', + }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/admin/bots/route.ts b/apps/web/app/api/admin/bots/route.ts new file mode 100644 index 0000000..9449deb --- /dev/null +++ b/apps/web/app/api/admin/bots/route.ts @@ -0,0 +1,33 @@ +import { getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value); + const res = await fetch(`${getApiBaseUrl()}/admin/bots`, { + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: 'no-store', + }); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function POST(req: Request): Promise { + 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/initiate`, { + 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); +} diff --git a/apps/web/app/api/admin/tenants/[id]/route.ts b/apps/web/app/api/admin/tenants/[id]/route.ts new file mode 100644 index 0000000..e73557f --- /dev/null +++ b/apps/web/app/api/admin/tenants/[id]/route.ts @@ -0,0 +1,35 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + 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/tenants/${id}`, { + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: 'no-store', + }); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + 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/tenants/${id}`, { + method: 'PATCH', + 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); +} diff --git a/apps/web/app/api/admin/tenants/route.ts b/apps/web/app/api/admin/tenants/route.ts new file mode 100644 index 0000000..5d22820 --- /dev/null +++ b/apps/web/app/api/admin/tenants/route.ts @@ -0,0 +1,16 @@ +import { getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value); + const res = await fetch(`${getApiBaseUrl()}/admin/tenants`, { + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: 'no-store', + }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts new file mode 100644 index 0000000..32c5469 --- /dev/null +++ b/apps/web/app/api/auth/login/route.ts @@ -0,0 +1,19 @@ +import { getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const upstream = await fetch(`${getApiBaseUrl()}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await upstream.json(); + if (!upstream.ok) return jsonResponse(payload, upstream.status); + const token: string | undefined = payload?.token; + if (!token) return jsonResponse({ message: 'Login response missing token' }, 502); + const cookieValue = `tower_token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`; + return jsonResponse({ admin: payload.admin }, 200, { 'Set-Cookie': cookieValue }); +} diff --git a/apps/web/app/api/auth/logout/route.ts b/apps/web/app/api/auth/logout/route.ts new file mode 100644 index 0000000..39ba3c3 --- /dev/null +++ b/apps/web/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import { TOKEN_COOKIE, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(): Promise { + const cookieValue = `${TOKEN_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; + return new Response(null, { status: 204, headers: { 'Set-Cookie': cookieValue } }); +} + +export function GET(): Response { + return jsonResponse({ message: 'Use POST to log out' }, 405); +} diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts new file mode 100644 index 0000000..3f8579a --- /dev/null +++ b/apps/web/app/api/auth/me/route.ts @@ -0,0 +1,9 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/auth/me'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/auth/signup/route.ts b/apps/web/app/api/auth/signup/route.ts new file mode 100644 index 0000000..262d0d9 --- /dev/null +++ b/apps/web/app/api/auth/signup/route.ts @@ -0,0 +1,19 @@ +import { getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const upstream = await fetch(`${getApiBaseUrl()}/auth/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await upstream.json(); + if (!upstream.ok) return jsonResponse(payload, upstream.status); + const token: string | undefined = payload?.token; + if (!token) return jsonResponse({ message: 'Signup response missing token' }, 502); + const cookieValue = `tower_token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`; + return jsonResponse({ admin: payload.admin }, 200, { 'Set-Cookie': cookieValue }); +} diff --git a/apps/web/app/api/auth/super/login/route.ts b/apps/web/app/api/auth/super/login/route.ts new file mode 100644 index 0000000..2a76ed5 --- /dev/null +++ b/apps/web/app/api/auth/super/login/route.ts @@ -0,0 +1,19 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/auth/super/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await res.json(); + const headers: Record = {}; + if (res.ok && payload.token) { + headers['Set-Cookie'] = `tower_super_token=${payload.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`; + } + return jsonResponse(payload, res.status, headers); +} diff --git a/apps/web/app/api/auth/super/logout/route.ts b/apps/web/app/api/auth/super/logout/route.ts new file mode 100644 index 0000000..6ab3bb8 --- /dev/null +++ b/apps/web/app/api/auth/super/logout/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(): Promise { + return jsonResponse({ ok: true }, 200, { + 'Set-Cookie': 'tower_super_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0', + }); +} diff --git a/apps/web/app/api/auth/super/me/route.ts b/apps/web/app/api/auth/super/me/route.ts new file mode 100644 index 0000000..3feba42 --- /dev/null +++ b/apps/web/app/api/auth/super/me/route.ts @@ -0,0 +1,16 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const token = await (await import('next/headers')).cookies().then(c => c.get('tower_super_token')?.value); + const res = await fetch(`${getApiBaseUrl()}/auth/super/me`, { + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: 'no-store', + }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/bot/[id]/route.ts b/apps/web/app/api/bot/[id]/route.ts new file mode 100644 index 0000000..00c0b04 --- /dev/null +++ b/apps/web/app/api/bot/[id]/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/bot/${id}`, { method: 'DELETE' }); + const payload = await res.json().catch(() => ({})); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/bot/attach/route.ts b/apps/web/app/api/bot/attach/route.ts new file mode 100644 index 0000000..3d4e86d --- /dev/null +++ b/apps/web/app/api/bot/attach/route.ts @@ -0,0 +1,15 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const res = await apiFetch('/admin/bot/attach', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }).catch(() => null); + if (!res) return jsonResponse({ message: 'Upstream unavailable' }, 502); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/bot/initiate/route.ts b/apps/web/app/api/bot/initiate/route.ts new file mode 100644 index 0000000..8e485cb --- /dev/null +++ b/apps/web/app/api/bot/initiate/route.ts @@ -0,0 +1,20 @@ +import { getApiBaseUrl, getToken, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json().catch(() => ({})); + const token = await getToken(); + const res = await fetch(`${getApiBaseUrl()}/admin/bot/initiate`, { + 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); +} diff --git a/apps/web/app/api/bot/qr/[token]/route.ts b/apps/web/app/api/bot/qr/[token]/route.ts new file mode 100644 index 0000000..c983f87 --- /dev/null +++ b/apps/web/app/api/bot/qr/[token]/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await params; + const res = await apiFetch(`/admin/bot/qr/${token}`); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/bot/reveal/route.ts b/apps/web/app/api/bot/reveal/route.ts new file mode 100644 index 0000000..d53b6d3 --- /dev/null +++ b/apps/web/app/api/bot/reveal/route.ts @@ -0,0 +1,17 @@ +import { getApiBaseUrl, getToken, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(): Promise { + const token = await getToken(); + const res = await fetch(`${getApiBaseUrl()}/admin/bot/reveal`, { + method: 'POST', + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: 'no-store', + }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/bot/route.ts b/apps/web/app/api/bot/route.ts new file mode 100644 index 0000000..1e7cfdb --- /dev/null +++ b/apps/web/app/api/bot/route.ts @@ -0,0 +1,10 @@ +import { apiFetch, jsonResponse } from '../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/admin/bot').catch(() => null); + if (!res) return jsonResponse({ message: 'Upstream unavailable' }, 502); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/groups/[id]/share/route.ts b/apps/web/app/api/groups/[id]/share/route.ts new file mode 100644 index 0000000..5a8cc8e --- /dev/null +++ b/apps/web/app/api/groups/[id]/share/route.ts @@ -0,0 +1,17 @@ +import { apiFetch, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const body = await req.json(); + const res = await apiFetch(`/admin/groups/${id}/share`, { + method: 'POST', + body: JSON.stringify(body), + }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/groups/[id]/unshare/[targetTenantId]/route.ts b/apps/web/app/api/groups/[id]/unshare/[targetTenantId]/route.ts new file mode 100644 index 0000000..ff02af0 --- /dev/null +++ b/apps/web/app/api/groups/[id]/unshare/[targetTenantId]/route.ts @@ -0,0 +1,14 @@ +import { apiFetch } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string; targetTenantId: string }> }, +): Promise { + const { id, targetTenantId } = await params; + const res = await apiFetch(`/admin/groups/${id}/share/${targetTenantId}`, { method: 'DELETE' }); + if (res.status === 204) return new Response(null, { status: 204 }); + const body = await res.text(); + return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); +} diff --git a/apps/web/app/api/groups/claim-token-info/route.ts b/apps/web/app/api/groups/claim-token-info/route.ts new file mode 100644 index 0000000..4aef895 --- /dev/null +++ b/apps/web/app/api/groups/claim-token-info/route.ts @@ -0,0 +1,12 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request): Promise { + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + if (!token) return new Response(JSON.stringify({ message: 'token required' }), { status: 400 }); + const res = await apiFetch(`/admin/groups/claim-token-info?token=${encodeURIComponent(token)}`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/groups/claim-with-token/route.ts b/apps/web/app/api/groups/claim-with-token/route.ts new file mode 100644 index 0000000..a9b1280 --- /dev/null +++ b/apps/web/app/api/groups/claim-with-token/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const res = await apiFetch('/admin/groups/claim-with-token', { + method: 'POST', + body: JSON.stringify(body), + }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/groups/shared-by-me/route.ts b/apps/web/app/api/groups/shared-by-me/route.ts new file mode 100644 index 0000000..deeaa46 --- /dev/null +++ b/apps/web/app/api/groups/shared-by-me/route.ts @@ -0,0 +1,9 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/groups/shared-by-me'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/groups/shared/route.ts b/apps/web/app/api/groups/shared/route.ts new file mode 100644 index 0000000..be88b39 --- /dev/null +++ b/apps/web/app/api/groups/shared/route.ts @@ -0,0 +1,9 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/groups/shared'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/messages/[id]/approve/route.ts b/apps/web/app/api/messages/[id]/approve/route.ts new file mode 100644 index 0000000..dd62f62 --- /dev/null +++ b/apps/web/app/api/messages/[id]/approve/route.ts @@ -0,0 +1,19 @@ +import { apiFetch, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST' }); + const body = await res.text(); + let payload: unknown = body; + try { + payload = JSON.parse(body); + } catch { + /* keep as text */ + } + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/messages/[id]/route.ts b/apps/web/app/api/messages/[id]/route.ts new file mode 100644 index 0000000..24022ab --- /dev/null +++ b/apps/web/app/api/messages/[id]/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/messages/${id}`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/messages/pending/count/route.ts b/apps/web/app/api/messages/pending/count/route.ts new file mode 100644 index 0000000..bac4cce --- /dev/null +++ b/apps/web/app/api/messages/pending/count/route.ts @@ -0,0 +1,9 @@ +import { apiFetch, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/admin/messages/pending/count'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/messages/pending/route.ts b/apps/web/app/api/messages/pending/route.ts new file mode 100644 index 0000000..1a8a057 --- /dev/null +++ b/apps/web/app/api/messages/pending/route.ts @@ -0,0 +1,9 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_req: Request): Promise { + const res = await apiFetch('/admin/messages/pending'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/account/route.ts b/apps/web/app/api/my/account/route.ts new file mode 100644 index 0000000..2c61c5b --- /dev/null +++ b/apps/web/app/api/my/account/route.ts @@ -0,0 +1,15 @@ +import { clearMemberCookie, jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function DELETE(): Promise { + const res = await memberApiFetch('/my/account', { method: 'DELETE' }); + if (res.status === 204) { + return new Response(null, { + status: 204, + headers: { 'Set-Cookie': clearMemberCookie() }, + }); + } + const body = await res.json().catch(() => ({})); + return jsonResponse(body, res.status, { 'Set-Cookie': clearMemberCookie() }); +} diff --git a/apps/web/app/api/my/groups/[id]/route.ts b/apps/web/app/api/my/groups/[id]/route.ts new file mode 100644 index 0000000..aa64ce9 --- /dev/null +++ b/apps/web/app/api/my/groups/[id]/route.ts @@ -0,0 +1,13 @@ +import { jsonResponse, memberApiFetch } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await memberApiFetch(`/my/groups/${id}`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/groups/route.ts b/apps/web/app/api/my/groups/route.ts new file mode 100644 index 0000000..ba8dc75 --- /dev/null +++ b/apps/web/app/api/my/groups/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/groups'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/logout/route.ts b/apps/web/app/api/my/logout/route.ts new file mode 100644 index 0000000..2bd172d --- /dev/null +++ b/apps/web/app/api/my/logout/route.ts @@ -0,0 +1,7 @@ +import { clearMemberCookie } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(): Promise { + return new Response(null, { status: 204, headers: { 'Set-Cookie': clearMemberCookie() } }); +} diff --git a/apps/web/app/api/my/opt-in/route.ts b/apps/web/app/api/my/opt-in/route.ts new file mode 100644 index 0000000..1b82066 --- /dev/null +++ b/apps/web/app/api/my/opt-in/route.ts @@ -0,0 +1,10 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const res = await memberApiFetch('/my/opt-in', { method: 'POST', body: JSON.stringify(body) }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/my/opt-out/route.ts b/apps/web/app/api/my/opt-out/route.ts new file mode 100644 index 0000000..72f5130 --- /dev/null +++ b/apps/web/app/api/my/opt-out/route.ts @@ -0,0 +1,10 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const res = await memberApiFetch('/my/opt-out', { method: 'POST', body: JSON.stringify(body) }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/my/profile/route.ts b/apps/web/app/api/my/profile/route.ts new file mode 100644 index 0000000..95e9c11 --- /dev/null +++ b/apps/web/app/api/my/profile/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/profile'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/onboard/verify-otp/route.ts b/apps/web/app/api/onboard/verify-otp/route.ts new file mode 100644 index 0000000..9c0f350 --- /dev/null +++ b/apps/web/app/api/onboard/verify-otp/route.ts @@ -0,0 +1,30 @@ +import { buildMemberCookie, getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json().catch(() => ({})); + const upstream = await fetch(`${getApiBaseUrl()}/public/auth/verify-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = (await upstream.json().catch(() => ({}))) as { + memberToken?: string; + user?: { id: string; tenantId: string; jid: string; displayName: string | null }; + consent?: { scopes: string[]; retentionDays: number; policyVersion: string }; + message?: string; + }; + if (!upstream.ok) { + return jsonResponse(payload, upstream.status); + } + if (!payload.memberToken) { + return jsonResponse({ message: 'Upstream response missing memberToken' }, 502); + } + return jsonResponse( + { user: payload.user, consent: payload.consent }, + 200, + { 'Set-Cookie': buildMemberCookie(payload.memberToken) }, + ); +} diff --git a/apps/web/app/api/routes/[id]/route.ts b/apps/web/app/api/routes/[id]/route.ts index aa65fe0..d3bbd49 100644 --- a/apps/web/app/api/routes/[id]/route.ts +++ b/apps/web/app/api/routes/[id]/route.ts @@ -1,11 +1,14 @@ -const API_URL = process.env.API_URL ?? 'http://localhost:3001'; +import { apiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> }, -) { +): Promise { const { id } = await params; - const res = await fetch(`${API_URL}/routes/${id}`, { method: 'DELETE' }); + const res = await apiFetch(`/routes/${id}`, { method: 'DELETE' }); if (res.status === 204) return new Response(null, { status: 204 }); - return Response.json(await res.json(), { status: res.status }); + const body = await res.text(); + return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } diff --git a/apps/web/app/api/routes/batch/route.ts b/apps/web/app/api/routes/batch/route.ts new file mode 100644 index 0000000..fab7c10 --- /dev/null +++ b/apps/web/app/api/routes/batch/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json(); + const res = await apiFetch('/routes/batch', { + method: 'POST', + body: JSON.stringify(body), + }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/api/routes/route.ts b/apps/web/app/api/routes/route.ts index b595330..0669628 100644 --- a/apps/web/app/api/routes/route.ts +++ b/apps/web/app/api/routes/route.ts @@ -1,19 +1,22 @@ -const API_URL = process.env.API_URL ?? 'http://localhost:3001'; +import { apiFetch, jsonResponse } from '../../_lib/api'; -export async function GET(req: Request) { +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request): Promise { const { searchParams } = new URL(req.url); - const url = new URL(`${API_URL}/routes`); - searchParams.forEach((v, k) => url.searchParams.set(k, v)); - const res = await fetch(url, { cache: 'no-store' }); - return Response.json(await res.json(), { status: res.status }); + const query = searchParams.toString(); + const path = query ? `/routes?${query}` : '/routes'; + const res = await apiFetch(path); + const body = await res.json(); + return jsonResponse(body, res.status); } -export async function POST(req: Request) { +export async function POST(req: Request): Promise { const body = await req.json(); - const res = await fetch(`${API_URL}/routes`, { + const res = await apiFetch('/routes', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - return Response.json(await res.json(), { status: res.status }); + const payload = await res.json(); + return jsonResponse(payload, res.status); } diff --git a/apps/web/app/api/rules/[id]/route.ts b/apps/web/app/api/rules/[id]/route.ts new file mode 100644 index 0000000..49e2ecb --- /dev/null +++ b/apps/web/app/api/rules/[id]/route.ts @@ -0,0 +1,28 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function PUT( + req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const body = await req.json(); + const res = await apiFetch(`/admin/rules/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/rules/${id}`, { method: 'DELETE' }); + if (res.status === 204) return new Response(null, { status: 204 }); + const body = await res.text(); + return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); +} diff --git a/apps/web/app/api/rules/route.ts b/apps/web/app/api/rules/route.ts new file mode 100644 index 0000000..ce8acc3 --- /dev/null +++ b/apps/web/app/api/rules/route.ts @@ -0,0 +1,19 @@ +import { apiFetch, jsonResponse } from '../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await apiFetch('/admin/rules'); + const body = await res.json(); + return jsonResponse(body, res.status); +} + +export async function POST(req: Request): Promise { + const body = await req.json(); + const res = await apiFetch('/admin/rules', { + method: 'POST', + body: JSON.stringify(body), + }); + const payload = await res.json(); + return jsonResponse(payload, res.status); +} diff --git a/apps/web/app/claim-group/page.tsx b/apps/web/app/claim-group/page.tsx new file mode 100644 index 0000000..1d0f208 --- /dev/null +++ b/apps/web/app/claim-group/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { Suspense, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +interface TokenInfo { + groupName: string; + expiresAt: string; + isConsumed: boolean; + isExpired: boolean; +} + +function ClaimGroupContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!token) { + setError('No claim token provided'); + return; + } + fetch(`/api/groups/claim-token-info?token=${encodeURIComponent(token)}`) + .then((r) => r.json().catch(() => null)) + .then((data) => { + if (data?.groupName) setInfo(data); + else setError('Invalid or expired claim link'); + }) + .catch(() => setError('Failed to load claim info')); + }, [token]); + + async function handleClaim() { + if (!token) return; + setBusy(true); + setError(null); + try { + const res = await fetch('/api/groups/claim-with-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (res.status === 401) { + router.push(`/signup?redirect=/claim-group?token=${encodeURIComponent(token)}`); + return; + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.message ?? 'Claim failed'); + return; + } + setSuccess(true); + } finally { + setBusy(false); + } + } + + if (error && !info) { + return ( +
+

Claim Group

+

{error}

+
+ ); + } + + if (success) { + return ( +
+

Group Claimed!

+

{info?.groupName ?? 'Group'} has been added to your tenant.

+ Go to Groups +
+ ); + } + + return ( +
+

Claim Group

+ {info && ( +
+

+ Group:{' '} + {info.groupName} +

+

+ Expires: {new Date(info.expiresAt).toLocaleDateString()} +

+ {info.isConsumed && ( +

This link has already been used.

+ )} + {info.isExpired && ( +

This link has expired.

+ )} +
+ )} + {error && ( +

{error}

+ )} + +
+ ); +} + +export default function ClaimGroupPage() { + return ( +

Loading…

}> + +
+ ); +} diff --git a/apps/web/app/groups/GroupsTabs.tsx b/apps/web/app/groups/GroupsTabs.tsx new file mode 100644 index 0000000..d1e7965 --- /dev/null +++ b/apps/web/app/groups/GroupsTabs.tsx @@ -0,0 +1,28 @@ +'use client'; +import Link from 'next/link'; + +interface Props { + current: 'mine' | 'shared' | 'all'; + counts: { mine: number; shared: number }; +} + +export function GroupsTabs({ current, counts }: Props) { + const tabs: { key: Props['current']; label: string; href: string }[] = [ + { key: 'mine', label: `My Groups (${counts.mine})`, href: '/groups' }, + { key: 'shared', label: `Shared with me (${counts.shared})`, href: '/groups?tab=shared' }, + ]; + return ( +
+ {tabs.map((t) => ( + + {t.label} + + ))} +
+ ); +} diff --git a/apps/web/app/groups/RouteManager.test.tsx b/apps/web/app/groups/RouteManager.test.tsx index 99c8783..3e15ecb 100644 --- a/apps/web/app/groups/RouteManager.test.tsx +++ b/apps/web/app/groups/RouteManager.test.tsx @@ -2,8 +2,9 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { RouteManager } from './RouteManager'; const groups = [ - { id: 'grp_1', name: 'Alpha', platform: 'whatsapp' }, - { id: 'grp_2', name: 'Beta', platform: 'whatsapp' }, + { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', isActive: true }, + { id: 'grp_2', name: 'Beta', platform: 'whatsapp', isActive: true }, + { id: 'grp_3', name: 'Gamma', platform: 'whatsapp', isActive: true }, ]; const routes = [ @@ -27,46 +28,77 @@ afterEach(() => { }); describe('RouteManager', () => { - it('renders existing routes with source → target names', () => { + it('renders routes grouped by source group', () => { render(); expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText('Beta')).toBeInTheDocument(); }); - it('renders two group select dropdowns for adding a route', () => { + it('shows source dropdown and target checkboxes', () => { render(); - expect(screen.getAllByRole('combobox')).toHaveLength(2); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); - it('calls POST /api/routes when Add route is submitted', async () => { + it('shows target checkboxes when a source is selected', () => { + render(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } }); + expect(screen.getAllByRole('checkbox')).toHaveLength(2); + }); + + it('calls POST /api/routes/batch with selected targetIds', async () => { fetchSpy.mockResolvedValueOnce( new Response( - JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }), + JSON.stringify([ + { id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }, + { id: 'rt_new2', sourceGroupId: 'grp_1', targetGroupId: 'grp_3', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Gamma' } }, + ]), { status: 201, headers: { 'Content-Type': 'application/json' } }, ), ); render(); - const [sourceSelect, targetSelect] = screen.getAllByRole('combobox'); - fireEvent.change(sourceSelect, { target: { value: 'grp_1' } }); - fireEvent.change(targetSelect, { target: { value: 'grp_2' } }); - fireEvent.click(screen.getByRole('button', { name: /add route/i })); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } }); + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + fireEvent.click(checkboxes[1]); + fireEvent.click(screen.getByRole('button', { name: /create 2 routes/i })); await waitFor(() => { expect(fetchSpy).toHaveBeenCalledWith( - '/api/routes', - expect.objectContaining({ method: 'POST' }), + '/api/routes/batch', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ sourceGroupId: 'grp_1', targetGroupIds: ['grp_2', 'grp_3'] }), + }), ); }); }); + it('shows error message on 409 conflict', async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ message: 'Routes already exist for: Beta' }), + { status: 409, headers: { 'Content-Type': 'application/json' } }, + ), + ); + render(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'grp_1' } }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + fireEvent.click(screen.getByRole('button', { name: /create 1 route/i })); + await waitFor(() => { + expect(screen.getByText('Routes already exist for: Beta')).toBeInTheDocument(); + }); + }); + it('shows a delete button for each existing route', () => { render(); - expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + const deleteBtn = screen.getByRole('button', { name: /delete route to beta/i }); + expect(deleteBtn).toBeInTheDocument(); }); it('calls DELETE /api/routes/:id when a route is deleted', async () => { fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); render(); - fireEvent.click(screen.getByRole('button', { name: /delete/i })); + fireEvent.click(screen.getByRole('button', { name: /delete route to beta/i })); await waitFor(() => { expect(fetchSpy).toHaveBeenCalledWith( '/api/routes/rt_1', diff --git a/apps/web/app/groups/RouteManager.tsx b/apps/web/app/groups/RouteManager.tsx index 1500c8b..fd303ba 100644 --- a/apps/web/app/groups/RouteManager.tsx +++ b/apps/web/app/groups/RouteManager.tsx @@ -6,6 +6,7 @@ interface Group { id: string; name: string; platform: string; + isActive: boolean; } interface Route { @@ -25,23 +26,51 @@ export function RouteManager({ }) { const [routes, setRoutes] = useState(initialRoutes); const [sourceId, setSourceId] = useState(''); - const [targetId, setTargetId] = useState(''); + const [targetIds, setTargetIds] = useState>(new Set()); const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); - async function addRoute() { - if (!sourceId || !targetId) return; + // Group routes by source group name + const grouped = new Map(); + for (const r of routes) { + const key = r.sourceGroup.name; + if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] }); + grouped.get(key)!.targets.push(r); + } + + function toggleTarget(id: string) { + setTargetIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + setError(null); + } + + async function addRoutes() { + if (!sourceId || targetIds.size === 0) return; setBusy(true); + setError(null); try { - const res = await fetch('/api/routes', { + const res = await fetch('/api/routes/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }), + body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }), }); - if (!res.ok) return; - const created: Route = await res.json(); - setRoutes((prev) => [created, ...prev]); + if (res.status === 409) { + const errBody = await res.json(); + setError(errBody.message ?? 'Some routes already exist'); + return; + } + if (!res.ok) { + setError('Failed to create routes'); + return; + } + const created: Route[] = await res.json(); + setRoutes((prev) => [...created, ...prev]); setSourceId(''); - setTargetId(''); + setTargetIds(new Set()); } finally { setBusy(false); } @@ -52,6 +81,8 @@ export function RouteManager({ if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id)); } + const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive); + return (
@@ -59,24 +90,26 @@ export function RouteManager({ {routes.length === 0 ? (

No routes configured.

) : ( -
    - {routes.map((route) => ( -
  • - - {route.sourceGroup.name} - - {route.targetGroup.name} - - +
      + {[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => ( +
    • +
      + {sourceName} +
      + {targets.map((r) => ( +
      + {r.targetGroup.name} + +
      + ))} +
      +
    • ))}
    @@ -84,37 +117,60 @@ export function RouteManager({
-

Add route

-
- - - +

Add routes

+
+
+ + + + {targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected + +
+ + {sourceId && eligibleTargets.length > 0 && ( +
+ {eligibleTargets.map((g) => { + const checked = targetIds.has(g.id); + return ( + + ); + })} +
+ )} + + {error && ( +

{error}

+ )} +
diff --git a/apps/web/app/groups/page.tsx b/apps/web/app/groups/page.tsx index 7945f75..cfd600a 100644 --- a/apps/web/app/groups/page.tsx +++ b/apps/web/app/groups/page.tsx @@ -1,9 +1,19 @@ import { RouteManager } from './RouteManager'; +import { GroupsTabs } from './GroupsTabs'; +import { apiFetch } from '../_lib/api'; interface Group { id: string; name: string; platform: string; + platformId: string; + isActive: boolean; + accountId: string | null; + tenantId: string | null; +} + +interface SharedGroup extends Group { + sharedByTenantName: string; } interface Route { @@ -14,27 +24,82 @@ interface Route { targetGroup: { name: string }; } -async function fetchJson(url: string): Promise { +type FetchResult = { ok: true; data: T } | { ok: false; status: number; error: string }; + +async function fetchJson(path: string): Promise> { + let res: Response; try { - const res = await fetch(url, { cache: 'no-store' }); - if (!res.ok) return null; - return res.json(); - } catch { - return null; + res = await apiFetch(path); + } catch (err) { + return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` }; } + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText }; + } + return { ok: true, data: (await res.json()) as T }; } -export default async function GroupsPage() { - const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; - const [groups, routes] = await Promise.all([ - fetchJson(`${apiUrl}/groups`), - fetchJson(`${apiUrl}/routes`), +export default async function GroupsPage({ + searchParams, +}: { + searchParams: Promise<{ tab?: 'mine' | 'shared' }>; +}) { + const { tab: rawTab } = await searchParams; + const tab: 'mine' | 'shared' = rawTab === 'shared' ? 'shared' : 'mine'; + const [groupsR, sharedR, routesR] = await Promise.all([ + fetchJson('/groups'), + tab === 'shared' ? fetchJson('/groups/shared') : Promise.resolve(null), + fetchJson('/routes'), ]); + const groups = groupsR?.ok ? groupsR.data : []; + const shared = sharedR && sharedR.ok ? sharedR.data : []; + const routes = routesR?.ok ? routesR.data : []; + const errors = [groupsR, sharedR, routesR] + .filter((r): r is { ok: false; status: number; error: string } => !!r && !r.ok && r.status !== 401) + .map((e) => `${e.status === 0 ? 'API unreachable' : `API ${e.status}`}: ${e.error}`); + return ( -
+

Groups & Routes

- + {errors.length > 0 && ( +
+
Failed to load some data
+
    + {errors.map((e) =>
  • {e}
  • )} +
+
+ )} + + {tab === 'shared' && ( +
+

Shared with me

+

+ Groups other tenants have shared with you. You can use them as TARGET groups in your routes. +

+ {shared.length === 0 ? ( +

No groups shared with you yet.

+ ) : ( +
    + {shared.map((g) => ( +
  • +
    + {g.name} + {!g.isActive && (Bot removed)} + shared by {g.sharedByTenantName} +
    +
  • + ))} +
+ )} +
+ )} + {tab === 'mine' && ( +
+ +
+ )}
); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1a37ebb..d8bb845 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import './globals.css'; +import { AuthProvider } from './_lib/auth-context'; +import { SuperAdminProvider } from './_lib/super-admin-context'; +import { Sidebar } from './_lib/sidebar'; export const metadata: Metadata = { title: 'Insignia TOWER', @@ -11,19 +14,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - -
{children}
+ + + +
{children}
+
+
); diff --git a/apps/web/app/login/page.test.tsx b/apps/web/app/login/page.test.tsx new file mode 100644 index 0000000..65b983c --- /dev/null +++ b/apps/web/app/login/page.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import LoginPage from './page'; +import { AuthProvider } from '../_lib/auth-context'; +import { SuperAdminProvider } from '../_lib/super-admin-context'; + +const pushMock = jest.fn(); +const refreshMock = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, refresh: refreshMock, replace: jest.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +function authResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +const UNAUTHORIZED = () => Promise.resolve(new Response('Unauthorized', { status: 401 })); + +function mockFetch(responses: Record Response | Promise>) { + fetchSpy.mockImplementation((url: string, init?: RequestInit) => { + const handler = responses[url]; + if (handler) return handler(url, init); + return responses['default']?.(url, init) ?? UNAUTHORIZED(); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + fetchSpy = jest.spyOn(global, 'fetch'); + mockFetch({ default: UNAUTHORIZED }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function renderWithProvider(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +describe('LoginPage', () => { + it('renders email and password fields and a submit button', async () => { + renderWithProvider(); + expect(await screen.findByLabelText(/email/i)).toBeInTheDocument(); + expect(await screen.findByLabelText(/password/i)).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + it('posts credentials to /api/auth/login and redirects on success', async () => { + mockFetch({ + '/api/auth/login': () => authResponse({ admin: { id: 'a-1', email: 'me@x.com' } }), + '/api/auth/me': () => authResponse({ admin: { id: 'a-1', email: 'me@x.com', role: 'OWNER', tenantId: 't1', tenantSlug: 'default', tenantName: 'Default' } }), + }); + renderWithProvider(); + await screen.findByLabelText(/email/i); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'me@x.com' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'me@x.com', password: 'secret' }), + }), + ), + ); + expect(pushMock).toHaveBeenCalledWith('/'); + }); + + it('shows an error message on 401', async () => { + mockFetch({ + '/api/auth/login': () => authResponse({ message: 'Invalid email or password' }, 401), + }); + renderWithProvider(); + await screen.findByLabelText(/email/i); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'me@x.com' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + expect(await screen.findByRole('alert')).toHaveTextContent(/invalid email or password/i); + expect(pushMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..5275e16 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; +import { useAuth } from '../_lib/auth-context'; +import { useSuperAdmin } from '../_lib/super-admin-context'; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { refresh } = useAuth(); + const { admin: superAdmin, loading: superLoading } = useSuperAdmin(); + const next = searchParams.get('next') ?? '/'; + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [redirecting, setRedirecting] = useState(false); + + useEffect(() => { + if (!superLoading && superAdmin) { + setRedirecting(true); + router.replace('/admin'); + } + }, [superAdmin, superLoading, router]); + + if (superLoading) { + return
; + } + + if (redirecting) { + return

Redirecting to admin panel…

; + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + credentials: 'include', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data?.message ?? 'Invalid email or password'); + return; + } + await refresh(); + router.push(next); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Network error'); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + {error &&

{error}

} + +
+ ); +} + +export default function LoginPage() { + return ( +
+

Sign in

+

TOWER administrative console

+ }> + + +

+ New here?{' '} + + Create a community + +

+
+ ); +} diff --git a/apps/web/app/messages/[id]/page.tsx b/apps/web/app/messages/[id]/page.tsx new file mode 100644 index 0000000..4fed4b4 --- /dev/null +++ b/apps/web/app/messages/[id]/page.tsx @@ -0,0 +1,158 @@ +import Link from 'next/link'; +import { apiFetch } from '../../_lib/api'; + +interface MessageDetail { + id: string; + tenantId: string; + platform: string; + platformMsgId: string; + sourceGroupId: string; + sourceGroup: { + id: string; + name: string; + platformId: string; + claimStatus: string; + isActive: boolean; + createdAt: string; + } | null; + senderJid: string; + senderName: string | null; + senderTowerUser: { + id: string; + jid: string; + phoneHash: string; + displayName: string | null; + createdAt: string; + } | null; + content: string; + mediaUrl: string | null; + tags: string[]; + status: string; + createdAt: string; + updatedAt: string; + approval: { + id: string; + adminId: string; + decision: string; + notes: string | null; + decidedAt: string; + } | null; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children ?? } +
+ ); +} + +export default async function MessageDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + let msg: MessageDetail | null = null; + let error: string | null = null; + try { + const res = await apiFetch(`/admin/messages/${id}`); + if (res.ok) { + msg = await res.json(); + } else { + error = `API returned ${res.status}`; + } + } catch { + error = 'Failed to load message'; + } + + if (error || !msg) { + return ( +
+ ← Back to search +

{error ?? 'Message not found'}

+
+ ); + } + + return ( +
+ ← Back to search +

Message Detail

+ +
+

{msg.content}

+
+ {msg.tags.map((tag) => ( + {tag} + ))} +
+
+ +
+

Metadata

+ + + + {msg.status} + + + {msg.id} + {msg.platform} + {msg.platformMsgId} + {new Date(msg.createdAt).toLocaleString()} + {new Date(msg.updatedAt).toLocaleString()} + +
+

Sender

+ {msg.senderJid} + {msg.senderName} + {msg.senderTowerUser && ( + <> + {msg.senderTowerUser.displayName} + {msg.senderTowerUser.phoneHash} + {msg.senderTowerUser.id} + + )} +
+ +
+

Source Group

+ {msg.sourceGroup ? ( + <> + {msg.sourceGroup.name} + {msg.sourceGroup.platformId} + {msg.sourceGroup.claimStatus} + {msg.sourceGroup.isActive ? 'Yes' : 'No'} + + ) : ( + (deleted) + )} +
+ + {msg.approval && ( +
+

Approval

+ + + {msg.approval.decision} + + + {msg.approval.adminId} + {new Date(msg.approval.decidedAt).toLocaleString()} + {msg.approval.notes} +
+ )} + + {msg.mediaUrl && ( +
+

Media

+ {msg.mediaUrl} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/messages/pending/page.tsx b/apps/web/app/messages/pending/page.tsx new file mode 100644 index 0000000..6709e65 --- /dev/null +++ b/apps/web/app/messages/pending/page.tsx @@ -0,0 +1,105 @@ +import { apiFetch } from '../../_lib/api'; + +interface PendingMessage { + id: string; + content: string; + senderJid: string; + senderName: string | null; + tags: string[]; + createdAt: string; + sourceGroupId: string; + sourceGroupName: string; + sourceGroupPlatformId: string; +} + +type FetchResult = { ok: true; data: T } | { ok: false; status: number; error: string }; + +async function fetchPending(): Promise> { + try { + const res = await apiFetch('/admin/messages/pending'); + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText }; + } + return { ok: true, data: (await res.json()) as PendingMessage[] }; + } catch (err) { + return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` }; + } +} + +export default async function PendingMessagesPage() { + const result = await fetchPending(); + const messages = result.ok ? result.data : []; + const error = !result.ok ? (result.status === 0 ? 'API unreachable' : `API ${result.status}: ${result.error}`) : null; + + return ( +
+

Pending messages

+

+ Flagged messages waiting for an admin to approve. Approving forwards them to every active + route from the source group and indexes them in search. +

+ + {error && ( +
+ Failed to load: {error} +
+ )} + + {!error && messages.length === 0 && ( +
+ No pending messages right now. New flagged messages will appear here as they arrive. +
+ )} + +
    + {messages.map((m) => ( + + ))} +
+
+ ); +} + +function PendingMessageRow({ message }: { message: PendingMessage }) { + return ( +
  • +
    +
    {message.sourceGroupName}
    +
    + {new Date(message.createdAt).toLocaleString()} +
    +
    +
    + From {message.senderName ?? message.senderJid} + {message.tags.length > 0 && ( + + {message.tags.map((t) => ( + + {t} + + ))} + + )} +
    +
    + {message.content} +
    +
    + +
    +
  • + ); +} diff --git a/apps/web/app/my/groups/GroupOptOutButton.tsx b/apps/web/app/my/groups/GroupOptOutButton.tsx new file mode 100644 index 0000000..939f556 --- /dev/null +++ b/apps/web/app/my/groups/GroupOptOutButton.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; + +export function GroupOptOutButton({ groupId, groupName }: { groupId: string; groupName: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function handleClick() { + if (!window.confirm(`Revoke consent for "${groupName}"? Your messages will no longer be archived from this group.`)) { + return; + } + setError(null); + startTransition(async () => { + const res = await fetch('/api/my/opt-out', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ groupId }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + setError(body.message ?? 'Opt-out failed'); + return; + } + router.refresh(); + }); + } + + return ( +
    + + {error && {error}} +
    + ); +} diff --git a/apps/web/app/my/groups/[id]/page.tsx b/apps/web/app/my/groups/[id]/page.tsx new file mode 100644 index 0000000..ae8271b --- /dev/null +++ b/apps/web/app/my/groups/[id]/page.tsx @@ -0,0 +1,88 @@ +import { getApiBaseUrl, getMemberToken } from '../../../_lib/api'; +import Link from 'next/link'; + +interface MemberGroupSummary { + id: string; + name: string; + tenantId: string; + scopes: string[]; + retentionDays: number; + policyVersion: string; + consentStatus: 'GRANTED' | 'REVOKED' | 'PENDING'; + joinedAt: string; +} + +async function fetchGroup(token: string, id: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/my/groups/${id}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + cache: 'no-store', + }).catch(() => null); + if (!res || !res.ok) return null; + return (await res.json()) as MemberGroupSummary; +} + +export default async function MyGroupDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const token = await getMemberToken(); + if (!token) { + return ( +
    +

    Sign in required

    +

    Complete onboarding to view group details.

    +
    + ); + } + + const group = await fetchGroup(token, id); + if (!group) { + return ( +
    +

    Group not found

    +

    + You aren't a member of this group, or it has been removed. +

    + + ← Back to your groups + +
    + ); + } + + return ( +
    + + ← All groups + +

    {group.name}

    +
    +
    +
    Status:
    +
    {group.consentStatus}
    +
    +
    +
    Scopes:
    +
    {group.scopes.join(', ')}
    +
    +
    +
    Retention:
    +
    {group.retentionDays} days
    +
    +
    +
    Policy version:
    +
    {group.policyVersion}
    +
    +
    +
    Joined:
    +
    {new Date(group.joinedAt).toLocaleString()}
    +
    +
    +

    + Use the "Revoke consent" button on{' '} + + your groups list + {' '} + to opt out of this group. +

    +
    + ); +} diff --git a/apps/web/app/my/groups/page.tsx b/apps/web/app/my/groups/page.tsx new file mode 100644 index 0000000..79741de --- /dev/null +++ b/apps/web/app/my/groups/page.tsx @@ -0,0 +1,89 @@ +import { getApiBaseUrl, getMemberToken } from '../../_lib/api'; +import Link from 'next/link'; +import { GroupOptOutButton } from './GroupOptOutButton'; + +interface MemberGroupSummary { + id: string; + name: string; + tenantId: string; + scopes: string[]; + retentionDays: number; + policyVersion: string; + consentStatus: 'GRANTED' | 'REVOKED' | 'PENDING'; + joinedAt: string; +} + +async function fetchGroups(token: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/my/groups`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + cache: 'no-store', + }).catch(() => null); + if (!res || !res.ok) return null; + return (await res.json()) as MemberGroupSummary[]; +} + +export default async function MyGroupsPage() { + const token = await getMemberToken(); + if (!token) { + return ( +
    +

    Sign in required

    +

    Complete onboarding to view your groups.

    +
    + ); + } + + const groups = await fetchGroups(token); + if (!groups) { + return ( +
    +

    Couldn't load your groups

    +

    Your session may have expired. Please try signing out and back in.

    +
    + ); + } + + return ( +
    +

    Your groups

    + {groups.length === 0 ? ( +

    + You haven't joined any TOWER-managed groups yet. +

    + ) : ( +
      + {groups.map((g) => ( +
    • +
      +
      + + {g.name} + +
      + Status:{' '} + + {g.consentStatus} + + {' · '}Retention {g.retentionDays}d · Policy {g.policyVersion} +
      +
      + Scopes: {g.scopes.join(', ')} +
      +
      + {g.consentStatus === 'GRANTED' && } +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/apps/web/app/my/page.tsx b/apps/web/app/my/page.tsx new file mode 100644 index 0000000..90d1ad2 --- /dev/null +++ b/apps/web/app/my/page.tsx @@ -0,0 +1,74 @@ +import { getMemberToken, getApiBaseUrl } from '../_lib/api'; +import Link from 'next/link'; + +interface MemberProfile { + id: string; + tenantId: string; + jid: string; + displayName: string | null; + createdAt: string; +} + +async function fetchProfile(token: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/my/profile`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + cache: 'no-store', + }).catch(() => null); + if (!res || !res.ok) return null; + return (await res.json()) as MemberProfile; +} + +export default async function MyPage() { + const token = await getMemberToken(); + if (!token) { + return ( +
    +

    Member portal

    +

    + No member session. Complete onboarding via the link a group admin sent you. +

    +
    + ); + } + + const profile = await fetchProfile(token); + if (!profile) { + return ( +
    +

    Couldn't load your account

    +

    + Your session may have expired. Please complete onboarding again. +

    +
    + +
    +
    + ); + } + + return ( +
    +

    Your account

    +
    +
    +
    Display name:
    +
    {profile.displayName ?? '—'}
    +
    +
    +
    JID:
    +
    {profile.jid}
    +
    +
    +
    Joined:
    +
    {new Date(profile.createdAt).toLocaleDateString()}
    +
    +
    +
    + Manage your groups → + Privacy & account settings → +
    +
    + ); +} diff --git a/apps/web/app/my/settings/DeleteAccountButton.tsx b/apps/web/app/my/settings/DeleteAccountButton.tsx new file mode 100644 index 0000000..b909a75 --- /dev/null +++ b/apps/web/app/my/settings/DeleteAccountButton.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; + +export function DeleteAccountButton() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function handleClick() { + if ( + !window.confirm( + 'Permanently delete your TOWER account? This cannot be undone.', + ) + ) { + return; + } + setError(null); + startTransition(async () => { + const res = await fetch('/api/my/account', { method: 'DELETE' }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + setError(body.message ?? 'Delete failed'); + return; + } + router.replace('/'); + }); + } + + return ( +
    + + {error && {error}} +
    + ); +} diff --git a/apps/web/app/my/settings/MemberLogoutButton.tsx b/apps/web/app/my/settings/MemberLogoutButton.tsx new file mode 100644 index 0000000..7465412 --- /dev/null +++ b/apps/web/app/my/settings/MemberLogoutButton.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; + +export function MemberLogoutButton() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + return ( + + ); +} diff --git a/apps/web/app/my/settings/page.tsx b/apps/web/app/my/settings/page.tsx new file mode 100644 index 0000000..dfab028 --- /dev/null +++ b/apps/web/app/my/settings/page.tsx @@ -0,0 +1,36 @@ +import { getMemberToken } from '../../_lib/api'; +import { DeleteAccountButton } from './DeleteAccountButton'; +import { MemberLogoutButton } from './MemberLogoutButton'; + +export default async function MySettingsPage() { + const token = await getMemberToken(); + if (!token) { + return ( +
    +

    Sign in required

    +

    Complete onboarding to manage your account.

    +
    + ); + } + + return ( +
    +
    +

    Session

    +

    + Sign out of your member portal. Your consent records are kept until you delete your account. +

    + +
    + +
    +

    Delete your account

    +

    + This permanently deletes your TOWER user record, all consent records, opt-out history, and + sessions. The messages themselves stay in their original groups. This cannot be undone. +

    + +
    +
    + ); +} diff --git a/apps/web/app/onboard/OnboardingForm.tsx b/apps/web/app/onboard/OnboardingForm.tsx new file mode 100644 index 0000000..2481f8a --- /dev/null +++ b/apps/web/app/onboard/OnboardingForm.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useState } from 'react'; + +interface Props { + token: string; + defaultScopes: string[]; + defaultRetentionDays: number; +} + +type Step = 'phone' | 'code' | 'done'; + +export function OnboardingForm({ token, defaultScopes, defaultRetentionDays }: Props) { + const [step, setStep] = useState('phone'); + const [phone, setPhone] = useState(''); + const [challengeId, setChallengeId] = useState(null); + const [code, setCode] = useState(''); + const [retentionDays, setRetentionDays] = useState(defaultRetentionDays); + const [scopes, setScopes] = useState(defaultScopes); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function requestOtp() { + setError(null); + setBusy(true); + try { + const res = await fetch(`${process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001'}/public/auth/request-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ onboardingToken: token, phone }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + setError(body.message ?? 'Failed to send code'); + return; + } + const data = (await res.json()) as { challengeId: string }; + setChallengeId(data.challengeId); + setStep('code'); + } finally { + setBusy(false); + } + } + + async function verifyOtp() { + setError(null); + setBusy(true); + try { + const res = await fetch('/api/onboard/verify-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + onboardingToken: token, + challengeId, + phone, + code, + scopes, + retentionDays, + }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + setError(body.message ?? 'Verification failed'); + return; + } + setStep('done'); + } finally { + setBusy(false); + } + } + + if (step === 'done') { + return ( +
    +

    You're verified. Your session is set.

    + + Go to your portal → + +
    + ); + } + + if (step === 'phone') { + return ( +
    + + {error &&

    {error}

    } + +

    + We'll DM a 6-digit code to your WhatsApp. +

    +
    + ); + } + + return ( +
    + +
    + Scopes + {['INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY'].map((s) => ( + + ))} +
    + + {error &&

    {error}

    } + +
    + ); +} diff --git a/apps/web/app/onboard/page.tsx b/apps/web/app/onboard/page.tsx new file mode 100644 index 0000000..5003d1e --- /dev/null +++ b/apps/web/app/onboard/page.tsx @@ -0,0 +1,66 @@ +import { OnboardingForm } from './OnboardingForm'; + +interface PublicOnboardInfo { + groupName: string; + tenantName: string; + policyVersion: string; + defaultScopes: string[]; + defaultRetentionDays: number; +} + +export default async function OnboardPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}) { + const { token } = await searchParams; + if (!token) { + return ( +
    +

    Invalid link

    +

    This onboarding link is missing the token parameter.

    +
    + ); + } + + let info: PublicOnboardInfo | null = null; + let error: string | null = null; + try { + const res = await fetch( + `${process.env['API_URL'] ?? 'http://localhost:3001'}/public/onboard/${encodeURIComponent(token)}`, + { headers: { Accept: 'application/json' }, cache: 'no-store' }, + ); + if (res.ok) { + info = (await res.json()) as PublicOnboardInfo; + } else { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + error = body.message ?? `Onboarding link rejected (${res.status})`; + } + } catch (e) { + error = e instanceof Error ? e.message : 'Network error'; + } + + if (error || !info) { + return ( +
    +

    Cannot start onboarding

    +

    {error ?? 'Unknown error'}

    +
    + ); + } + + return ( +
    +

    Join {info.groupName}

    +

    Managed by {info.tenantName}

    +

    + Policy version: {info.policyVersion} · Default retention: {info.defaultRetentionDays} days +

    + +
    + ); +} diff --git a/apps/web/app/search/page.tsx b/apps/web/app/search/page.tsx index 8a5adaf..f126e12 100644 --- a/apps/web/app/search/page.tsx +++ b/apps/web/app/search/page.tsx @@ -1,3 +1,6 @@ +import Link from 'next/link'; +import { apiFetch } from '../_lib/api'; + interface MeiliHit { id: string; content: string; @@ -52,20 +55,25 @@ export function SearchResults({

      {hits.map((hit) => ( -
    • -

      {hit.content}

      -
      - {hit.senderName} - · - {hit.sourceGroupName} - · - {new Date(hit.approvedAt).toLocaleDateString()} - {hit.tags.map((tag) => ( - - {tag} - - ))} -
      +
    • + +

      {hit.content}

      +
      + {hit.senderName} + · + {hit.sourceGroupName} + · + {new Date(hit.approvedAt).toLocaleDateString()} + {hit.tags.map((tag) => ( + + {tag} + + ))} +
      +
    • ))}
    @@ -81,14 +89,10 @@ export default async function SearchPage({ searchParams: Promise<{ q?: string; page?: string }>; }) { const { q = '', page = '1' } = await searchParams; - const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; - const url = new URL(`${apiUrl}/search`); - url.searchParams.set('q', q); - url.searchParams.set('page', page); let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q }; try { - const res = await fetch(url, { cache: 'no-store' }); + const res = await apiFetch(`/search?q=${encodeURIComponent(q)}&page=${page}`); if (res.ok) data = await res.json(); } catch { // API unavailable — render empty results diff --git a/apps/web/app/settings/bot/BotSettingsCard.tsx b/apps/web/app/settings/bot/BotSettingsCard.tsx new file mode 100644 index 0000000..44986f3 --- /dev/null +++ b/apps/web/app/settings/bot/BotSettingsCard.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useState } from 'react'; + +type Status = 'ACTIVE' | 'DISCONNECTED' | 'BANNED' | 'PAIRING'; + +interface BotSummary { + id: string; + jid: string | null; + displayName: string | null; + status: Status; +} + +export function BotSettingsCard({ initial }: { initial: { bot: BotSummary | null; shared: boolean; sharedBotId?: string } }) { + const [bot, setBot] = useState(initial.bot); + const [revealedJid, setRevealedJid] = useState(null); + const [error, setError] = useState(null); + + async function onReveal() { + setError(null); + const res = await fetch('/api/bot/reveal', { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + const data = (await res.json()) as { jid: string }; + setRevealedJid(data.jid); + } else { + setError('Reveal failed'); + } + } + + if (!bot) { + return ( +
    +

    Bot

    +

    + No bot assigned yet. Contact your platform administrator to get one assigned. +

    +
    + ); + } + + return ( +
    +
    +

    Bot

    + + {bot.status} + +
    +
    +
    +
    Number:
    +
    + {revealedJid ?? (bot.jid ? '••••••••••' : '—')} + {!revealedJid && bot.status === 'ACTIVE' && ( + + )} +
    +
    +
    +
    Display name:
    +
    {bot.displayName ?? '—'}
    +
    +
    +

    + Bot is assigned by the platform administrator. Contact support to change or remove it. +

    + {error &&

    {error}

    } +
    + ); +} diff --git a/apps/web/app/settings/bot/page.tsx b/apps/web/app/settings/bot/page.tsx new file mode 100644 index 0000000..a384605 --- /dev/null +++ b/apps/web/app/settings/bot/page.tsx @@ -0,0 +1,41 @@ +import { BotSettingsCard } from './BotSettingsCard'; +import { apiFetch } from '../../_lib/api'; + +interface BotSummary { + id: string; + platform: string; + jid: string | null; + displayName: string | null; + status: 'ACTIVE' | 'DISCONNECTED' | 'BANNED' | 'PAIRING'; + isBot: boolean; + createdAt: string; + updatedAt: string; +} + +interface BotState { + bot: BotSummary | null; + shared: boolean; +} + +export default async function BotSettingsPage() { + let state: BotState = { bot: null, shared: false }; + try { + const res = await apiFetch('/admin/bot'); + if (res.ok) { + state = (await res.json()) as BotState; + } + } catch { + state = { bot: null, shared: false }; + } + + return ( +
    +

    Bot Settings

    +

    + TOWER runs on a dedicated WhatsApp number. Adding the bot to a group makes it eligible for + claim by any tenant admin. The bot number is hidden from members and the public web. +

    + +
    + ); +} diff --git a/apps/web/app/settings/rules/RuleManager.tsx b/apps/web/app/settings/rules/RuleManager.tsx new file mode 100644 index 0000000..08abac9 --- /dev/null +++ b/apps/web/app/settings/rules/RuleManager.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState } from 'react'; + +interface RuleData { + id: string; + matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; + matchValue: string; + action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; + priority: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +const MATCH_TYPE_LABELS: Record = { + HASHTAG: 'Hashtag', + PREFIX: 'Prefix', + REACTION_EMOJI: 'Reaction Emoji', +}; + +const ACTION_LABELS: Record = { + FLAG: 'Flag (Pending)', + AUTO_APPROVE: 'Auto-approve', + SKIP: 'Skip (Silent Drop)', + REJECT: 'Reject (Visible)', +}; + +export function RuleManager({ initial }: { initial: RuleData[] }) { + const [rules, setRules] = useState(initial); + const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG'); + const [matchValue, setMatchValue] = useState(''); + const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG'); + const [priority, setPriority] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + async function addRule() { + if (!matchValue.trim()) return; + setBusy(true); + setError(''); + try { + const res = await fetch('/api/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, priority }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: 'Failed to create rule' })); + setError(err.message ?? 'Failed to create rule'); + return; + } + const created: RuleData = await res.json(); + setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); + setMatchValue(''); + setAction('FLAG'); + setPriority(0); + } finally { + setBusy(false); + } + } + + async function toggleRule(rule: RuleData) { + const res = await fetch(`/api/rules/${rule.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !rule.isActive }), + }); + if (res.ok) { + const updated: RuleData = await res.json(); + setRules((prev) => prev.map((r) => (r.id === rule.id ? updated : r))); + } + } + + async function deleteRule(id: string) { + const res = await fetch(`/api/rules/${id}`, { method: 'DELETE' }); + if (res.ok) setRules((prev) => prev.filter((r) => r.id !== id)); + } + + return ( +
    +
    +

    Add Rule

    +
    +
    + + +
    +
    + + setMatchValue(e.target.value)} + placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'} + className="border border-gray-300 rounded px-3 py-2 text-sm w-40" + /> +
    +
    + + +
    +
    + + setPriority(Number(e.target.value))} + className="border border-gray-300 rounded px-3 py-2 text-sm w-20" + /> +
    + +
    + {error &&

    {error}

    } +
    + +
    +

    Active Rules

    + {rules.length === 0 ? ( +

    No rules configured. Messages without matching rules are ignored.

    + ) : ( + + + + + + + + + + + + {rules.map((rule) => ( + + + + + + + + + ))} + +
    TypeValueActionPriorityActive +
    {MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}{rule.matchValue}{ACTION_LABELS[rule.action] ?? rule.action}{rule.priority} + + + +
    + )} +
    +
    + ); +} diff --git a/apps/web/app/settings/rules/page.tsx b/apps/web/app/settings/rules/page.tsx new file mode 100644 index 0000000..ab309b4 --- /dev/null +++ b/apps/web/app/settings/rules/page.tsx @@ -0,0 +1,36 @@ +import { RuleManager } from './RuleManager'; +import { apiFetch } from '../../_lib/api'; + +interface RuleData { + id: string; + matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; + matchValue: string; + action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; + priority: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export default async function RulesSettingsPage() { + let rules: RuleData[] = []; + try { + const res = await apiFetch('/admin/rules'); + if (res.ok) { + rules = (await res.json()) as RuleData[]; + } + } catch { + rules = []; + } + + return ( +
    +

    Rules Engine

    +

    + Configure which hashtags, prefixes, and reaction emojis trigger message processing + and what action TOWER should take. Rules are matched in priority order. +

    + +
    + ); +} diff --git a/apps/web/app/signup/SignupForm.tsx b/apps/web/app/signup/SignupForm.tsx new file mode 100644 index 0000000..fba7489 --- /dev/null +++ b/apps/web/app/signup/SignupForm.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useAuth } from '../_lib/auth-context'; + +function slugify(input: string): string { + return input + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +export function SignupForm({ redirect }: { redirect?: string }) { + const router = useRouter(); + const { refresh } = useAuth(); + const [tenantName, setTenantName] = useState(''); + const [tenantSlug, setTenantSlug] = useState(''); + const [slugTouched, setSlugTouched] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const computedSlug = slugify(tenantName); + const effectiveSlug = slugTouched ? tenantSlug : computedSlug; + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (effectiveSlug.length < 2) { + setError('Tenant name must produce a valid slug (lowercase letters, digits, dashes)'); + return; + } + setSubmitting(true); + try { + const res = await fetch('/api/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantName, tenantSlug: effectiveSlug, email, password }), + credentials: 'include', + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { message?: string }; + const messages = Array.isArray((data as { message?: unknown }).message) + ? ((data as unknown as { message: string[] }).message.join(', ')) + : data.message; + setError(messages ?? 'Signup failed'); + return; + } + await refresh(); + router.push(redirect || '/'); + } finally { + setSubmitting(false); + } + } + + return ( +
    + + + + + + {error &&

    {error}

    } + +

    + You'll be the first OWNER. You can invite others from settings later. +

    +
    + ); +} diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx new file mode 100644 index 0000000..bcab566 --- /dev/null +++ b/apps/web/app/signup/page.tsx @@ -0,0 +1,24 @@ +import { SignupForm } from './SignupForm'; + +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise<{ redirect?: string }>; +}) { + const { redirect } = await searchParams; + return ( +
    +
    +

    Create your TOWER community

    +

    + Already have an account?{' '} + + Sign in + + . +

    + +
    +
    + ); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..f0498b8 --- /dev/null +++ b/apps/web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/global.d.ts","../../node_modules/.pnpm/csstype@3.2.3/node_modules/csstype/index.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/styled-jsx/types/css.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/styled-jsx/types/macro.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/styled-jsx/types/style.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/styled-jsx/types/global.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/styled-jsx/types/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/get-page-files.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/web-globals/events.d.ts","../../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/web-globals/navigator.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/web-globals/storage.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/inspector.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/inspector.generated.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/sqlite.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@22.19.19/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/canary.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/experimental.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/index.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/canary.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/experimental.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/fallback.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/compiled/webpack/webpack.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/entry-constants.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/constants.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/bundler.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/config.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/load-custom-routes.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/image-config.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/body-streams.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/search-params.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/segment-cache/vary-params-decoding.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/vary-params.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/params.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-kind.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-definitions/route-definition.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-matches/route-match.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/app-router-headers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/cache-control.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/app-router-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/cache-handlers/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/use-cache/use-cache-wrapper.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/constants.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/render-result.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/response-cache/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/response-cache/index.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/userspace/pages/pages-dev-overlay-setup.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/static-paths/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/adapter/setup-node-env.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/instrumentation/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/setup-exception-listeners.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/worker.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/experimental/ppr.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/page-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/analysis/get-page-static-info.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/require-hook.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-polyfill-crypto.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-baseline.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/console-file.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/console-exit.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/console-dim.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/unhandled-rejection.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/random.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/date.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment-extensions/fast-set-immediate.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/node-environment.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/page-extensions-type.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/i18n-provider.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/next-url.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/cookies.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/request.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/deep-readonly.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/incremental-cache/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/mitt.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/with-router.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/router.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/route-loader.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/page-loader.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/bloom-filter.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/router.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/readonly-url-search-params.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/flight-data-helpers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/cache-key.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/segment-cache/segment-value-encoding.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/scheduler.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/cache-map.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/vary-path.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/cache.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/router-reducer/ppr-navigations.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/segment-cache/navigation.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/templates/pages.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/pages/module.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/render.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-matchers/route-matcher.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/normalizer.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/request/suffix.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/request/rsc.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/request/next-data.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/after/builtin-request-context.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/normalizers/request/segment-prefix-rsc.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/pages/builtin/_error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/load-default-error-components.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/base-server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/after/after.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/after/after-context.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/use-cache/cache-life.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/lazy-result.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/create-error-handler.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/action-revalidation-kind.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/async-storage/work-store.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/http.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/hooks-server-context.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/redirect-status-code.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/redirect-error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/cache-signal.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/instant-validation/boundary-tracking.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/instant-validation/instant-validation-error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/instant-validation/instant-samples.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/implicit-tags.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/staged-rendering.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/templates/app-route.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-route/module.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/segment-config/app/app-segments.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/get-supported-browsers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/utils.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/rendering-mode.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/router-utils/build-prefetch-segment-data-route.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/cpu-profile.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/turborepo-access-trace/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/turborepo-access-trace/result.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/turborepo-access-trace/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/export/routes/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/export/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/export/worker.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/worker.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/coalesced-function.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/router-utils/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/trace/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/trace/trace.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/trace/shared.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/trace/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/load-jsconfig.d.ts","../../node_modules/.pnpm/@next+env@16.2.6/node_modules/@next/env/dist/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/telemetry-plugin/use-cache-tracker-utils.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/plugins/telemetry-plugin/telemetry-plugin.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/telemetry/storage.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/build-context.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack-config.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/swc/generated-native.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/define-env.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/swc/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/swc/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/dev/parse-version-info.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/shared/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/dev/dev-indicator-server-state.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/dev-overlay/cache-indicator.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/parse-stack.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/server/shared.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/shared/stack-frame.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/dev-overlay/utils/get-error-by-type.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/dev-overlay/container/runtime-error/render-error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/dev-overlay/shared.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/dev/debug-channel.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/dev/hot-reloader-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/response.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/base-http/node.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/async-callback-set.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","../../node_modules/.pnpm/sharp@0.34.5/node_modules/sharp/lib/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/image-optimizer.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/next-server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/lru-cache.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/dev-bundler-service.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/dev/static-paths-worker.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/dev/next-dev-server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/next.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/render-server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/router-server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/router-utils/router-server-context.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/route-module.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/load-components.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/adapter.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/lib/app-dir-module.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/app-render.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/error-boundary.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/layout-router.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/render-from-template-context.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/client-page.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/client-segment.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/extra-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/resolvers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/types/icons.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/metadata/metadata.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/lib/framework/boundary-components.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/rsc/preloads.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/rsc/postpone.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/rsc/taint.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/collect-segment-data.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/instant-validation/instant-validation.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/next-devtools/userspace/app/segment-explorer-node.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/app-render/entry-base.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/templates/app-page.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-page/helpers/prerender-manifest-matcher.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/jsx-dev-runtime.d.ts","../../node_modules/.pnpm/@types+react@19.2.15/node_modules/@types/react/compiler-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-page/vendored/rsc/entrypoints.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/client.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/static.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-page/vendored/ssr/entrypoints.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/route-modules/app-page/module.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/fallback-params.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/image-response.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/url-pattern.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/after/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/connection.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/exports/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request-meta.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/cli/next-test.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/size-limit.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/config-shared.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/base-http/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/api-utils/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/build/adapter/build-complete.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/utils.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/pages/_app.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/app.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/use-cache/cache-tag.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/cache.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/pages/_document.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/document.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/dynamic.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dynamic.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/pages/_error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/catch-error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/api/error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/head.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/head.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/cookies.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/headers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/server/request/draft-mode.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/headers.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/get-img-props.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/image-component.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/shared/lib/image-external.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/image.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/link.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/link.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/unrecognized-action-error.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/redirect.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/not-found.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/forbidden.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/unauthorized.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/unstable-rethrow.server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/unstable-rethrow.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/navigation.react-server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/components/navigation.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/navigation.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/router.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/client/script.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/script.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/dist/compiled/@vercel/og/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/server.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/types/global.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/types/compiled.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/types.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/index.d.ts","../../node_modules/.pnpm/next@16.2.6_@babel+core@7.29.7_react-dom@19.2.6_react@19.2.6__react@19.2.6/node_modules/next/image-types/global.d.ts","./.next/dev/types/routes.d.ts","./next-env.d.ts","../../node_modules/.pnpm/@types+yargs-parser@21.0.3/node_modules/@types/yargs-parser/index.d.ts","../../node_modules/.pnpm/@types+yargs@17.0.35/node_modules/@types/yargs/index.d.ts","../../node_modules/.pnpm/@types+yargs@17.0.35/node_modules/@types/yargs/index.d.mts","../../node_modules/.pnpm/@types+istanbul-lib-coverage@2.0.6/node_modules/@types/istanbul-lib-coverage/index.d.ts","../../node_modules/.pnpm/chalk@4.1.2/node_modules/chalk/index.d.ts","../../node_modules/.pnpm/@types+istanbul-lib-report@3.0.3/node_modules/@types/istanbul-lib-report/index.d.ts","../../node_modules/.pnpm/@types+istanbul-reports@3.0.4/node_modules/@types/istanbul-reports/index.d.ts","../../node_modules/.pnpm/@sinclair+typebox@0.27.10/node_modules/@sinclair/typebox/typebox.d.ts","../../node_modules/.pnpm/@jest+schemas@29.6.3/node_modules/@jest/schemas/build/index.d.ts","../../node_modules/.pnpm/@jest+types@29.6.3/node_modules/@jest/types/build/index.d.ts","../../node_modules/.pnpm/@types+stack-utils@2.0.3/node_modules/@types/stack-utils/index.d.ts","../../node_modules/.pnpm/jest-message-util@29.7.0/node_modules/jest-message-util/build/index.d.ts","../../node_modules/.pnpm/@jest+console@29.7.0/node_modules/@jest/console/build/index.d.ts","../../node_modules/.pnpm/@types+graceful-fs@4.1.9/node_modules/@types/graceful-fs/index.d.ts","../../node_modules/.pnpm/jest-haste-map@29.7.0/node_modules/jest-haste-map/build/index.d.ts","../../node_modules/.pnpm/jest-resolve@29.7.0/node_modules/jest-resolve/build/index.d.ts","../../node_modules/.pnpm/collect-v8-coverage@1.0.3/node_modules/collect-v8-coverage/index.d.ts","../../node_modules/.pnpm/@jest+test-result@29.7.0/node_modules/@jest/test-result/build/index.d.ts","../../node_modules/.pnpm/@jest+reporters@29.7.0/node_modules/@jest/reporters/build/index.d.ts","../../node_modules/.pnpm/jest-changed-files@29.7.0/node_modules/jest-changed-files/build/index.d.ts","../../node_modules/.pnpm/emittery@0.13.1/node_modules/emittery/index.d.ts","../../node_modules/.pnpm/jest-watcher@29.7.0/node_modules/jest-watcher/build/index.d.ts","../../node_modules/.pnpm/jest-runner@29.7.0/node_modules/jest-runner/build/index.d.ts","../../node_modules/.pnpm/@jest+core@29.7.0_ts-node@10.9.2_@types+node@22.19.19_typescript@5.9.3_/node_modules/@jest/core/build/index.d.ts","../../node_modules/.pnpm/jest-cli@29.7.0_@types+node@22.19.19_ts-node@10.9.2_@types+node@22.19.19_typescript@5.9.3_/node_modules/jest-cli/build/index.d.ts","../../node_modules/.pnpm/jest@29.7.0_@types+node@22.19.19_ts-node@10.9.2_@types+node@22.19.19_typescript@5.9.3_/node_modules/jest/build/index.d.ts","../../node_modules/.pnpm/@types+aria-query@5.0.4/node_modules/@types/aria-query/index.d.ts","../../node_modules/.pnpm/@testing-library+jest-dom@6.9.1/node_modules/@testing-library/jest-dom/types/matchers.d.ts","../../node_modules/.pnpm/@testing-library+jest-dom@6.9.1/node_modules/@testing-library/jest-dom/types/jest.d.ts","../../node_modules/.pnpm/@testing-library+jest-dom@6.9.1/node_modules/@testing-library/jest-dom/types/index.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/matches.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/wait-for.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/query-helpers.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/queries.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/get-queries-for-element.d.ts","../../node_modules/.pnpm/pretty-format@27.5.1/node_modules/pretty-format/build/types.d.ts","../../node_modules/.pnpm/pretty-format@27.5.1/node_modules/pretty-format/build/index.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/screen.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/wait-for-element-to-be-removed.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/get-node-text.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/events.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/pretty-dom.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/role-helpers.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/config.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/suggestions.d.ts","../../node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/types/index.d.ts","../../node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.15/node_modules/@types/react-dom/test-utils/index.d.ts","../../node_modules/.pnpm/@testing-library+react@16.3.2_@testing-library+dom@10.4.1_@types+react-dom@19.2.3_@type_6d47d6ccd2a6a07f1dbfe4f0136d418d/node_modules/@testing-library/react/types/index.d.ts","./jest.setup.ts","./next.config.ts","./app/_lib/api.ts","./app/api/auth/login/route.ts","./app/api/auth/logout/route.ts","./app/api/auth/me/route.ts","./app/api/auth/signup/route.ts","./app/api/bot/route.ts","./app/api/bot/[id]/route.ts","./app/api/bot/attach/route.ts","./app/api/bot/initiate/route.ts","./app/api/bot/qr/[token]/route.ts","./app/api/bot/reveal/route.ts","./app/api/groups/[id]/claim/route.ts","./app/api/groups/[id]/release/route.ts","./app/api/my/account/route.ts","./app/api/my/groups/route.ts","./app/api/my/groups/[id]/route.ts","./app/api/my/logout/route.ts","./app/api/my/opt-in/route.ts","./app/api/my/opt-out/route.ts","./app/api/my/profile/route.ts","./app/api/onboard/verify-otp/route.ts","./app/api/routes/route.ts","./app/api/routes/[id]/route.ts","./app/_lib/auth-context.tsx","./app/_lib/sidebar.tsx","./app/layout.tsx","./app/page.tsx","./app/page.test.tsx","./app/_lib/auth-context.test.tsx","./app/groups/groupstabs.tsx","./app/groups/pendingclaimlist.tsx","./app/groups/routemanager.tsx","./app/groups/routemanager.test.tsx","./app/groups/page.tsx","./app/login/page.tsx","./app/login/page.test.tsx","./app/my/page.tsx","./app/my/groups/groupoptoutbutton.tsx","./app/my/groups/page.tsx","./app/my/groups/[id]/page.tsx","./app/my/settings/deleteaccountbutton.tsx","./app/my/settings/memberlogoutbutton.tsx","./app/my/settings/page.tsx","./app/onboard/onboardingform.tsx","./app/onboard/page.tsx","./app/search/page.tsx","./app/search/page.test.tsx","./app/settings/bot/botsettingscard.tsx","./app/settings/bot/page.tsx","./app/signup/page.tsx","./.next/dev/types/cache-life.d.ts","./.next/dev/types/validator.ts"],"fileIdsList":[[97,146,163,164,488,489,490,491],[97,146,163,164],[97,146,163,164,231,529,532,535,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,612,613,620,621,623,625,626,629,631,632,635,636],[97,146,163,164,231,506],[97,146,163,164,231,584,610],[85,97,146,163,164,231],[85,97,146,163,164,231,512,522,610],[97,146,163,164,231,587],[97,146,163,164,231,512],[97,146,163,164,231,587,616,617,618],[85,97,146,163,164,231,522],[97,146,163,164,231,584,618],[97,146,163,164,231,512,530,533,610,611],[97,146,163,164,231,584,610,621],[85,97,146,163,164,231,522,610],[97,146,163,164,231,512,587],[97,146,163,164,231,512,587,624],[97,146,163,164,231,587,627,628],[97,146,163,164,231,630],[97,146,163,164,231,584,613],[97,146,163,164,231,584,632],[97,146,163,164,231,587,634],[97,146,163,164,231,584],[97,146,163,164,533,534,535],[97,146,163,164,231,533],[97,146,149,163,164,190,196,546,548],[97,146,163,164,546,554,555,556,558,559],[97,146,163,164,196,546,554],[97,146,163,164,544],[97,146,163,164,540,546,549,551,552,553],[97,146,163,164,196,539,540,541,543,545],[97,146,163,164,570],[97,146,163,164,567,568,569,570,571,574,575,576,577,578,579,580,581],[97,146,163,164,563],[97,146,163,164,573],[97,146,163,164,567,568,569],[97,146,163,164,567,568],[97,146,163,164,570,571,573],[97,146,163,164,568],[97,146,163,164,565],[97,146,163,164,562,564],[85,97,146,163,164,201,464,582,583],[97,146,158,163,164,196],[97,146,163,164,540],[97,146,163,164,542],[97,143,144,146,163,164],[97,145,146,163,164],[146,163,164],[97,146,151,163,164,181],[97,146,147,152,157,163,164,166,178,189],[97,146,147,148,157,163,164,166],[92,93,94,97,146,163,164],[97,146,149,163,164,190],[97,146,150,151,158,163,164,167],[97,146,151,163,164,178,186],[97,146,152,154,157,163,164,166],[97,145,146,153,163,164],[97,146,154,155,163,164],[97,146,156,157,163,164],[97,145,146,157,163,164],[97,146,157,158,159,163,164,178,189],[97,146,157,158,159,163,164,173,178,181],[97,139,146,154,157,160,163,164,166,178,189],[97,146,157,158,160,161,163,164,166,178,186,189],[97,146,160,162,163,164,178,186,189],[95,96,97,98,99,100,101,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195],[97,146,157,163,164],[97,146,163,164,165,189],[97,146,154,157,163,164,166,178],[97,146,163,164,167],[97,146,163,164,168],[97,145,146,163,164,169],[97,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195],[97,146,163,164,171],[97,146,163,164,172],[97,146,157,163,164,173,174],[97,146,163,164,173,175,190,192],[97,146,158,163,164],[97,146,157,163,164,178,179,181],[97,146,163,164,180,181],[97,146,163,164,178,179],[97,146,163,164,181],[97,146,163,164,182],[97,143,146,163,164,178,183,189],[97,146,157,163,164,184,185],[97,146,163,164,184,185],[97,146,151,163,164,166,178,186],[97,146,163,164,187],[97,146,163,164,166,188],[97,146,160,163,164,172,189],[97,146,151,163,164,190],[97,146,163,164,178,191],[97,146,163,164,165,192],[97,146,163,164,193],[97,139,146,163,164],[97,139,146,157,159,163,164,169,178,181,189,191,192,194],[97,146,163,164,178,195],[85,89,97,146,163,164,197,198,199,201,483,528],[85,97,146,163,164],[85,89,97,146,163,164,197,198,199,200,464,483,528],[85,89,97,146,163,164,197,198,200,201,483,528],[85,97,146,163,164,201,464,465],[85,97,146,163,164,201,464],[85,89,97,146,163,164,198,199,200,201,483,528],[85,89,97,146,163,164,197,199,200,201,483,528],[83,84,97,146,163,164],[97,146,163,164,538],[97,146,163,164,537],[97,146,163,164,196],[97,146,163,164,539],[97,146,163,164,196,546,550],[97,146,163,164,546,547],[97,146,163,164,551],[97,146,163,164,546,554,558],[97,146,163,164,196,546,554,557],[97,146,163,164,546,560,561],[97,146,163,164,486],[97,146,163,164,434,497,498],[97,146,163,164,206,207,209,221,245,360,371,479],[97,146,163,164,209,240,241,242,244,479],[97,146,163,164,209,377,379,381,382,384,479,481],[97,146,163,164,209,243,280,479],[97,146,163,164,207,209,220,221,227,233,238,359,360,361,370,479,481],[97,146,163,164,479],[97,146,163,164,216,222,241,261,356],[97,146,163,164,209],[97,146,163,164,202,216,222],[97,146,163,164,388],[97,146,163,164,385,386,388],[97,146,163,164,385,387,479],[97,146,160,163,164,261,458,476],[97,146,160,163,164,332,335,351,356,476],[97,146,160,163,164,304,476],[97,146,163,164,364],[97,146,163,164,363,364,365],[97,146,163,164,363],[91,97,146,160,163,164,202,209,221,227,233,239,241,245,246,259,260,327,357,358,371,479,483],[97,146,163,164,206,209,243,280,377,378,383,479,531],[97,146,163,164,243,531],[97,146,163,164,206,260,429,479,531],[97,146,163,164,531],[97,146,163,164,209,243,244,531],[97,146,163,164,380,531],[97,146,163,164,246,359,362,369],[85,97,146,163,164,434],[97,146,163,164,172,216,231],[97,146,163,164,216,231],[85,97,146,163,164,301],[85,97,146,163,164,222,231,434],[97,146,163,164,216,287,301,302,513,520],[97,146,163,164,286,514,515,516,517,519],[97,146,163,164,337],[97,146,163,164,337,338],[97,146,163,164,220,222,289,290],[97,146,163,164,222,296,297],[97,146,163,164,222,291,299],[97,146,163,164,296],[97,146,163,164,214,222,289,290,291,292,293,294,295,296,299],[97,146,163,164,222,289,296,297,298,300],[97,146,163,164,222,290,292,293],[97,146,163,164,290,292,295,297],[97,146,163,164,518],[97,146,163,164,222],[85,97,146,163,164,210,507],[85,97,146,163,164,189],[85,97,146,163,164,243,278],[85,97,146,163,164,243,371],[97,146,163,164,276,281],[85,97,146,163,164,277,485],[85,89,97,146,160,163,164,197,198,199,200,201,483,527],[97,146,160,163,164,222],[97,146,160,163,164,221,226,307,324,366,367,371,426,428,479,480],[97,146,163,164,259,368],[97,146,163,164,483],[97,146,163,164,208],[85,97,146,163,164,213,216,431,447,449],[97,146,163,164,172,216,431,446,447,448,530],[97,146,163,164,440,441,442,443,444,445],[97,146,163,164,442],[97,146,163,164,446],[97,146,163,164,231,395,396,398],[85,97,146,163,164,222,389,390,391,392,397],[97,146,163,164,395,397],[97,146,163,164,393],[97,146,163,164,394],[85,97,146,163,164,231,277,485],[85,97,146,163,164,231,484,485],[85,97,146,163,164,231,485],[97,146,163,164,324,325],[97,146,163,164,325],[97,146,160,163,164,480,485],[97,146,163,164,354],[97,145,146,163,164,353],[97,146,163,164,216,222,228,230,332,345,349,351,428,431,468,469,476,480],[97,146,163,164,222,271,293],[97,146,163,164,332,343,346,351],[85,97,146,163,164,213,216,332,335,351,354,388,435,436,437,438,439,450,451,452,453,454,455,456,457,531],[97,146,163,164,213,216,241,332,339,340,341,344,345],[97,146,163,164,178,222,241,343,350,431,432,476],[97,146,163,164,347],[97,146,160,163,164,172,210,222,226,236,268,269,272,324,327,392,426,427,468,479,480,481,483,531],[97,146,163,164,213,214,216],[97,146,163,164,332],[97,145,146,163,164,241,268,269,326,327,328,329,330,331,480],[97,146,163,164,351],[97,145,146,163,164,215,216,226,230,266,332,339,340,341,342,343,346,347,348,349,350,469],[97,146,160,163,164,266,267,339,480,481],[97,146,163,164,241,269,324,327,332,428,480],[97,146,160,163,164,479,481],[97,146,160,163,164,178,476,480,481],[97,146,160,163,164,172,202,216,221,228,230,233,236,243,263,268,269,270,271,272,307,308,310,313,315,318,319,320,321,323,371,426,428,476,479,480,481],[97,146,160,163,164,178],[97,146,163,164,209,210,211,239,476,477,478,483,485,531],[97,146,163,164,206,207,479],[97,146,163,164,400],[97,146,160,163,164,178,189,218,384,388,389,390,391,392,398,399,531],[97,146,163,164,172,189,202,216,218,230,233,269,308,313,323,324,377,404,405,406,412,415,416,426,428,476,479],[97,146,163,164,233,239,246,259,269,327,479],[97,146,160,163,164,189,210,221,230,269,410,476,479],[97,146,163,164,430],[97,146,160,163,164,400,413,414,423],[97,146,163,164,476,479],[97,146,163,164,329,469],[97,146,163,164,230,268,371,485],[97,146,160,163,164,172,208,313,373,377,406,412,415,418,476],[97,146,160,163,164,246,259,377,419],[97,146,163,164,209,270,371,421,479,481],[97,146,160,163,164,189,392,479],[97,146,160,163,164,243,270,371,372,373,382,400,420,422,479],[91,97,146,160,163,164,268,425,483,485],[97,146,163,164,322,426],[97,146,160,163,164,172,216,219,221,222,228,230,236,245,246,259,269,272,308,310,320,323,324,371,404,405,406,407,409,411,426,428,476,485],[97,146,160,163,164,178,246,412,417,423,476],[97,146,163,164,249,250,251,252,253,254,255,256,257,258],[97,146,163,164,263,314],[97,146,163,164,316],[97,146,163,164,314],[97,146,163,164,316,317],[97,146,160,163,164,220,221,222,226,227,480],[97,146,160,163,164,172,208,210,228,232,268,271,272,306,426,476,481,483,485],[97,146,160,163,164,172,189,212,219,220,230,232,269,424,469,475,480],[97,146,163,164,339],[97,146,163,164,340],[97,146,163,164,222,233,468],[97,146,163,164,341],[97,146,163,164,215],[97,146,163,164,217,229],[97,146,160,163,164,217,221,228],[97,146,163,164,224,229],[97,146,163,164,225],[97,146,163,164,217,218],[97,146,163,164,217,273],[97,146,163,164,217],[97,146,163,164,219,263,312],[97,146,163,164,311],[97,146,163,164,216,218,219],[97,146,163,164,219,309],[97,146,163,164,216,218],[97,146,163,164,268,371],[97,146,163,164,468],[97,146,160,163,164,189,228,230,234,268,371,425,428,431,432,433,459,460,463,467,469,476,480],[97,146,163,164,282,285,287,288,301,302],[85,97,146,163,164,199,201,231,461,462],[85,97,146,163,164,199,201,231,461,462,466],[97,146,163,164,355],[97,146,163,164,241,262,267,268,332,333,334,335,336,338,351,352,354,357,425,428,479,481],[97,146,163,164,301],[97,146,160,163,164,306,476],[97,146,163,164,306],[97,146,160,163,164,228,274,303,305,307,425,476,483,485],[97,146,163,164,282,283,284,285,287,288,301,302,484],[91,97,146,160,163,164,172,189,217,218,230,236,268,269,272,371,423,424,426,476,479,480,483],[97,146,163,164,213,216,223],[97,146,163,164,267,269,401,404],[97,146,163,164,267,402,470,471,472,473,474],[97,146,160,163,164,263,479],[97,146,160,163,164],[97,146,163,164,266,351],[97,146,163,164,265],[97,146,163,164,267,320],[97,146,163,164,264,266,479],[97,146,160,163,164,212,267,401,402,403,476,479,480],[85,97,146,163,164,216,222,300],[85,97,146,163,164,214],[97,146,163,164,204,205],[85,97,146,163,164,210],[85,97,146,163,164,216,286],[85,91,97,146,163,164,268,272,483,485],[97,146,163,164,210,507,508],[85,97,146,163,164,281],[85,97,146,163,164,172,189,208,275,277,279,280,485],[97,146,163,164,216,243,480],[97,146,163,164,216,408],[85,97,146,158,160,163,164,172,206,208,281,379,483,484],[85,97,146,163,164,197,198,199,200,201,483,528],[85,86,87,88,89,97,146,163,164],[97,146,151,163,164],[97,146,163,164,374,375,376],[97,146,163,164,374],[85,89,97,146,160,162,163,164,172,196,197,198,199,200,201,202,208,236,241,418,446,481,482,485,528],[97,146,163,164,493],[97,146,163,164,495],[97,146,163,164,499],[97,146,163,164,501],[97,146,163,164,503,504,505],[97,146,163,164,509],[90,97,146,163,164,487,492,494,496,500,502,506,510,512,522,523,525,529,530,531,532],[97,146,163,164,511],[97,146,163,164,521],[97,146,163,164,277],[97,146,163,164,524],[97,145,146,163,164,267,401,402,404,470,471,473,474,526,528],[97,146,163,164,572],[97,146,163,164,178,196],[97,111,115,146,163,164,189],[97,111,146,163,164,178,189],[97,106,146,163,164],[97,108,111,146,163,164,186,189],[97,146,163,164,166,186],[97,106,146,163,164,196],[97,108,111,146,163,164,166,189],[97,103,104,107,110,146,157,163,164,178,189],[97,111,118,146,163,164],[97,103,109,146,163,164],[97,111,132,133,146,163,164],[97,107,111,146,163,164,181,189,196],[97,132,146,163,164,196],[97,105,106,146,163,164,196],[97,111,146,163,164],[97,105,106,107,108,109,110,111,112,113,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,133,134,135,136,137,138,146,163,164],[97,111,126,146,163,164],[97,111,118,119,146,163,164],[97,109,111,119,120,146,163,164],[97,110,146,163,164],[97,103,106,111,146,163,164],[97,111,115,119,120,146,163,164],[97,115,146,163,164],[97,109,111,114,146,163,164,189],[97,103,108,111,118,146,163,164],[97,146,163,164,178],[97,106,111,132,146,163,164,194,196]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"bd7dee3446a5b94651d58000ddfda40296f073e9372891f65003a524b4620697","impliedFormat":1},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"21da358700a3893281ce0c517a7a30cbd46be020d9f0c3f2834d0a8ad1f5fc75","impliedFormat":1},{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"4967529644e391115ca5592184d4b63980569adf60ee685f968fd59ab1557188","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"9855e02d837744303391e5623a531734443a5f8e6e8755e018c41d63ad797db2","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"c906fb15bd2aabc9ed1e3f44eb6a8661199d6c320b3aa196b826121552cb3695","impliedFormat":1},{"version":"22295e8103f1d6d8ea4b5d6211e43421fe4564e34d0dd8e09e520e452d89e659","impliedFormat":1},{"version":"58647d85d0f722a1ce9de50955df60a7489f0593bf1a7015521efe901c06d770","impliedFormat":1},{"version":"73b5fa37db36eeac90c4d752e39586f1b57187400c4f5280fd05f16437287a45","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"a3fc63c0d7b031693f665f5494412ba4b551fe644ededccc0ab5922401079c95","impliedFormat":1},{"version":"80523c00b8544a2000ae0143e4a90a00b47f99823eb7926c1e03c494216fc363","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"746911b62b329587939560deb5c036aca48aece03147b021fa680223255d5183","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"c8d3e5a18ba35629954e48c4cc8f11dc88224650067a172685c736b27a34a4dc","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2b55d426ff2b9087485e52ac4bc7cfafe1dc420fc76dad926cd46526567c501a","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"47613031a5a31510831304405af561b0ffaedb734437c595256bb61a90f9311b","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"8a1a0d0a4a06a8d278947fcb66bf684f117bf147f89b06e50662d79a53be3e9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"9f55299850d4f0921e79b6bf344b47c420ce0f507b9dcf593e532b09ea7eeea1","impliedFormat":1},{"version":"2beff543f6e9a9701df88daeee3cdd70a34b4a1c11cb4c734472195a5cb2af54","impliedFormat":1},{"version":"2e07abf27aa06353d46f4448c0bbac73431f6065eef7113128a5cd804d0c384d","impliedFormat":1},{"version":"be1cc4d94ea60cbe567bc29ed479d42587bf1e6cba490f123d329976b0fe4ee5","impliedFormat":1},{"version":"42bc0e1a903408137c3df2b06dfd7e402cdab5bbfa5fcfb871b22ebfdb30bd0b","impliedFormat":1},{"version":"9894dafe342b976d251aac58e616ac6df8db91fb9d98934ff9dd103e9e82578f","impliedFormat":1},{"version":"413df52d4ea14472c2fa5bee62f7a40abd1eb49be0b9722ee01ee4e52e63beb2","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"446a50749b24d14deac6f8843e057a6355dd6437d1fac4f9e5ce4a5071f34bff","impliedFormat":1},{"version":"182e9fcbe08ac7c012e0a6e2b5798b4352470be29a64fdc114d23c2bab7d5106","impliedFormat":1},{"version":"2f4e6b4d39426a1b85ecf4bdeb9dddbf4d9b3397d95d8555d46f925c9519ec7d","impliedFormat":1},{"version":"78a2869ad0cbf3f9045dda08c0d4562b7e1b2bfe07b19e0db072f5c3c56e9584","impliedFormat":1},{"version":"89d5d28d4f57e000b836ac273079be1b75710e28ce14750d081fb420d37e2ca5","impliedFormat":1},{"version":"fd4e24ccff3966390600d7f5d6aa1fed5a512e92ada735ea5fbc933d313ad3d3","impliedFormat":1},{"version":"b7cddfe1aa6b86b5fad3c9ccb30d05b3ccb165aebbf112f48d2d8a5f69dd98b1","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"ad0d1d75d129b1c80f911be438d6b61bfa8703930a8ff2be2f0e1f8a91841c64","impliedFormat":1},{"version":"bd2c7ada3dee03653d3f601011d30072194bc3970cd93208f9588fbdc0c69347","impliedFormat":1},{"version":"e480da45d32313e7174b265674da504f075f59ef326852f0c5a5d863b438ae85","impliedFormat":1},{"version":"ad54850f61fcf5d014e11be80d2f46fea9265cfa7e77456da876f7833ef81769","impliedFormat":1},{"version":"6f7c9e8bd2b5b6a080b07080065f94900bd3c7e5ebbd3047bc33fcce2fab1dd8","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"da5950ee2a90721df6f3fba45f5d05308f7e4c35835392215dd2cd404505e2de","impliedFormat":1},{"version":"ce75b1aebb33d510ff28af960a9221410a3eaf7f18fc5f21f9404075fba77256","impliedFormat":1},{"version":"f42d5fed19610d485c646a0c430e768115567d078c7fc855c57b0c578b3d6cd3","impliedFormat":1},{"version":"ee8df1cb8d0faaca4013a1b442e99130769ce06f438d18d510fed95890067563","impliedFormat":1},{"version":"d5630f2ad9b4541e5ce891648121022f9412ecdca1820baa1f0104f70fd7eff7","impliedFormat":1},{"version":"4d15375ab13497104bc8fe56fdef2b5fd6853f29255737d23a33fa306ff7fd69","impliedFormat":1},{"version":"2cd3fc1d0d6a1e85baffd2d4f50f5efb192b5446eef567e97c94765402f0aad4","impliedFormat":1},{"version":"e4cbf2f1e89ecccaddd2c045e600ae41b732295953fb06247c7dcbc2d281ed30","impliedFormat":1},{"version":"6dcedaef57dff0d79a05ab0ab602cde74db803d1e765468bf91263786a383e1b","impliedFormat":1},{"version":"8c1697d90c394a6fd955b98eae01238eff628e129b987a68aea10f898a48e7da","impliedFormat":1},{"version":"7580e62139cb2b44a0270c8d01abcbfcba2819a02514a527342447fa69b34ef1","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"f374cb24e93e7798c4d9e83ff872fa52d2cdb36306392b840a6ddf46cb925cb6","impliedFormat":1},{"version":"d10d63718e1646c2279e3b33831f82c60e31f622b2b7020f1196409ca4c09242","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"148679c6d0f449210a96e7d2e562d589e56fcde87f843a92808b3ff103f1a774","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"02436d7e9ead85e09a2f8e27d5f47d9464bced31738dec138ca735390815c9f0","impliedFormat":1},{"version":"f8d5ff8eafd37499f2b6a98659dd9b45a321de186b8db6b6142faed0fea3de77","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"a22dd55aa4d39906252000ab8e8a1b83b195eef7f4274eb51e457c1f11cf6580","impliedFormat":1},{"version":"540cc83ab772a2c6bc509fe1354f314825b5dba3669efdfbe4693ecd3048e34f","impliedFormat":1},{"version":"121b0696021ab885c570bbeb331be8ad82c6efe2f3b93a6e63874901bebc13e3","impliedFormat":1},{"version":"612d9da66bb046a9c1e2e8d026245ded881fc4b9f98cbfae714415d57ee0ae0b","impliedFormat":1},{"version":"32c2ad9494dad5d11b0564a619fee18f388db6c1e9e2cd3c360b3122549691eb","impliedFormat":1},{"version":"6c301d40aec56a74ec7bd7324e31a728dadf9bfba3e96def02938d3d973534ec","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"aa14cee20aa0db79f8df101fc027d929aec10feb5b8a8da3b9af3895d05b7ba2","impliedFormat":1},{"version":"493c700ac3bd317177b2eb913805c87fe60d4e8af4fb39c41f04ba81fae7e170","impliedFormat":1},{"version":"aeb554d876c6b8c818da2e118d8b11e1e559adbe6bf606cc9a611c1b6c09f670","impliedFormat":1},{"version":"acf5a2ac47b59ca07afa9abbd2b31d001bf7448b041927befae2ea5b1951d9f9","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"d71291eff1e19d8762a908ba947e891af44749f3a2cbc5bd2ec4b72f72ea795f","impliedFormat":1},{"version":"c0480e03db4b816dff2682b347c95f2177699525c54e7e6f6aa8ded890b76be7","impliedFormat":1},{"version":"25a5f6fd3a2243c859eddc99ab5fba11d970af2fe7a5df9c32b7668f76f97b01","impliedFormat":1},{"version":"8d207e1f9d2c30d6f77dfa693f3827c3fbf0d89240297e10bdfe1041d433df68","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"d682336018141807fb602709e2d95a192828fcb8d5ba06dda3833a8ea98f69e3","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"4fbd3116e00ed3a6410499924b6403cc9367fdca303e34838129b328058ede40","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"8c70ddc0c22d85e56011d49fddfaae3405eb53d47b59327b9dd589e82df672e7","impliedFormat":1},{"version":"2f9c89cbb29d362290531b48880a4024f258c6033aaeb7e59fbc62db26819650","impliedFormat":1},{"version":"a365c4d3bed3be4e4e20793c999c51f5cd7e6792322f14650949d827fbcd170f","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"273782b8454e78f6a8b30d2cfbf6860499c930595095fcc1689637115f0eddda","affectsGlobalScope":true,"impliedFormat":1},{"version":"3fbdd025f9d4d820414417eeb4107ffa0078d454a033b506e22d3a23bc3d9c41","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a8f8e6ab2fa07b45251f403548b78eaf2022f3c2254df3dc186cb2671fe4996d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"9f9bb6755a8ce32d656ffa4763a8144aa4f274d6b69b59d7c32811031467216e","impliedFormat":1},{"version":"5c32bdfbd2d65e8fffbb9fbda04d7165e9181b08dad61154961852366deb7540","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"0c05e9842ec4f8b7bfebfd3ca61604bb8c914ba8da9b5337c4f25da427a005f2","impliedFormat":1},{"version":"faed7a5153215dbd6ebe76dfdcc0af0cfe760f7362bed43284be544308b114cf","impliedFormat":1},{"version":"7029e566b8df176f703fb59fd437a38670c7a0e02c58b2d66dfb5b2e2b2defdb","impliedFormat":1},{"version":"7f2aa4d4989a82530aaac3f72b3dceca90e9c25bee0b1a327e8a08a1262435ad","impliedFormat":1},{"version":"d96b39301d0ded3f1a27b47759676a33a02f6f5049bfcbde81e533fd10f50dcb","impliedFormat":1},{"version":"e9f147ecca73d9346a4c073432843c159ccbe50bdcb678a78f6da10eae2cecf4","impliedFormat":1},{"version":"de061f7d72bd65c06fc1419f841dfdcb29a8e22fe6fa527d1e6eb20b897d4de0","impliedFormat":1},{"version":"663beafc2446079574570cba86e9b15f986f908ddb1b01274509970126fee945","impliedFormat":1},{"version":"a3102887d5058bf4cb5b37fa6964c09e9527c42053b3b5c642b89878620748de","impliedFormat":1},{"version":"0aaaa1727edd29673d85c9b26d7ca4d54e5407a48586903c51b48b7f7d196f61","impliedFormat":1},{"version":"d35bca0b261bff02635758c48e8ab99c61c420d0dfabbcf467e847171d876b7d","impliedFormat":1},{"version":"3bc12c40d90c342ff88a3d876996c555ed5cbee5fe8c3308a240b321f401ee46","impliedFormat":1},{"version":"ba130768aae855a5477e9e148e5c879548e6e7ccbcc56fd1934c8a18ea5b7569","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"d38530db0601215d6d767f280e3a3c54b2a83b709e8d9001acb6f61c67e965fc","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b499af2054a037a162b3b72cd886f48bbf32a3502c865c6e29fac7d2ab3ce0b5","impliedFormat":1},{"version":"b83cb14474fa60c5f3ec660146b97d122f0735627f80d82dd03e8caa39b4388c","impliedFormat":1},{"version":"48773ca557b0319c2ee62ae249cf52a81709e8be139920d6479a66274de7c4ed","impliedFormat":1},{"version":"7274fbffbd7c9589d8d0ffba68157237afd5cecff1e99881ea3399127e60572f","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"8b4327413e5af38cd8cb97c59f48c3c866015d5d642f28518e3a891c469f240e","impliedFormat":1},{"version":"4cceef18d7f088e797a463e90b7a9dad10c6bc667724b7686e3e740ae00122be","impliedFormat":1},{"version":"7ee86fbb3754388e004de0ef9e6505485ddfb3be7640783d6d015711c03d302d","impliedFormat":1},{"version":"cc1954b539604b1e562319119ac7e888172208b32ca873f9a357a92c826bd046","impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","impliedFormat":1},{"version":"bb8f2dbc03533abca2066ce4655c119bff353dd4514375beb93c08590c03e023","impliedFormat":1},{"version":"706dd95827e7ebaabda91d5db2b755233e0952d98570e9c032b0f066a15c1177","affectsGlobalScope":true,"impliedFormat":1},{"version":"0b103e9abfe82d14c0ad06a55d9f91d6747154ef7cacc73cf27ecad2bfb3afcf","impliedFormat":1},{"version":"cd9304972e6d616197fb44fce00540a904f38b54306a1951b5dbeaf3c01ab5bd","impliedFormat":1},{"version":"77438e2c397a3db78407621cfc57241a305b310ddea2c185f1d555248297f587","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"a6805fcafed712aea7759f8bc731014f9d22738c1d6ef9d43b8091d1d48346d5","impliedFormat":1},{"version":"c49469a5349b3cc1965710b5b0f98ed6c028686aa8450bcb3796728873eb923e","impliedFormat":1},{"version":"4a889f2c763edb4d55cb624257272ac10d04a1cad2ed2948b10ed4a7fda2a428","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"d88ea80a6447d7391f52352ec97e56b52ebec934a4a4af6e2464cfd8b39c3ba8","impliedFormat":1},{"version":"142617b3cdf902b69c6464c9fbd942b60ab3e733ca18c032b19e0f7e2adbefe8","impliedFormat":1},{"version":"0b603555f1881f87256ffd6344d3e3ed6d466c2e701eabf381f28be8c2125892","impliedFormat":1},{"version":"897e4f7662488e3ecc79e743bdd3b78f13bdb69a97851afa5b440c4211e32ea9","impliedFormat":1},{"version":"e2e1c6d3b2d93add5200bd7bc1a8cccb4e446836b2111ece45db8683a2c765de","impliedFormat":1},{"version":"251b03d5cd243854ce870d9a9a39f491faf69898c5d6b5eee28cc7649c57417b","impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","impliedFormat":1},{"version":"2c4de79f406d137390608e8c0a44fba2ff8e00bacfcae7c9d1781fef10e9440d","impliedFormat":1},{"version":"07ba23a10465791be5d22deaf5ef7de7658774ddff53721e5ea17fedea1bc721","impliedFormat":1},{"version":"dca8c645c5afeb03b1ecedbf16323f33e7d0afaa6256c8e047e6e38087a97f53","impliedFormat":1},{"version":"775f181bd4a533d6f8b5e55ec1d9f1624559720ae8a70e9432258da26b38d27c","impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","impliedFormat":1},{"version":"7715134a0cf07dd41a9da2895d708625a3a303a0385e355ecaaf0b8bfaef2550","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"622694a8522b46f6310c2a9b5d2530dde1e2854cb5829354e6d1ff8f371cf469","impliedFormat":1},{"version":"cd8ce8d68567f62dd580b3c3c37777ac3f5b81944c7417f5ea83030eab533385","impliedFormat":1},{"version":"e5c939d896565dcac0f6fbdbada11284e7728ef26a069561c09aa5aa4a788393","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"a9e6c0ff3f8186fccd05752cf75fc94e147c02645087ac6de5cc16403323d870","impliedFormat":1},{"version":"49af4b52f0d4d2304c5f2c6fe5fab3e153e0acc38830d0202821b877c097dd02","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"e68b8e5a1df7c1be2bc105141456ecba70215806e1c28bfbc5c12bfce4be6e68","impliedFormat":1},{"version":"511c8f02329808d47d00b859c532ae9115590048b17325a946c74dac48428650","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"b5f9e66625783eefcbe3d2da074b2e7ba2066d61ce3fc6ef4f22805ad946cab4","impliedFormat":1},{"version":"e37115962d284b9f7a37c2bdd2add50f88365dde41f5e0ff591ffc48a8ec7575","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"f89488602bec98a142072fae7ea5ba99431a569ff580c64b7be39896474799d8","impliedFormat":1},{"version":"bbbc47961f39a57df103cf4ca3bb8f8732b4b6678a18225a0aa76d59c466956c","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"2ffb043dc5163458e473b7010859f86e01dc4edffcae0a93d885d028b426a546","impliedFormat":1},{"version":"c8f004e6036aa1c764ad4ec543cf89a5c1893a9535c80ef3f2b653e370de45e6","impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","impliedFormat":1},{"version":"b064c36f35de7387d71c599bfcf28875849a1dbc733e82bd26cae3d1cd060521","impliedFormat":1},{"version":"05c7280d72f3ed26f346cbe7cbbbb002fb7f15739197cbbee6ab3fd1a6cb9347","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"04b7b2e0832dfd3c31e81df3975e8d8fda28e7ff999b0aa2932608a8f6661d5c","impliedFormat":1},{"version":"ca2d34c6ed5cbd3070b8b6f32f42ae54adcc6499c1e4b99f0a5798b3f27cc653","impliedFormat":1},{"version":"9ec68995e66dd6b9dac834bf5ae85fde802714ea2e82151a5d1d53ef01b463ef","impliedFormat":1},{"version":"5c4d626b4902f2ef8a1cc146d761d276cef988016dc674e3b98fbad70e64bc9f","impliedFormat":1},{"version":"fdfaa0aad899524962e2955287b5b991ffe3be50f64e02eb60c933ca44644a94","impliedFormat":1},{"version":"53c972a0f9bc3a4ec70fff7314123ea8cfcf75b3703046f767d2dc1eea87b2fb","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"7d73b24e7bf31dfb8a931ca6c4245f6bb0814dfae17e4b60c9e194a631fe5f7b","impliedFormat":1},{"version":"d130c5f73768de51402351d5dc7d1b36eaec980ca697846e53156e4ea9911476","impliedFormat":1},{"version":"413586add0cfe7369b64979d4ec2ed56c3f771c0667fbde1bf1f10063ede0b08","impliedFormat":1},{"version":"06472528e998d152375ad3bd8ebcb69ff4694fd8d2effaf60a9d9f25a37a097a","impliedFormat":1},{"version":"7303b45138d2511035056a5901a1490ebdcbf055cbb1276f8629c5121cbe733e","impliedFormat":1},{"version":"27f874cd5327507eeff699a74567f60c1215b94509f4308633a7b01922471ed2","impliedFormat":1},{"version":"a401617604fa1f6ce437b81689563dfdc377069e4c58465dbd8d16069aede0a5","impliedFormat":1},{"version":"2c6cf04bc525caf6546e859e8ef10bfb9573837ec0bc5ec7b53a7b1b8ca72781","impliedFormat":1},{"version":"8695dec09ad439b0ceef3776ea68a232e381135b516878f0901ed2ea114fd0fe","impliedFormat":1},{"version":"304b44b1e97dd4c94697c3313df89a578dca4930a104454c99863f1784a54357","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"87cc05fe13108f02e12da7e3efd8e360fef78d96a0c9e11408ea1b1b9fb3e03d","impliedFormat":1},{"version":"1abbf67c218d23c2ce76887caac2df6c7dab3d97ba2b65348432b876f510002a","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","impliedFormat":1},{"version":"c06ef3b2569b1c1ad99fcd7fe5fba8d466e2619da5375dfa940a94e0feea899b","impliedFormat":1},{"version":"f7d628893c9fa52ba3ab01bcb5e79191636c4331ee5667ecc6373cbccff8ae12","impliedFormat":1},{"version":"1d879125d1ec570bf04bc1f362fdbe0cb538315c7ac4bcfcdf0c1e9670846aa6","impliedFormat":1},{"version":"dad97c99382889e9c7d1a9d8275500ff71235130fae9f8916fdbf3641d56e592","impliedFormat":1},{"version":"a6dba407fc287f1e25454e75028c91bbc00675f2d1c4e8b3edcc36c08611a486","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"e91f7b1344577a02f051b9b471f33044fef8334a76dc9e1de003d17595a5219b","impliedFormat":1},{"version":"c0723195c85e19656d6b5b9fdb81d3f3403c1ae4679e722c6ea058c516b38d12","impliedFormat":1},{"version":"b55eb9f72166093b5460d34b34f5d8699c968de3bc3fc696e40f2c93f2ebf650","impliedFormat":1},{"version":"71d9eb4c4e99456b78ae182fb20a5dfc20eb1667f091dbb9335b3c017dd1c783","impliedFormat":1},{"version":"cfa846a7b7847a1d973605fbb8c91f47f3a0f0643c18ac05c47077ebc72e71c7","impliedFormat":1},{"version":"1594da19968752a22b2ac48c2d0e60575700e745c577a8a4a676b841238ad5bb","impliedFormat":1},{"version":"e0cee12109e0a10a4c3d6769fcc7644b7c1ea7f52365bea51728f5af29f8a137","impliedFormat":1},{"version":"7d4254b4c6c67a29d5e7f65e67d72540480ac2cfb041ca484847f5ae70480b62","impliedFormat":1},{"version":"3536968defef8a75514f547ead5e2e9c1e984820290ec9b00c5fdfb6ef786535","impliedFormat":1},{"version":"d83773870080c30a230e322ce13a9c6f3398e8dacea4ea8a83e26370f3bac23e","impliedFormat":1},{"version":"dcfeaf98d66314fec29a9076c4290e45d0b196a65827becc19138e9c7b855f37","impliedFormat":1},{"version":"6849fe9210fe4946d5f085bfed36758f33dc6ae15a751338d178dd4daa017c46","impliedFormat":1},{"version":"888cda0fa66d7f74e985a3f7b1af1f64b8ff03eb3d5e80d051c3cbdeb7f32ab7","impliedFormat":1},{"version":"60681e13f3545be5e9477acb752b741eae6eaf4cc01658a25ec05bff8b82a2ef","impliedFormat":1},{"version":"ffae4e1e06aa848a1e4bcef162cd1c48e5909b26223515981310af9c036bdfc7","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"34e16eb7c31768a11a08aebcfb3d70d7b8f0b016197e98d8419e566ceae6d6c8","impliedFormat":1},{"version":"f94ec1f7e4b709d26960306c9082a7a1b728a6e13089346aa48ba57c74cbf47e","impliedFormat":1},{"version":"9a11cb4033405e96c247cd5aa29790212aaffdd127869e8a5219103f0b389fd5","impliedFormat":1},{"version":"01479d9d5a5dda16d529b91811375187f61a06e74be294a35ecce77e0b9e8d6c","impliedFormat":1},{"version":"aff5213585cb72e94054dfe17250ff315f3569b3919d1ef1ad235f37c4ee894e","impliedFormat":1},{"version":"fb2ea35e1be6388d722d7725e2b49c697d34d9c890c3b96758faaeb86d35cef8","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"1a4dc28334a926d90ba6a2d811ba0ff6c22775fcc13679521f034c124269fd40","impliedFormat":1},{"version":"f05315ff85714f0b87cc0b54bcd3dde2716e5a6b99aedcc19cad02bf2403e08c","impliedFormat":1},{"version":"5fad3b31fc17a5bc58095118a8b160f5260964787c52e7eb51e3d4fcf5d4a6f0","impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","impliedFormat":1},{"version":"456006a6975b26c0a1785feddae165f6d307e2d601ffde27e21fc4a790e448a4","impliedFormat":1},{"version":"c857e0aae3f5f444abd791ec81206020fbcc1223e187316677e026d1c1d6fe08","impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","impliedFormat":1},{"version":"1fe0d18b111e1145a7e7601855bccd4ca20f24e3b9a5aba6bb1fa9d1a7059170","impliedFormat":1},{"version":"5632c3c26d420c063eebe64c45b1248b9492a67bf44f1d0c57e9dc8f6cf449bb","impliedFormat":1},{"version":"0df5aa619ab12993a39ea6dae062ee46eadbb4d738916460e636ada52bced75b","impliedFormat":1},{"version":"8fca3039857709484e5893c05c1f9126ab7451fa6c29e19bb8c2411a2e937345","impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","impliedFormat":1},{"version":"10ab7be91f87ebe8916b62cf28af2e45b5601fc7b0e311adf838f912c6b31dd8","impliedFormat":1},{"version":"bc636fbc08e0979ceb7eb0731a33000283d77a33b62e1f71ee65be50394e40ba","impliedFormat":1},{"version":"7e0b7f91c5ab6e33f511efc640d36e6f933510b11be24f98836a20a2dc914c2d","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"75bbd3be047d539988a0ff0b56384ef7a6a25f3b676ad96bee547d44c31622a7","impliedFormat":1},{"version":"42960001a776b089ade681ab5cfddc936e0afb0615133ec1841f3dee89d3e1bf","impliedFormat":1},{"version":"0aedb02516baf3e66b2c1db9fef50666d6ed257edac0f866ea32f1aa05aa474f","impliedFormat":1},{"version":"da47712b394d944328245482603bc6f416d3949b67c9392279caab595076b510","affectsGlobalScope":true,"impliedFormat":1},{"version":"37d0071d8f0a06dc55c2c5e0ec3391affd4fd107c53410bf358196ec0bf3923f","impliedFormat":1},{"version":"b213dad76ca37fd552274c9499056e1c0d9c1bd38a55bb7f68b22ba6b84c3ad7","impliedFormat":1},{"version":"56ccb49443bfb72e5952f7012f0de1a8679f9f75fc93a5c1ac0bafb28725fc5f","impliedFormat":1},{"version":"20fa37b636fdcc1746ea0738f733d0aed17890d1cd7cb1b2f37010222c23f13e","impliedFormat":1},{"version":"d90b9f1520366d713a73bd30c5a9eb0040d0fb6076aff370796bc776fd705943","impliedFormat":1},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},{"version":"19df3488557c2fc9b4d8f0bac0fd20fb59aa19dec67c81f93813951a81a867f8","affectsGlobalScope":true,"impliedFormat":1},{"version":"b25350193e103ae90423c5418ddb0ad1168dc9c393c9295ef34980b990030617","affectsGlobalScope":true,"impliedFormat":1},{"version":"bef86adb77316505c6b471da1d9b8c9e428867c2566270e8894d4d773a1c4dc2","impliedFormat":1},{"version":"5a49adaef698b7ad7e6127949fa1b0bbd3d46b7cbd11c54e392a4dcdd51f5190","impliedFormat":1},{"version":"6ee598cdfdd0fa52039dca135b3dfff7b49035dc13292143e0a93843e3861967","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"2489bf04d77dc025ba67f49f1a56eb24b9db477d5ff88123d887e163ed1776aa","impliedFormat":1},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"0b77b819b5417775fccb20c678293cf614c054a5b1a65421a5b933a9124ba998","impliedFormat":1},{"version":"eb5acb58487367e502d994b57e2c58255d8241f481ea8efa8e79af23af3f41c2","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"b1f1d57fde8247599731b24a733395c880a6561ec0c882efaaf20d7df968c5af","impliedFormat":1},{"version":"6715dc4eb59c8ea9abe2b78c235ed331dc710a06fe56798868dbc4d40cd1b707","impliedFormat":1},{"version":"35e6379c3f7cb27b111ad4c1aa69538fd8e788ab737b8ff7596a1b40e96f4f90","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"5a3ea721d03a361ccbdd7390ccd75f6e84cbca3a3f01f4b331ecc9af31890c49","impliedFormat":1},{"version":"e7dfaee4af38d45b1cab8a1ee0b3bc1f85ddcf64545ed391d675d78ae6526274","affectsGlobalScope":true,"impliedFormat":1},{"version":"e8daa443eaf9a27fd382cc1f8ebe30330c0f4d89511cfb469166874806751d35","impliedFormat":1},{"version":"af48e58339188d5737b608d41411a9c054685413d8ae88b8c1d0d9bfabdf6e7e","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"1de8c302fd35220d8f29dea378a4ae45199dc8ff83ca9923aca1400f2b28848a","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"332248ee37cca52903572e66c11bef755ccc6e235835e63d3c3e60ddda3e9b93","impliedFormat":1},{"version":"94e8cc88ae2ef3d920bb3bdc369f48436db123aa2dc07f683309ad8c9968a1e1","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"320f4091e33548b554d2214ce5fc31c96631b513dffa806e2e3a60766c8c49d9","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"d90d5f524de38889d1e1dbc2aeef00060d779f8688c02766ddb9ca195e4a713d","impliedFormat":1},{"version":"07ed3ddab975995eea41b22f3010506fb9f5fb301d04820b07d7a1aee5477d7c","impliedFormat":1},{"version":"969d8b0965849f4bae7cab0ba90bd1e1220e95999c2c6f01117fa7500901c017","impliedFormat":1},{"version":"6ec840ee5e2bc103f557fe38b1d585ee250540468713d7634ee066de372bf332","impliedFormat":1},{"version":"b0309e1eda99a9e76f87c18992d9c3689b0938266242835dd4611f2b69efe456","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"6ceb10ca57943be87ff9debe978f4ab73593c0c85ee802c051a93fc96aaf7a20","impliedFormat":1},{"version":"1de3ffe0cc28a9fe2ac761ece075826836b5a02f340b412510a59ba1d41a505a","impliedFormat":1},{"version":"e46d6cc08d243d8d0d83986f609d830991f00450fb234f5b2f861648c42dc0d8","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"ff863d17c6c659440f7c5c536e4db7762d8c2565547b2608f36b798a743606ca","impliedFormat":1},{"version":"5412ad0043cd60d1f1406fc12cb4fb987e9a734decbdd4db6f6acf71791e36fe","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"e297c0a524edee7677939122f90027bfbe5f2698939d9a85728e5044b39c7124","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"bc9ee0192f056b3d5527bcd78dc3f9e527a9ba2bdc0a2c296fbc9027147df4b2","impliedFormat":1},{"version":"b62381cae176db34f003cc6172ee8f3e0122014889d66391aa73698105cf4934","impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","impliedFormat":1},{"version":"30d8da250766efa99490fc02801047c2c6d72dd0da1bba6581c7e80d1d8842a4","impliedFormat":1},{"version":"03566202f5553bd2d9de22dfab0c61aa163cabb64f0223c08431fb3fc8f70280","impliedFormat":1},{"version":"41eb514d9ce0a6e87957f08a4b7af70d93f87637f37dee706e2d92a6601c25a9","impliedFormat":1},{"version":"e7765aa8bcb74a38b3230d212b4547686eb9796621ffb4367a104451c3f9614f","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"7bd01f0f28cd3aeb2046274d85208e245965f6f2948edf4f7b2057bcf9f22ccc","impliedFormat":99},{"version":"d2f2cf2b8cc92bea913cda4a076e0f790b23a21e84f989d12f0116a7fe3906e0","impliedFormat":99},{"version":"6de125ea94866c736c6d58d68eb15272cf7d1020a5b459fea1c660027eca9a90","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5b20bc288ee49989c95b20847fc93b96bf61cc0845598897a6a53a967dd7d07","affectsGlobalScope":true,"impliedFormat":1},{"version":"064ac1c2ac4b2867c2ceaa74bbdce0cb6a4c16e7c31a6497097159c18f74aa7c","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"d3b315763d91265d6b0e7e7fa93cfdb8a80ce7cdd2d9f55ba0f37a22db00bdb8","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},{"version":"0405c745f276c12f963d9f641451651661d3293041f18d47cae93b5bf0b5fc13","affectsGlobalScope":true},"7ad303e40d4fddf44f156129e397511953a71481c5cfd86b1862649aaaf240cc",{"version":"bae8d023ef6b23df7da26f51cea44321f95817c190342a36882e93b80d07a960","impliedFormat":1},{"version":"26a770cec4bd2e7dbba95c6e536390fffe83c6268b78974a93727903b515c4e7","impliedFormat":1},{"version":"dd5115b329c19c4385af13eda13e3ab03355e711c3f313173fd54ed7d08cfd39","impliedFormat":99},{"version":"035a5df183489c2e22f3cf59fc1ed2b043d27f357eecc0eb8d8e840059d44245","impliedFormat":1},{"version":"0d14fa22c41fdc7277e6f71473b20ebc07f40f00e38875142335d5b63cdfc9d2","impliedFormat":1},{"version":"a4809f4d92317535e6b22b01019437030077a76fec1d93b9881c9ed4738fcc54","impliedFormat":1},{"version":"5f53fa0bd22096d2a78533f94e02c899143b8f0f9891a46965294ee8b91a9434","impliedFormat":1},{"version":"e1028394c1cf96d5d057ecc647e31e457b919092f882ed0c7092152b077fed9d","impliedFormat":1},{"version":"f315e1e65a1f80992f0509e84e4ae2df15ecd9ef73df975f7c98813b71e4c8da","impliedFormat":1},{"version":"e00243d23c495ca2170c9b9e20b5c92331239100b51efdc2b4401cdad859bbef","impliedFormat":1},{"version":"ab82804a14454734010dcdcd43f564ff7b0389bee4c5692eec76ff5b30d4cf66","impliedFormat":1},{"version":"6fa5d56af71f07dc276aae3f6f30807a9cccf758517fb39742af72e963553d80","impliedFormat":1},{"version":"253b95673c4e01189af13e855c76a7f7c24197f4179954521bf2a50db5cfe643","impliedFormat":1},{"version":"afe73051ff6a03a9565cbd8ebb0e956ee3df5e913ad5c1ded64218aabfa3dcb5","impliedFormat":1},{"version":"31f24e33f22172ba0cc8cdc640779fb14c3480e10b517ad1b4564e83fa262a2b","impliedFormat":1},{"version":"f380ae8164792d9690a74f6b567b9e43d5323b580f074e50f68f983c0d073b5b","impliedFormat":1},{"version":"0fd641a3b3e3ec89058051a284135a3f30b94a325fb809c4e4159ec5495b5cdc","impliedFormat":1},{"version":"7b20065444d0353a2bc63145481e519e02d9113a098a2db079da21cb60590ef0","impliedFormat":1},{"version":"9f162ee475383c13e350c73e24db5adc246fba830b9d0cc11d7048af9bbd0a29","impliedFormat":1},{"version":"ce7c3363c40cd2fcc994517c7954954d1c70de2d972df7e45fa83837593b8687","impliedFormat":1},{"version":"6ab1224e0149cc983d5da72ff3540bc0cad8ee7b23cf2a3da136f77f76d01763","impliedFormat":1},{"version":"e059fb0805a29ea3976d703a6f082c1493ac5583ca8011e8c5b86d0a23667d0d","impliedFormat":1},{"version":"16fbf548a0337a83d30552e990b6832fd24bbc47042a8c491e1dc93029b4222f","impliedFormat":1},{"version":"0c4c7303956a4726568c801dcd81e9fbce32fbf74565f735bbcf46ba66417769","impliedFormat":1},{"version":"f39848c7895fd6373d5e30089e7fb1d10c464e7eeb37ce1ea47d188a707b162c","impliedFormat":1},{"version":"9249c34e7282d17a2749677c3521ea625f73c2b48792af08fa9c5e09abc6a882","impliedFormat":1},{"version":"ae77d81a5541a8abb938a0efedf9ac4bea36fb3a24cc28cfa11c598863aba571","impliedFormat":1},{"version":"f329dfad7970297cbf07ddc8fce2ad4a24e2a3855917c661922ef86eb24dd1f1","impliedFormat":1},{"version":"841784cfa9046a2b3e453d638ea5c3e53680eb8225a45db1c13813f6ea4095e5","affectsGlobalScope":true,"impliedFormat":1},{"version":"646ef1cff0ec3cf8e96adb1848357788f244b217345944c2be2942a62764b771","impliedFormat":1},{"version":"3cfb7c0c642b19fb75132154040bb7cd840f0002f9955b14154e69611b9b3f81","impliedFormat":1},{"version":"8387ec1601cf6b8948672537cf8d430431ba0d87b1f9537b4597c1ab8d3ade5b","impliedFormat":1},{"version":"d16f1c460b1ca9158e030fdf3641e1de11135e0c7169d3e8cf17cc4cc35d5e64","impliedFormat":1},{"version":"a934063af84f8117b8ce51851c1af2b76efe960aa4c7b48d0343a1b15c01aedf","impliedFormat":1},{"version":"e3c5ad476eb2fca8505aee5bdfdf9bf11760df5d0f9545db23f12a5c4d72a718","impliedFormat":1},{"version":"462bccdf75fcafc1ae8c30400c9425e1a4681db5d605d1a0edb4f990a54d8094","impliedFormat":1},{"version":"5923d8facbac6ecf7c84739a5c701a57af94a6f6648d6229a6c768cf28f0f8cb","impliedFormat":1},{"version":"d0570ce419fb38287e7b39c910b468becb5b2278cf33b1000a3d3e82a46ecae2","impliedFormat":1},{"version":"3aca7f4260dad9dcc0a0333654cb3cde6664d34a553ec06c953bce11151764d7","impliedFormat":1},{"version":"a0a6f0095f25f08a7129bc4d7cb8438039ec422dc341218d274e1e5131115988","impliedFormat":1},{"version":"b58f396fe4cfe5a0e4d594996bc8c1bfe25496fbc66cf169d41ac3c139418c77","impliedFormat":1},{"version":"45785e608b3d380c79e21957a6d1467e1206ac0281644e43e8ed6498808ace72","impliedFormat":1},{"version":"bece27602416508ba946868ad34d09997911016dbd6893fb884633017f74e2c5","impliedFormat":1},{"version":"2a90177ebaef25de89351de964c2c601ab54d6e3a157cba60d9cd3eaf5a5ee1a","impliedFormat":1},{"version":"82200e963d3c767976a5a9f41ecf8c65eca14a6b33dcbe00214fcbe959698c46","impliedFormat":1},{"version":"b4966c503c08bbd9e834037a8ab60e5f53c5fd1092e8873c4a1c344806acdab2","impliedFormat":1},{"version":"3d3208d0f061e4836dd5f144425781c172987c430f7eaee483fadaa3c5780f9f","impliedFormat":1},{"version":"34a8a5b4c21e7a6d07d3b6bce72371da300ec1aed58961067e13f1f4dc849712","impliedFormat":1},{"version":"32a2eb76c54115dac8d3e69cc17458c4b11ea5a21869cdfbd7930ede7530bd52","signature":"a46d66851af2c056e805fdd574bf5ec3adb1181c43c5e41f0a1c592e338afe64"},{"version":"8d0a08ceba47917e3f3667a238807ae13018e6f76133753542626b827b7bdb3e","signature":"fe30465f81a37f23a168022109187dbd1951404e3ec2fbc6516d584c597f1325"},{"version":"62d97e6a1566c6233262b5de854241b68128d51fe49c65eb1329f26a686e263d","signature":"4a10be7c30a11996bf5752d7256207227e9e8cb64e985b9fb2904a002b9b6502"},{"version":"9109c457faa11e4e122379d2c0b72b1b733b3bfa5cefc03f22b3b7106b423930","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"386786974044aaade3854f63b870246fb0766a2556cb68a44cac289e2fd9cac3","signature":"58f744f4520828eecd9a0587d6faf65a68fede43393c2de82a4acfc352637cbf"},{"version":"1fab86663f650569edb41a10810fdb909f9b636dbb57ed8fabdc0dde770fa0f6","signature":"439adc231d360317c411ce4c88b98492a63e242b8c1d1664b5c534bcd7662d4b"},{"version":"071d68ed5f23e36981c63466c618bf5e33ccf0e88f56c80c956df162adf856e1","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"91cab884fcbe2fa48858e16cb5fc36da20b0fef888e6c0488cd56faecb02a500","signature":"439adc231d360317c411ce4c88b98492a63e242b8c1d1664b5c534bcd7662d4b"},{"version":"96f86abc428e52cb4b2eb4a7ef675cfabcb4d99a51f6e102762fac26ed687747","signature":"3704be09b2e5297a18c95ef01c369c8548ee6ee4afe41c6536b52df1105d0961"},{"version":"be8f1069dd25e34d4c000b927c0f5f909ce5f65735df2b2804f714217e573d2a","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"428738754103b3eab401fd038ae9d74e4bf77bcf4dd765aba138d993e23e3535","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"d55a874ed2783bbf4aaa13333384296efae2b934a14e6e415d6fb043e8ac7b0b","signature":"13c9020c88ebb4a44fe64a82fed9395798b9aebb49177137b3b42666ea150371"},{"version":"975180250120397db0ddcc723994886e0345128c4f40528d1ca6ad0c15aef2d1","signature":"691bb3e7aad52b4557579f838a5f87e203b74b916eb794b1bdc326d02bf34de2"},{"version":"557f30573e112f344238b0321121c9d6741b5a07de59240d2afa8a73badc2d72","signature":"37f379552fcce4c75400013076cdc42f0b3bbefd16ee978cd87c5bccc94d173f"},{"version":"12c0d1d96bf61f8462dde5d85db8610a53cde158d677ccc60eed3cae1d2f4da0","signature":"37f379552fcce4c75400013076cdc42f0b3bbefd16ee978cd87c5bccc94d173f"},{"version":"ca4d3b3b5d9cc4b51c23f59c4c9b6c71e25f21aff3e56010c90f2bad77ceaf0e","signature":"d7ccf6184e7f93272615ae70633ca9e5c5a5e454b4f0b781835b4652effc95f9"},{"version":"fcb420804f308c76d637cb55b0525d2626307abd8caa876a5cdab9447a91fd14","signature":"439adc231d360317c411ce4c88b98492a63e242b8c1d1664b5c534bcd7662d4b"},{"version":"32af6a49e41856c4c7a4cbc0290c8cd5dcc98b4beeb7a81c466f4a2fd44b62cf","signature":"429f3f57fd9f5c60809df3da1ea885d4b4b848f198918350b2868eb6450b9aa3"},{"version":"f79a083745f83b82e0c8ee6d36ce76d48e705cab5aee03f78b08a0ccc6a2c843","signature":"691bb3e7aad52b4557579f838a5f87e203b74b916eb794b1bdc326d02bf34de2"},{"version":"d16bc26ae8216d0d0e927b031b723f7b10a17bde1ddaf9670d4d4ace645aa065","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"f5980d624c06a7365308880028eef90f4c303698c65fbcf88980eefc6d108674","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"43be7414a7c18e8864e694b1c1e03f67c528754f431133aab2e6768a72085108","signature":"439adc231d360317c411ce4c88b98492a63e242b8c1d1664b5c534bcd7662d4b"},{"version":"45dbb46df27b22b8c45dc7c37ef4c5b75e59048468d03c4e0afcdde913e7c67e","signature":"131ec4ba95a194783a3ef845c6c54cd22966f62dc8b65040fbba51ce470261fc"},{"version":"6fbc3fcd34f226f4d2aad9fec2585277e285f2a2e9953f3952b942027ef92ac4","signature":"755685c3a8437b328bdb165da474d7198c3fa674b577e237a6a0d5f9ca69b70d"},{"version":"25227d6938565ee8fd597d9f3447a5ccb26956b1cc42eedf030ae157df2cea11","signature":"3704be09b2e5297a18c95ef01c369c8548ee6ee4afe41c6536b52df1105d0961"},{"version":"18e68b87913d4c78c975aad1310f4fb562467a6b6f391ebf5ab957d02161a14c","signature":"bffa58f023fd56d847f5dc3feec4fc7e4e00ffb8537720a4d50540be97ed14b7"},{"version":"4ce750a17effe2816acfe591ea3b53a02f04f27f8e574461aa1c49b81b2433fc","signature":"ff53dc24dd9f26eaca109b151c98025c3e47f72426866ab0bcd101e453dbc9dd"},{"version":"0b472738e1f60363dc43ce6574c53a4e2af98723fe3b00151a980bcd69079b34","signature":"ac9ef23443101f1bedc7a70fce40117a8f90b67184bd0e705196d930a342b86f"},{"version":"87ce72f614a0a36d078ac9149c1cfca6f497ffeeb30fd343e1cd64ea8b48c1c7","signature":"ff6dfda5378fc7b1ffb6f75724fa0b04c32109d9459a9df45de1ae5186b44a1c"},{"version":"5e596d359e7bc189f8557b8a3acb220aa05d552390fee951c6c083bd6b3e865f","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"7fe85776186a87720d347474c108ccc33b2c4d323bb249df50f5a42e3829f198","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"a3375d47af258077e30b0f2945ff925e386cf4f0a68a6a8733e91b44d154edff","signature":"738b65cbb4e4fabe95f46cf4a6f41235581ae2690fea880e3cf147f0cf81becd"},{"version":"da7d1496bece3c769275c25bad4c49a3ee1131f3725dd588c656888a1a978c2a","signature":"c378b096fee5448b4ac5299d72b6626f5662211c2a88bad6dff5c1255a283558"},{"version":"d62aa073aa1036168f02c738115a40cae79292d4f729ce5f71ecdb5ce11e73c5","signature":"571035eb938292d482b20f06e178226ff80bde3859559cf5e27045628df1eb82"},{"version":"624d87ff4530fdcdf5acaab5194b6f9c3a8cbd609feea9cf7b11fe051334d5e0","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"188ce27ee8a9e8487a7cdbb0c98278736ff727f7237ba43bbb816d5f9baa6370","signature":"4473b2662aa2bfeb8f651f781643fe66a0b08d53a1deacc6e4df2de2e5628133"},{"version":"8c0fb08a1520ac4e164a445df53aa5cd3a741079543ad70b460cc52dac949623","signature":"26ba71f79eb1ccc526399c703ceb9595712a793b7d939584ca92df4a3ce4fea8"},{"version":"5a836dd7cb14ea297e7fc274a6669a5a0210f6cde83319fb9d0f2f2ccfad9a6c","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"1ee6c2b9f49070b9d45ff8f822e64f83c3835b7a8675b12d18564a2c2334e3c9","signature":"2937356fbfa7776a641dd44c956406dfb41635b13c69e1281a7bf52a59486f84"},{"version":"fe2afbd0b1e3dc477b1bac9a12dbd1ebefba0012a29e026b70d36262601f6aaa","signature":"3b96427184d663a055585324543a58030c9e40c6918c803d46471d2f5cdf7c84"},{"version":"8e675fba3440939c5d7769e464dfd0b9ba29847940cc95d02570a63dec2004de","signature":"daa65f371b57b8a7ee28ae4bc9604b659bb0bf7d6704c13523bc1dd9d80015f2"},{"version":"ca8c7550b5649547839cdf78bafc1289c446e6da62b2e52793e33c1ef6ead380","signature":"77d1bde70b8f35ac0c09f901594135c0fcdaaa70ab6e50bc2a9d9055934eb089"},{"version":"f2f9792d17f61d6268afb32799d62802e2401e7c721a172fa1b313205d4da75d","signature":"737539acff8d7ea0a367055ec1218d8d2b5e60168e107a3ee0961c67fcf186a1"},{"version":"76bd54e58bf7b683ca4f311eca599d314a14714b0b4c60f70ff45e4357509879","signature":"cc5009d26a7ca8bd5e88cd604584049123ebdd1fe682c49d34d7afcddd204603"},{"version":"9176422857f1369ea5c0e7d536eade399a45bfff56811240eb358067882f3172","signature":"49d9c6855bc9d4c4751b82fb966e179bd8a9364d84050ce61c2ac7fbb009af09"},{"version":"ffb383b6abbcc8334c9122e531df75492558f54b3126b3676b927f52c9af8127","signature":"b4014bce1fbe5461bce6836d2cb17f45d22a6bde4f108cbe3754303052e29c8e"},{"version":"3ade81d3c280fbe937f8a007f85e819b5cf139fc5c040784931e92ba013e9a87","signature":"558ffcd74aa40d1cd2c1402a287f3157a6218ecfa7567343dfc3ee084a94f7df"},{"version":"a5ab2e5dffc6fbfb3150bd1610f65be16aceedfb8230129de2574a5bb2bbd96b","signature":"875f9d588470e9bbb3f48aea8e8bfdb41e6b18d679e9c5c2399109b80bac1192"},{"version":"e11126b1d76234244de410c9b739d530ddd43df3cb1d694263517caf107869be","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"a51bfbd9f50b84fd3836d9200d97dc1322344c34b26f7cc8da5f33773a167354","signature":"1b10bc8bc10ecf55564e4428aaeb9c6daf57d4e00191643b373916df62f8198a"},{"version":"2c4e576963fa839840370e29d9f4f3504747a5f229d4f7a59db010b1cd365cf0","signature":"52aa3f512c91442aef74c75ec789fd659c83912d8cd6315513d83b5ce13e57cd"},{"version":"ee558bc4ddedefc2602f6fa938d2afd2c21fe9404f6e5cf705b1e23ee82a6c0d","signature":"ef38fc7498f6f123b36fcb4a4c22f9df96c764ea263417be343d43376416ff73"},"d1986184a09a52db8228cb2bb2a61a8c05c9354e5b93cec8e2628d8579c892d7",{"version":"7871a13d74f840767cecde7920e08cd6d4bf64c9a62ad65be1fa1d67346e6a47","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"}],"root":[535,536,[585,638]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":4,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[637,1],[535,2],[638,3],[587,4],[615,5],[610,6],[611,7],[588,8],[589,8],[590,8],[591,8],[593,8],[594,8],[595,8],[596,8],[597,8],[592,8],[598,8],[599,8],[600,8],[602,8],[601,8],[603,8],[604,8],[605,8],[606,8],[607,8],[609,8],[608,8],[616,9],[620,10],[617,11],[619,12],[618,6],[612,13],[622,14],[621,15],[626,16],[624,11],[625,17],[623,16],[627,11],[628,11],[629,18],[630,6],[631,19],[614,20],[613,9],[633,21],[632,8],[634,6],[635,22],[636,15],[585,23],[536,24],[586,25],[549,26],[560,27],[555,28],[545,29],[554,30],[546,31],[379,2],[544,2],[580,2],[577,2],[576,2],[571,32],[582,33],[567,34],[578,35],[570,36],[569,37],[579,2],[574,38],[581,2],[575,39],[568,2],[566,40],[565,41],[564,34],[584,42],[563,2],[550,43],[540,2],[542,44],[543,45],[143,46],[144,46],[145,47],[97,48],[146,49],[147,50],[148,51],[92,2],[95,52],[93,2],[94,2],[149,53],[150,54],[151,55],[152,56],[153,57],[154,58],[155,58],[156,59],[157,60],[158,61],[159,62],[98,2],[96,2],[160,63],[161,64],[162,65],[196,66],[163,67],[164,2],[165,68],[166,69],[167,70],[168,71],[169,72],[170,73],[171,74],[172,75],[173,76],[174,76],[175,77],[176,2],[177,78],[178,79],[180,80],[179,81],[181,82],[182,83],[183,84],[184,85],[185,86],[186,87],[187,88],[188,89],[189,90],[190,91],[191,92],[192,93],[193,94],[99,2],[100,2],[101,2],[140,95],[141,2],[142,2],[194,96],[195,97],[200,98],[464,99],[201,100],[199,101],[466,102],[465,103],[583,99],[197,104],[462,2],[198,105],[83,2],[85,106],[461,99],[231,99],[547,2],[537,2],[539,107],[538,108],[102,2],[541,2],[553,109],[84,2],[557,2],[556,2],[561,110],[551,111],[548,112],[552,113],[559,114],[558,115],[562,116],[487,117],[492,1],[499,118],[482,119],[235,2],[243,120],[383,121],[386,122],[358,2],[371,123],[378,124],[260,2],[360,2],[241,2],[357,125],[403,126],[242,2],[233,127],[385,128],[387,129],[388,130],[459,131],[352,132],[305,133],[365,134],[366,135],[364,136],[363,2],[359,137],[384,138],[244,139],[429,2],[430,140],[271,141],[245,142],[272,141],[308,141],[211,141],[381,143],[380,2],[370,144],[477,2],[220,2],[498,145],[437,146],[438,147],[434,148],[516,2],[335,2],[439,6],[435,149],[521,150],[520,151],[515,2],[286,2],[338,152],[337,2],[514,153],[436,99],[291,154],[298,155],[300,156],[290,2],[295,157],[297,158],[299,159],[294,160],[292,2],[296,161],[517,2],[513,2],[519,162],[518,2],[289,163],[508,164],[511,165],[279,166],[278,167],[277,168],[524,99],[276,169],[265,2],[526,2],[527,99],[528,170],[203,2],[367,171],[368,172],[369,173],[207,2],[372,2],[227,174],[202,2],[451,99],[209,175],[450,176],[449,177],[440,2],[441,2],[448,2],[443,2],[446,178],[442,2],[444,179],[447,180],[445,179],[240,2],[237,2],[238,141],[392,2],[397,181],[398,182],[396,183],[394,184],[395,185],[390,2],[457,6],[232,6],[486,186],[493,187],[497,188],[326,189],[325,2],[320,2],[473,190],[481,191],[353,192],[354,193],[432,194],[342,2],[455,195],[330,99],[347,196],[458,197],[343,2],[346,198],[344,2],[456,199],[453,200],[452,2],[454,2],[350,2],[428,201],[215,202],[328,203],[332,204],[348,205],[351,206],[340,207],[333,208],[480,209],[406,210],[324,211],[212,212],[479,213],[208,214],[399,215],[391,2],[400,216],[417,217],[389,2],[416,218],[91,2],[411,219],[236,2],[431,220],[407,2],[221,2],[223,2],[362,2],[415,221],[239,2],[263,222],[349,223],[269,224],[329,2],[414,2],[393,2],[419,225],[420,226],[361,2],[422,227],[424,228],[423,229],[373,2],[413,212],[426,230],[323,231],[412,232],[418,233],[248,2],[252,2],[251,2],[250,2],[255,2],[249,2],[258,2],[257,2],[254,2],[253,2],[256,2],[259,234],[247,2],[315,235],[314,2],[319,236],[316,237],[318,238],[321,236],[317,237],[228,239],[307,240],[476,241],[474,2],[503,242],[505,243],[469,244],[504,245],[216,246],[213,246],[246,2],[230,247],[229,248],[225,249],[226,250],[234,251],[262,251],[273,251],[309,252],[274,252],[218,253],[217,2],[313,254],[312,255],[311,256],[310,257],[219,258],[460,259],[261,260],[468,261],[433,262],[463,263],[467,264],[356,265],[355,266],[336,267],[322,268],[304,269],[306,270],[303,271],[425,272],[327,2],[491,2],[224,273],[427,274],[475,275],[334,2],[264,276],[341,277],[339,278],[266,279],[401,280],[470,2],[267,281],[402,281],[489,2],[488,2],[490,2],[472,2],[471,2],[404,282],[331,2],[301,283],[222,284],[280,2],[206,285],[268,2],[495,99],[205,2],[507,286],[288,99],[501,6],[287,287],[484,288],[285,286],[210,2],[509,289],[283,99],[284,99],[275,2],[204,2],[282,290],[281,291],[270,292],[345,75],[405,75],[421,2],[409,293],[408,2],[293,163],[214,2],[302,99],[478,174],[485,294],[86,99],[89,295],[90,296],[87,99],[88,2],[382,297],[377,298],[376,2],[375,299],[374,2],[483,300],[494,301],[496,302],[500,303],[502,304],[506,305],[534,306],[510,306],[533,307],[512,308],[522,309],[523,310],[525,311],[529,312],[532,174],[531,2],[530,109],[573,313],[572,2],[410,314],[81,2],[82,2],[13,2],[14,2],[16,2],[15,2],[2,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[24,2],[3,2],[25,2],[26,2],[4,2],[27,2],[31,2],[28,2],[29,2],[30,2],[32,2],[33,2],[34,2],[5,2],[35,2],[36,2],[37,2],[38,2],[6,2],[42,2],[39,2],[40,2],[41,2],[43,2],[7,2],[44,2],[49,2],[50,2],[45,2],[46,2],[47,2],[48,2],[8,2],[54,2],[51,2],[52,2],[53,2],[55,2],[9,2],[56,2],[57,2],[58,2],[60,2],[59,2],[61,2],[62,2],[10,2],[63,2],[64,2],[65,2],[11,2],[66,2],[67,2],[68,2],[69,2],[70,2],[1,2],[71,2],[72,2],[12,2],[76,2],[74,2],[79,2],[78,2],[73,2],[77,2],[75,2],[80,2],[118,315],[128,316],[117,315],[138,317],[109,318],[108,319],[137,109],[131,320],[136,321],[111,322],[125,323],[110,324],[134,325],[106,326],[105,109],[135,327],[107,328],[112,329],[113,2],[116,329],[103,2],[139,330],[129,331],[120,332],[121,333],[123,334],[119,335],[122,336],[132,109],[114,337],[115,338],[124,339],[104,340],[127,331],[126,329],[130,2],[133,341]],"semanticDiagnosticsPerFile":[[614,[{"start":85,"length":8,"messageText":"Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":117,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":183,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":278,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":352,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":451,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":525,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304}]],[615,[{"start":509,"length":11,"messageText":"Namespace 'global.jest' has no exported member 'SpyInstance'.","category":1,"code":2694},{"start":523,"length":10,"messageText":"Cannot find name 'beforeEach'.","category":1,"code":2304},{"start":555,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":589,"length":9,"messageText":"Cannot find name 'afterEach'.","category":1,"code":2304},{"start":609,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":638,"length":8,"messageText":"Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":673,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1142,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1214,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1289,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1633,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1706,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":2178,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2348,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2406,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2457,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304}]],[619,[{"start":449,"length":11,"messageText":"Namespace 'global.jest' has no exported member 'SpyInstance'.","category":1,"code":2694},{"start":463,"length":10,"messageText":"Cannot find name 'beforeEach'.","category":1,"code":2304},{"start":495,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":529,"length":9,"messageText":"Cannot find name 'afterEach'.","category":1,"code":2304},{"start":549,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":578,"length":8,"messageText":"Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":613,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":751,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":810,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":873,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1014,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1080,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1830,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1900,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1971,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":2104,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2190,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":2507,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2582,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304}]],[622,[{"start":180,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":211,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":223,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":330,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":417,"length":11,"messageText":"Namespace 'global.jest' has no exported member 'SpyInstance'.","category":1,"code":2694},{"start":431,"length":10,"messageText":"Cannot find name 'beforeEach'.","category":1,"code":2304},{"start":452,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":487,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":521,"length":9,"messageText":"Cannot find name 'afterEach'.","category":1,"code":2304},{"start":541,"length":4,"messageText":"Cannot use namespace 'jest' as a value.","category":1,"code":2708},{"start":679,"length":8,"messageText":"Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":711,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":822,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":887,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":955,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1042,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1968,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2042,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2201,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2254,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":2804,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":2898,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304}]],[633,[{"start":288,"length":8,"messageText":"Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":324,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":458,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":528,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":752,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":817,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":889,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1027,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1088,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1259,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1319,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1474,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304},{"start":1575,"length":2,"messageText":"Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`.","category":1,"code":2582},{"start":1707,"length":6,"messageText":"Cannot find name 'expect'.","category":1,"code":2304}]]],"affectedFilesPendingEmit":[638,587,615,610,611,588,589,590,591,593,594,595,596,597,592,598,599,600,602,601,603,604,605,606,607,609,608,616,620,617,619,618,612,622,621,626,624,625,623,627,628,629,630,631,614,613,633,632,634,635,636,585,586],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/worker/package.json b/apps/worker/package.json index 0ba0d4e..fbc73a3 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -18,11 +18,13 @@ "@whiskeysockets/baileys": "7.0.0-rc13", "bullmq": "^5.0.0", "ioredis": "^5.0.0", + "nodemailer": "^8.0.10", "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@types/jest": "^29.0.0", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.0", "@types/qrcode-terminal": "^0.12.2", "dotenv": "^17.4.2", "jest": "^29.0.0", diff --git a/apps/worker/src/core/approval.test.ts b/apps/worker/src/core/approval.test.ts index 7c18ddd..936bd41 100644 --- a/apps/worker/src/core/approval.test.ts +++ b/apps/worker/src/core/approval.test.ts @@ -1,6 +1,9 @@ -import { handleStarReaction } from './approval'; +import { handleReaction } from './approval'; +import { approveMessage } from './approve-message'; import { NormalizedReaction } from '@tower/types'; +jest.mock('./approve-message'); + function makeReaction(overrides: Partial = {}): NormalizedReaction { return { reactorJid: '919876543210@s.whatsapp.net', @@ -12,131 +15,88 @@ function makeReaction(overrides: Partial = {}): NormalizedRe }; } -const adminJids = ['919876543210@s.whatsapp.net']; - function makeMessage(overrides: object = {}) { return { id: 'msg_1', + tenantId: 'tnt_1', status: 'PENDING', - approval: null, - content: 'hello world', - senderName: 'Alice', - sourceGroupId: 'grp_1', - tags: ['#important'], - platform: 'whatsapp', - sourceGroup: { name: 'UP Parivar Dallas', syncRoutesFrom: [] }, + sourceGroup: { platformId: '120363043312345678@g.us' }, ...overrides, }; } -function makePrisma(messageOverrides: object = {}, txCount = 1) { +function makePool(isAdmin: boolean) { return { - message: { findUnique: jest.fn().mockResolvedValue(makeMessage(messageOverrides)) }, - $transaction: jest.fn().mockImplementation(async (fn: any) => - fn({ - message: { updateMany: jest.fn().mockResolvedValue({ count: txCount }) }, - approval: { create: jest.fn().mockResolvedValue({}) }, + get: jest.fn().mockReturnValue({ + groupMetadata: jest.fn().mockResolvedValue({ + participants: [ + { id: '919876543210@s.whatsapp.net', admin: isAdmin ? 'admin' : undefined }, + { id: 'other@s.whatsapp.net', admin: 'superadmin' }, + ], }), - ), - } as any; + }), + }; } -describe('handleStarReaction', () => { - it('returns null for non-star emoji', async () => { - expect(await handleStarReaction(makeReaction({ emoji: '👍' }), adminJids, {} as any)).toBeNull(); - }); +const mockPrisma = { + message: { findUnique: jest.fn() }, + tenantRule: { findMany: jest.fn() }, +}; - it('returns null when reactor is not an admin', async () => { - expect( - await handleStarReaction(makeReaction({ reactorJid: 'stranger@s.whatsapp.net' }), adminJids, {} as any), - ).toBeNull(); +const mockForwardQueue = { add: jest.fn() }; +const mockIndexQueue = { add: jest.fn() }; + +describe('handleReaction', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.message.findUnique.mockResolvedValue(makeMessage()); + (approveMessage as jest.Mock).mockResolvedValue({ forwardJobs: [], indexDoc: {} }); }); it('returns null when message not found', async () => { - const prisma = { message: { findUnique: jest.fn().mockResolvedValue(null) } } as any; - expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); - expect(prisma.message.findUnique).toHaveBeenCalledWith({ - where: { platform_platformMsgId: { platform: 'whatsapp', platformMsgId: 'TARGET_MSG_123' } }, - include: { - approval: true, - sourceGroup: { - include: { syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } } }, - }, - }, - }); + mockPrisma.message.findUnique.mockResolvedValue(null); + expect(await handleReaction(makeReaction(), mockPrisma, makePool(true))).toBeNull(); }); - it('returns null when message status is not PENDING', async () => { - const prisma = { - message: { findUnique: jest.fn().mockResolvedValue(makeMessage({ status: 'REJECTED' })) }, - } as any; - expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); + it('returns null when message is not PENDING', async () => { + mockPrisma.message.findUnique.mockResolvedValue(makeMessage({ status: 'APPROVED' })); + expect(await handleReaction(makeReaction(), mockPrisma, makePool(true))).toBeNull(); }); - it('returns null when approval record already exists', async () => { - const prisma = { - message: { - findUnique: jest.fn().mockResolvedValue(makeMessage({ status: 'APPROVED', approval: { id: 'appr_1' } })), - }, - } as any; - expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); + it('returns null when no reaction rule matches', async () => { + mockPrisma.tenantRule.findMany.mockResolvedValue([]); + expect(await handleReaction(makeReaction(), mockPrisma, makePool(true))).toBeNull(); }); - it('returns null on double-approval race (updateMany count=0)', async () => { - const result = await handleStarReaction(makeReaction(), adminJids, makePrisma({}, 0)); - expect(result).toBeNull(); + it('returns null when reactor is not a group admin', async () => { + mockPrisma.tenantRule.findMany.mockResolvedValue([ + { id: 'r1', matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE', priority: 0 }, + ]); + expect(await handleReaction(makeReaction(), mockPrisma, makePool(false))).toBeNull(); }); - it('returns ApprovalResult with empty forwardJobs and valid indexDoc when no sync routes', async () => { - const result = await handleStarReaction(makeReaction(), adminJids, makePrisma()); + it('approves when reactor is admin and reaction rule matches', async () => { + mockPrisma.tenantRule.findMany.mockResolvedValue([ + { id: 'r1', matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE', priority: 0 }, + ]); + const result = await handleReaction(makeReaction(), mockPrisma, makePool(true)); expect(result).not.toBeNull(); - expect(result!.forwardJobs).toEqual([]); - expect(result!.indexDoc).toMatchObject({ - messageId: 'msg_1', - content: 'hello world', - senderName: 'Alice', - sourceGroupId: 'grp_1', - sourceGroupName: 'UP Parivar Dallas', - tags: ['#important'], - platform: 'whatsapp', - }); - expect(result!.indexDoc.approvedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(approveMessage).toHaveBeenCalledWith('msg_1', 'tnt_1', '919876543210@s.whatsapp.net', mockPrisma); }); - it('returns ForwardJobData for each active sync route', async () => { - const prisma = makePrisma({ - content: 'important announcement', - senderName: 'Bob', - sourceGroup: { - name: 'Source Group', - syncRoutesFrom: [ - { targetGroup: { platformId: '999@g.us', accountId: 'acc_2' } }, - { targetGroup: { platformId: '888@g.us', accountId: null } }, - ], - }, - }); + it('returns null when socket not available', async () => { + mockPrisma.tenantRule.findMany.mockResolvedValue([ + { id: 'r1', matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE', priority: 0 }, + ]); + const pool = { get: jest.fn().mockReturnValue(undefined) }; + expect(await handleReaction(makeReaction(), mockPrisma, pool)).toBeNull(); + }); - const result = await handleStarReaction(makeReaction(), adminJids, prisma); - expect(result!.forwardJobs).toHaveLength(2); - expect(result!.forwardJobs[0]).toMatchObject({ - messageId: 'msg_1', - content: 'important announcement', - sourceGroupName: 'Source Group', - senderName: 'Bob', - toGroupJid: '999@g.us', - fromAccountId: 'acc_2', - }); - expect(result!.forwardJobs[1]).toMatchObject({ - toGroupJid: '888@g.us', - fromAccountId: 'acc_1', - }); - expect(result!.indexDoc).toMatchObject({ - messageId: 'msg_1', - content: 'important announcement', - senderName: 'Bob', - sourceGroupId: 'grp_1', - tags: ['#important'], - platform: 'whatsapp', - }); + it('returns null when approveMessage returns null', async () => { + mockPrisma.tenantRule.findMany.mockResolvedValue([ + { id: 'r1', matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE', priority: 0 }, + ]); + (approveMessage as jest.Mock).mockResolvedValue(null); + expect(await handleReaction(makeReaction(), mockPrisma, makePool(true))).toBeNull(); }); }); diff --git a/apps/worker/src/core/approval.ts b/apps/worker/src/core/approval.ts index 5f9c059..7600d20 100644 --- a/apps/worker/src/core/approval.ts +++ b/apps/worker/src/core/approval.ts @@ -1,80 +1,106 @@ import { NormalizedReaction, ForwardJobData, IndexJobData } from '@tower/types'; +import { createLogger } from '@tower/logger'; +import { matchReactionRules, TenantRuleRow } from '../whatsapp/match-rules'; +import { approveMessage } from './approve-message'; + +const logger = createLogger('approval'); export interface ApprovalResult { forwardJobs: ForwardJobData[]; indexDoc: IndexJobData; } -export async function handleStarReaction( +export async function handleReaction( reaction: NormalizedReaction, - adminJids: string[], prisma: any, + pool: any, + forwardQueue?: any, + indexQueue?: any, ): Promise { - if (reaction.emoji !== '⭐') return null; - if (!adminJids.includes(reaction.reactorJid)) return null; + logger.info({ emoji: reaction.emoji, reactorJid: reaction.reactorJid }, 'Reaction received'); + // Find the target message to resolve tenant const message = await prisma.message.findUnique({ where: { platform_platformMsgId: { - // TODO: derive platform from NormalizedReaction when multi-platform support is added platform: 'whatsapp', platformMsgId: reaction.targetMsgId, }, }, - include: { - approval: true, - sourceGroup: { - include: { - syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } }, - }, - }, - }, + select: { id: true, tenantId: true, status: true, sourceGroup: { select: { platformId: true } } }, }); if (!message) return null; if (message.status !== 'PENDING') return null; - if (message.approval) return null; - let approved = false; - await 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: { - messageId: message.id, - adminId: reaction.reactorJid, - decision: 'APPROVED', - }, - }); + // Load active reaction rules for this tenant + const ruleRows: TenantRuleRow[] = await prisma.tenantRule.findMany({ + where: { tenantId: message.tenantId, isActive: true, matchType: 'REACTION_EMOJI' }, + select: { id: true, matchType: true, matchValue: true, action: true, priority: true }, + orderBy: { priority: 'asc' }, }); - if (!approved) return null; + const matched = matchReactionRules(reaction.emoji, ruleRows); + if (!matched) { + logger.info({ emoji: reaction.emoji }, 'No reaction rule matched — ignoring'); + return null; + } - const forwardJobs: ForwardJobData[] = message.sourceGroup.syncRoutesFrom - .filter((route: any) => route.targetGroup != null) - .map((route: any) => ({ - messageId: message.id, - content: message.content, - sourceGroupName: message.sourceGroup.name, - senderName: message.senderName ?? undefined, - toGroupJid: route.targetGroup.platformId, - fromAccountId: route.targetGroup.accountId ?? reaction.accountId, - })); + logger.info({ emoji: reaction.emoji, action: matched.action }, 'Reaction rule matched'); - const indexDoc: IndexJobData = { - 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(), - }; + // For approval-type actions, verify the reactor is a group admin + if (matched.action === 'AUTO_APPROVE' || matched.action === 'FLAG') { + const sock = pool.get(reaction.accountId); + if (!sock) { + logger.warn({ accountId: reaction.accountId }, 'No active socket for group admin check'); + return null; + } - return { forwardJobs, indexDoc }; + let isAdmin = false; + try { + const metadata = await sock.groupMetadata(reaction.sourceGroupJid); + const participantJids = metadata.participants?.map((p: any) => ({ id: p.id, admin: p.admin })) ?? []; + logger.info({ participantJids, reactorJid: reaction.reactorJid }, 'Group metadata participants'); + + // Compare both with and without server suffix + const reactorBase = reaction.reactorJid.replace(/@.+$/, ''); + isAdmin = metadata.participants?.some( + (p: any) => { + const pBase = p.id.replace(/@.+$/, ''); + return (p.id === reaction.reactorJid || pBase === reactorBase) && + (p.admin === 'admin' || p.admin === 'superadmin'); + }, + ) ?? false; + } catch (err) { + logger.warn({ err, reactorJid: reaction.reactorJid }, 'Failed to check group admin status — rejecting reaction'); + return null; + } + + if (!isAdmin) { + logger.warn({ reactorJid: reaction.reactorJid }, 'Reactor is not a group admin — ignoring reaction'); + return null; + } + } + + // Handle the matched action + if (matched.action === 'SKIP' || matched.action === 'REJECT') { + // For reactions, SKIP and REJECT don't make sense for already-PENDING messages. + // We simply don't act on them. + logger.info({ action: matched.action }, 'Reaction matched SKIP/REJECT — no action taken'); + return null; + } + + // AUTO_APPROVE or FLAG — approve the message (both lead to approval for reactions) + const result = await approveMessage(message.id, message.tenantId, reaction.reactorJid, prisma); + if (!result) { + logger.warn({ messageId: message.id }, 'Could not approve message via reaction'); + return null; + } + + logger.info( + { messageId: message.id, forwardCount: result.forwardJobs.length }, + 'Message approved by reaction', + ); + + return result; } diff --git a/apps/worker/src/core/approve-message.ts b/apps/worker/src/core/approve-message.ts new file mode 100644 index 0000000..efe4131 --- /dev/null +++ b/apps/worker/src/core/approve-message.ts @@ -0,0 +1,86 @@ +import { ForwardJobData, IndexJobData } from '@tower/types'; +import { createLogger } from '@tower/logger'; + +const logger = createLogger('approve-message'); + +export interface ApproveMessageResult { + forwardJobs: ForwardJobData[]; + indexDoc: IndexJobData; +} + +/** + * Approve a PENDING message: mark APPROVED in a transaction, create an Approval row, + * and return the forward jobs + index document. + * Returns null if the message is not in PENDING state or is already approved. + */ +export async function approveMessage( + messageId: string, + tenantId: string, + adminId: string, // JID of the reactor or system + prisma: any, +): Promise { + const message = await prisma.message.findUnique({ + where: { id: messageId }, + include: { + approval: true, + sourceGroup: { + include: { + syncRoutesFrom: { + where: { isActive: true, targetGroup: { isActive: true } }, + include: { targetGroup: { select: { platformId: true, isActive: true, accountId: true } } }, + }, + }, + }, + }, + }); + + if (!message) return null; + if (message.status !== 'PENDING') return null; + if (message.approval) return null; + + let approved = false; + await 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) return null; + + const forwardJobs: ForwardJobData[] = (message.sourceGroup?.syncRoutesFrom ?? []) + .filter((route: any) => route.targetGroup != null) + .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 ?? '', + })); + + 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(), + }; + + return { forwardJobs, indexDoc }; +} diff --git a/apps/worker/src/email/email.service.spec.ts b/apps/worker/src/email/email.service.spec.ts new file mode 100644 index 0000000..1553f28 --- /dev/null +++ b/apps/worker/src/email/email.service.spec.ts @@ -0,0 +1,46 @@ +import { createEmailService, EmailService } from './email.service'; + +describe('email.service', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env['SMTP_HOST']; + delete process.env['SMTP_PORT']; + delete process.env['SMTP_USER']; + delete process.env['SMTP_PASS']; + delete process.env['SMTP_FROM']; + delete process.env['SMTP_SECURE']; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('returns a no-op service when SMTP_HOST is unset', () => { + const service = createEmailService(); + expect(service).toBeDefined(); + expect(typeof service.sendPendingClaimNotification).toBe('function'); + }); + + it('logs but does not throw when no-op service is asked to send', async () => { + const service: EmailService = createEmailService(); + await expect( + service.sendPendingClaimNotification({ + to: 'owner@example.com', + groupName: 'Family Chat', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }), + ).resolves.toBeUndefined(); + }); + + it('creates a transport when SMTP_HOST is set', () => { + process.env['SMTP_HOST'] = 'smtp.ethereal.email'; + process.env['SMTP_PORT'] = '587'; + process.env['SMTP_USER'] = 'user@test'; + process.env['SMTP_PASS'] = 'pass'; + process.env['SMTP_FROM'] = 'TOWER '; + const service = createEmailService(); + expect(service).toBeDefined(); + }); +}); diff --git a/apps/worker/src/email/email.service.ts b/apps/worker/src/email/email.service.ts new file mode 100644 index 0000000..4445702 --- /dev/null +++ b/apps/worker/src/email/email.service.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@tower/logger'; +import nodemailer, { Transporter } from 'nodemailer'; + +const logger = createLogger('email'); + +export interface EmailService { + sendPendingClaimNotification(input: { + to: string; + groupName: string; + expiresAt: Date; + }): Promise; +} + +interface SmtpConfig { + host: string; + port: number; + user?: string; + pass?: string; + from: string; + secure: boolean; +} + +function readSmtpConfig(): SmtpConfig | null { + const host = process.env['SMTP_HOST']?.trim(); + if (!host) return null; + return { + host, + port: Number(process.env['SMTP_PORT'] ?? 587), + user: process.env['SMTP_USER']?.trim() || undefined, + pass: process.env['SMTP_PASS']?.trim() || undefined, + from: process.env['SMTP_FROM']?.trim() || 'TOWER ', + secure: (process.env['SMTP_SECURE'] ?? '').toLowerCase() === 'true', + }; +} + +export function createEmailService(): EmailService { + const cfg = readSmtpConfig(); + if (!cfg) { + logger.warn('SMTP_HOST not set — email notifications disabled'); + return new NoopEmailService(); + } + const transporter: Transporter = nodemailer.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure, + ...(cfg.user ? { auth: { user: cfg.user, pass: cfg.pass ?? '' } } : {}), + }); + logger.info({ host: cfg.host, port: cfg.port, from: cfg.from }, 'SMTP transport ready'); + return new SmtpEmailService(transporter, cfg.from); +} + +class NoopEmailService implements EmailService { + async sendPendingClaimNotification(input: { to: string; groupName: string; expiresAt: Date }): Promise { + logger.info( + { to: input.to, groupName: input.groupName, expiresAt: input.expiresAt.toISOString() }, + '[email-disabled] would send GROUP_PENDING_CLAIM notification', + ); + } +} + +class SmtpEmailService implements EmailService { + constructor( + private readonly transporter: Transporter, + private readonly from: string, + ) {} + + async sendPendingClaimNotification(input: { to: string; groupName: string; expiresAt: Date }): Promise { + const expiresInDays = Math.max(1, Math.ceil((input.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000))); + const subject = `[TOWER] New group awaiting claim: ${input.groupName}`; + const text = [ + `A WhatsApp group joined the TOWER bot and needs to be claimed by a tenant admin.`, + ``, + `Group: ${input.groupName}`, + `Claim by: ${input.expiresAt.toISOString()} (within ${expiresInDays} day${expiresInDays === 1 ? '' : 's'})`, + ``, + `Sign in to your TOWER portal and go to Groups → Pending Claim to take ownership.`, + `If no owner claims it before the deadline, the bot will stop listening and the group will be marked EXPIRED.`, + ].join('\n'); + try { + await this.transporter.sendMail({ from: this.from, to: input.to, subject, text }); + logger.info({ to: input.to, groupName: input.groupName }, 'Sent GROUP_PENDING_CLAIM notification'); + } catch (err) { + logger.error({ err, to: input.to, groupName: input.groupName }, 'Failed to send GROUP_PENDING_CLAIM notification'); + } + } +} diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index dfde1db..dca01f0 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -9,9 +9,12 @@ import { createForwardWorker } from './queues/forward.processor'; import { createIndexQueue } from './queues/index.queue'; import { createIndexWorker } from './queues/index.processor'; import { WhatsAppSessionPool } from './whatsapp/session-pool'; -import { detectTags, isFlagged } from './whatsapp/tag-detector'; +import { matchContentRules, TenantRuleRow } from './whatsapp/match-rules'; import { syncGroups } from './whatsapp/group-sync'; -import { handleStarReaction } from './core/approval'; +import { handleReaction } from './core/approval'; +import { approveMessage } from './core/approve-message'; +import { startOtpSenderLoop } from './whatsapp/otp-sender'; +import { handleCommand } from './whatsapp/command-handler'; const logger = createLogger('tower-worker'); @@ -19,11 +22,6 @@ async function bootstrap() { const env = validateEnv(); const prisma = new PrismaClient(); await prisma.$connect(); - - const adminJids = env.TOWER_ADMIN_JIDS - ? env.TOWER_ADMIN_JIDS.split(',').map((j) => j.trim()).filter(Boolean) - : []; - const meiliClient = createMeiliClient(env.MEILI_URL, env.MEILI_MASTER_KEY); await configureIndex(meiliClient).catch((err) => logger.warn({ err }, 'Failed to configure Meilisearch index — search may be degraded'), @@ -34,7 +32,7 @@ async function bootstrap() { const indexQueue = createIndexQueue(env.REDIS_URL); const pool = new WhatsAppSessionPool(); - const ingestWorker = createIngestWorker(env.REDIS_URL, prisma); + const ingestWorker = createIngestWorker(env.REDIS_URL, prisma, pool, forwardQueue, indexQueue); const forwardWorker = createForwardWorker(env.REDIS_URL, pool); const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient); @@ -54,8 +52,12 @@ async function bootstrap() { account.id, account.sessionPath, async (msg, accountId) => { - const tags = detectTags(msg.content, msg.senderJid, adminJids); - if (!isFlagged(tags)) return; + logger.info({ groupJid: msg.sourceGroupJid, senderJid: msg.senderJid, content: msg.content?.slice(0, 80) }, 'Message received'); + + // Command handler intercepts STOP/START/PORTAL from non-bot members + await handleCommand(msg, accountId, prisma, pool).catch((err) => + logger.error({ err }, 'Command handler error'), + ); const groupMap = groupMaps.get(accountId); if (!groupMap) { @@ -68,9 +70,76 @@ async function bootstrap() { return; } + // Resolve tenant from the group; drop messages from unclaimed groups + const group = await prisma.group.findUnique({ + where: { id: sourceGroupId }, + select: { tenantId: true }, + }); + if (!group || !group.tenantId) { + logger.info({ sourceGroupId }, 'Group not yet claimed — message dropped'); + return; + } + + // Check tenant state + const tenant = await prisma.tenant.findUnique({ + where: { id: group.tenantId }, + select: { isActive: true, isForwardingPaused: true }, + }); + if (!tenant || !tenant.isActive) { + logger.info({ tenantId: group.tenantId, sourceGroupId }, 'Tenant is inactive — message dropped'); + return; + } + if (tenant.isForwardingPaused) { + logger.info({ tenantId: group.tenantId, sourceGroupId }, 'Forwarding paused for tenant — message dropped'); + return; + } + + // Load active rules for this tenant + const ruleRows: TenantRuleRow[] = await prisma.tenantRule.findMany({ + where: { tenantId: group.tenantId, isActive: true }, + select: { id: true, matchType: true, matchValue: true, action: true, priority: true }, + orderBy: { priority: 'asc' }, + }); + + const { tags, effectiveAction } = matchContentRules(msg.content, ruleRows); + logger.info({ tags, effectiveAction }, 'Rule match result'); + + // No matching rules — drop the message + if (tags.length === 0) return; + + // SKIP action — silently drop + if (effectiveAction === 'SKIP') { + logger.info({ platformMsgId: msg.platformMsgId }, 'Message skipped by rule'); + return; + } + + // For AUTO_APPROVE, check if the sender is a group admin + let finalAction = effectiveAction; + if (effectiveAction === 'AUTO_APPROVE') { + try { + const sock = pool.get(accountId); + if (sock) { + const metadata = await sock.groupMetadata(msg.sourceGroupJid); + const isAdmin = metadata.participants?.some( + (p: any) => p.id === msg.senderJid && (p.admin === 'admin' || p.admin === 'superadmin'), + ); + if (isAdmin) { + finalAction = 'AUTO_APPROVE'; + } else { + logger.info({ senderJid: msg.senderJid }, 'Sender is not a group admin — downgrading AUTO_APPROVE to FLAG'); + finalAction = 'FLAG'; + } + } + } catch (err) { + logger.warn({ err, senderJid: msg.senderJid }, 'Failed to check group admin status — downgrading to FLAG'); + finalAction = 'FLAG'; + } + } + await ingestQueue.add( 'ingest', { + tenantId: group.tenantId, platformMsgId: msg.platformMsgId, platform: 'whatsapp', accountId, @@ -79,14 +148,15 @@ async function bootstrap() { senderName: msg.senderName, content: msg.content, tags, + effectiveAction: finalAction ?? undefined, }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } }, ); - logger.info({ platformMsgId: msg.platformMsgId, tags }, 'Message enqueued'); + logger.info({ platformMsgId: msg.platformMsgId, tags, effectiveAction: finalAction }, 'Message enqueued'); }, async (reaction) => { - const result = await handleStarReaction(reaction, adminJids, prisma); + const result = await handleReaction(reaction, prisma, pool, forwardQueue, indexQueue); if (!result) return; const { forwardJobs, indexDoc } = result; @@ -110,13 +180,13 @@ async function bootstrap() { }, async (groups, accountId) => { logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups'); - const map = await syncGroups(groups, accountId, prisma); + const map = await syncGroups(groups, accountId, prisma, pool); groupMaps.set(accountId, map); }, async (qr, accountId) => { await prisma.account.update({ where: { id: accountId }, - data: { qrCode: qr }, + data: { qrCode: qr, status: 'PAIRING' }, }).catch((err) => logger.error({ accountId, err }, 'Failed to store QR in DB')); logger.info({ accountId }, 'QR code updated'); }, @@ -133,6 +203,12 @@ async function bootstrap() { data: { status: 'DISCONNECTED' }, }).catch((err) => logger.error({ accountId, err }, 'Failed to update account status')); logger.info({ accountId }, 'Account logged out — awaiting QR scan'); + } else if (status === 'disconnected') { + await prisma.account.update({ + where: { id: accountId }, + data: { status: 'DISCONNECTED' }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account disconnected'); } }, ); @@ -141,14 +217,14 @@ async function bootstrap() { } } - // Load ACTIVE and DISCONNECTED accounts at startup (DISCONNECTED ones need re-auth) + // Load bot accounts at startup: ACTIVE, DISCONNECTED (need re-auth), PAIRING (mid-pairing) const accounts = await prisma.account.findMany({ - where: { status: { in: ['ACTIVE', 'DISCONNECTED'] }, platform: 'whatsapp' }, + where: { isBot: true, status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] }, platform: 'whatsapp' }, select: { id: true, sessionPath: true }, }); if (accounts.length === 0) { - logger.warn('No WhatsApp accounts found — add one via the dashboard'); + logger.warn('No bot accounts found — pair one via /settings/bot'); } for (const account of accounts) { @@ -157,16 +233,18 @@ async function bootstrap() { logger.info({ accountCount: accounts.length }, 'Tower worker ready'); - // Poll every 30s for accounts added via the dashboard while worker is running + // Poll every 30s for accounts added via the dashboard while worker is running. + // Every 60s, also re-fetch groups for each active session so newly-added groups + // are detected without requiring a worker restart. setInterval(async () => { try { const all = await prisma.account.findMany({ - where: { status: { in: ['ACTIVE', 'DISCONNECTED'] }, platform: 'whatsapp' }, + where: { isBot: true, status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] }, platform: 'whatsapp' }, select: { id: true, sessionPath: true }, }); for (const account of all) { if (!pool.get(account.id)) { - logger.info({ accountId: account.id }, 'New account detected — starting session'); + logger.info({ accountId: account.id }, 'New bot account detected — starting session'); await startAccount(account); } } @@ -175,6 +253,26 @@ async function bootstrap() { } }, 30_000); + setInterval(async () => { + try { + for (const [accountId, sock] of pool.getAll()) { + try { + const groups = await sock.groupFetchAllParticipating(); + logger.info({ count: Object.keys(groups).length, accountId }, 'Periodic group re-sync'); + const map = await syncGroups(groups, accountId, prisma, pool); + groupMaps.set(accountId, map); + } catch (err) { + logger.warn({ accountId, err }, 'Periodic group re-sync failed'); + } + } + } catch (err) { + logger.error({ err }, 'Error in periodic group re-sync loop'); + } + }, 60_000); + + // OTP-sender loop: pick up unsent OtpChallenges and DM them via the bot + startOtpSenderLoop(prisma, pool, logger); + const shutdown = async () => { logger.info('Shutting down...'); await pool.closeAll(); diff --git a/apps/worker/src/queues/forward.processor.test.ts b/apps/worker/src/queues/forward.processor.test.ts index f93c822..18f64fb 100644 --- a/apps/worker/src/queues/forward.processor.test.ts +++ b/apps/worker/src/queues/forward.processor.test.ts @@ -4,6 +4,7 @@ import { ForwardJobData } from '@tower/types'; const mockPool = { sendMessage: jest.fn().mockResolvedValue(undefined) }; const baseJob: ForwardJobData = { + tenantId: 'tnt_1', messageId: 'msg_1', content: 'Event this Saturday at the temple', sourceGroupName: 'UP Parivar Dallas', diff --git a/apps/worker/src/queues/index.processor.test.ts b/apps/worker/src/queues/index.processor.test.ts index 139180a..b1407f7 100644 --- a/apps/worker/src/queues/index.processor.test.ts +++ b/apps/worker/src/queues/index.processor.test.ts @@ -9,6 +9,7 @@ jest.mock('@tower/search', () => ({ function makeJob(overrides: Partial = {}): IndexJobData { return { + tenantId: 'tnt-1', messageId: 'msg-1', content: 'hello world', senderName: 'Alice', @@ -24,11 +25,12 @@ function makeJob(overrides: Partial = {}): IndexJobData { describe('processIndexJob', () => { beforeEach(() => jest.clearAllMocks()); - it('calls indexMessage with MeiliDocument shape', async () => { + it('calls indexMessage with MeiliDocument shape including tenantId', async () => { const mockClient = {} as any; await processIndexJob(makeJob(), mockClient); expect(indexMessage).toHaveBeenCalledWith(mockClient, { id: 'msg-1', + tenantId: 'tnt-1', content: 'hello world', senderName: 'Alice', sourceGroupId: 'grp-1', diff --git a/apps/worker/src/queues/index.processor.ts b/apps/worker/src/queues/index.processor.ts index e49d554..9e79fc8 100644 --- a/apps/worker/src/queues/index.processor.ts +++ b/apps/worker/src/queues/index.processor.ts @@ -10,6 +10,7 @@ export async function processIndexJob(job: IndexJobData, meiliClient: MeiliSearc } const doc: MeiliDocument = { id: job.messageId, + tenantId: job.tenantId, content: job.content, senderName: job.senderName ?? '', sourceGroupId: job.sourceGroupId, diff --git a/apps/worker/src/queues/ingest.processor.test.ts b/apps/worker/src/queues/ingest.processor.test.ts index 33dd3e4..ee4b91f 100644 --- a/apps/worker/src/queues/ingest.processor.test.ts +++ b/apps/worker/src/queues/ingest.processor.test.ts @@ -1,17 +1,24 @@ import { processIngestJob } from './ingest.processor'; import { IngestJobData } from '@tower/types'; +import { approveMessage } from '../core/approve-message'; + +jest.mock('../core/approve-message'); const mockPrisma = { - message: { - upsert: jest.fn(), - }, + message: { upsert: jest.fn(), findUnique: jest.fn() }, + group: { findUnique: jest.fn() }, + tenant: { findUnique: jest.fn() }, + memberOptOut: { findFirst: jest.fn() }, + towerUser: { upsert: jest.fn() }, + approval: { upsert: jest.fn() }, }; const sampleJob: IngestJobData = { + tenantId: 'tnt-1', platformMsgId: 'WA_MSG_001', platform: 'whatsapp', accountId: 'account-1', - sourceGroupId: 'clxxxxxx', + sourceGroupId: 'grp-1', senderJid: '919876543210@s.whatsapp.net', senderName: 'Alice', content: '#important update from the committee', @@ -19,21 +26,40 @@ const sampleJob: IngestJobData = { }; describe('processIngestJob', () => { - beforeEach(() => jest.clearAllMocks()); - - it('upserts a message with PENDING status', async () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', claimStatus: 'CLAIMED' }); + mockPrisma.tenant.findUnique.mockResolvedValue({ isActive: true, isForwardingPaused: false }); + mockPrisma.memberOptOut.findFirst.mockResolvedValue(null); + mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' }); mockPrisma.message.upsert.mockResolvedValue({ id: 'msg-db-id' }); + (approveMessage as jest.Mock).mockResolvedValue({ forwardJobs: [], indexDoc: {} }); + }); + it('drops message when group is not CLAIMED', async () => { + mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', claimStatus: 'PENDING_CLAIM' }); await processIngestJob(sampleJob, mockPrisma as any); + expect(mockPrisma.message.upsert).not.toHaveBeenCalled(); + }); + it('drops message when sender has opted out', async () => { + mockPrisma.memberOptOut.findFirst.mockResolvedValue({ id: 'opt-1' }); + await processIngestJob(sampleJob, mockPrisma as any); + expect(mockPrisma.message.upsert).not.toHaveBeenCalled(); + }); + + it('upserts a message with PENDING status by default', async () => { + await processIngestJob(sampleJob, mockPrisma as any); expect(mockPrisma.message.upsert).toHaveBeenCalledWith({ where: { platform_platformMsgId: { platform: 'whatsapp', platformMsgId: 'WA_MSG_001' } }, create: { + tenantId: 'tnt-1', platform: 'whatsapp', platformMsgId: 'WA_MSG_001', - sourceGroupId: 'clxxxxxx', + sourceGroupId: 'grp-1', senderJid: '919876543210@s.whatsapp.net', senderName: 'Alice', + senderTowerUserId: 'user-1', content: '#important update from the committee', tags: ['#important'], status: 'PENDING', @@ -51,4 +77,35 @@ describe('processIngestJob', () => { mockPrisma.message.upsert.mockRejectedValue(new Error('DB connection lost')); await expect(processIngestJob(sampleJob, mockPrisma as any)).rejects.toThrow('DB connection lost'); }); + + it('creates REJECTED message when effectiveAction is REJECT', async () => { + mockPrisma.message.upsert.mockResolvedValue({ id: 'msg-rejected' }); + await processIngestJob({ ...sampleJob, effectiveAction: 'REJECT' }, mockPrisma as any); + expect(mockPrisma.message.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ status: 'REJECTED' }), + }), + ); + expect(mockPrisma.approval.upsert).toHaveBeenCalledWith({ + where: { messageId: 'msg-rejected' }, + update: {}, + create: { + tenantId: 'tnt-1', + messageId: 'msg-rejected', + adminId: 'system', + decision: 'REJECTED', + }, + }); + }); + + it('calls approveMessage when effectiveAction is AUTO_APPROVE', async () => { + mockPrisma.message.upsert.mockResolvedValue({ id: 'msg-auto' }); + await processIngestJob({ ...sampleJob, effectiveAction: 'AUTO_APPROVE' }, mockPrisma as any); + expect(approveMessage).toHaveBeenCalledWith('msg-auto', 'tnt-1', 'system', mockPrisma); + }); + + it('does not call approveMessage for FLAG (default)', async () => { + await processIngestJob(sampleJob, mockPrisma as any); + expect(approveMessage).not.toHaveBeenCalled(); + }); }); diff --git a/apps/worker/src/queues/ingest.processor.ts b/apps/worker/src/queues/ingest.processor.ts index 26cb1b2..19a543c 100644 --- a/apps/worker/src/queues/ingest.processor.ts +++ b/apps/worker/src/queues/ingest.processor.ts @@ -1,9 +1,71 @@ -import { Worker } from 'bullmq'; -import { IngestJobData } from '@tower/types'; +import { Worker, Queue } from 'bullmq'; +import { IngestJobData, ForwardJobData, IndexJobData } from '@tower/types'; import { parseRedisUrl } from './redis-connection'; +import { approveMessage } from '../core/approve-message'; +import { createLogger } from '@tower/logger'; -export async function processIngestJob(job: IngestJobData, prisma: any): Promise { - await prisma.message.upsert({ +const logger = createLogger('ingest-processor'); + +export async function processIngestJob( + job: IngestJobData, + prisma: any, + pool?: any, + forwardQueue?: Queue, + indexQueue?: Queue, +): Promise { + // Defensive: drop messages from non-CLAIMED groups + const group = await prisma.group.findUnique({ + where: { id: job.sourceGroupId }, + select: { claimStatus: true }, + }); + if (!group || group.claimStatus !== 'CLAIMED') { + return; + } + + // Safety net: drop if tenant is inactive or paused + const tenant = await prisma.tenant.findUnique({ + where: { id: job.tenantId }, + select: { isActive: true, isForwardingPaused: true }, + }); + if (!tenant || !tenant.isActive || tenant.isForwardingPaused) { + return; + } + + // If the sender has opted out of this group, drop the message + const phoneHash = `jid:${job.senderJid}`; + const optOut = await prisma.memberOptOut.findFirst({ + where: { + tenantId: job.tenantId, + groupId: job.sourceGroupId, + user: { jid: job.senderJid, phoneHash }, + }, + select: { id: true }, + }); + if (optOut) { + return; + } + + // Resolve or create TowerUser for sender + const user = await prisma.towerUser.upsert({ + where: { tenantId_phoneHash: { tenantId: job.tenantId, phoneHash } }, + update: {}, + create: { + tenantId: job.tenantId, + phoneHash, + jid: job.senderJid, + displayName: job.senderName ?? job.senderJid, + }, + }); + + // Determine the initial status based on effectiveAction + let initialStatus: string; + if (job.effectiveAction === 'REJECT') { + initialStatus = 'REJECTED'; + } else { + initialStatus = 'PENDING'; + } + + const msg = await prisma.message.upsert({ where: { platform_platformMsgId: { platform: job.platform, @@ -11,23 +73,70 @@ export async function processIngestJob(job: IngestJobData, prisma: any): Promise }, }, create: { + tenantId: job.tenantId, platform: job.platform, platformMsgId: job.platformMsgId, sourceGroupId: job.sourceGroupId, senderJid: job.senderJid, senderName: job.senderName, + senderTowerUserId: user.id, content: job.content, tags: job.tags, - status: 'PENDING', + status: initialStatus, }, update: {}, }); + + // For REJECT, create an approval record so it's searchable as rejected + if (job.effectiveAction === 'REJECT') { + await prisma.approval.upsert({ + where: { messageId: msg.id }, + update: {}, + create: { + tenantId: job.tenantId, + messageId: msg.id, + adminId: 'system', + decision: 'REJECTED', + }, + }); + logger.info({ messageId: msg.id }, 'Message rejected by rule'); + return; + } + + // For AUTO_APPROVE, immediately approve via approveMessage helper + if (job.effectiveAction === 'AUTO_APPROVE') { + const result = await approveMessage(msg.id, job.tenantId, 'system', prisma); + if (result) { + if (forwardQueue) { + for (const fwd of result.forwardJobs) { + await forwardQueue.add('forward', fwd, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + }); + } + } + if (indexQueue) { + await indexQueue.add('index', result.indexDoc, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + }); + } + logger.info({ messageId: msg.id, forwardCount: result.forwardJobs.length }, 'Message auto-approved'); + } + return; + } } -export function createIngestWorker(redisUrl: string, prisma: any): Worker { +export function createIngestWorker( + redisUrl: string, + prisma: any, + pool?: any, + forwardQueue?: Queue, + indexQueue?: Queue, +): Worker { return new Worker( 'tower-ingest', - async (job) => processIngestJob(job.data, prisma), + async (job) => processIngestJob(job.data, prisma, pool, forwardQueue, indexQueue), { connection: parseRedisUrl(redisUrl) }, ); } diff --git a/apps/worker/src/whatsapp/command-handler.test.ts b/apps/worker/src/whatsapp/command-handler.test.ts new file mode 100644 index 0000000..4427710 --- /dev/null +++ b/apps/worker/src/whatsapp/command-handler.test.ts @@ -0,0 +1,124 @@ +import { handleCommand } from './command-handler'; +import type { NormalizedMessage } from '@tower/types'; + +function makeMsg(overrides: Partial = {}): NormalizedMessage { + return { + platformMsgId: 'WA_MSG_001', + sourceGroupJid: '120363043312345678@g.us', + senderJid: '919876543210@s.whatsapp.net', + senderName: 'Alice', + content: '', + accountId: 'acc-1', + ...overrides, + }; +} + +const mockPrisma: any = { + group: { findUnique: jest.fn() }, + towerUser: { upsert: jest.fn() }, + consentRecord: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() }, + memberOptOut: { create: jest.fn() }, + auditEvent: { create: jest.fn() }, +}; + +const mockPool: any = { + sendMessage: jest.fn().mockResolvedValue(undefined), +}; + +describe('handleCommand', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns false for non-command messages', async () => { + const handled = await handleCommand(makeMsg({ content: 'hello world' }), 'acc-1', mockPrisma, mockPool); + expect(handled).toBe(false); + expect(mockPool.sendMessage).not.toHaveBeenCalled(); + }); + + describe('STOP', () => { + it('creates opt-out and revokes consent for claimed group', async () => { + mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1', claimStatus: 'CLAIMED' }); + mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' }); + mockPrisma.consentRecord.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.memberOptOut.create.mockResolvedValue({}); + mockPrisma.auditEvent.create.mockResolvedValue({}); + + const handled = await handleCommand( + makeMsg({ content: 'STOP' }), + 'acc-1', + mockPrisma, + mockPool, + ); + expect(handled).toBe(true); + expect(mockPrisma.consentRecord.updateMany).toHaveBeenCalled(); + expect(mockPrisma.memberOptOut.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ reason: 'STOP_KEYWORD' }) }), + ); + expect(mockPool.sendMessage).toHaveBeenCalledWith( + 'acc-1', + '919876543210@s.whatsapp.net', + expect.stringContaining("opted out"), + ); + }); + + it('is a no-op for non-claimed groups', async () => { + mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', claimStatus: 'PENDING_CLAIM' }); + const handled = await handleCommand( + makeMsg({ content: 'STOP' }), + 'acc-1', + mockPrisma, + mockPool, + ); + expect(handled).toBe(false); + expect(mockPrisma.consentRecord.updateMany).not.toHaveBeenCalled(); + }); + }); + + describe('START', () => { + it('re-grants default scopes', async () => { + mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1', claimStatus: 'CLAIMED' }); + mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' }); + mockPrisma.consentRecord.findFirst.mockResolvedValue({ + id: 'c-1', + scopes: ['INGEST'], + retentionDays: 90, + }); + mockPrisma.consentRecord.update.mockResolvedValue({}); + + const handled = await handleCommand( + makeMsg({ content: 'start' }), + 'acc-1', + mockPrisma, + mockPool, + ); + expect(handled).toBe(true); + expect(mockPrisma.consentRecord.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'c-1' }, + data: expect.objectContaining({ status: 'GRANTED' }), + }), + ); + }); + }); + + describe('PORTAL', () => { + it('sends an onboarding link', async () => { + mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp-1', tenantId: 'tnt-1', claimStatus: 'CLAIMED' }); + mockPrisma.towerUser.upsert.mockResolvedValue({ id: 'user-1' }); + + const handled = await handleCommand( + makeMsg({ content: 'portal' }), + 'acc-1', + mockPrisma, + mockPool, + ); + expect(handled).toBe(true); + expect(mockPool.sendMessage).toHaveBeenCalledWith( + 'acc-1', + '919876543210@s.whatsapp.net', + expect.stringContaining('/onboard?token='), + ); + }); + }); +}); diff --git a/apps/worker/src/whatsapp/command-handler.ts b/apps/worker/src/whatsapp/command-handler.ts new file mode 100644 index 0000000..7a02b61 --- /dev/null +++ b/apps/worker/src/whatsapp/command-handler.ts @@ -0,0 +1,232 @@ +import type { NormalizedMessage } from '@tower/types'; +import { createLogger } from '@tower/logger'; +import { WhatsAppSessionPool } from './session-pool'; + +const logger = createLogger('command-handler'); + +const PORTAL_BASE = process.env['TOWER_PORTAL_BASE_URL'] ?? 'http://localhost:3000'; + +const STOP_REGEX = /^\s*stop\s*$/i; +const START_REGEX = /^\s*start\s*$/i; +const PORTAL_REGEX = /^\s*portal\s*$/i; +const COMMANDS_REGEX = /^\s*commands\s*$/i; + +export async function handleCommand( + msg: NormalizedMessage, + accountId: string, + prisma: any, + pool: WhatsAppSessionPool, +): Promise { + if (!msg.content) return false; + const text = msg.content.trim(); + + if (STOP_REGEX.test(text)) { + return await handleStop(msg, accountId, prisma, pool); + } + if (START_REGEX.test(text)) { + return await handleStart(msg, accountId, prisma, pool); + } + if (PORTAL_REGEX.test(text)) { + return await handlePortal(msg, accountId, prisma, pool); + } + if (COMMANDS_REGEX.test(text)) { + return await handleCommands(msg, accountId, pool); + } + return false; +} + +async function handleStop( + msg: NormalizedMessage, + accountId: string, + prisma: any, + pool: WhatsAppSessionPool, +): Promise { + const group = await prisma.group.findUnique({ + where: { platform_platformId: { platform: 'whatsapp', platformId: msg.sourceGroupJid } }, + select: { id: true, tenantId: true, claimStatus: true }, + }); + if (!group || group.claimStatus !== 'CLAIMED' || !group.tenantId) { + return false; + } + // Find or create a TowerUser by jid (phone not yet known for STOP-only flow) + const phoneHash = `stop:${msg.senderJid}`; + const user = await prisma.towerUser.upsert({ + where: { tenantId_phoneHash: { tenantId: group.tenantId, phoneHash } }, + update: { jid: msg.senderJid }, + create: { + tenantId: group.tenantId, + phoneHash, + jid: msg.senderJid, + displayName: msg.senderName ?? msg.senderJid, + }, + }); + // Revoke all active consents in this group + await prisma.consentRecord.updateMany({ + where: { userId: user.id, tenantId: group.tenantId, groupId: group.id, status: 'GRANTED' }, + data: { status: 'REVOKED', revokedAt: new Date() }, + }); + await prisma.memberOptOut.create({ + data: { + tenantId: group.tenantId, + userId: user.id, + groupId: group.id, + reason: 'STOP_KEYWORD', + }, + }); + await prisma.auditEvent.create({ + data: { + tenantId: group.tenantId, + actorType: 'MEMBER', + actorId: user.id, + action: 'MEMBER_OPT_OUT', + resourceType: 'TowerUser', + resourceId: user.id, + payload: { jid: msg.senderJid, groupId: group.id, reason: 'STOP_KEYWORD' }, + }, + }); + try { + await pool.sendMessage( + accountId, + msg.senderJid, + "You've been opted out. Type START in this group to rejoin.", + ); + } catch (err) { + logger.warn({ err, jid: msg.senderJid }, 'Failed to send STOP confirmation DM'); + } + logger.info({ jid: msg.senderJid, groupId: group.id }, 'STOP processed'); + return true; +} + +async function handleStart( + msg: NormalizedMessage, + accountId: string, + prisma: any, + pool: WhatsAppSessionPool, +): Promise { + const group = await prisma.group.findUnique({ + where: { platform_platformId: { platform: 'whatsapp', platformId: msg.sourceGroupJid } }, + select: { id: true, tenantId: true, claimStatus: true }, + }); + if (!group || group.claimStatus !== 'CLAIMED' || !group.tenantId) { + return false; + } + const phoneHash = `stop:${msg.senderJid}`; + const user = await prisma.towerUser.upsert({ + where: { tenantId_phoneHash: { tenantId: group.tenantId, phoneHash } }, + update: { jid: msg.senderJid }, + create: { + tenantId: group.tenantId, + phoneHash, + jid: msg.senderJid, + displayName: msg.senderName ?? msg.senderJid, + }, + }); + const existing = await prisma.consentRecord.findFirst({ + where: { userId: user.id, tenantId: group.tenantId, groupId: group.id }, + }); + if (existing) { + await prisma.consentRecord.update({ + where: { id: existing.id }, + data: { + status: 'GRANTED', + scopes: existing.scopes.length > 0 ? existing.scopes : ['INGEST', 'DISPLAY'], + retentionDays: existing.retentionDays, + revokedAt: null, + effectiveAt: new Date(), + }, + }); + } else { + await prisma.consentRecord.create({ + data: { + tenantId: group.tenantId, + groupId: group.id, + userId: user.id, + scopes: ['INGEST', 'DISPLAY'], + retentionDays: 90, + policyVersion: 'v1', + status: 'GRANTED', + proofEventId: user.id, + }, + }); + } + try { + await pool.sendMessage( + accountId, + msg.senderJid, + "Welcome back. Your default scopes (INGEST, DISPLAY) are re-granted. Visit your portal to customize: " + + `${PORTAL_BASE}/my`, + ); + } catch (err) { + logger.warn({ err, jid: msg.senderJid }, 'Failed to send START confirmation DM'); + } + logger.info({ jid: msg.senderJid, groupId: group.id }, 'START processed'); + return true; +} + +async function handlePortal( + msg: NormalizedMessage, + accountId: string, + prisma: any, + pool: WhatsAppSessionPool, +): Promise { + const group = await prisma.group.findUnique({ + where: { platform_platformId: { platform: 'whatsapp', platformId: msg.sourceGroupJid } }, + select: { id: true, tenantId: true, claimStatus: true }, + }); + if (!group || group.claimStatus !== 'CLAIMED' || !group.tenantId) { + return false; + } + // Reuse same phoneHash scheme as STOP/START so the same user is found + const phoneHash = `stop:${msg.senderJid}`; + const user = await prisma.towerUser.upsert({ + where: { tenantId_phoneHash: { tenantId: group.tenantId, phoneHash } }, + update: { jid: msg.senderJid }, + create: { + tenantId: group.tenantId, + phoneHash, + jid: msg.senderJid, + displayName: msg.senderName ?? msg.senderJid, + }, + }); + const onboardingToken = await issueOnboardingToken(prisma, group.tenantId, group.id, msg.senderJid); + try { + await pool.sendMessage( + accountId, + msg.senderJid, + `Manage your data: ${PORTAL_BASE}/onboard?token=${onboardingToken}`, + ); + } catch (err) { + logger.warn({ err, jid: msg.senderJid }, 'Failed to send PORTAL link DM'); + } + return true; +} + +async function handleCommands( + msg: NormalizedMessage, + accountId: string, + pool: WhatsAppSessionPool, +): Promise { + try { + await pool.sendMessage( + accountId, + msg.senderJid, + 'TOWER commands: STOP (opt out), START (rejoin), PORTAL (get your data link).', + ); + } catch (err) { + logger.warn({ err, jid: msg.senderJid }, 'Failed to send COMMANDS reply'); + } + return true; +} + +async function issueOnboardingToken( + prisma: any, + tenantId: string, + groupId: string, + jid: string, +): Promise { + // For Phase 2B the onboarding token is the base64url({groupId, jid, tenantId}) shape. + // The API re-verifies this token (decodes the payload) and trusts the OTP step + // for actual authentication. In a future phase we can sign with JWT_SECRET here. + const payload = { tenantId, groupId, jid }; + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} diff --git a/apps/worker/src/whatsapp/group-sync.test.ts b/apps/worker/src/whatsapp/group-sync.test.ts index 7488503..35e8cbd 100644 --- a/apps/worker/src/whatsapp/group-sync.test.ts +++ b/apps/worker/src/whatsapp/group-sync.test.ts @@ -1,45 +1,48 @@ import { syncGroups } from './group-sync'; import { GroupMetadata } from '@whiskeysockets/baileys'; +const makeGroup = (id: string, name: string, desc?: string, participants?: any[]): GroupMetadata => ({ + id, + subject: name, + desc, + participants: participants ?? [], + creation: 0, + owner: undefined, + restrict: false, + announce: false, + subjectOwner: undefined, + subjectTime: 0, + size: 0, + ephemeralDuration: 0, + inviteCode: undefined, +}); + const mockGroups: Record = { - '120363043312345678@g.us': { - id: '120363043312345678@g.us', - subject: 'UP Parivar Dallas', - desc: 'Main community group', - participants: [], - creation: 0, - owner: undefined, - restrict: false, - announce: false, - subjectOwner: undefined, - subjectTime: 0, - size: 0, - ephemeralDuration: 0, - inviteCode: undefined, - }, - '999999999@g.us': { - id: '999999999@g.us', - subject: 'Events Committee', - desc: undefined, - participants: [], - creation: 0, - owner: undefined, - restrict: false, - announce: false, - subjectOwner: undefined, - subjectTime: 0, - size: 0, - ephemeralDuration: 0, - inviteCode: undefined, - }, + '120363043312345678@g.us': makeGroup('120363043312345678@g.us', 'UP Parivar Dallas', 'Main community group', [ + { id: 'superadmin@s.whatsapp.net', admin: 'superadmin' }, + { id: 'admin@s.whatsapp.net', admin: 'admin' }, + ]), + '999999999@g.us': makeGroup('999999999@g.us', 'Events Committee', undefined, [ + { id: 'creator@s.whatsapp.net', admin: 'superadmin' }, + ]), }; const mockPrisma = { group: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), upsert: jest.fn(), + update: jest.fn(), }, + tenant: { findFirst: jest.fn().mockResolvedValue({ id: 'tenant-system' }) }, + groupClaimToken: { create: jest.fn().mockResolvedValue({ id: 'tok_1' }) }, + auditEvent: { create: jest.fn().mockResolvedValue(undefined) }, }; +const mockPool = { + sendMessage: jest.fn().mockResolvedValue(undefined), +} as any; + describe('syncGroups', () => { beforeEach(() => jest.clearAllMocks()); @@ -48,59 +51,187 @@ describe('syncGroups', () => { .mockResolvedValueOnce({ id: 'db-group-1' }) .mockResolvedValueOnce({ id: 'db-group-2' }); - const result = await syncGroups(mockGroups, 'account-1', mockPrisma as any); + const result = await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool); expect(mockPrisma.group.upsert).toHaveBeenCalledTimes(2); expect(result.get('120363043312345678@g.us')).toBe('db-group-1'); expect(result.get('999999999@g.us')).toBe('db-group-2'); }); - it('calls upsert with correct create payload', async () => { + it('upserts with no claimStatus, isActive, and accountId', async () => { mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); await syncGroups( { '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] }, 'account-1', mockPrisma as any, - ); - - expect(mockPrisma.group.upsert).toHaveBeenCalledWith({ - where: { platform_platformId: { platform: 'whatsapp', platformId: '120363043312345678@g.us' } }, - create: { - platform: 'whatsapp', - platformId: '120363043312345678@g.us', - name: 'UP Parivar Dallas', - description: 'Main community group', - isActive: true, - accountId: 'account-1', - }, - update: { - name: 'UP Parivar Dallas', - description: 'Main community group', - accountId: 'account-1', - }, - }); - }); - - it('handles groups with no description', async () => { - mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-2' }); - - await syncGroups( - { '999999999@g.us': mockGroups['999999999@g.us'] }, - 'account-1', - mockPrisma as any, + mockPool, ); expect(mockPrisma.group.upsert).toHaveBeenCalledWith( expect.objectContaining({ - create: expect.objectContaining({ description: undefined, accountId: 'account-1' }), + where: { platform_platformId: { platform: 'whatsapp', platformId: '120363043312345678@g.us' } }, + create: expect.objectContaining({ + isActive: true, + accountId: 'account-1', + }), + update: expect.objectContaining({ + name: 'UP Parivar Dallas', + accountId: 'account-1', + }), + }), + ); + // No claimStatus or claimExpiresAt in upsert + expect(mockPrisma.group.upsert.mock.calls[0][0].create).not.toHaveProperty('claimStatus'); + }); + + it('sends intro message AND DMs claim link to superadmin on new group', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + + await syncGroups( + { '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] }, + 'account-1', + mockPrisma as any, + mockPool, + ); + + // Intro message to group + expect(mockPool.sendMessage).toHaveBeenCalledWith( + 'account-1', + '120363043312345678@g.us', + expect.stringContaining("I'm TOWER"), + ); + + // Claim link DM to superadmin + expect(mockPool.sendMessage).toHaveBeenCalledWith( + 'account-1', + 'superadmin@s.whatsapp.net', + expect.stringContaining('tower.app/claim-group?token='), + ); + }); + + it('generates GroupClaimToken for new group', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + + await syncGroups( + { '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] }, + 'account-1', + mockPrisma as any, + mockPool, + ); + + expect(mockPrisma.groupClaimToken.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + groupId: 'db-group-1', + creatorJid: 'superadmin@s.whatsapp.net', + }), }), ); }); - it('returns an empty map when given empty groups', async () => { - const result = await syncGroups({}, 'account-1', mockPrisma as any); + it('emits GROUP_CLAIM_TOKEN_SENT audit event', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + + await syncGroups( + { '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] }, + 'account-1', + mockPrisma as any, + mockPool, + ); + + expect(mockPrisma.auditEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'GROUP_CLAIM_TOKEN_SENT', + resourceType: 'Group', + resourceId: 'db-group-1', + actorType: 'SYSTEM', + }), + }), + ); + }); + + it('does NOT send claim link when group is already claimed (bot re-added)', async () => { + mockPrisma.group.findUnique.mockResolvedValue({ id: 'db-group-1', tenantId: 'tnt-A', accountId: 'old-account' }); + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + + await syncGroups( + { '120363043312345678@g.us': mockGroups['120363043312345678@g.us'] }, + 'account-1', + mockPrisma as any, + mockPool, + ); + + // Intro still sent + expect(mockPool.sendMessage).toHaveBeenCalledWith( + 'account-1', + '120363043312345678@g.us', + expect.any(String), + ); + + // But no token generated + expect(mockPrisma.groupClaimToken.create).not.toHaveBeenCalled(); + }); + + it('returns empty map when given empty groups', async () => { + const result = await syncGroups({}, 'account-1', mockPrisma as any, mockPool); expect(result.size).toBe(0); expect(mockPrisma.group.upsert).not.toHaveBeenCalled(); }); + + // --- removal / re-add detection ------------------------------------------- + + it('marks groups inactive and emits GROUP_BOT_REMOVED when bot is removed', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-current' }); + mockPrisma.group.findMany + .mockResolvedValueOnce([{ id: 'db-group-stale', platformId: 'old-group@g.us', tenantId: 'tnt-A', name: 'Old Group' }]) + .mockResolvedValueOnce([]); + + await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool); + + expect(mockPrisma.group.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'db-group-stale' }, data: { isActive: false } }), + ); + expect(mockPrisma.auditEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'GROUP_BOT_REMOVED', + tenantId: 'tnt-A', + resourceId: 'db-group-stale', + }), + }), + ); + }); + + it('marks groups active and emits GROUP_BOT_RE_ADDED when bot is re-added', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + mockPrisma.group.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'db-group-returned', platformId: '120363043312345678@g.us', tenantId: 'tnt-A', name: 'Returned Group' }]); + + await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool); + + expect(mockPrisma.group.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'db-group-returned' }, data: { isActive: true } }), + ); + expect(mockPrisma.auditEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'GROUP_BOT_RE_ADDED', + tenantId: 'tnt-A', + resourceId: 'db-group-returned', + }), + }), + ); + }); + + it('does not touch any groups when all current groups are accounted for', async () => { + mockPrisma.group.upsert.mockResolvedValue({ id: 'db-group-1' }); + mockPrisma.group.findMany.mockResolvedValue([]); + + await syncGroups(mockGroups, 'account-1', mockPrisma as any, mockPool); + + expect(mockPrisma.group.update).not.toHaveBeenCalled(); + }); }); diff --git a/apps/worker/src/whatsapp/group-sync.ts b/apps/worker/src/whatsapp/group-sync.ts index 11dfc5b..244a7ad 100644 --- a/apps/worker/src/whatsapp/group-sync.ts +++ b/apps/worker/src/whatsapp/group-sync.ts @@ -1,16 +1,32 @@ +import { randomBytes } from 'crypto'; import { GroupMetadata } from '@whiskeysockets/baileys'; import { createLogger } from '@tower/logger'; +import { WhatsAppSessionPool } from './session-pool'; const logger = createLogger('group-sync'); +const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; + +const INTRO_MESSAGE = + "Hi, I'm TOWER. I'll be archiving messages from this group."; + +const CLAIM_DM = (groupName: string, token: string) => + `TOWER was added to "${groupName}". Claim it here (one-time, expires 48h): tower.app/claim-group?token=${token}`; + export async function syncGroups( groups: Record, accountId: string, prisma: any, + pool: WhatsAppSessionPool, ): Promise> { const jidToDbId = new Map(); for (const [jid, meta] of Object.entries(groups)) { + const existing = await prisma.group.findUnique({ + where: { platform_platformId: { platform: 'whatsapp', platformId: jid } }, + select: { id: true, tenantId: true, accountId: true }, + }); + const group = await prisma.group.upsert({ where: { platform_platformId: { platform: 'whatsapp', platformId: jid } }, create: { @@ -28,8 +44,121 @@ export async function syncGroups( }, }); jidToDbId.set(jid, group.id); + + // New or re-added group — send intro + DM claim link to superadmin + if (!existing || existing.accountId !== accountId) { + logger.info({ jid, groupId: group.id, name: meta.subject }, 'New group detected — sending intro'); + + // Intro message to group + try { + await pool.sendMessage(accountId, jid, INTRO_MESSAGE); + } catch (err) { + logger.warn({ jid, err }, 'Failed to post intro message'); + } + + // If the group is already claimed (bot re-added), don't generate a new token + if (existing?.tenantId) { + logger.info({ jid, groupId: group.id, tenantId: existing.tenantId }, 'Group already claimed — skipping token'); + continue; + } + + // Find superadmin (the group creator) + const superadmin = meta.participants?.find((p: any) => p.admin === 'superadmin'); + if (!superadmin) { + logger.warn({ jid, groupId: group.id }, 'No superadmin found in group metadata — cannot send claim link'); + continue; + } + + // Generate claim token + const token = randomBytes(32).toString('hex'); + await prisma.groupClaimToken.create({ + data: { + groupId: group.id, + token, + creatorJid: superadmin.id, + expiresAt: new Date(Date.now() + TOKEN_TTL_MS), + }, + }); + + // DM the superadmin + try { + await pool.sendMessage(accountId, superadmin.id, CLAIM_DM(meta.subject, token)); + logger.info({ jid, groupId: group.id, superadminJid: superadmin.id }, 'Claim link DM sent'); + } catch (err) { + logger.warn({ jid, superadminJid: superadmin.id, err }, 'Failed to DM claim link to superadmin'); + } + + // Audit event + const auditTenantId = await firstTenantId(prisma); + await prisma.auditEvent + .create({ + data: { + tenantId: auditTenantId ?? 'system', + actorType: 'SYSTEM', + action: 'GROUP_CLAIM_TOKEN_SENT', + resourceType: 'Group', + resourceId: group.id, + payload: { jid, name: meta.subject, superadminJid: superadmin.id }, + }, + }) + .catch((err: unknown) => logger.warn({ err }, 'Failed to write GROUP_CLAIM_TOKEN_SENT audit event')); + } + } + + // --- Detect removed groups ------------------------------------------------ + const currentJids = new Set(Object.keys(groups)); + const activeDbGroups = await prisma.group.findMany({ + where: { accountId, isActive: true }, + select: { id: true, platformId: true, tenantId: true, name: true }, + }); + + const removedGroups = activeDbGroups.filter((g: any) => !currentJids.has(g.platformId)); + for (const g of removedGroups) { + logger.info({ groupId: g.id, name: g.name, platformId: g.platformId }, 'Bot removed from group'); + await prisma.group.update({ where: { id: g.id }, data: { isActive: false } }); + await prisma.auditEvent + .create({ + data: { + tenantId: g.tenantId ?? (await firstTenantId(prisma)) ?? 'system', + actorType: 'SYSTEM', + action: 'GROUP_BOT_REMOVED', + resourceType: 'Group', + resourceId: g.id, + payload: { name: g.name, platformId: g.platformId }, + }, + }) + .catch((err: unknown) => logger.warn({ err }, 'Failed to write GROUP_BOT_REMOVED audit event')); + } + + // --- Detect re-added groups ----------------------------------------------- + const inactiveDbGroups = await prisma.group.findMany({ + where: { accountId, isActive: false }, + select: { id: true, platformId: true, tenantId: true, name: true }, + }); + + const reAddedGroups = inactiveDbGroups.filter((g: any) => currentJids.has(g.platformId)); + for (const g of reAddedGroups) { + logger.info({ groupId: g.id, name: g.name, platformId: g.platformId }, 'Bot re-added to group'); + await prisma.group.update({ where: { id: g.id }, data: { isActive: true } }); + await prisma.auditEvent + .create({ + data: { + tenantId: g.tenantId ?? (await firstTenantId(prisma)) ?? 'system', + actorType: 'SYSTEM', + action: 'GROUP_BOT_RE_ADDED', + resourceType: 'Group', + resourceId: g.id, + payload: { name: g.name, platformId: g.platformId }, + }, + }) + .catch((err: unknown) => logger.warn({ err }, 'Failed to write GROUP_BOT_RE_ADDED audit event')); } logger.info({ count: jidToDbId.size, accountId }, 'Groups synced'); return jidToDbId; } + +async function firstTenantId(prisma: any): Promise { + const t = await prisma.tenant.findFirst({ select: { id: true } }); + return t?.id ?? null; +} diff --git a/apps/worker/src/whatsapp/match-rules.test.ts b/apps/worker/src/whatsapp/match-rules.test.ts new file mode 100644 index 0000000..ec611db --- /dev/null +++ b/apps/worker/src/whatsapp/match-rules.test.ts @@ -0,0 +1,103 @@ +import { matchContentRules, matchReactionRules, TenantRuleRow } from './match-rules'; + +const makeRule = (overrides: Partial = {}): TenantRuleRow => ({ + id: 'r1', + matchType: 'HASHTAG', + matchValue: '#gooo', + action: 'FLAG', + priority: 0, + ...overrides, +}); + +describe('matchContentRules', () => { + it('matches hashtag at start of message', () => { + const result = matchContentRules('#gooo hello', [makeRule()]); + expect(result.tags).toContain('#gooo'); + expect(result.effectiveAction).toBe('FLAG'); + }); + + it('matches hashtag in middle of message', () => { + const result = matchContentRules('hello #gooo world', [makeRule()]); + expect(result.tags).toContain('#gooo'); + }); + + it('matches hashtag at end of message', () => { + const result = matchContentRules('hello #gooo', [makeRule()]); + expect(result.tags).toContain('#gooo'); + }); + + it('matches hashtag with punctuation after', () => { + const result = matchContentRules('check #gooo, please', [makeRule()]); + expect(result.tags).toContain('#gooo'); + }); + + it('does not match hashtag as part of another word', () => { + const result = matchContentRules('this is #goooogle', [makeRule()]); + expect(result.tags).not.toContain('#gooo'); + }); + + it('does not match hashtag attached to preceding text', () => { + const result = matchContentRules('foo#gooo bar', [makeRule()]); + expect(result.tags).not.toContain('#gooo'); + }); + + it('matches PREFIX rule when text starts with prefix', () => { + const result = matchContentRules('/tower help', [ + makeRule({ matchType: 'PREFIX', matchValue: '/tower', action: 'FLAG' }), + ]); + expect(result.tags).toContain('/tower...'); + }); + + it('does not match PREFIX when text does not start with it', () => { + const result = matchContentRules('do /tower help', [ + makeRule({ matchType: 'PREFIX', matchValue: '/tower', action: 'FLAG' }), + ]); + expect(result.tags).toEqual([]); + }); + + it('returns no matches when no rules match', () => { + const result = matchContentRules('hello world', [makeRule()]); + expect(result.tags).toEqual([]); + expect(result.effectiveAction).toBeNull(); + }); + + it('SKIP takes precedence over FLAG', () => { + const result = matchContentRules('#gooo event', [ + makeRule({ matchValue: '#gooo', action: 'FLAG', priority: 0 }), + makeRule({ id: 'r2', matchValue: '#gooo', action: 'SKIP', priority: 1, matchType: 'HASHTAG' }), + ]); + expect(result.effectiveAction).toBe('SKIP'); + }); + + it('REJECT takes precedence over AUTO_APPROVE', () => { + const result = matchContentRules('#gooo event', [ + makeRule({ matchValue: '#gooo', action: 'AUTO_APPROVE', priority: 0 }), + makeRule({ id: 'r2', matchValue: '#gooo', action: 'REJECT', priority: 1, matchType: 'HASHTAG' }), + ]); + expect(result.effectiveAction).toBe('REJECT'); + }); + + it('AUTO_APPROVE takes precedence over FLAG', () => { + const result = matchContentRules('#gooo event', [ + makeRule({ matchValue: '#gooo', action: 'FLAG', priority: 0 }), + makeRule({ id: 'r2', matchValue: '#gooo', action: 'AUTO_APPROVE', priority: 1, matchType: 'HASHTAG' }), + ]); + expect(result.effectiveAction).toBe('AUTO_APPROVE'); + }); +}); + +describe('matchReactionRules', () => { + it('matches reaction emoji', () => { + const result = matchReactionRules('⭐', [ + makeRule({ matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE' }), + ]); + expect(result).toEqual({ action: 'AUTO_APPROVE' }); + }); + + it('returns null when no reaction rule matches', () => { + const result = matchReactionRules('👍', [ + makeRule({ matchType: 'REACTION_EMOJI', matchValue: '⭐', action: 'AUTO_APPROVE' }), + ]); + expect(result).toBeNull(); + }); +}); diff --git a/apps/worker/src/whatsapp/match-rules.ts b/apps/worker/src/whatsapp/match-rules.ts new file mode 100644 index 0000000..8866ca4 --- /dev/null +++ b/apps/worker/src/whatsapp/match-rules.ts @@ -0,0 +1,109 @@ +/** + * DB-driven rule matching — replaces the old hardcoded tag-detector.ts. + * Loads active TenantRule rows from the DB and matches them against + * message content (for HASHTAG / PREFIX rules) or reaction emoji (for REACTION_EMOJI rules). + */ + +export interface TenantRuleRow { + id: string; + matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; + matchValue: string; + action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; + priority: number; +} + +export interface MatchResult { + tags: string[]; + effectiveAction: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' | null; +} + +/** + * Match message content against a set of HASHTAG and PREFIX rules. + * Returns detected tags and the most important effective action. + * SKIP and REJECT take precedence over AUTO_APPROVE and FLAG. + * Within the same precedence tier, the highest priority (lowest number) wins. + */ +export function matchContentRules( + text: string, + rules: TenantRuleRow[], +): MatchResult { + const tags: string[] = []; + const relevantRules = rules.filter( + (r) => r.matchType === 'HASHTAG' || r.matchType === 'PREFIX', + ); + + let effectiveAction: MatchResult['effectiveAction'] = null; + + // Action precedence: SKIP > REJECT > AUTO_APPROVE > FLAG + const actionRank: Record = { + SKIP: 4, + REJECT: 3, + AUTO_APPROVE: 2, + FLAG: 1, + }; + + for (const rule of relevantRules) { + let matched = false; + + if (rule.matchType === 'HASHTAG') { + // Match the hashtag as a standalone token. + // `\b` doesn't work here because `#` is a non-word character, + // so `\b#gooo` fails at the start of a message. Use `(?:^|\W)` + // to allow start-of-string or a non-word char before the hashtag. + const escaped = escapeRegex(rule.matchValue); + const pattern = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i'); + if (pattern.test(text)) { + tags.push(rule.matchValue); + matched = true; + } + } else if (rule.matchType === 'PREFIX') { + // Match if text starts with the prefix + if (text.trimStart().startsWith(rule.matchValue)) { + tags.push(`${rule.matchValue}...`); + matched = true; + } + } + + if (matched) { + const rank = actionRank[rule.action] ?? 0; + const currentRank = effectiveAction ? (actionRank[effectiveAction] ?? 0) : 0; + if (rank > currentRank) { + effectiveAction = rule.action; + } + } + } + + return { tags, effectiveAction }; +} + +/** + * Match a reaction emoji against REACTION_EMOJI rules. + * Returns the first matching rule action, or null. + */ +/** + * Strip Unicode variation selectors (U+FE0F–U+FE0F) and other + * formatting codepoints that can differ between platforms. + */ +function stripVariationSelectors(s: string): string { + return [...s].filter((c) => c.codePointAt(0) !== 0xfe0f).join(''); +} + +export function matchReactionRules( + emoji: string, + rules: TenantRuleRow[], +): { action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' } | null { + const reactionRules = rules.filter((r) => r.matchType === 'REACTION_EMOJI'); + const cleanEmoji = stripVariationSelectors(emoji); + + for (const rule of reactionRules) { + if (cleanEmoji === stripVariationSelectors(rule.matchValue)) { + return { action: rule.action }; + } + } + + return null; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/worker/src/whatsapp/normalizer.ts b/apps/worker/src/whatsapp/normalizer.ts index 902923d..57676b9 100644 --- a/apps/worker/src/whatsapp/normalizer.ts +++ b/apps/worker/src/whatsapp/normalizer.ts @@ -46,6 +46,7 @@ export function normalizeMessage( export function normalizeReaction( msg: proto.IWebMessageInfo, accountId: string, + selfJid?: string, ): NormalizedReaction | null { const key = msg.key; if (!key) return null; @@ -53,8 +54,7 @@ export function normalizeReaction( const remoteJid = key.remoteJid ?? ''; // only group messages (group JIDs end with @g.us) if (!remoteJid.endsWith('@g.us')) return null; - // skip our own outgoing messages - if (key.fromMe) return null; + // Allow fromMe reactions — admin may star from the connected account const reaction = msg.message?.reactionMessage; if (!reaction) return null; @@ -62,8 +62,8 @@ export function normalizeReaction( const targetMsgId = reaction.key?.id; if (!targetMsgId) return null; - // Ensure reactorJid is not empty (group message must have a participant) - const reactorJid = key.participant; + // For fromMe reactions Baileys uses LID internally; use selfJid (PN format) to match TOWER_ADMIN_JIDS + const reactorJid = key.fromMe ? (selfJid ?? key.participant) : key.participant; if (!reactorJid) return null; return { diff --git a/apps/worker/src/whatsapp/otp-sender.ts b/apps/worker/src/whatsapp/otp-sender.ts new file mode 100644 index 0000000..35f5008 --- /dev/null +++ b/apps/worker/src/whatsapp/otp-sender.ts @@ -0,0 +1,57 @@ +import { WhatsAppSessionPool } from './session-pool'; +import { createLogger } from '@tower/logger'; + +const POLL_INTERVAL_MS = 5_000; +const MAX_ATTEMPTS = 3; + +const OTP_MESSAGE = (code: string): string => + `Your TOWER verification code is ${code}. It expires in 5 minutes. Reply STOP to opt out.`; + +export function startOtpSenderLoop( + prisma: any, + pool: WhatsAppSessionPool, + logger: ReturnType, +): void { + let running = false; + + const tick = async (): Promise => { + if (running) return; + running = true; + try { + const pending = await prisma.otpChallenge.findMany({ + where: { sentAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: 'asc' }, + take: 10, + }); + for (const challenge of pending) { + const accounts = await prisma.account.findMany({ + where: { isBot: true, status: 'ACTIVE' }, + select: { id: true }, + }); + if (accounts.length === 0) { + logger.warn({ challengeId: challenge.id }, 'No active bot — OTP delivery deferred'); + continue; + } + const account = accounts[0]; + try { + await pool.sendMessage(account.id, challenge.jid, OTP_MESSAGE(challenge.code)); + await prisma.otpChallenge.update({ + where: { id: challenge.id }, + data: { sentAt: new Date() }, + }); + logger.info({ challengeId: challenge.id, jid: challenge.jid }, 'OTP sent'); + } catch (err) { + logger.error({ err, challengeId: challenge.id }, 'Failed to send OTP'); + } + } + } catch (err) { + logger.error({ err }, 'otp-sender tick failed'); + } finally { + running = false; + } + }; + + setTimeout(() => void tick(), 3_000); + setInterval(() => void tick(), POLL_INTERVAL_MS); + logger.info(`otp-sender loop scheduled (every ${POLL_INTERVAL_MS / 1000}s)`); +} diff --git a/apps/worker/src/whatsapp/session.ts b/apps/worker/src/whatsapp/session.ts index b1e2cdd..e835369 100644 --- a/apps/worker/src/whatsapp/session.ts +++ b/apps/worker/src/whatsapp/session.ts @@ -37,7 +37,7 @@ export async function createWhatsAppSession( version, auth: state, printQRInTerminal: false, - logger: logger as any, + logger: { level: 'silent', trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, child: () => ({ level: 'silent', trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, child: () => ({} as any) }) } as any, }); sock.ev.on('creds.update', saveCreds); @@ -91,10 +91,11 @@ export async function createWhatsAppSession( }); sock.ev.on('messages.upsert', ({ messages, type }) => { + logger.info({ type, count: messages.length }, 'messages.upsert received'); if (type !== 'notify') return; for (const msg of messages) { if (msg.message?.reactionMessage) { - const reaction = normalizeReaction(msg, accountId); + const reaction = normalizeReaction(msg, accountId, sock.user?.id); if (reaction) { void Promise.resolve(onReaction(reaction)).catch((err) => logger.error({ err }, 'Error processing reaction'), diff --git a/backups/phase2b-pre-20260604T160707Z.sql b/backups/phase2b-pre-20260604T160707Z.sql new file mode 100644 index 0000000..0eefe55 --- /dev/null +++ b/backups/phase2b-pre-20260604T160707Z.sql @@ -0,0 +1,319 @@ +-- +-- PostgreSQL database dump +-- + +\restrict IirtrUcG6cYkagSJO1iPjb4tmnvXR7Zp0YeDWGaP17VSpqXMw7sP520vA1uA4QT + +-- Dumped from database version 17.10 +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: Account; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public."Account" VALUES ('cmpyeuxg9000joie6pdh4q6tn', 'whatsapp', '917991186361:52@s.whatsapp.net', './sessions/1c28dee9-42bf-424c-b004-2c58be22807a', 'bot 1', 'ACTIVE', '2026-06-03 18:39:36.153', '2026-06-04 15:37:31.898', NULL, 'default'); + + +-- +-- Data for Name: Group; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public."Group" VALUES ('cmpyex4t00011oi4agsizlyet', 'whatsapp', '120363091621625330@g.us', 'Ghost themes and other stuffs', NULL, true, '2026-06-03 18:41:18.996', '2026-06-04 15:37:34.447', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4t80015oi4alhx7g099', 'whatsapp', '120363361923692164@g.us', 'Badminton ', NULL, true, '2026-06-03 18:41:19.004', '2026-06-04 15:37:34.453', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4tg0019oi4a7w8eacnm', 'whatsapp', '120363166637164226@g.us', 'SIH Trial ', 'Mental health and well-being surveillance, assessment and tracking solution among children.', true, '2026-06-03 18:41:19.012', '2026-06-04 15:37:34.461', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4tk001boi4a29py1s5i', 'whatsapp', '120363147210272115@g.us', 'Software Engg. Practical', NULL, true, '2026-06-03 18:41:19.016', '2026-06-04 15:37:34.465', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4tt001foi4aomdtbfqx', 'whatsapp', '120363145678835102@g.us', 'Black Moon(Status code 0)', NULL, true, '2026-06-03 18:41:19.025', '2026-06-04 15:37:34.472', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4u2001joi4a8ugk3yzi', 'whatsapp', '120363082158265074@g.us', 'Newtown chats', NULL, true, '2026-06-03 18:41:19.034', '2026-06-04 15:37:34.479', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4uc001noi4aox909hjw', 'whatsapp', '120363409544933614@g.us', 'Frontend team', NULL, true, '2026-06-03 18:41:19.044', '2026-06-04 15:37:34.485', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4uj001roi4aaeprhj17', 'whatsapp', '120363025055847943@g.us', 'PES (IT DEPARTMENT)', NULL, true, '2026-06-03 18:41:19.052', '2026-06-04 15:37:34.493', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4qe0003oi4a8i6jvd9a', 'whatsapp', '120363419994057575@g.us', 'GCCD 2024', 'Hi everyone! This community is for consolidating the groups of GCCD 2024', true, '2026-06-03 18:41:18.903', '2026-06-04 15:37:34.365', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4qv0005oi4ai622f59j', 'whatsapp', '120363405418436958@g.us', 'GetOnGlobal – Student Jobs & Internships Community', 'This group is created to share verified job and internship opportunities for students and fresh graduates via GetOnGlobal. + +🔹 Roles across Tech, Marketing, Business, HR, Operations, Content & more +🔹 Opportunities sourced from companies and consolidated from platforms like LinkedIn, Naukri & Indeed , in one place +🔹 Only genuine, student-relevant openings +🔹 Free to apply, no consultancies or agents + +📌 Group Guidelines + +This is an information-only group + +No spam, promotions, or irrelevant messages + +Job updates and important announcements only + +🌐 Login and Explore more opportunities: https://getonglobal.com/login', true, '2026-06-03 18:41:18.919', '2026-06-04 15:37:34.371', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4r70007oi4awre7kb6w', 'whatsapp', '120363316815908381@g.us', '2x Your Tech Salary!', 'career growth anonymous ama: https://forms.gle/wyQzq6VEDPUjKXvUA', true, '2026-06-03 18:41:18.932', '2026-06-04 15:37:34.377', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4rb0009oi4afuhoim9i', 'whatsapp', '120363046525525646@g.us', 'Flutter Kolkata', 'LinkedIn: https://www.linkedin.com/company/flutter-kolkata/ +Twitter: https://twitter.com/flutterkolkata +Meetup: https://www.meetup.com/flutter-kolkata/', true, '2026-06-03 18:41:18.936', '2026-06-04 15:37:34.382', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4rh000boi4a0si32avj', 'whatsapp', '120363028115545670@g.us', 'GDG TINT', '✨ Welcome to GDG TINT – Official Community! ✨ + +This is where our Google Developer Group comes alive 🚀. +📢 Get updates on upcoming events, registrations & workshops. +🎁 Swags, 🏅 Certificates & 💡 endless learning opportunities. +🤝 Connect, collaborate & grow with like-minded innovators. + +👉 Stay active, stay curious — the journey starts from here!', true, '2026-06-03 18:41:18.942', '2026-06-04 15:37:34.387', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4rl000doi4a0ebawjak', 'whatsapp', '120363421697588133@g.us', '🤝', NULL, true, '2026-06-03 18:41:18.946', '2026-06-04 15:37:34.392', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4rp000foi4ae1jqmyo5', 'whatsapp', '917991186361-1602734655@g.us', 'Hello', NULL, true, '2026-06-03 18:41:18.95', '2026-06-04 15:37:34.399', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4rt000hoi4aee57z5bs', 'whatsapp', '120363036603589801@g.us', 'GDSC TINT', 'Google developer student club (Techno International New Town)', true, '2026-06-03 18:41:18.954', '2026-06-04 15:37:34.404', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4rw000joi4au1dxokdx', 'whatsapp', '917991186361-1599906899@g.us', 'Notes', NULL, true, '2026-06-03 18:41:18.957', '2026-06-04 15:37:34.409', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4s1000loi4at4f4gn59', 'whatsapp', '120363304047844813@g.us', 'Groww ', NULL, true, '2026-06-03 18:41:18.961', '2026-06-04 15:37:34.413', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4s6000noi4a1e4zgvho', 'whatsapp', '120363078924145528@g.us', 'Bio project 4th sem', 'Evolution merits and demerits of four kingdom classification system', true, '2026-06-03 18:41:18.966', '2026-06-04 15:37:34.419', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4sb000poi4agj4u504t', 'whatsapp', '120363425930536990@g.us', 'Test', NULL, true, '2026-06-03 18:41:18.971', '2026-06-04 15:37:34.424', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4sf000roi4ar8hh9t2g', 'whatsapp', '120363421516033542@g.us', 'Badminton', NULL, true, '2026-06-03 18:41:18.976', '2026-06-04 15:37:34.428', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4sk000toi4awf9oru12', 'whatsapp', '120363040646143584@g.us', 'Temp Group', NULL, true, '2026-06-03 18:41:18.98', '2026-06-04 15:37:34.432', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4sn000voi4apo1tlxul', 'whatsapp', '917667219914-1620970092@g.us', 'BILLIONAIRE 💸🤑', NULL, true, '2026-06-03 18:41:18.984', '2026-06-04 15:37:34.437', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4sr000xoi4a3xg3x1pc', 'whatsapp', '120363040957438115@g.us', 'Ghost busters', NULL, true, '2026-06-03 18:41:18.988', '2026-06-04 15:37:34.44', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4sv000zoi4aymxy653c', 'whatsapp', '120363362120782803@g.us', 'Website SHA-SIB', NULL, true, '2026-06-03 18:41:18.992', '2026-06-04 15:37:34.443', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4t40013oi4a44cvfalg', 'whatsapp', '120363299087048686@g.us', 'Cure India', NULL, true, '2026-06-03 18:41:19', '2026-06-04 15:37:34.451', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4tc0017oi4ar7b7n16b', 'whatsapp', '120363027735137241@g.us', 'Coding at 8:00 PM ', NULL, true, '2026-06-03 18:41:19.008', '2026-06-04 15:37:34.457', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4to001doi4aee3kbc3o', 'whatsapp', '120363023952184318@g.us', 'Roleplay English lab', NULL, true, '2026-06-03 18:41:19.021', '2026-06-04 15:37:34.468', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4tx001hoi4azvf3sszu', 'whatsapp', '120363225243281416@g.us', 'Final year project 2k25', NULL, true, '2026-06-03 18:41:19.03', '2026-06-04 15:37:34.476', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4u7001loi4aml3xz4nx', 'whatsapp', '917991186361-1542385862@g.us', 'Champions FC🏆🏆', NULL, true, '2026-06-03 18:41:19.039', '2026-06-04 15:37:34.483', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4uf001poi4a6wcxst66', 'whatsapp', '120363425512461112@g.us', 'Last Picnic : 2025', NULL, true, '2026-06-03 18:41:19.047', '2026-06-04 15:37:34.489', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4uo001toi4apja3xvnh', 'whatsapp', '120363404757646832@g.us', 'Kolkata Alumni Group', NULL, true, '2026-06-03 18:41:19.056', '2026-06-04 15:37:34.496', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4w2002noi4a56mummy0', 'whatsapp', '120363403208352516@g.us', 'Indoor Badminton', NULL, true, '2026-06-03 18:41:19.107', '2026-06-04 15:37:34.537', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4w5002poi4aa1zp0goq', 'whatsapp', '120363426335640528@g.us', 'Trippppppp 2026🥳☠️', NULL, true, '2026-06-03 18:41:19.109', '2026-06-04 15:37:34.539', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4w7002roi4aalczafbf', 'whatsapp', '120363405219530495@g.us', 'Frontend Architecture', NULL, true, '2026-06-03 18:41:19.112', '2026-06-04 15:37:34.542', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wa002toi4a6s9tfdc9', 'whatsapp', '120363421909745460@g.us', 'Vigilante (nights watch)', NULL, true, '2026-06-03 18:41:19.114', '2026-06-04 15:37:34.544', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wc002voi4a87m62hxt', 'whatsapp', '120363040554039206@g.us', 'IT 91-117( MAR & MOOCs)', NULL, true, '2026-06-03 18:41:19.116', '2026-06-04 15:37:34.547', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4we002xoi4ar0wzs6rd', 'whatsapp', '120363296509388526@g.us', 'Bonfire cashback ', NULL, true, '2026-06-03 18:41:19.118', '2026-06-04 15:37:34.55', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wf002zoi4amus3o1yo', 'whatsapp', '919709543186-1592561406@g.us', 'JFC ( jalwabad ⚽ club )🏅🏆', NULL, true, '2026-06-03 18:41:19.12', '2026-06-04 15:37:34.553', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wh0031oi4a2kyx81ku', 'whatsapp', '918372931099-1632713760@g.us', 'IT~SEC-B {Gr-B}×(90-120+)', 'You Can''t able see the Group Description', true, '2026-06-03 18:41:19.122', '2026-06-04 15:37:34.556', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wj0033oi4aayhpd8ml', 'whatsapp', '120363029459299752@g.us', 'Favor Talk', 'https://chat.whatsapp.com/I20Uxv9c7uJ72u76EkRqbh', true, '2026-06-03 18:41:19.123', '2026-06-04 15:37:34.558', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wl0035oi4an9txx0ho', 'whatsapp', '120363241166896279@g.us', 'GCCD 2024 - Volunteers', 'Gallery: https://photos.app.goo.gl/qJsgfvTcwVjVmgZN8', true, '2026-06-03 18:41:19.125', '2026-06-04 15:37:34.562', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wm0037oi4a7a3f46e0', 'whatsapp', '120363418215294345@g.us', 'GCCD 2024', 'Hi everyone! This community is for consolidating the groups of GCCD 2024', true, '2026-06-03 18:41:19.127', '2026-06-04 15:37:34.564', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wo0039oi4adnihcta8', 'whatsapp', '120363040006122845@g.us', 'Section B grp B', NULL, true, '2026-06-03 18:41:19.128', '2026-06-04 15:37:34.566', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wq003boi4aib8gcfr5', 'whatsapp', '120363215496516107@g.us', '(BYT) players⚽', NULL, true, '2026-06-03 18:41:19.13', '2026-06-04 15:37:34.569', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wr003doi4atxywd1xg', 'whatsapp', '120363040875673509@g.us', 'Scared to Compile', NULL, true, '2026-06-03 18:41:19.132', '2026-06-04 15:37:34.573', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wt003foi4af3ffciw8', 'whatsapp', '919831648328-1633427796@g.us', 'ESEE-101_IT Sec-B', NULL, true, '2026-06-03 18:41:19.134', '2026-06-04 15:37:34.576', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wv003hoi4ayu21koer', 'whatsapp', '120363147549214249@g.us', 'Announcements: Team GCCD Kol 2023', NULL, true, '2026-06-03 18:41:19.135', '2026-06-04 15:37:34.579', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wx003joi4ai5177r1p', 'whatsapp', '120363418024300654@g.us', 'Smart Traders', 'Join our community of traders using technical analysis and charts to navigate the markets! We share insights, strategies, and grow together.', true, '2026-06-03 18:41:19.137', '2026-06-04 15:37:34.582', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4wz003loi4a7h87bf7o', 'whatsapp', '120363053461427064@g.us', 'Biology_IT2', 'https://drive.google.com/drive/folders/1j0IIxwxXDWWUPmtMw2VAUDnFZs-BZOp-?usp=sharing +For biology notes', true, '2026-06-03 18:41:19.14', '2026-06-04 15:37:34.585', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4x1003noi4a6qwopw75', 'whatsapp', '120363030674740428@g.us', 'Team CCD Kol 2023', 'Join our discord server: https://discord.gg/XmMcWw88wG + +Assets: bit.ly/ccdkol-drive +Calendar: bit.ly/ccdkol-calendar +Ideas: bit.ly/ccdkol-ideas + +Site: https://gdgcloud.kolkata.dev/ccd2023/ + +------ For Promotion ----- +Hi all! 👋🏻 + +The Early Bird tickets for GCCD Kolkata 2023 are selling on our website - https://gdgcloud.kolkata.dev/ccd2023/ + +Do grab this limited time offer to be a part of the largest GDG event of India 🙌🏻', true, '2026-06-03 18:41:19.142', '2026-06-04 15:37:34.589', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4x4003poi4a0mrqwxid', 'whatsapp', '120363147415708960@g.us', 'HSMC 501( IT 2 5th sem) room no. 512', NULL, true, '2026-06-03 18:41:19.144', '2026-06-04 15:37:34.591', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yz0053oi4acadwjd9x', 'whatsapp', '120363307557973980@g.us', 'AHMAD ISLAMIC SHOP 🛍️🖊️📖', 'All islamic items +Books Topi tasbeeh jainamaz Imamah dastarkhan atar surma miswak kurta deeniyat bag +Naqab hijab stole namazi dupatta nosepiece hand gloves hijab cap +Islamic frames and ect... +available in AHMAD ISLAMIC SHOP 🛍️🛍️🛍️ JALWABAD KODERMA.....', true, '2026-06-03 18:41:19.212', '2026-06-04 15:37:34.672', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4us001voi4ad4g04gh9', 'whatsapp', '120363025552200286@g.us', 'IT 2nd YEAR Football TEAM', NULL, true, '2026-06-03 18:41:19.06', '2026-06-04 15:37:34.498', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4uy001xoi4akkmpi19q', 'whatsapp', '120363041015742302@g.us', 'Programming Family', NULL, true, '2026-06-03 18:41:19.067', '2026-06-04 15:37:34.502', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4v1001zoi4avk0c1rlp', 'whatsapp', '918822941338-1504768869@g.us', 'CHAMPIONS 24/25', 'YNWA', true, '2026-06-03 18:41:19.07', '2026-06-04 15:37:34.504', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4v90023oi4aab8tky4o', 'whatsapp', '120363043213339962@g.us', 'Football in the evening', NULL, true, '2026-06-03 18:41:19.077', '2026-06-04 15:37:34.509', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vc0025oi4aqor19pnl', 'whatsapp', '120363419711088937@g.us', 'Badminton Group', NULL, true, '2026-06-03 18:41:19.081', '2026-06-04 15:37:34.511', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vg0027oi4adi1ler8g', 'whatsapp', '120363190036428682@g.us', 'Swing trading group', 'No buy and sell recommendations, apna research karo aur paisa dalo', true, '2026-06-03 18:41:19.084', '2026-06-04 15:37:34.514', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vj0029oi4ag15uhnob', 'whatsapp', '120363423949147374@g.us', 'SmartTraders', NULL, true, '2026-06-03 18:41:19.088', '2026-06-04 15:37:34.516', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vm002boi4aeu05s64q', 'whatsapp', '120363186126252211@g.us', 'attendance grp', NULL, true, '2026-06-03 18:41:19.09', '2026-06-04 15:37:34.52', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vp002doi4awqxd0g8t', 'whatsapp', '120363152038989582@g.us', 'GDSC TINT CORE FAMILY ', NULL, true, '2026-06-03 18:41:19.093', '2026-06-04 15:37:34.524', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vr002foi4a6i4f9vmb', 'whatsapp', '120363047091467792@g.us', 'GCCDKol23 Tech Team', 'Tracker : https://docs.google.com/spreadsheets/d/1Ykvb7jNSg9yHb-UxuMbxwHiBzJ0LdXv9XMN6XVFVTqE/edit?usp=share_link +Assets: https://drive.google.com/drive/folders/1a-AKCerRqlxWDvVCfI15IPgZn8lDhL3-?usp=share_link', true, '2026-06-03 18:41:19.096', '2026-06-04 15:37:34.527', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vx002joi4a4au0rz6t', 'whatsapp', '120363300447195016@g.us', 'Cricket group jalwabad ', NULL, true, '2026-06-03 18:41:19.101', '2026-06-04 15:37:34.532', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4w0002loi4a88g8s8zh', 'whatsapp', '916202741771-1614420259@g.us', 'Volleyball group 🏐🏐🏐', 'Lets play 🏐', true, '2026-06-03 18:41:19.104', '2026-06-04 15:37:34.534', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4y3004hoi4ahecnd1a0', 'whatsapp', '916291921472-1630224681@g.us', 'IT 2nd Yr 21-25 [Zombie]', NULL, true, '2026-06-03 18:41:19.18', '2026-06-04 15:37:34.632', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4y6004joi4al6sl1ea0', 'whatsapp', '919836783659-1633320903@g.us', 'IT PCC CS 401 2023', 'https://drive.google.com/drive/folders/1rlAs-Wj3vtTUszPaFkO2smYoS3u55-RP?usp=sharing', true, '2026-06-03 18:41:19.182', '2026-06-04 15:37:34.635', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4y8004loi4awwcse2x2', 'whatsapp', '120363408028058943@g.us', 'GetOnGlobal – Student Jobs & Internships Community', 'This group is created to share verified job and internship opportunities for students and fresh graduates via GetOnGlobal. + +🔹 Roles across Tech, Marketing, Business, HR, Operations, Content & more +🔹 Opportunities sourced from companies and consolidated from platforms like LinkedIn, Naukri & Indeed , in one place +🔹 Only genuine, student-relevant openings +🔹 Free to apply, no consultancies or agents + +📌 Group Guidelines + +This is an information-only group + +No spam, promotions, or irrelevant messages + +Job updates and important announcements only + +🌐 Login and Explore more opportunities: https://getonglobal.com/login', true, '2026-06-03 18:41:19.185', '2026-06-04 15:37:34.638', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yb004noi4aszd5j3vj', 'whatsapp', '120363300776568990@g.us', 'IT 2025- Placement', NULL, true, '2026-06-03 18:41:19.188', '2026-06-04 15:37:34.641', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yi004roi4a5fpk9psx', 'whatsapp', '120363370617329912@g.us', 'RAJA MEN''S WEAR', 'Jisko purchase karna hoga +Admin se contact kare ya shop me aye +Address -- domchach bazar rode +Shop name -- RAJA MENS WERE', true, '2026-06-03 18:41:19.194', '2026-06-04 15:37:34.647', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4ye004poi4a6hn8rhdj', 'whatsapp', '919547386871-1630685586@g.us', 'IT Gàñg ✌️😎', 'We, the crs and other students are planning for a picnic to be held in the month of December(This is entirely for IT 2nd yr and IT faculty members) +https://forms.gle/CqoEvFmCJt6Rk5Rx8 +Those who are interested please do fill this form by tonight. All the details regarding venue time and transportation have been provided in the form. +We expect Maximum participation from all of u. +Thank u!', true, '2026-06-03 18:41:19.191', '2026-06-04 15:37:34.644', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yk004toi4a71evei30', 'whatsapp', '120363041567264567@g.us', 'IT 21-25 pass out', NULL, true, '2026-06-03 18:41:19.197', '2026-06-04 15:37:34.651', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yn004voi4a7y8xot3z', 'whatsapp', '120363044280156067@g.us', 'Kullo Yaomin Dars', 'https://youtube.com/@masjidahlehadeestantibagh?si=stb0EgDVtjn-8HyR + +https://youtube.com/@SalafiManhajInfo?feature=shared + +https://www.facebook.com/salafimanhaj.info?mibextid=avESrC + +https://youtube.com/@SalafyFiqhChannel?si=r9eB9NyJrOWwSiio + +https://youtube.com/@KOLKATADUROOS?si=J-LnsLeis7pir6V7', true, '2026-06-03 18:41:19.199', '2026-06-04 15:37:34.656', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yq004xoi4aqskjeeta', 'whatsapp', '120363032274974565@g.us', 'GDSC TINT 2022-26 BATCH', '*Chapter Link- https://gdsc.community.dev/techno-international-new-town-kolkata/*', true, '2026-06-03 18:41:19.202', '2026-06-04 15:37:34.659', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yt004zoi4arovg0e17', 'whatsapp', '120363046537944466@g.us', 'GDSC TINT 2024 & 2025 Batch', '*Chapter Link- https://gdsc.community.dev/techno-international-new-town-kolkata/*', true, '2026-06-03 18:41:19.206', '2026-06-04 15:37:34.663', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4yw0051oi4abwmq8ggo', 'whatsapp', '918886506452-1391527464@g.us', 'HYDERABAD KOP', 'Proud Supporters of LFC, from Hyderabad + +Facebook: https://www.facebook.com/LFCHYD/ + +Twitter: +https://www.twitter.com/LFCHyderabad/ + +Instagram: https://www.instagram.com/lfchyderabad/', true, '2026-06-03 18:41:19.209', '2026-06-04 15:37:34.667', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4x8003toi4a1qp29dqr', 'whatsapp', '120363219816095532@g.us', 'ITB 3rd yr Data mining', NULL, true, '2026-06-03 18:41:19.148', '2026-06-04 15:37:34.594', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xa003voi4awsnsza8y', 'whatsapp', '120363162116192826@g.us', 'OOP IT Section - 2 ', NULL, true, '2026-06-03 18:41:19.151', '2026-06-04 15:37:34.599', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xd003xoi4atb6q5w37', 'whatsapp', '120363145013908615@g.us', 'IT2 3rd yr..PECIT601B', NULL, true, '2026-06-03 18:41:19.153', '2026-06-04 15:37:34.602', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xf003zoi4a1g74veze', 'whatsapp', '120363025948895203@g.us', 'IT SecB Official(for CRs)', NULL, true, '2026-06-03 18:41:19.156', '2026-06-04 15:37:34.604', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xi0041oi4ahfxtiuc5', 'whatsapp', '120363039820394484@g.us', 'IT SecB 21-25', NULL, true, '2026-06-03 18:41:19.158', '2026-06-04 15:37:34.608', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xk0043oi4akj7rs22o', 'whatsapp', '120363026816739904@g.us', 'Project Management & Entrepreneurship - IT B', NULL, true, '2026-06-03 18:41:19.16', '2026-06-04 15:37:34.61', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xo0047oi4a195vuw64', 'whatsapp', '916207217274-1632722516@g.us', 'BÏG Légèñds øf sêc B 😎🤟', 'Srijita will not be able to appear for CA4 exam.', true, '2026-06-03 18:41:19.165', '2026-06-04 15:37:34.613', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xm0045oi4adf0i03qh', 'whatsapp', '120363407823175256@g.us', 'Launch Circle | Founders & Makers 🚀', 'Accurated community for founders makers and product hunt enthusiasts. +•Share what you are building. •Get honest feedback before launch. +• Support each other on Product Hunt +• Learn what actually works. + +No spam. No noise. Only real builders helping builders. +Built for people who are serious about launching.', true, '2026-06-03 18:41:19.162', '2026-06-04 15:37:34.616', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xr0049oi4aiiyi3pzi', 'whatsapp', '120363422997971960@g.us', 'GetonGlobal - Jobs & Internship Opportunities', 'This group is created to share verified job and internship opportunities for students and fresh graduates via GetOnGlobal. +🔹 Roles across Tech, Marketing, Business, HR, Operations, Content & more +🔹 Opportunities sourced from companies and consolidated from platforms like LinkedIn, Naukri & Indeed — in one place +🔹 Only genuine, student-relevant openings +🔹 Free to apply — no consultancies or agents +📌 Group Guidelines +This is an information-only group +No spam, promotions, or irrelevant messages +Job updates and important announcements only +🌐 Login & Explore more opportunities: https://getonglobal.com/login', true, '2026-06-03 18:41:19.167', '2026-06-04 15:37:34.619', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xt004boi4axn3lom8w', 'whatsapp', '120363149657307975@g.us', 'Kolkata Devs', 'GCCD 23 + KCD 23 + GCCD 24', true, '2026-06-03 18:41:19.169', '2026-06-04 15:37:34.623', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xw004doi4a7soxwlq7', 'whatsapp', '120363319566506827@g.us', 'TINT IT 2025 Cloud Computing 4th yr', NULL, true, '2026-06-03 18:41:19.172', '2026-06-04 15:37:34.626', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4xz004foi4a5z2zwsq3', 'whatsapp', '120363025469070351@g.us', 'IT_Discrete Math_PCC-CS401', 'IT_Discrete Math_PCC-CS401', true, '2026-06-03 18:41:19.175', '2026-06-04 15:37:34.629', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4z20055oi4a2mvjmq2e', 'whatsapp', '120363045907651579@g.us', 'GDG TINT', 'Google Developer Student Club, is a student run technological community at TINT. Initiated and currently lead by Srinjoy Ghosh (IT ''24), in it''s second session since inception. We focus on a lot of technological domains ranging from web, android development to machine learning, blockchain and new innovations.', true, '2026-06-03 18:41:19.215', '2026-06-04 15:37:34.676', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4q30001oi4a2j2o3evi', 'whatsapp', '120363423608102552@g.us', 'Launch Circle | Founders & Makers 🚀', 'Accurated community for founders makers and product hunt enthusiasts. +•Share what you are building. •Get honest feedback before launch. +• Support each other on Product Hunt +• Learn what actually works. + +No spam. No noise. Only real builders helping builders. +Built for people who are serious about launching. + +🚀 How to Submit Your Launch +If you’re launching on Product Hunt: +Post your launch details in the group "Hunting Zone 🎯" +Tag an admin +Use the format below +We’ll review and share it in the announcement channel 🙌 +Format: +• Product Name: +• Tagline: +• Launch Link: +• What it does (1–2 lines): +Note: +Please engage in the community before submitting 🙌 + +Submission Rule 🚀 +To get featured in announcements: +• Be active in the community +• Support others +• No spam / low-effort launches +We prioritize contributors 🙌', true, '2026-06-03 18:41:18.891', '2026-06-04 15:37:34.345', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4v50021oi4aocq87eq1', 'whatsapp', '120363172540111215@g.us', 'Attendance debo na', 'Debjit:"who made this pic ?💀" +Arnab:"why"', true, '2026-06-03 18:41:19.074', '2026-06-04 15:37:34.506', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4vu002hoi4a1wmp217j', 'whatsapp', '120363275798229147@g.us', 'GCCD 2024 - Web', 'Task Tracker: https://docs.google.com/spreadsheets/d/1pr3sf32ze6Agfw28lDUlIEU6mowa-PErcpstgwjRbjk/edit?usp=sharing + +GitHub: https://github.com/gdgcloudkol + +CCD24: https://github.com/gdgcloudkol/ccd2024 + +Figma: https://www.figma.com/file/zWThTpdalD7txb1zunLJYc/GCCD-2024-Design-Team?type=design&node-id=0%3A1&mode=design&t=q54UqpqzjN6fsin3-1', true, '2026-06-03 18:41:19.099', '2026-06-04 15:37:34.53', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4z50057oi4attk9h874', 'whatsapp', '120363149099070324@g.us', 'GDSC TINT', NULL, true, '2026-06-03 18:41:19.218', '2026-06-04 15:37:34.68', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4z90059oi4a8qxykebe', 'whatsapp', '120363408759413814@g.us', 'Team Sajid Hussain (लल्लू भैया) Koderma FC', NULL, true, '2026-06-03 18:41:19.221', '2026-06-04 15:37:34.684', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4zc005boi4a6faffhcu', 'whatsapp', '917778819822-1592455690@g.us', 'Flutter Kolkata', 'LinkedIn: https://www.linkedin.com/company/flutter-kolkata/ +Twitter: https://twitter.com/flutterkolkata +Meetup: https://www.meetup.com/flutter-kolkata/ + +Flutter Kolkata - https://chat.whatsapp.com/GlQHgukaqQoBM8xsLvxDEs +Telegram:- https://t.me/flutterkolkatameetup', true, '2026-06-03 18:41:19.224', '2026-06-04 15:37:34.689', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4x6003roi4a89yaxbto', 'whatsapp', '120363407403128982@g.us', 'Hunting Zone 🎯', '🚀 How to Submit Your Launch +If you’re launching on Product Hunt: +Post your launch details here +Tag an admin +Use the format below +We’ll review and share it in the announcement channel 🙌 +Format: +• Product Name: +• Tagline: +• Launch Link: +• What it does (1–2 lines): +Note: +Please engage in the community before submitting 🙌 + +Submission Rule 🚀 +To get featured in announcements: +• Be active in the community +• Support others +• No spam / low-effort launches +We prioritize contributors 🙌', true, '2026-06-03 18:41:19.146', '2026-06-04 15:37:34.597', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4ze005doi4axfls7ub1', 'whatsapp', '120363222750751830@g.us', 'TINT 2025 MJ', NULL, true, '2026-06-03 18:41:19.227', '2026-06-04 15:37:34.694', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4zh005foi4a2x4cdjwc', 'whatsapp', '120363047320489722@g.us', 'Flutter Kolkata', 'LinkedIn: https://www.linkedin.com/company/flutter-kolkata/ +Twitter: https://twitter.com/flutterkolkata +Meetup: https://www.meetup.com/flutter-kolkata/', true, '2026-06-03 18:41:19.23', '2026-06-04 15:37:34.699', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4zk005hoi4ag4m7t97k', 'whatsapp', '120363333741605614@g.us', 'Vyson Waitlist', '​Get a personal 1:1 FAANG mentor here: https://vyson.dev/', true, '2026-06-03 18:41:19.233', '2026-06-04 15:37:34.704', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); +INSERT INTO public."Group" VALUES ('cmpyex4zn005joi4a5awz3qky', 'whatsapp', '120363338024346424@g.us', '2x Your Tech Salary!', '‎Hi everyone! This community is for members to chat in topic-based groups and get important announcements.', true, '2026-06-03 18:41:19.236', '2026-06-04 15:37:34.709', 'cmpyeuxg9000joie6pdh4q6tn', 'default'); + + +-- +-- Data for Name: Message; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: Approval; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: ConsentRecord; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: SyncRoute; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict IirtrUcG6cYkagSJO1iPjb4tmnvXR7Zp0YeDWGaP17VSpqXMw7sP520vA1uA4QT + diff --git a/docs/superpowers/plans/2026-05-28-admin-dashboard.md b/docs/superpowers/plans/2026-05-28-admin-dashboard.md new file mode 100644 index 0000000..ca3cc66 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-admin-dashboard.md @@ -0,0 +1,1198 @@ +# Admin Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Next.js admin dashboard with a full-text search page and a groups/routes management page that lets admins configure which WhatsApp groups forward messages to which. + +**Architecture:** Two new NestJS modules (`GroupsModule`, `RoutesModule`) expose REST endpoints the dashboard consumes. The Next.js web app (App Router, server components) calls NestJS directly server-to-server for read operations; client-side mutations (add/delete sync routes) go through Next.js Route Handler proxies to avoid CORS. A sidebar nav wraps both pages in the shared layout. + +**Tech Stack:** Next.js 16, React 19, Tailwind CSS 4, NestJS 11, Prisma 6 (`@prisma/client` already in `apps/api`), `@testing-library/react`, `jest-environment-jsdom`, `@nestjs/testing`, TypeScript 5 + +--- + +## Codebase orientation + +- `apps/api/src/prisma/prisma.service.ts` — `PrismaService extends PrismaClient`; `PrismaModule` is `@Global()` so every module gets it injected without re-importing. +- `apps/api/src/app.module.ts` — imports `ConfigModule`, `PrismaModule`, `HealthModule`, `SearchModule`. Add new modules here. +- NestJS test pattern: `Test.createTestingModule({ providers: [Service, { provide: PrismaService, useValue: mockPrisma }] })`. +- `apps/web` uses `next/jest` in `jest.config.js`, `jest-environment-jsdom`, `@testing-library/react`. `fetch` is available as a global in tests. +- `process.env.API_URL ?? 'http://localhost:3001'` is the server-side base URL for all server→API calls. Not a public env var. +- Prisma schema key models: `Group { id, name, platform, platformId, isActive, accountId }`, `SyncRoute { id, sourceGroupId, targetGroupId, isActive, createdAt }`. + +--- + +## File Map + +**Create:** +- `apps/api/src/modules/groups/groups.module.ts` +- `apps/api/src/modules/groups/groups.service.ts` +- `apps/api/src/modules/groups/groups.service.spec.ts` +- `apps/api/src/modules/groups/groups.controller.ts` +- `apps/api/src/modules/groups/groups.controller.spec.ts` +- `apps/api/src/modules/routes/routes.module.ts` +- `apps/api/src/modules/routes/routes.service.ts` +- `apps/api/src/modules/routes/routes.service.spec.ts` +- `apps/api/src/modules/routes/routes.controller.ts` +- `apps/api/src/modules/routes/routes.controller.spec.ts` +- `apps/web/app/search/page.tsx` — server component + exported `SearchResults` for testing +- `apps/web/app/search/page.test.tsx` +- `apps/web/app/groups/page.tsx` — server component, fetches groups + routes +- `apps/web/app/groups/RouteManager.tsx` — `'use client'` component for add/delete +- `apps/web/app/groups/RouteManager.test.tsx` +- `apps/web/app/api/routes/route.ts` — GET + POST proxy +- `apps/web/app/api/routes/[id]/route.ts` — DELETE proxy + +**Modify:** +- `apps/api/src/app.module.ts` — add `GroupsModule`, `RoutesModule` +- `apps/web/app/layout.tsx` — add sidebar nav +- `apps/web/app/page.tsx` — dashboard home with navigation cards +- `apps/web/app/page.test.tsx` — update tests for new home page content + +--- + +### Task 1: GroupsModule API — GET /groups + +**Files:** +- Create: `apps/api/src/modules/groups/groups.service.ts` +- Create: `apps/api/src/modules/groups/groups.service.spec.ts` +- Create: `apps/api/src/modules/groups/groups.controller.ts` +- Create: `apps/api/src/modules/groups/groups.controller.spec.ts` +- Create: `apps/api/src/modules/groups/groups.module.ts` + +- [ ] **Step 1: Write the failing service test** + +Create `apps/api/src/modules/groups/groups.service.spec.ts`: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { GroupsService } from './groups.service'; +import { PrismaService } from '../../prisma/prisma.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 }, +]; + +describe('GroupsService', () => { + let service: GroupsService; + const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GroupsService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(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 }, + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @tower/api test -- --testPathPattern=groups.service +``` +Expected: FAIL with "Cannot find module './groups.service'" + +- [ ] **Step 3: Implement GroupsService** + +Create `apps/api/src/modules/groups/groups.service.ts`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class GroupsService { + constructor(private readonly prisma: PrismaService) {} + + list() { + return this.prisma.group.findMany({ + orderBy: { name: 'asc' }, + select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true }, + }); + } +} +``` + +- [ ] **Step 4: Run service test to verify it passes** + +```bash +pnpm --filter @tower/api test -- --testPathPattern=groups.service +``` +Expected: PASS (1 test) + +- [ ] **Step 5: Write the failing controller test** + +Create `apps/api/src/modules/groups/groups.controller.spec.ts`: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; + +const mockGroups = [ + { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' }, +]; +const mockService = { list: jest.fn().mockResolvedValue(mockGroups) }; + +describe('GroupsController', () => { + let controller: GroupsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GroupsController], + providers: [{ provide: GroupsService, useValue: mockService }], + }).compile(); + controller = module.get(GroupsController); + }); + + it('returns groups from service', async () => { + const result = await controller.list(); + expect(result).toEqual(mockGroups); + expect(mockService.list).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 6: Implement GroupsController and GroupsModule** + +Create `apps/api/src/modules/groups/groups.controller.ts`: + +```typescript +import { Controller, Get } from '@nestjs/common'; +import { GroupsService } from './groups.service'; + +@Controller('groups') +export class GroupsController { + constructor(private readonly groupsService: GroupsService) {} + + @Get() + list() { + return this.groupsService.list(); + } +} +``` + +Create `apps/api/src/modules/groups/groups.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; + +@Module({ + controllers: [GroupsController], + providers: [GroupsService], +}) +export class GroupsModule {} +``` + +- [ ] **Step 7: Run controller test to verify it passes** + +```bash +pnpm --filter @tower/api test -- --testPathPattern=groups.controller +``` +Expected: PASS (1 test) + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/modules/groups/ +git commit -m "feat(api): add GroupsModule with GET /groups endpoint" +``` + +--- + +### Task 2: RoutesModule API — GET /routes, POST /routes, DELETE /routes/:id — wire both into AppModule + +**Files:** +- Create: `apps/api/src/modules/routes/routes.service.ts` +- Create: `apps/api/src/modules/routes/routes.service.spec.ts` +- Create: `apps/api/src/modules/routes/routes.controller.ts` +- Create: `apps/api/src/modules/routes/routes.controller.spec.ts` +- Create: `apps/api/src/modules/routes/routes.module.ts` +- Modify: `apps/api/src/app.module.ts` + +A populated `SyncRoute` record (with includes) looks like: +``` +{ id, sourceGroupId, targetGroupId, isActive, createdAt, sourceGroup: { name }, targetGroup: { name } } +``` + +- [ ] **Step 1: Write the failing service tests** + +Create `apps/api/src/modules/routes/routes.service.spec.ts`: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { RoutesService } from './routes.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +const mockRoute = { + id: 'rt_1', + sourceGroupId: 'grp_1', + targetGroupId: 'grp_2', + isActive: true, + createdAt: new Date(), + sourceGroup: { name: 'Alpha' }, + targetGroup: { name: 'Beta' }, +}; + +describe('RoutesService', () => { + let service: RoutesService; + const mockPrisma = { + syncRoute: { + findMany: jest.fn().mockResolvedValue([mockRoute]), + create: jest.fn().mockResolvedValue(mockRoute), + delete: jest.fn().mockResolvedValue(mockRoute), + }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoutesService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(RoutesService); + }); + + describe('list', () => { + it('returns all routes with group names', async () => { + const result = await service.list(); + 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' }, + }); + }); + + it('filters by sourceGroupId when provided', async () => { + await service.list('grp_1'); + expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { 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'); + expect(result).toEqual(mockRoute); + expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({ + data: { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' }, + include: { + sourceGroup: { select: { name: true } }, + targetGroup: { select: { name: true } }, + }, + }); + }); + + it('throws BadRequestException when sourceGroupId is empty', async () => { + await expect(service.create('', 'grp_2')).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when targetGroupId is empty', async () => { + await expect(service.create('grp_1', '')).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('deletes a route by id', async () => { + await service.remove('rt_1'); + expect(mockPrisma.syncRoute.delete).toHaveBeenCalledWith({ where: { id: 'rt_1' } }); + }); + + it('throws NotFoundException when route does not exist (Prisma P2025)', async () => { + mockPrisma.syncRoute.delete.mockRejectedValueOnce({ code: 'P2025' }); + await expect(service.remove('bad_id')).rejects.toThrow(NotFoundException); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm --filter @tower/api test -- --testPathPattern=routes.service +``` +Expected: FAIL with "Cannot find module './routes.service'" + +- [ ] **Step 3: Implement RoutesService** + +Create `apps/api/src/modules/routes/routes.service.ts`: + +```typescript +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +const routeInclude = { + sourceGroup: { select: { name: true } }, + targetGroup: { select: { name: true } }, +} as const; + +@Injectable() +export class RoutesService { + constructor(private readonly prisma: PrismaService) {} + + list(sourceGroupId?: string) { + return this.prisma.syncRoute.findMany({ + where: sourceGroupId ? { sourceGroupId } : undefined, + include: routeInclude, + orderBy: { createdAt: 'desc' }, + }); + } + + create(sourceGroupId: string, targetGroupId: string) { + if (!sourceGroupId || !targetGroupId) { + throw new BadRequestException('sourceGroupId and targetGroupId are required'); + } + return this.prisma.syncRoute.create({ + data: { sourceGroupId, targetGroupId }, + include: routeInclude, + }); + } + + async remove(id: string) { + try { + await this.prisma.syncRoute.delete({ where: { id } }); + } catch (e) { + if ((e as { code?: string })?.code === 'P2025') { + throw new NotFoundException(`Route ${id} not found`); + } + throw e; + } + } +} +``` + +- [ ] **Step 4: Run service tests to verify they pass** + +```bash +pnpm --filter @tower/api test -- --testPathPattern=routes.service +``` +Expected: PASS (7 tests) + +- [ ] **Step 5: Write the failing controller tests** + +Create `apps/api/src/modules/routes/routes.controller.spec.ts`: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { RoutesController } from './routes.controller'; +import { RoutesService } from './routes.service'; + +const mockRoute = { + id: 'rt_1', + sourceGroupId: 'grp_1', + targetGroupId: 'grp_2', + sourceGroup: { name: 'Alpha' }, + targetGroup: { name: 'Beta' }, +}; +const mockService = { + list: jest.fn().mockResolvedValue([mockRoute]), + create: jest.fn().mockResolvedValue(mockRoute), + remove: jest.fn().mockResolvedValue(undefined), +}; + +describe('RoutesController', () => { + let controller: RoutesController; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + controllers: [RoutesController], + providers: [{ provide: RoutesService, useValue: mockService }], + }).compile(); + controller = module.get(RoutesController); + }); + + it('list() delegates to service with no filter', async () => { + const result = await controller.list(undefined); + expect(result).toEqual([mockRoute]); + expect(mockService.list).toHaveBeenCalledWith(undefined); + }); + + it('list() passes sourceGroupId filter to service', async () => { + await controller.list('grp_1'); + expect(mockService.list).toHaveBeenCalledWith('grp_1'); + }); + + it('create() extracts body fields and delegates to service', async () => { + const result = await controller.create({ sourceGroupId: 'grp_1', targetGroupId: 'grp_2' }); + expect(result).toEqual(mockRoute); + expect(mockService.create).toHaveBeenCalledWith('grp_1', 'grp_2'); + }); + + it('remove() delegates id to service', async () => { + await controller.remove('rt_1'); + expect(mockService.remove).toHaveBeenCalledWith('rt_1'); + }); +}); +``` + +- [ ] **Step 6: Implement RoutesController, RoutesModule, and wire into AppModule** + +Create `apps/api/src/modules/routes/routes.controller.ts`: + +```typescript +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common'; +import { RoutesService } from './routes.service'; + +@Controller('routes') +export class RoutesController { + constructor(private readonly routesService: RoutesService) {} + + @Get() + list(@Query('sourceGroupId') sourceGroupId?: string) { + return this.routesService.list(sourceGroupId); + } + + @Post() + create(@Body() body: { sourceGroupId?: string; targetGroupId?: string }) { + return this.routesService.create(body.sourceGroupId ?? '', body.targetGroupId ?? ''); + } + + @Delete(':id') + @HttpCode(204) + remove(@Param('id') id: string) { + return this.routesService.remove(id); + } +} +``` + +Create `apps/api/src/modules/routes/routes.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { RoutesController } from './routes.controller'; +import { RoutesService } from './routes.service'; + +@Module({ + controllers: [RoutesController], + providers: [RoutesService], +}) +export class RoutesModule {} +``` + +Replace `apps/api/src/app.module.ts` in full: + +```typescript +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from './prisma/prisma.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'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + HealthModule, + SearchModule, + GroupsModule, + RoutesModule, + ], +}) +export class AppModule {} +``` + +- [ ] **Step 7: Run controller tests and full API suite** + +```bash +pnpm --filter @tower/api test -- --testPathPattern=routes.controller +``` +Expected: PASS (4 tests) + +```bash +pnpm --filter @tower/api test +``` +Expected: All API test suites pass + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/modules/routes/ apps/api/src/app.module.ts +git commit -m "feat(api): add RoutesModule with GET/POST/DELETE /routes endpoints" +``` + +--- + +### Task 3: Web dashboard layout and home page + +**Files:** +- Modify: `apps/web/app/layout.tsx` +- Modify: `apps/web/app/page.tsx` +- Modify: `apps/web/app/page.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Replace `apps/web/app/page.test.tsx` in full: + +```tsx +import { render, screen } from '@testing-library/react'; +import Home from './page'; + +describe('Home page', () => { + it('renders the TOWER heading', () => { + render(); + expect(screen.getByRole('heading', { name: /insignia tower/i })).toBeInTheDocument(); + }); + + it('renders a link to the search page', () => { + render(); + expect(screen.getByRole('link', { name: /search/i })).toHaveAttribute('href', '/search'); + }); + + it('renders a link to the groups page', () => { + render(); + expect(screen.getByRole('link', { name: /groups/i })).toHaveAttribute('href', '/groups'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify the two new ones fail** + +```bash +pnpm --filter @tower/web test -- --testPathPattern=app/page +``` +Expected: 2 failures — "renders a link to the search page" and "renders a link to the groups page" + +- [ ] **Step 3: Update layout.tsx and page.tsx** + +Replace `apps/web/app/layout.tsx` in full: + +```tsx +import type { Metadata } from 'next'; +import Link from 'next/link'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Insignia TOWER', + description: 'Community Knowledge Infrastructure Platform', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
    {children}
    + + + ); +} +``` + +Replace `apps/web/app/page.tsx` in full: + +```tsx +import Link from 'next/link'; + +export default function Home() { + return ( +
    +

    Insignia TOWER

    +

    Community Knowledge Infrastructure Platform

    +
    + +

    Search

    +

    Full-text search of approved messages

    + + +

    Groups

    +

    Manage groups and sync routes

    + +
    +
    + ); +} +``` + +- [ ] **Step 4: Run tests to verify all 3 pass** + +```bash +pnpm --filter @tower/web test -- --testPathPattern=app/page +``` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/app/layout.tsx apps/web/app/page.tsx apps/web/app/page.test.tsx +git commit -m "feat(web): add sidebar nav layout and dashboard home page" +``` + +--- + +### Task 4: Web search page + +**Files:** +- Create: `apps/web/app/search/page.tsx` +- Create: `apps/web/app/search/page.test.tsx` + +The page is an async server component. It reads `searchParams.q` and `searchParams.page`, fetches from `${API_URL}/search` server-to-server, and delegates rendering to an exported `SearchResults` component (which is pure JSX and testable with RTL). + +- [ ] **Step 1: Write the failing tests** + +Create `apps/web/app/search/page.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { SearchResults } from './page'; + +const makeHit = (id: string, content: string) => ({ + id, + content, + senderName: 'Alice', + sourceGroupName: 'UP Parivar Dallas', + tags: ['#important'], + approvedAt: 1748390400000, +}); + +describe('SearchResults', () => { + it('shows "no results" when hits array is empty', () => { + render(); + expect(screen.getByText(/no results/i)).toBeInTheDocument(); + }); + + it('renders each hit content', () => { + render( + , + ); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + expect(screen.getByText('Event tonight')).toBeInTheDocument(); + }); + + it('shows the total result count', () => { + render(); + expect(screen.getByText(/42/)).toBeInTheDocument(); + }); + + it('shows sender name and group name for each hit', () => { + render(); + expect(screen.getByText(/alice/i)).toBeInTheDocument(); + expect(screen.getByText(/UP Parivar Dallas/i)).toBeInTheDocument(); + }); + + it('shows tags for each hit', () => { + render(); + expect(screen.getByText('#important')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm --filter @tower/web test -- --testPathPattern=search/page +``` +Expected: FAIL with "Cannot find module './page'" + +- [ ] **Step 3: Implement the search page** + +Create `apps/web/app/search/page.tsx`: + +```tsx +interface MeiliHit { + id: string; + content: string; + senderName: string; + sourceGroupName: string; + tags: string[]; + approvedAt: number; +} + +interface SearchResponse { + hits: MeiliHit[]; + total: number; + page: number; + limit: number; + query: string; +} + +export function SearchResults({ + hits, + total, + q, + page, +}: { + hits: MeiliHit[]; + total: number; + q: string; + page: number; +}) { + return ( +
    +
    + + +
    + + {hits.length === 0 ? ( +

    No results{q ? ` for "${q}"` : ''}.

    + ) : ( + <> +

    + {total} result{total !== 1 ? 's' : ''} +

    +
      + {hits.map((hit) => ( +
    • +

      {hit.content}

      +
      + {hit.senderName} + · + {hit.sourceGroupName} + · + {new Date(hit.approvedAt).toLocaleDateString()} + {hit.tags.map((tag) => ( + + {tag} + + ))} +
      +
    • + ))} +
    + + )} +
    + ); +} + +export default async function SearchPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string }>; +}) { + const { q = '', page = '1' } = await searchParams; + const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; + const url = new URL(`${apiUrl}/search`); + url.searchParams.set('q', q); + url.searchParams.set('page', page); + + let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q }; + try { + const res = await fetch(url, { cache: 'no-store' }); + if (res.ok) data = await res.json(); + } catch { + // API unavailable — render empty results + } + + return ( +
    +

    Search

    + +
    + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pnpm --filter @tower/web test -- --testPathPattern=search/page +``` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/app/search/ +git commit -m "feat(web): add search page with full-text message search UI" +``` + +--- + +### Task 5: Web groups page — RouteManager, groups page, and Route Handler proxies + +**Files:** +- Create: `apps/web/app/groups/RouteManager.tsx` +- Create: `apps/web/app/groups/RouteManager.test.tsx` +- Create: `apps/web/app/groups/page.tsx` +- Create: `apps/web/app/api/routes/route.ts` +- Create: `apps/web/app/api/routes/[id]/route.ts` + +Types used throughout this task: +- `Group`: `{ id: string; name: string; platform: string }` +- `Route`: `{ id: string; sourceGroupId: string; targetGroupId: string; sourceGroup: { name: string }; targetGroup: { name: string } }` + +- [ ] **Step 1: Write the failing RouteManager tests** + +Create `apps/web/app/groups/RouteManager.test.tsx`: + +```tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { RouteManager } from './RouteManager'; + +const groups = [ + { id: 'grp_1', name: 'Alpha', platform: 'whatsapp' }, + { id: 'grp_2', name: 'Beta', platform: 'whatsapp' }, +]; + +const routes = [ + { + id: 'rt_1', + sourceGroupId: 'grp_1', + targetGroupId: 'grp_2', + sourceGroup: { name: 'Alpha' }, + targetGroup: { name: 'Beta' }, + }, +]; + +let fetchSpy: jest.SpyInstance; + +beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('RouteManager', () => { + it('renders existing routes with source → target names', () => { + render(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('renders two group select dropdowns for adding a route', () => { + render(); + expect(screen.getAllByRole('combobox')).toHaveLength(2); + }); + + it('calls POST /api/routes when Add route is submitted', async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }), + { status: 201, headers: { 'Content-Type': 'application/json' } }, + ), + ); + render(); + const [sourceSelect, targetSelect] = screen.getAllByRole('combobox'); + fireEvent.change(sourceSelect, { target: { value: 'grp_1' } }); + fireEvent.change(targetSelect, { target: { value: 'grp_2' } }); + fireEvent.click(screen.getByRole('button', { name: /add route/i })); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + '/api/routes', + expect.objectContaining({ method: 'POST' }), + ); + }); + }); + + it('shows a delete button for each existing route', () => { + render(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('calls DELETE /api/routes/:id when a route is deleted', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); + render(); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + '/api/routes/rt_1', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager +``` +Expected: FAIL with "Cannot find module './RouteManager'" + +- [ ] **Step 3: Implement RouteManager** + +Create `apps/web/app/groups/RouteManager.tsx`: + +```tsx +'use client'; + +import { useState } from 'react'; + +interface Group { + id: string; + name: string; + platform: string; +} + +interface Route { + id: string; + sourceGroupId: string; + targetGroupId: string; + sourceGroup: { name: string }; + targetGroup: { name: string }; +} + +export function RouteManager({ + groups, + initialRoutes, +}: { + groups: Group[]; + initialRoutes: Route[]; +}) { + const [routes, setRoutes] = useState(initialRoutes); + const [sourceId, setSourceId] = useState(''); + const [targetId, setTargetId] = useState(''); + const [busy, setBusy] = useState(false); + + async function addRoute() { + if (!sourceId || !targetId) return; + setBusy(true); + try { + const res = await fetch('/api/routes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }), + }); + if (!res.ok) return; + const created: Route = await res.json(); + setRoutes((prev) => [created, ...prev]); + setSourceId(''); + setTargetId(''); + } finally { + setBusy(false); + } + } + + async function deleteRoute(id: string) { + const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' }); + if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id)); + } + + return ( +
    +
    +

    Active sync routes

    + {routes.length === 0 ? ( +

    No routes configured.

    + ) : ( +
      + {routes.map((route) => ( +
    • + + {route.sourceGroup.name} + + {route.targetGroup.name} + + +
    • + ))} +
    + )} +
    + +
    +

    Add route

    +
    + + + + +
    +
    +
    + ); +} +``` + +- [ ] **Step 4: Run RouteManager tests to verify they pass** + +```bash +pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager +``` +Expected: PASS (5 tests) + +- [ ] **Step 5: Implement the groups page and route handler proxies** + +Create `apps/web/app/groups/page.tsx`: + +```tsx +import { RouteManager } from './RouteManager'; + +interface Group { + id: string; + name: string; + platform: string; +} + +interface Route { + id: string; + sourceGroupId: string; + targetGroupId: string; + sourceGroup: { name: string }; + targetGroup: { name: string }; +} + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +export default async function GroupsPage() { + const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; + const [groups, routes] = await Promise.all([ + fetchJson(`${apiUrl}/groups`), + fetchJson(`${apiUrl}/routes`), + ]); + + return ( +
    +

    Groups & Routes

    + +
    + ); +} +``` + +Create `apps/web/app/api/routes/route.ts`: + +```typescript +const API_URL = process.env.API_URL ?? 'http://localhost:3001'; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const url = new URL(`${API_URL}/routes`); + searchParams.forEach((v, k) => url.searchParams.set(k, v)); + const res = await fetch(url, { cache: 'no-store' }); + return Response.json(await res.json(), { status: res.status }); +} + +export async function POST(req: Request) { + const body = await req.json(); + const res = await fetch(`${API_URL}/routes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return Response.json(await res.json(), { status: res.status }); +} +``` + +Create `apps/web/app/api/routes/[id]/route.ts`: + +```typescript +const API_URL = process.env.API_URL ?? 'http://localhost:3001'; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const res = await fetch(`${API_URL}/routes/${id}`, { method: 'DELETE' }); + return new Response(null, { status: res.status }); +} +``` + +- [ ] **Step 6: Run the full test suite to verify everything passes** + +```bash +pnpm test +``` +Expected: All test suites pass (worker: 9, API: 8, search: 1, web: 3, config: 1) + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/app/groups/ apps/web/app/api/ +git commit -m "feat(web): add groups page with RouteManager and route handler proxies" +``` diff --git a/docs/superpowers/plans/2026-05-29-whatsapp-qr-dashboard.md b/docs/superpowers/plans/2026-05-29-whatsapp-qr-dashboard.md new file mode 100644 index 0000000..43ffce9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-whatsapp-qr-dashboard.md @@ -0,0 +1,2395 @@ +# WhatsApp QR Re-Authentication Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When WhatsApp logs out (or on first-time setup), automatically restart the auth flow and surface the QR code in the admin dashboard so any admin can re-authenticate by scanning — no terminal access needed. Admins can also add new WhatsApp accounts directly from the dashboard without touching the database or restarting the worker. + +**Architecture:** The worker's `session.ts` detects `loggedOut`, clears stale session files, and restarts the Baileys connection to generate a fresh QR; this raw QR string is written to `Account.qrCode` in the database via Prisma. The NestJS API exposes a new `/accounts` module that reads account status and converts the raw QR string to a PNG data URL using the `qrcode` package. The Next.js web app adds an Accounts page with a client component that shows a connected/disconnected badge, polls for QR images, and lets admins add new accounts. The worker polls the DB every 30 seconds for new accounts and starts sessions automatically — no restart needed. + +**Tech Stack:** Baileys (WhatsApp), Prisma 6 (PostgreSQL), NestJS 11, Next.js 16 App Router, `qrcode` npm package, React Testing Library + +--- + +## File Structure + +**Modified:** +- `apps/api/prisma/schema.prisma` — add `qrCode String?` to Account model +- `apps/worker/src/whatsapp/session.ts` — add `onQr`/`onStatus` callbacks (with JID); on `loggedOut` clear session files and restart +- `apps/worker/src/whatsapp/session-pool.ts` — thread `onQr`/`onStatus` (with JID) callbacks through `add()` +- `apps/worker/src/main.ts` — Prisma-writing handlers + extract `startAccount()` helper + 30s polling loop; initial load includes DISCONNECTED accounts +- `apps/api/src/modules/accounts/accounts.service.ts` — list + QR + create account +- `apps/api/src/modules/accounts/accounts.controller.ts` — `GET /accounts`, `GET /accounts/:id/qr`, `POST /accounts` +- `apps/api/src/app.module.ts` — add `AccountsModule` +- `apps/web/app/api/accounts/route.ts` — Next.js proxy → `GET` + `POST /accounts` +- `apps/web/app/layout.tsx` — add Accounts nav link + +**Created:** +- `apps/api/src/modules/accounts/accounts.module.ts` — NestJS module +- `apps/api/src/modules/accounts/accounts.service.spec.ts` — unit tests +- `apps/api/src/modules/accounts/accounts.controller.spec.ts` — unit tests +- `apps/web/app/api/accounts/[id]/qr/route.ts` — Next.js proxy → `GET /accounts/:id/qr` +- `apps/web/app/accounts/AccountCard.tsx` — client component with status badge + QR polling +- `apps/web/app/accounts/AccountCard.test.tsx` — unit tests +- `apps/web/app/accounts/AccountsList.tsx` — client component: manages accounts state + add account form +- `apps/web/app/accounts/AccountsList.test.tsx` — unit tests +- `apps/web/app/accounts/page.tsx` — server component + +**Test files updated:** +- `apps/worker/src/whatsapp/session.test.ts` — update `onStatus('connected')` assertion to include jid +- `apps/worker/src/whatsapp/session-pool.test.ts` — add QR and status callback threading tests + +--- + +## Task 1: Prisma Schema Migration — Add `qrCode` to Account + +**Files:** +- Modify: `apps/api/prisma/schema.prisma` + +- [ ] **Step 1: Add `qrCode` field to Account model in schema.prisma** + + Open `apps/api/prisma/schema.prisma`. The `Account` model currently ends with: + ```prisma + model Account { + id String @id @default(cuid()) + platform String + jid String + sessionPath String + displayName String? + status AccountStatus @default(ACTIVE) + groups Group[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([platform, jid]) + } + ``` + + Change it to: + ```prisma + 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 + + @@unique([platform, jid]) + } + ``` + +- [ ] **Step 2: Create and apply the migration** + + ```bash + cd apps/api && pnpm exec prisma migrate dev --name add_account_qr_code + ``` + + Expected output: + ``` + Environment variables loaded from .env + Prisma schema loaded from prisma/schema.prisma + Datasource "db": PostgreSQL database "tower_dev" at "localhost:5433" + + Applying migration `20260529000000_add_account_qr_code` + + The following migration(s) have been applied: + + migrations/ + └─ 20260529000000_add_account_qr_code/ + └─ migration.sql + + Your database is now in sync with your schema. + ``` + + `prisma migrate dev` automatically regenerates the Prisma client after migration. No separate `prisma generate` needed. + +- [ ] **Step 3: Commit** + + ```bash + git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/ + git commit -m "feat: add qrCode field to Account for QR re-auth" + ``` + +--- + +## Task 2: Worker Session — QR/Status Callbacks + Auto-Restart on Logout + +**Files:** +- Modify: `apps/worker/src/whatsapp/session.ts` +- Create: `apps/worker/src/whatsapp/session.test.ts` + +- [ ] **Step 1: Write the failing tests for session.ts** + + Create `apps/worker/src/whatsapp/session.test.ts`: + + ```typescript + import { Boom } from '@hapi/boom'; + import { createWhatsAppSession } from './session'; + import * as fs from 'fs/promises'; + + // Capture connection.update handler so tests can trigger it + let connectionUpdateHandler: (update: any) => Promise; + let credsUpdateHandler: () => void; + + const mockSock = { + ev: { + on: jest.fn().mockImplementation((event: string, handler: any) => { + if (event === 'connection.update') connectionUpdateHandler = handler; + if (event === 'creds.update') credsUpdateHandler = handler; + if (event === 'messages.upsert') { /* no-op */ } + }), + }, + end: jest.fn(), + groupFetchAllParticipating: jest.fn().mockResolvedValue({}), + }; + + jest.mock('@whiskeysockets/baileys', () => ({ + default: jest.fn().mockReturnValue(mockSock), + useMultiFileAuthState: jest.fn().mockResolvedValue({ state: {}, saveCreds: jest.fn() }), + fetchLatestBaileysVersion: jest.fn().mockResolvedValue({ version: [2, 0, 0] }), + DisconnectReason: { loggedOut: 401 }, + })); + + jest.mock('qrcode-terminal', () => ({ generate: jest.fn() })); + + jest.mock('fs/promises', () => ({ + rm: jest.fn().mockResolvedValue(undefined), + mkdir: jest.fn().mockResolvedValue(undefined), + })); + + describe('createWhatsAppSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls onQr with raw QR string when QR event fires', async () => { + const onQr = jest.fn(); + await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, onQr); + await connectionUpdateHandler({ qr: 'test-qr-string' }); + expect(onQr).toHaveBeenCalledWith('test-qr-string'); + }); + + it('calls onStatus with "connected" when connection opens', async () => { + const onStatus = jest.fn(); + await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus); + await connectionUpdateHandler({ connection: 'open' }); + expect(onStatus).toHaveBeenCalledWith('connected'); + }); + + it('calls onStatus with "disconnected" on non-logout close', async () => { + const onStatus = jest.fn(); + await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus); + const error = new Boom('Connection lost', { statusCode: 408 }); + await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } }); + expect(onStatus).toHaveBeenCalledWith('disconnected'); + }); + + it('calls onStatus with "logged_out" on loggedOut close', async () => { + const onStatus = jest.fn(); + await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn(), undefined, undefined, onStatus); + const error = new Boom('Logged out', { statusCode: 401 }); + await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } }); + expect(onStatus).toHaveBeenCalledWith('logged_out'); + }); + + it('deletes session directory on loggedOut', async () => { + await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn()); + const error = new Boom('Logged out', { statusCode: 401 }); + await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } }); + expect(fs.rm).toHaveBeenCalledWith('/sessions/1', { recursive: true, force: true }); + expect(fs.mkdir).toHaveBeenCalledWith('/sessions/1', { recursive: true }); + }); + + it('does not delete session directory on non-logout close', async () => { + await createWhatsAppSession('acc_1', '/sessions/1', jest.fn(), jest.fn(), jest.fn()); + const error = new Boom('Timeout', { statusCode: 408 }); + await connectionUpdateHandler({ connection: 'close', lastDisconnect: { error } }); + expect(fs.rm).not.toHaveBeenCalled(); + }); + }); + ``` + +- [ ] **Step 2: Run tests to verify they fail** + + ```bash + cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverage + ``` + + Expected: FAIL — `session.test.ts` has import errors or assertion failures since `session.ts` doesn't yet export the right callbacks. + +- [ ] **Step 3: Implement the changes in session.ts** + + Replace the full contents of `apps/worker/src/whatsapp/session.ts`: + + ```typescript + import makeWASocket, { + useMultiFileAuthState, + fetchLatestBaileysVersion, + DisconnectReason, + WASocket, + GroupMetadata, + } from '@whiskeysockets/baileys'; + import { Boom } from '@hapi/boom'; + import { rm, mkdir } from 'fs/promises'; + import qrcode from 'qrcode-terminal'; + import { NormalizedMessage, NormalizedReaction } from '@tower/types'; + import { normalizeMessage, normalizeReaction } from './normalizer'; + import { createLogger } from '@tower/logger'; + + const logger = createLogger('whatsapp-session'); + + export type OnMessageCallback = (msg: NormalizedMessage) => Promise | void; + export type OnReactionCallback = (reaction: NormalizedReaction) => Promise | void; + export type OnGroupsCallback = (groups: Record) => Promise | void; + export type OnQrCallback = (qr: string) => Promise | void; + export type OnStatusCallback = (status: 'connected' | 'disconnected' | 'logged_out') => Promise | void; + + export async function createWhatsAppSession( + accountId: string, + sessionPath: string, + onMessage: OnMessageCallback, + onReaction: OnReactionCallback, + onGroups: OnGroupsCallback, + onReconnect?: (newSocket: WASocket) => void, + onQr?: OnQrCallback, + onStatus?: OnStatusCallback, + ): Promise { + const { state, saveCreds } = await useMultiFileAuthState(sessionPath); + const { version } = await fetchLatestBaileysVersion(); + + const sock = makeWASocket({ + version, + auth: state, + printQRInTerminal: false, + logger: logger as any, + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', async ({ connection, qr, lastDisconnect }) => { + if (qr) { + qrcode.generate(qr, { small: true }); + await Promise.resolve(onQr?.(qr)).catch((err) => + logger.error({ err }, 'Error storing QR'), + ); + } + + if (connection === 'close') { + const reason = (lastDisconnect?.error as Boom)?.output?.statusCode; + const isLoggedOut = reason === DisconnectReason.loggedOut; + logger.info({ reason, isLoggedOut }, 'Connection closed'); + + if (isLoggedOut) { + logger.info({ accountId }, 'Logged out — clearing session and restarting for QR'); + await onStatus?.('logged_out'); + await rm(sessionPath, { recursive: true, force: true }); + await mkdir(sessionPath, { recursive: true }); + setTimeout(async () => { + const newSocket = await createWhatsAppSession( + accountId, sessionPath, onMessage, onReaction, onGroups, onReconnect, onQr, onStatus, + ); + onReconnect?.(newSocket); + }, 1000); + } else { + await onStatus?.('disconnected'); + logger.info('Reconnecting in 5s...'); + setTimeout(async () => { + const newSocket = await createWhatsAppSession( + accountId, sessionPath, onMessage, onReaction, onGroups, onReconnect, onQr, onStatus, + ); + onReconnect?.(newSocket); + }, 5000); + } + } else if (connection === 'open') { + await onStatus?.('connected'); + try { + logger.info({ accountId }, 'WhatsApp connected'); + const groups = await sock.groupFetchAllParticipating(); + await Promise.resolve(onGroups(groups)).catch((err) => + logger.error({ err }, 'Group sync error'), + ); + } catch (err) { + logger.error({ err }, 'Failed to fetch groups on connect'); + } + } + }); + + sock.ev.on('messages.upsert', ({ messages, type }) => { + if (type !== 'notify') return; + for (const msg of messages) { + if (msg.message?.reactionMessage) { + const reaction = normalizeReaction(msg, accountId); + if (reaction) { + void Promise.resolve(onReaction(reaction)).catch((err) => + logger.error({ err }, 'Error processing reaction'), + ); + } + continue; + } + const normalized = normalizeMessage(msg, accountId); + if (!normalized) continue; + void Promise.resolve(onMessage(normalized)).catch((err) => + logger.error({ err }, 'Error processing message'), + ); + } + }); + + return sock; + } + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + ```bash + cd apps/worker && pnpm test -- --testPathPattern=session.test.ts --no-coverage + ``` + + Expected: PASS — 6 tests pass. + +- [ ] **Step 5: Commit** + + ```bash + git add apps/worker/src/whatsapp/session.ts apps/worker/src/whatsapp/session.test.ts + git commit -m "feat: add onQr/onStatus callbacks to session; auto-restart on loggedOut" + ``` + +--- + +## Task 3: Worker Session Pool + main.ts — Thread Callbacks and Update DB + +**Files:** +- Modify: `apps/worker/src/whatsapp/session-pool.ts` +- Modify: `apps/worker/src/whatsapp/session-pool.test.ts` +- Modify: `apps/worker/src/main.ts` + +- [ ] **Step 1: Add QR callback threading test to session-pool.test.ts** + + Open `apps/worker/src/whatsapp/session-pool.test.ts`. Add this test at the end of the `describe` block (before the closing `}`): + + ```typescript + it('add() injects accountId into onQr callback', async () => { + const onQr = jest.fn(); + const { createWhatsAppSession } = require('./session'); + + let capturedOnQr: any; + (createWhatsAppSession as jest.Mock).mockImplementationOnce( + (_id: string, _path: string, _onMsg: any, _onReaction: any, _onGroups: any, _onReconnect: any, qrCb: any) => { + capturedOnQr = qrCb; + return Promise.resolve({ sendMessage: jest.fn(), logout: jest.fn(), end: jest.fn() }); + }, + ); + + await pool.add('acc_1', './sessions/1', jest.fn(), jest.fn(), jest.fn(), onQr); + await capturedOnQr('test-qr'); + expect(onQr).toHaveBeenCalledWith('test-qr', 'acc_1'); + }); + ``` + +- [ ] **Step 2: Run tests to verify the new test fails** + + ```bash + cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverage + ``` + + Expected: FAIL — the new `add() injects accountId into onQr callback` test fails because `pool.add()` doesn't yet accept `onQr`. + +- [ ] **Step 3: Update session-pool.ts to thread onQr and onStatus** + + Replace `apps/worker/src/whatsapp/session-pool.ts` with: + + ```typescript + import type { WASocket } from '@whiskeysockets/baileys'; + import { Boom } from '@hapi/boom'; + import { NormalizedMessage, NormalizedReaction } from '@tower/types'; + import { createWhatsAppSession } from './session'; + import { createLogger } from '@tower/logger'; + + const logger = createLogger('session-pool'); + + export type PoolMessageCallback = (msg: NormalizedMessage, accountId: string) => Promise | void; + export type PoolReactionCallback = (reaction: NormalizedReaction, accountId: string) => Promise | void; + export type PoolGroupsCallback = (groups: any, accountId: string) => Promise | void; + export type PoolQrCallback = (qr: string, accountId: string) => Promise | void; + export type PoolStatusCallback = (status: string, accountId: string) => Promise | void; + + export class WhatsAppSessionPool { + private sessions = new Map(); + + async add( + accountId: string, + sessionPath: string, + onMessage: PoolMessageCallback, + onReaction: PoolReactionCallback, + onGroups: PoolGroupsCallback, + onQr?: PoolQrCallback, + onStatus?: PoolStatusCallback, + ): Promise { + logger.info({ accountId }, 'Starting session'); + const sock = await createWhatsAppSession( + accountId, + sessionPath, + (msg) => onMessage(msg, accountId), + (reaction) => onReaction(reaction, accountId), + (groups) => onGroups(groups, accountId), + (newSocket) => { + logger.info({ accountId }, 'Session reconnected — updating pool'); + this.sessions.set(accountId, newSocket); + }, + onQr ? (qr) => onQr(qr, accountId) : undefined, + onStatus ? (status) => onStatus(status, accountId) : undefined, + ); + this.sessions.set(accountId, sock); + } + + get(accountId: string): WASocket | undefined { + return this.sessions.get(accountId); + } + + getAll(): Map { + return this.sessions; + } + + async sendMessage(accountId: string, groupJid: string, text: string): Promise { + const sock = this.sessions.get(accountId); + if (!sock) { + const available = Array.from(this.sessions.keys()).join(', ') || 'none'; + throw new Error(`No active session for account ${accountId}. Active accounts: [${available}]`); + } + await sock.sendMessage(groupJid, { text }); + } + + async remove(accountId: string): Promise { + const sock = this.sessions.get(accountId); + if (sock) { + await sock.logout().catch(() => {}); + this.sessions.delete(accountId); + logger.info({ accountId }, 'Session removed'); + } + } + + async closeAll(): Promise { + logger.info({ count: this.sessions.size }, 'Closing all WhatsApp sessions'); + for (const [accountId, sock] of this.sessions) { + try { + sock.end(new Boom('Shutdown', { statusCode: 401 })); + logger.info({ accountId }, 'Session closed'); + } catch (err) { + logger.error({ accountId, err }, 'Error closing session'); + } + } + this.sessions.clear(); + } + } + ``` + +- [ ] **Step 4: Run session-pool tests to verify all pass** + + ```bash + cd apps/worker && pnpm test -- --testPathPattern=session-pool.test.ts --no-coverage + ``` + + Expected: PASS — all tests including the new QR threading test pass. + +- [ ] **Step 5: Update main.ts to pass QR/status handlers** + + In `apps/worker/src/main.ts`, find the `pool.add(...)` call (around line 62). It currently has 5 arguments (accountId, sessionPath, onMessage, onReaction, onGroups). Add two more callbacks after the groups callback: + + ```typescript + await pool.add( + account.id, + account.sessionPath, + async (msg, accountId) => { + const tags = detectTags(msg.content, msg.senderJid, adminJids); + if (!isFlagged(tags)) return; + + const groupMap = groupMaps.get(accountId); + if (!groupMap) { + logger.error({ accountId }, 'No group map for account — message dropped'); + return; + } + const sourceGroupId = groupMap.get(msg.sourceGroupJid); + if (!sourceGroupId) { + logger.warn({ jid: msg.sourceGroupJid, accountId }, 'Unknown group — skipping message'); + return; + } + + await ingestQueue.add( + 'ingest', + { + platformMsgId: msg.platformMsgId, + platform: 'whatsapp', + accountId, + sourceGroupId, + senderJid: msg.senderJid, + senderName: msg.senderName, + content: msg.content, + tags, + }, + { attempts: 3, backoff: { type: 'exponential', delay: 1000 } }, + ); + + logger.info({ platformMsgId: msg.platformMsgId, tags }, 'Message enqueued'); + }, + async (reaction) => { + const result = await handleStarReaction(reaction, adminJids, prisma); + if (!result) return; + + const { forwardJobs, indexDoc } = result; + + await indexQueue.add('index', indexDoc, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + }); + + for (const job of forwardJobs) { + await forwardQueue.add('forward', job, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + }); + } + + logger.info( + { messageId: indexDoc.messageId, forwardCount: forwardJobs.length }, + 'Message approved — indexed and forwarded', + ); + }, + async (groups, accountId) => { + logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups'); + const map = await syncGroups(groups, accountId, prisma); + groupMaps.set(accountId, map); + }, + async (qr, accountId) => { + await prisma.account.update({ + where: { id: accountId }, + data: { qrCode: qr }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to store QR in DB')); + logger.info({ accountId }, 'QR code updated'); + }, + async (status, accountId) => { + if (status === 'connected') { + await prisma.account.update({ + where: { id: accountId }, + data: { qrCode: null, status: 'ACTIVE' }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account connected — QR cleared'); + } else if (status === 'logged_out') { + await prisma.account.update({ + where: { id: accountId }, + data: { status: 'DISCONNECTED' }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account logged out — awaiting QR scan'); + } + }, + ); + ``` + + The full `main.ts` after this change: + + ```typescript + import { PrismaClient } from '@prisma/client'; + import { createLogger } from '@tower/logger'; + import { validateEnv } from '@tower/config'; + import { createMeiliClient, configureIndex } from '@tower/search'; + import { createIngestQueue } from './queues/ingest.queue'; + import { createIngestWorker } from './queues/ingest.processor'; + import { createForwardQueue } from './queues/forward.queue'; + import { createForwardWorker } from './queues/forward.processor'; + import { createIndexQueue } from './queues/index.queue'; + import { createIndexWorker } from './queues/index.processor'; + import { WhatsAppSessionPool } from './whatsapp/session-pool'; + import { detectTags, isFlagged } from './whatsapp/tag-detector'; + import { syncGroups } from './whatsapp/group-sync'; + import { handleStarReaction } from './core/approval'; + + const logger = createLogger('tower-worker'); + + async function bootstrap() { + const env = validateEnv(); + const prisma = new PrismaClient(); + await prisma.$connect(); + + const adminJids = env.TOWER_ADMIN_JIDS + ? env.TOWER_ADMIN_JIDS.split(',').map((j) => j.trim()).filter(Boolean) + : []; + + const meiliClient = createMeiliClient(env.MEILI_URL, env.MEILI_MASTER_KEY); + await configureIndex(meiliClient).catch((err) => + logger.warn({ err }, 'Failed to configure Meilisearch index — search may be degraded'), + ); + + const ingestQueue = createIngestQueue(env.REDIS_URL); + const forwardQueue = createForwardQueue(env.REDIS_URL); + const indexQueue = createIndexQueue(env.REDIS_URL); + const pool = new WhatsAppSessionPool(); + + const ingestWorker = createIngestWorker(env.REDIS_URL, prisma); + const forwardWorker = createForwardWorker(env.REDIS_URL, pool); + const indexWorker = createIndexWorker(env.REDIS_URL, meiliClient); + + ingestWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Ingest job completed')); + ingestWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Ingest job failed')); + forwardWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Forward job completed')); + forwardWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Forward job failed')); + indexWorker.on('completed', (job) => logger.info({ jobId: job.id }, 'Index job completed')); + indexWorker.on('failed', (job, err) => logger.error({ jobId: job?.id, err }, 'Index job failed')); + + const accounts = await prisma.account.findMany({ + where: { status: 'ACTIVE', platform: 'whatsapp' }, + }); + + if (accounts.length === 0) { + logger.warn('No active WhatsApp accounts found — seed one in the Account table (see docs)'); + } + + const groupMaps = new Map>(); + + for (const account of accounts) { + groupMaps.set(account.id, new Map()); + + try { + await pool.add( + account.id, + account.sessionPath, + async (msg, accountId) => { + const tags = detectTags(msg.content, msg.senderJid, adminJids); + if (!isFlagged(tags)) return; + + const groupMap = groupMaps.get(accountId); + if (!groupMap) { + logger.error({ accountId }, 'No group map for account — message dropped'); + return; + } + const sourceGroupId = groupMap.get(msg.sourceGroupJid); + if (!sourceGroupId) { + logger.warn({ jid: msg.sourceGroupJid, accountId }, 'Unknown group — skipping message'); + return; + } + + await ingestQueue.add( + 'ingest', + { + platformMsgId: msg.platformMsgId, + platform: 'whatsapp', + accountId, + sourceGroupId, + senderJid: msg.senderJid, + senderName: msg.senderName, + content: msg.content, + tags, + }, + { attempts: 3, backoff: { type: 'exponential', delay: 1000 } }, + ); + + logger.info({ platformMsgId: msg.platformMsgId, tags }, 'Message enqueued'); + }, + async (reaction) => { + const result = await handleStarReaction(reaction, adminJids, prisma); + if (!result) return; + + const { forwardJobs, indexDoc } = result; + + await indexQueue.add('index', indexDoc, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + }); + + for (const job of forwardJobs) { + await forwardQueue.add('forward', job, { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + }); + } + + logger.info( + { messageId: indexDoc.messageId, forwardCount: forwardJobs.length }, + 'Message approved — indexed and forwarded', + ); + }, + async (groups, accountId) => { + logger.info({ count: Object.keys(groups).length, accountId }, 'Syncing groups'); + const map = await syncGroups(groups, accountId, prisma); + groupMaps.set(accountId, map); + }, + async (qr, accountId) => { + await prisma.account.update({ + where: { id: accountId }, + data: { qrCode: qr }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to store QR in DB')); + logger.info({ accountId }, 'QR code updated'); + }, + async (status, accountId) => { + if (status === 'connected') { + await prisma.account.update({ + where: { id: accountId }, + data: { qrCode: null, status: 'ACTIVE' }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account connected — QR cleared'); + } else if (status === 'logged_out') { + await prisma.account.update({ + where: { id: accountId }, + data: { status: 'DISCONNECTED' }, + }).catch((err) => logger.error({ accountId, err }, 'Failed to update account status')); + logger.info({ accountId }, 'Account logged out — awaiting QR scan'); + } + }, + ); + } catch (err) { + logger.error({ accountId: account.id, err }, 'Failed to start session — skipping account'); + } + } + + logger.info({ accountCount: accounts.length }, 'Tower worker ready'); + + const shutdown = async () => { + logger.info('Shutting down...'); + await pool.closeAll(); + await ingestWorker.close(); + await forwardWorker.close(); + await indexWorker.close(); + await ingestQueue.close(); + await forwardQueue.close(); + await indexQueue.close(); + await prisma.$disconnect(); + process.exit(0); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + } + + bootstrap().catch((err) => { + console.error('Worker failed to start', err); + process.exit(1); + }); + ``` + +- [ ] **Step 6: Run all worker tests** + + ```bash + cd apps/worker && pnpm test --no-coverage + ``` + + Expected: PASS — all worker tests pass. + +- [ ] **Step 7: Commit** + + ```bash + git add apps/worker/src/whatsapp/session-pool.ts apps/worker/src/whatsapp/session-pool.test.ts apps/worker/src/main.ts + git commit -m "feat: thread QR/status callbacks through session pool; persist to DB in main" + ``` + +--- + +## Task 4: API AccountsModule — List Accounts + QR Endpoint + +**Files:** +- Create: `apps/api/src/modules/accounts/accounts.service.ts` +- Create: `apps/api/src/modules/accounts/accounts.service.spec.ts` +- Create: `apps/api/src/modules/accounts/accounts.controller.ts` +- Create: `apps/api/src/modules/accounts/accounts.controller.spec.ts` +- Create: `apps/api/src/modules/accounts/accounts.module.ts` +- Modify: `apps/api/src/app.module.ts` + +- [ ] **Step 1: Install `qrcode` in the API** + + ```bash + pnpm add qrcode --filter @tower/api + pnpm add @types/qrcode --filter @tower/api --save-dev + ``` + + Expected: `qrcode` and `@types/qrcode` added to `apps/api/package.json`. + +- [ ] **Step 2: Write the failing service tests** + + Create `apps/api/src/modules/accounts/accounts.service.spec.ts`: + + ```typescript + import { Test, TestingModule } from '@nestjs/testing'; + import { AccountsService } from './accounts.service'; + import { PrismaService } from '../../prisma/prisma.service'; + import * as QRCode from 'qrcode'; + + jest.mock('qrcode', () => ({ + toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,fakedata'), + })); + + const mockAccounts = [ + { id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test Account', status: 'ACTIVE' }, + ]; + + const mockPrisma = { + account: { + findMany: jest.fn().mockResolvedValue(mockAccounts), + findUnique: jest.fn(), + }, + }; + + describe('AccountsService', () => { + let service: AccountsService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AccountsService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + service = module.get(AccountsService); + }); + + describe('list()', () => { + it('returns accounts from Prisma without qrCode field', async () => { + const result = await service.list(); + expect(result).toEqual(mockAccounts); + expect(mockPrisma.account.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: expect.not.objectContaining({ qrCode: true }) }), + ); + }); + }); + + describe('getQr()', () => { + it('returns null qrDataUrl when account has no qrCode', async () => { + mockPrisma.account.findUnique.mockResolvedValue({ status: 'ACTIVE', qrCode: null }); + const result = await service.getQr('acc_1'); + expect(result).toEqual({ status: 'ACTIVE', qrDataUrl: null }); + expect(QRCode.toDataURL).not.toHaveBeenCalled(); + }); + + it('converts qrCode string to data URL when qrCode is present', async () => { + mockPrisma.account.findUnique.mockResolvedValue({ status: 'DISCONNECTED', qrCode: 'raw-qr-string' }); + const result = await service.getQr('acc_1'); + expect(QRCode.toDataURL).toHaveBeenCalledWith('raw-qr-string'); + expect(result).toEqual({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fakedata' }); + }); + + it('returns not_found status when account does not exist', async () => { + mockPrisma.account.findUnique.mockResolvedValue(null); + const result = await service.getQr('nonexistent'); + expect(result).toEqual({ status: 'not_found', qrDataUrl: null }); + }); + }); + }); + ``` + +- [ ] **Step 3: Run tests to verify they fail** + + ```bash + cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverage + ``` + + Expected: FAIL — `accounts.service.ts` does not exist yet. + +- [ ] **Step 4: Implement accounts.service.ts** + + Create `apps/api/src/modules/accounts/accounts.service.ts`: + + ```typescript + import { Injectable } from '@nestjs/common'; + import { PrismaService } from '../../prisma/prisma.service'; + import * as QRCode from 'qrcode'; + + export interface AccountSummary { + id: string; + platform: string; + jid: string; + displayName: string | null; + status: string; + } + + export interface AccountQr { + status: string; + qrDataUrl: string | null; + } + + @Injectable() + export class AccountsService { + constructor(private readonly prisma: PrismaService) {} + + list(): Promise { + return this.prisma.account.findMany({ + orderBy: { createdAt: 'asc' }, + select: { id: true, platform: true, jid: true, displayName: true, status: true }, + }); + } + + async getQr(id: string): Promise { + const account = await this.prisma.account.findUnique({ + where: { id }, + select: { status: true, qrCode: true }, + }); + if (!account) return { status: 'not_found', qrDataUrl: null }; + if (!account.qrCode) return { status: account.status, qrDataUrl: null }; + const qrDataUrl = await QRCode.toDataURL(account.qrCode); + return { status: account.status, qrDataUrl }; + } + } + ``` + +- [ ] **Step 5: Run service tests to verify they pass** + + ```bash + cd apps/api && pnpm test -- --testPathPattern=accounts.service.spec.ts --no-coverage + ``` + + Expected: PASS — 4 tests pass. + +- [ ] **Step 6: Write the failing controller tests** + + Create `apps/api/src/modules/accounts/accounts.controller.spec.ts`: + + ```typescript + import { Test, TestingModule } from '@nestjs/testing'; + import { AccountsController } from './accounts.controller'; + import { AccountsService } from './accounts.service'; + + const mockAccounts = [ + { id: 'acc_1', platform: 'whatsapp', jid: '111@s.whatsapp.net', displayName: 'Test', status: 'ACTIVE' }, + ]; + const mockService = { + list: jest.fn().mockResolvedValue(mockAccounts), + getQr: jest.fn().mockResolvedValue({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,fake' }), + }; + + describe('AccountsController', () => { + let controller: AccountsController; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + controllers: [AccountsController], + providers: [{ provide: AccountsService, useValue: mockService }], + }).compile(); + controller = module.get(AccountsController); + }); + + it('list() returns accounts from service', async () => { + const result = await controller.list(); + expect(result).toEqual(mockAccounts); + expect(mockService.list).toHaveBeenCalled(); + }); + + it('getQr() calls service with the account id', async () => { + const result = await controller.getQr('acc_1'); + expect(mockService.getQr).toHaveBeenCalledWith('acc_1'); + expect(result.qrDataUrl).toBe('data:image/png;base64,fake'); + }); + }); + ``` + +- [ ] **Step 7: Run controller tests to verify they fail** + + ```bash + cd apps/api && pnpm test -- --testPathPattern=accounts.controller.spec.ts --no-coverage + ``` + + Expected: FAIL — `accounts.controller.ts` does not exist yet. + +- [ ] **Step 8: Implement accounts.controller.ts and accounts.module.ts** + + Create `apps/api/src/modules/accounts/accounts.controller.ts`: + + ```typescript + import { Controller, Get, Param } from '@nestjs/common'; + import { AccountsService } from './accounts.service'; + + @Controller('accounts') + export class AccountsController { + constructor(private readonly service: AccountsService) {} + + @Get() + list() { + return this.service.list(); + } + + @Get(':id/qr') + getQr(@Param('id') id: string) { + return this.service.getQr(id); + } + } + ``` + + Create `apps/api/src/modules/accounts/accounts.module.ts`: + + ```typescript + import { Module } from '@nestjs/common'; + import { AccountsController } from './accounts.controller'; + import { AccountsService } from './accounts.service'; + + @Module({ + controllers: [AccountsController], + providers: [AccountsService], + }) + export class AccountsModule {} + ``` + +- [ ] **Step 9: Wire AccountsModule into app.module.ts** + + Open `apps/api/src/app.module.ts`. Add the import and module: + + ```typescript + import { Module } from '@nestjs/common'; + import { ConfigModule } from '@nestjs/config'; + import { PrismaModule } from './prisma/prisma.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 { AccountsModule } from './modules/accounts/accounts.module'; + + @Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + HealthModule, + SearchModule, + GroupsModule, + RoutesModule, + AccountsModule, + ], + }) + export class AppModule {} + ``` + +- [ ] **Step 10: Run all API tests** + + ```bash + cd apps/api && pnpm test --no-coverage + ``` + + Expected: PASS — all tests pass including the new accounts tests. + +- [ ] **Step 11: Commit** + + ```bash + git add apps/api/src/modules/accounts/ apps/api/src/app.module.ts apps/api/package.json pnpm-lock.yaml + git commit -m "feat: add AccountsModule with list and QR endpoints" + ``` + +--- + +## Task 5: Web Route Handlers — Proxy Accounts Endpoints + +**Files:** +- Create: `apps/web/app/api/accounts/route.ts` +- Create: `apps/web/app/api/accounts/[id]/qr/route.ts` + +- [ ] **Step 1: Create the accounts list proxy** + + Create `apps/web/app/api/accounts/route.ts`: + + ```typescript + const API_URL = process.env.API_URL ?? 'http://localhost:3001'; + + export async function GET() { + const res = await fetch(`${API_URL}/accounts`, { cache: 'no-store' }); + return Response.json(await res.json(), { status: res.status }); + } + ``` + +- [ ] **Step 2: Create the QR proxy** + + Create `apps/web/app/api/accounts/[id]/qr/route.ts`: + + ```typescript + const API_URL = process.env.API_URL ?? 'http://localhost:3001'; + + export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const res = await fetch(`${API_URL}/accounts/${id}/qr`, { cache: 'no-store' }); + return Response.json(await res.json(), { status: res.status }); + } + ``` + +- [ ] **Step 3: Commit** + + ```bash + git add apps/web/app/api/accounts/ + git commit -m "feat: add Next.js proxy routes for accounts list and QR endpoints" + ``` + +--- + +## Task 6: Web Accounts UI — AccountCard + Page + Nav + +**Files:** +- Create: `apps/web/app/accounts/AccountCard.tsx` +- Create: `apps/web/app/accounts/AccountCard.test.tsx` +- Create: `apps/web/app/accounts/page.tsx` +- Modify: `apps/web/app/layout.tsx` + +- [ ] **Step 1: Write the failing AccountCard tests** + + Create `apps/web/app/accounts/AccountCard.test.tsx`: + + ```typescript + import { render, screen, waitFor, act } from '@testing-library/react'; + import { AccountCard } from './AccountCard'; + + const activeAccount = { + id: 'acc_1', + jid: '111@s.whatsapp.net', + displayName: 'My Account', + status: 'ACTIVE', + platform: 'whatsapp', + }; + + const disconnectedAccount = { + id: 'acc_2', + jid: '222@s.whatsapp.net', + displayName: null, + status: 'DISCONNECTED', + platform: 'whatsapp', + }; + + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('AccountCard', () => { + it('shows displayName and Connected badge when ACTIVE', () => { + render(); + expect(screen.getByText('My Account')).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('falls back to jid when displayName is null', () => { + render(); + expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument(); + }); + + it('shows Awaiting scan badge when DISCONNECTED', () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + render(); + expect(screen.getByText('Awaiting scan')).toBeInTheDocument(); + }); + + it('does not fetch QR when account is ACTIVE', () => { + render(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + render(); + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'), + ); + }); + + it('shows QR image when qrDataUrl is returned', async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + render(); + await waitFor(() => { + expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument(); + }); + expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute( + 'src', + 'data:image/png;base64,abc123', + ); + }); + + it('shows waiting message when DISCONNECTED but no QR yet', async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + render(); + await waitFor(() => { + expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument(); + }); + }); + }); + ``` + +- [ ] **Step 2: Run tests to verify they fail** + + ```bash + cd apps/web && pnpm test -- --testPathPattern=AccountCard.test.tsx --no-coverage + ``` + + Expected: FAIL — `AccountCard.tsx` does not exist yet. + +- [ ] **Step 3: Implement AccountCard.tsx** + + Create `apps/web/app/accounts/AccountCard.tsx`: + + ```tsx + 'use client'; + import { useEffect, useState } from 'react'; + + interface Account { + id: string; + jid: string; + displayName: string | null; + status: string; + platform: string; + } + + export function AccountCard({ account }: { account: Account }) { + const [qrDataUrl, setQrDataUrl] = useState(null); + const isDisconnected = account.status === 'DISCONNECTED'; + + useEffect(() => { + if (!isDisconnected) { + setQrDataUrl(null); + return; + } + + async function fetchQr() { + const res = await fetch(`/api/accounts/${account.id}/qr`); + if (!res.ok) return; + const data = await res.json(); + setQrDataUrl(data.qrDataUrl ?? null); + } + + fetchQr(); + const interval = setInterval(fetchQr, 5000); + return () => clearInterval(interval); + }, [account.id, isDisconnected]); + + return ( +
    +
    +
    +

    {account.displayName ?? account.jid}

    +

    {account.jid}

    +
    + + {account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'} + +
    + + {isDisconnected && qrDataUrl && ( +
    +

    + Open WhatsApp → Linked Devices → Link a Device → scan below +

    + WhatsApp QR Code +
    + )} + + {isDisconnected && !qrDataUrl && ( +

    Waiting for QR code from worker...

    + )} +
    + ); + } + ``` + +- [ ] **Step 4: Run AccountCard tests to verify they pass** + + ```bash + cd apps/web && pnpm test -- --testPathPattern=AccountCard.test.tsx --no-coverage + ``` + + Expected: PASS — all 7 tests pass. + +- [ ] **Step 5: Implement accounts/page.tsx** + + Create `apps/web/app/accounts/page.tsx`: + + ```tsx + import { AccountCard } from './AccountCard'; + + interface Account { + id: string; + jid: string; + displayName: string | null; + status: string; + platform: string; + } + + export default async function AccountsPage() { + const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; + let accounts: Account[] = []; + try { + const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' }); + if (res.ok) accounts = await res.json(); + } catch {} + + return ( +
    +

    Accounts

    + {accounts.length === 0 ? ( +

    No accounts found.

    + ) : ( +
    + {accounts.map((a) => ( + + ))} +
    + )} +
    + ); + } + ``` + +- [ ] **Step 6: Add Accounts link to layout.tsx** + + Open `apps/web/app/layout.tsx`. Find the `