Compare commits

..

61 Commits

Author SHA1 Message Date
maaz519 73956fd1e3 feat: route-centric rules, approve popup, emoji picker, 5-column sync table
- Schema: SyncRouteRuleMap junction table, ruleIntent on TenantRule,
  ForwardFormat/Frequency/ApprovalMode columns on SyncRoute, INTEREST
  match type
- Worker: remove global AUTO_APPROVE forwarding; per-route rule evaluation
  in ingest processor — POSITIVE rules auto-forward that route, NEGATIVE
  rules block it, fallback to approvalMode
- API: PATCH /routes/:id, rule assign/unassign endpoints, approve now
  accepts targetGroupIds, new GET /messages/:id/routes for popup data
- Web: 5-column route table with settings + rules dialogs, ForwardTarget
  popup with rule-based pre-selection, ruleIntent toggle + emoji dropdown
  (👍/👎 only) + INTEREST type in rules manager

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:58:02 +05:30
maaz519 e598073dc7 feat: enrich AI stream with replyToMessageId, sourceGroupName, organizationId + tenant/source filters
- Extract quotedPlatformMsgId from Baileys contextInfo.stanzaId in normalizer
- Thread quotedPlatformMsgId through IngestJobData → ingest processor → Message record
- Widen ingest processor group/tenant selects to include name and organizationId
- Add replyToMessageId, sourceGroupName, organizationId to StreamMessagePayload and XADD
- Update StreamRecord, parseFields, and streamMessages in AI stream service with new fields
- Add optional ?tenant= and ?source= SSE filters; cursor advances for filtered entries
  so Last-Event-ID resume remains accurate across reconnects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 01:07:42 +05:30
maaz519 5a0f7aa58f fix: route browser→API calls through BFF to avoid CORS
- Create /api/onboard/request-otp and /api/auth/member/login BFF routes
- Update OnboardingForm and member-login page to call BFF instead of API directly
- Add vercel.json with monorepo build config and rootDirectory hint
- Add .vercelignore to exclude .turbo, node_modules, .next (was uploading 2.3GB)
- Add apps/web/.gitignore and update root .gitignore for tsbuildinfo files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:01:05 +05:30
maaz519 a3798b3059 feat: show tenancy scope (Org → Chapter) in chapter & member portals
Surface where the logged-in user sits in the hierarchy via a ScopeCard
in the sidebar, below the brand mark.

API:
- Enrich chapter login/me responses with tenantName + organization
  (LoginResponse.admin, AdminProfile types updated)
- Add GET /my/scope returning member's chapter + organization

Web:
- ScopeCard component (Organisation row + Chapter row, themed icons)
- Chapter portal: scope from auth context, blue accent
- Member portal: scope fetched server-side in layout, emerald accent
- PortalShell gains a `scope` slot rendered under the brand

Org and admin portals intentionally omit it — org IS the scope, admin is
platform-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:27:23 +05:30
maaz519 205418fc4e feat: isolate all four portals with route groups + polished design system
Fix blank /org/login (and /admin/login): the guarded portal layout was
wrapping its own login route, rendering null when unauthenticated. Move
each portal's authed pages into a (authed)/(chapter) route group so login
pages live outside the guard.

Structure:
- app/(chapter)/* — chapter admin pages + guarded layout (was root-level)
- app/org/(authed)/* — org pages + guarded layout; /org/login now free
- app/admin/(authed)/* — admin pages + guarded layout; /admin/login free
- Root layout slimmed to providers only (no shared sidebar)
- Convert moved files' relative imports to @/app alias

Design system:
- PortalShell: shared sidebar shell (brand mark, themed active nav with
  accent bar, avatar dropdown user menu, loading skeletons)
- portal-theme.ts: per-portal theme tokens (violet/slate/blue/emerald)
- PortalLoginShell redesigned: two-column with gradient branding panel,
  dotted pattern, value-prop highlights, built-in back link
- New shadcn components: Avatar, DropdownMenu, Skeleton
- Move shared DraftCard to _components/draft-card.tsx (used by 2 portals)
- Portal selector: remove Super Admin card, icon tiles, hover lift
- All 4 login pages use react-hook-form + Zod + themed shell
- Member nav restored to full 11-section list with icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:20:06 +05:30
maaz519 d7b5988858 feat: portal redesign — shadcn/ui, React Query, Zod, isolated portals
Foundation:
- CLAUDE.md with full coding rules (tech stack, patterns, redirect rules)
- Install @tanstack/react-query, zod, react-hook-form, shadcn/ui deps
- CSS variables design system (Tailwind v4 + tw-animate-css)
- Shared components: Button, Input, Label, Card, Badge, Separator, Form
- PortalLoginShell: two-column login layout (branding left, form right)
- PortalNav: shared sidebar nav used by all portals
- ReactQueryProvider in root layout
- api-client.ts: typed fetch wrapper (no raw fetch in components)

Portal isolation:
- Sidebar now returns null for all non-chapter routes; portals own their layout
- Admin layout: auth guard + PortalNav (slate accent)
- Org layout: auth guard + PortalNav (violet accent)
- Member layout: server-side cookie check → MemberNav client component
- Chapter admin sidebar: PortalNav (blue accent)

Login pages (all use react-hook-form + Zod + PortalLoginShell):
- /login → defaults next to /search (was /) — fixes redirect bug
- /admin/login → replaces /admin
- /org/login → replaces /org
- /member-login → two-step phone+OTP with proper form validation

Portal selector (/) redesigned with accent border-left cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:01:27 +05:30
maaz519 f8b7afcc38 feat: bot assign UI in super admin bot pool page
Click "Assign" on any bot row to expand an inline form with a chapter
dropdown. Selecting a chapter and confirming calls POST /api/admin/bots/:id/assign
with the tenantId. The chapter list is loaded alongside bots on mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:37:08 +05:30
maaz519 8d720278cb feat: portal selector home page + remove self-serve signup
Replace the home page with a portal selector showing three access paths:
- Organisation Portal → /org/login
- Chapter Portal → /login
- Member Portal → /member-login

Remove /signup and its BFF route — tenants are now only created by org
owners through the org portal or assigned by super admins. Self-serve
community creation is no longer supported.

Update sidebar to bypass auth guard for / (exact match) without
accidentally matching all paths via startsWith.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:28:38 +05:30
maaz519 6bff55db64 feat: org admin can create chapter (tenant) admins
Add POST /org/tenants/:id/admins so org admins can provision the first
login for a chapter's admin team. New chapter admins can then sign in at
/login with email + password.

- OrgService.createTenantAdmin — validates chapter belongs to org, hashes
  password, creates Admin record
- OrgController POST tenants/:id/admins endpoint
- BFF route /api/org/tenants/[id]/admins
- Add Admin form on /org/tenants/[id] page (email, password, role select)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:21:50 +05:30
maaz519 b26a635283 fix: exclude /org paths from tenant auth guard in sidebar
/org/* is the org-admin portal — it has its own auth context and should
not redirect to /login. Add ORG_PATHS bypass and render null sidebar
on those routes (same pattern as /my/*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:17:54 +05:30
maaz519 484ae7bf9d fix: add OrgAdminProvider to root layout
/org/login was throwing "useOrgAdmin must be used within <OrgAdminProvider>"
because the provider was never mounted in the app shell.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:15:21 +05:30
maaz519 8a61908df1 fix: accept role field when creating OrgAdmin
ValidationPipe forbids unknown properties; the UI sends role so the DTO
must declare it. Pass it through to the service so ORG_OWNER/ORG_ADMIN
is honoured on creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:08:58 +05:30
maaz519 abb6799c3d feat: admin org management API + member login fixes
Add NestJS endpoints the admin panel org UI was calling (all returning 404):
- GET/POST /admin/orgs — list and create organizations
- GET /admin/orgs/:id — org detail with tenants and orgAdmins
- POST /admin/orgs/:id/admins — create OrgAdmin with hashed password
- PATCH /admin/tenants/:id/org — assign/unassign tenant from org

Also ship the member login page and sidebar fix from the previous session:
- Move /my/login to /member-login to break infinite redirect loop
- Add /member-login to PUBLIC_PATHS in sidebar so it is not intercepted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:03:51 +05:30
maaz519 a1d2c6e6c2 feat: member phone login — POST /public/auth/member-login + /my/login page
Returning members can now sign in with just their phone number.
The bot DMs them a 6-digit OTP; on verify a 30-day session cookie is set.
First-time users are directed to their invite link from the login page.

- Make OtpChallenge.groupId optional (migration) for re-login challenges
- Add memberLogin / memberVerify service methods
- Add POST /public/auth/member-login and /member-verify controller endpoints
- Add /api/my/login BFF route (sets tower_member_token cookie)
- Add /my/login page (phone → OTP two-step form)
- /my/* now redirects to /my/login instead of /onboard on no session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 16:37:19 +05:30
maaz519 e61b0dfebd fix: remove Redis password auth (internal network only, no public port) 2026-06-20 13:37:30 +05:30
maaz519 c851bd9678 fix: pass full REDIS_URL to streamRedis so password is included 2026-06-20 13:30:48 +05:30
maaz519 530f81416a feat: AI dev stream over HTTPS (SSE read + draft writeback)
Replaces the Redis-over-Tailscale handoff with a far simpler HTTP surface
on the existing public API — no Redis client, Tailscale, or firewall changes
needed on the dev side.

- GET /ai/stream — SSE feed of every ingested message, reads the internal
  Redis stream; resumes from Last-Event-ID on reconnect (no missed messages);
  heartbeats keep the connection warm through proxies
- POST /ai/drafts — writeback: creates a ContentDraft (PENDING_REVIEW) that
  shows up in the admin /admin/drafts review UI
- AiStreamGuard — static bearer token auth (AI_STREAM_TOKEN), accepts header
  or ?token= query for browser EventSource
- Remove Tailscale sidecar + public Redis port; Redis stays internal-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 13:06:17 +05:30
maaz519 7cafbc8ba6 feat: reach Redis over Tailscale instead of public port (OCI firewall workaround)
- Add tailscale-redis sidecar sharing Redis network namespace
- Remove public 6380:6379 port mapping — nothing exposed to internet
- Redis now reachable on tailnet as tower-redis:6379 with password auth
- Tailscale uses only outbound connections, so no OCI Security List or
  host firewall changes needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:58:45 +05:30
maaz519 a0e5629f77 fix: use port 6380 for Redis external binding (6379 already allocated on host)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:49:40 +05:30
maaz519 4fdd8160fd feat: expose Redis externally with password auth for AI dev stream access
- Add requirepass to Redis with REDIS_PASSWORD env var
- Expose port 6379:6379 for external connections
- Update REDIS_URL in api and worker to include password
- Update healthcheck to pass auth flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:58:54 +05:30
maaz519 7fd425959f fix: resolve worker build errors blocking Docker deploy
- Add OPENROUTER_API_KEY (optional) to env schema so tsc can resolve it
- Add pg + @types/pg to worker deps for outbox-listener
- Explicit Error type on pg client error handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:06:48 +05:30
maaz519 d21bd6bcb4 feat: add Redis Stream handoff for AI dev + monitor all messages
- Publish every non-spam message to tower:stream:messages immediately
  after DB insert in ingest.processor — before any admin approval or
  routing decision
- Remove early tags.length === 0 drop in main.ts so no-rule-match
  messages are now stored and streamed (monitor-all behaviour)
- Wire classify worker, review/p1/dnc queues into main.ts
- Add streams/message-stream.ts with XADD wrapper (capped at ~50k entries)
- Add docs/DATA_MODELS.md — full data model reference for all 29 models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:56:10 +05:30
maaz519 6de78fe270 docs: add full architecture reference for onboarding developers
826-line doc covering: tech stack, tenancy hierarchy, full message
pipeline (G1+G2), all 55 DB models with field descriptions, 4 auth
realms, all API endpoints, member portal routes, AI dev handoff spec
(queue payloads + how to write ContentDraft output back), environment
variables, repo structure, migration workflow, and known gaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:45:47 +05:30
maaz519 39ca91f07a feat: auto-detect events, seva tasks, and FAQs from WhatsApp messages
Adds the AI-driven content pipeline so portal content is never manually
created — the system proposes it, admins review and approve.

Worker:
- `tower.event.extract` queue + Event Extractor processor: LLM extracts
  event title/date/time/location from messages with #event hashtag,
  date/time patterns, RSVP phrases, or location terms; seva action items
  within event messages become separate SEVA_TASK drafts
- `tower.faq.candidate` queue + FAQ Curator processor: LLM extracts Q&A
  pairs from messages with question patterns + meaningful answer length
- `classify.processor.ts`: `isEventCandidate()` and `isFaqCandidate()`
  run after rule matching on all non-spam, non-DNC messages; candidates
  are enqueued to the extraction lanes in parallel with normal routing
- `match-rules.ts`: add P1 to TenantRuleRow action union so P1 rules
  are fully typed end-to-end
- `NormalizedMessage`: add `quotedPlatformMsgId` field
- `IngestJobData.effectiveAction`: add `'P1'` to the union

Schema:
- `ContentDraft` model with `DraftType` (EVENT|SEVA_TASK|FAQ_ITEM) and
  `DraftStatus` (PENDING_REVIEW|APPROVED|REJECTED); carries `proposedJson`
  from the LLM, `confidence` score, and `publishedId` after approval
- Migration: `20260617270000_add_content_drafts`

API:
- `DraftsModule`: `GET /admin/drafts`, `POST /admin/drafts/:id/approve`,
  `POST /admin/drafts/:id/reject`; approve creates the actual
  Event/SevaOpportunity/KnowledgeItem and marks the draft APPROVED;
  reject marks it REJECTED — both audit-logged

Web:
- `/admin/drafts` and `/drafts` — tabbed view by type (All/Events/Seva/FAQ)
- `DraftCard` — shows source message excerpt, AI proposed content, and
  approve/reject buttons; approve/reject immediately refresh the list
- "AI Drafts" added to both super-admin and tenant-admin sidebars
- "AI Content Drafts" button added to admin dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:49:57 +05:30
maaz519 3e86e0ffc0 feat: member portal Sprint 11 — Memories / Gallery
- GalleryAlbum + GalleryItem models + migration (publish gate, cover image, ordered items)
- GalleryModule: admin CRUD for albums + items
- Member endpoints: GET /my/memories (published albums w/ cover + count), GET /my/memories/:id (album + photos)
- /my/memories page: album grid with cover thumbnails
- /my/memories/[id] page: photo grid with captions
- Memories nav item; register GalleryModule

Completes Phase 2 (Intelligence) of the member portal — Sprints 9-11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:10:27 +05:30
maaz519 5801cbf85b feat: member portal Sprint 10 — Knowledge Base
- KnowledgeBoard + KnowledgeItem models + migration (DRAFT/PUBLISHED/ARCHIVED status, slug per tenant)
- KnowledgeModule: admin CRUD for boards + items (auto-slug, status workflow)
- Member endpoint GET /my/knowledge?q= — published items grouped by board, optional text search over question/answer
- /my/knowledge page: search box + collapsible boards with accordion FAQ items
- KnowledgeItem accordion + KnowledgeSearch (debounced URL-driven) client components
- Knowledge nav item; register KnowledgeModule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:06:26 +05:30
maaz519 f61c070428 feat: member portal Sprint 9 — Ask AI (RAG over message archive)
- AskAIQuery model + migration (logs question/answer/citations per member)
- AskAIService: RAG pipeline — retrieve via tenant-isolated SearchService (Meili),
  ground LLM in top-8 snippets with inline [n] citations, degrade gracefully when
  OPENROUTER_API_KEY absent or no context (snippet fallback)
- Shared api/common/llm-client.ts (mirrors worker's OpenRouter client)
- SearchModule now exports SearchService
- Member endpoints: GET/POST /my/ask (history + ask)
- /my/ask page + AskChat client component: question box, answer cards with sources
- Ask AI nav item (top of member nav)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:03:01 +05:30
maaz519 6d89d8a609 feat: member portal Sprint 8 — Member Directory
- GET /my/directory in MyService/MyController with q + interest filters
- Respects directoryVisible; excludes self; never exposes jid/phone
- q searches displayName/hometown/currentLocation (case-insensitive);
  interest filter via array `has`; aggregates top-20 interest filter chips
- BFF route forwards query string
- /my/directory page: server-rendered cards (avatar initials, location, interest tags)
- DirectorySearch client component: debounced search box + toggleable interest chips that drive URL params
- Directory nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:55:30 +05:30
maaz519 57fe9d9bf1 feat: member portal Sprint 7 — Circles (interest/affinity sub-groups)
- Circle + CircleMembership models + migration (CircleRole: MEMBER/LEAD, public/invite-only)
- CirclesModule: admin CRUD + member list; unique circle name per tenant
- Member endpoints in MyController: GET /my/circles, POST /my/circles/:id/join|leave
- listForMember returns public circles + private ones the member belongs to, with isMember/myRole/memberCount
- join() enforces invite-only; leave() removes membership
- /my/circles page: "My circles" + "Discover" split, join/leave button, LEAD + Private badges
- Member + admin BFF routes; Circles nav item
- Register CirclesModule; add CIRCLE_CREATED/DELETED audit actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:52:04 +05:30
maaz519 cd1eef0098 feat: member portal Sprint 6 — Seva + multi-signal gamification
- SevaOpportunity + SevaEntry + GamificationEvent models + migration
- GamificationService: multi-signal point table (helpful answer +10, seva +20,
  event +5, FAQ +15, welcome +3, profile +5, business +5), idempotent award via
  unique(userId,type,refId), lifetime + time-decayed "recent" score, leaderboard
- SevaModule: admin CRUD + mark-entry-complete (awards SEVA_COMPLETED points)
- Member endpoints in MyController: GET /my/seva, POST /my/seva/:id/signup|cancel
- PROFILE_COMPLETED auto-awarded on profile update (name+hometown+interest)
- /my/seva page: points hero with per-type breakdown, open opportunities w/ signup,
  leaderboard (respects directoryVisible), my seva history
- Member + admin BFF routes; Seva & Points nav item
- Register GamificationModule + SevaModule; add SEVA_* audit actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:44:43 +05:30
maaz519 a0dc94ce79 feat: member portal Sprint 5 — onboarding portal experience + restore Thread schema
- Redesign onboarding into a 5-step wizard: welcome → phone → consent → code → done
- Progress bar, human-readable consent scopes (INGEST/ARCHIVE/REPLICATE/DISPLAY) with descriptions
- Resend-code action, back navigation, indigo theme matching member portal
- "Done" step introduces portal features (digests, events, privacy) with CTA
- Onboard page shell: rounded card, policy footer
- Restore Thread model + Message.expiresAt/quotedPlatformMsgId/threadId + RAW/DNC statuses to schema.prisma (wiped by prior reset; matches existing add_hybrid_ai_architecture migration) — unblocks ThreadsModule registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:30:58 +05:30
maaz519 0ffdc362b5 feat: member portal Sprint 4 — Events + RSVP
- Event + EventRsvp models + migration (RsvpStatus enum: GOING/MAYBE/NOT_GOING)
- EventsModule: admin CRUD (create/update/delete/publish) + RSVP list
- GET /my/events + POST /my/events/:id/rsvp in MyController/MyService
- Admin BFF routes: GET/POST /api/admin/events, PATCH/DELETE /api/admin/events/[id]
- Member BFF routes: GET /api/my/events, POST /api/my/events/[id]/rsvp
- /my/events page: upcoming/past split, RSVP button (client component, optimistic)
- Register DigestModule, OrgModule, ThreadsModule, EventsModule in AppModule
- Add EVENT_CREATED/DELETED/UPDATED and DIGEST_SENT to AuditAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:20:11 +05:30
maaz519 f7922454ca feat: member portal Sprint 3 — profile edit form and privacy center
- PATCH /my/profile API endpoint (displayName, hometown, currentLocation, interests, language, digestPreference, directoryVisible)
- BFF PATCH /api/my/profile route
- ProfileForm client component: interest chips, digest preference, language, directory toggle
- Settings page rewrite: profile edit + privacy center (consent list + opt-out) + session + account delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:06:20 +05:30
maaz519 bc9fe7c145 feat: member portal Sprint 2 — digest history page and restore Digest/DigestConfig schema models
- Restore Digest + DigestConfig models to schema.prisma (wiped by prior git reset)
- Add Tenant.digestConfig / Tenant.digests relations
- GET /my/digest API endpoint (last 20 digests, truncated summary)
- BFF route /api/my/digest
- /my/digest page: digest cards with date, message count, summary preview
- Add Digest nav item to MemberShell sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:04:31 +05:30
maaz519 a685f8b4d5 feat: add spam detection lane (tower.classify.spam) with content-aware filtering
- Add SpamJobData + sync missing job types (DncJobData, ClassifyJobData, P1JobData, etc.) from dist back to source in @tower/types
- spam-detector.ts: emoji-only, short-greeting, link-only detection + duplicate-hash (1h window)
- spam.queue.ts / spam.processor.ts: BullMQ tower.classify.spam lane
- classify.processor.ts: spam check (Step 0) before rule engine, accepts spamQueue param
- main.ts: wire spamQueue + spamWorker; spam pre-filter before ingestQueue.add
- dnc.processor.ts: labelled reason logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:00:48 +05:30
maaz519 566690bd04 feat: member portal Sprint 1 — dashboard with MemberShell layout and TowerUser profile fields
- Add avatar, hometown, currentLocation, interests, language, digestPreference, directoryVisible fields to TowerUser
- Migration: 20260617200000_add_tower_user_profile_fields
- GET /my/dashboard API endpoint with stats (groups, messages, consents)
- BFF route /api/my/dashboard
- MemberShell 3-column layout (220px nav + flex-1 main + 280px rail)
- Dashboard page: hero card, stat cards, group list, interests chips
- Sidebar returns null for /my paths (MemberShell owns the nav)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:44:58 +05:30
maaz519 622737fdca feat: add web app pages for org portal, thread viewer, and admin org management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:40:40 +05:30
maaz519 435b0e7806 feat: add API modules for digest scheduling, org admin portal, and thread views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:40:32 +05:30
maaz519 47f345c4bf feat: add worker queue fabric — classify, dnc, p1, review, digest, thread-resolve, outbox, AI modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:40:28 +05:30
maaz519 6aec093516 feat: add org layer to schema and export OrgAdminJwtPayload, adapter, hash from types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:40:24 +05:30
maaz519 978f7effc0 feat: add prisma migrations for p1/digest models, hybrid AI, outbox, and org layer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:40:20 +05:30
maaz519 4f1df96b34 fix: add retry loop for database connection in entrypoint 2026-06-09 16:50:56 +05:30
maaz519 1b63f62ca0 fix: add retry loop for database connection in entrypoint 2026-06-09 16:47:54 +05:30
maaz519 e66f198785 fix: add retry loop for database connection in entrypoint 2026-06-09 16:41:47 +05:30
maaz519 d6da151d16 fix: add retry loop for database connection in entrypoint 2026-06-09 16:35:27 +05:30
maaz519 50026c8a95 fix: add retry loop for database connection in entrypoint 2026-06-09 16:29:46 +05:30
maaz519 9761564c22 fix: shrink images + fix prisma generate in runner 2026-06-09 16:21:55 +05:30
maaz519 9ac3e29a20 fix: add @prisma/client to root deps for Docker runner 2026-06-09 16:09:29 +05:30
maaz519 c8943e8d88 fix: use turbo build in worker Dockerfile for dependency resolution 2026-06-09 15:56:56 +05:30
maaz519 000a06fa0e fix: add prisma to dependencies for Docker build 2026-06-09 15:51:32 +05:30
maaz519 f9d5749dba added changes 2 2026-06-09 15:45:34 +05:30
maaz519 ff4d0f90e8 added changes 2026-06-09 15:33:21 +05:30
maaz519 249d759e6a good forst commit 2026-06-09 02:02:40 +05:30
maaz519 801c1d7121 fix: create new accounts with DISCONNECTED status so QR displays immediately 2026-05-29 11:57:42 +05:30
maaz519 afff6fdbdf feat: add POST /accounts endpoint to create new WhatsApp account records
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:50:01 +05:30
maaz519 2f88e883b2 feat: add AccountsList with Add Account form; proxy POST /accounts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:49:23 +05:30
maaz519 952a0e9b49 feat: pass JID on connect; extract startAccount() helper; poll 30s for new accounts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:44:54 +05:30
maaz519 e8aaae4188 feat: add Accounts page with QR code display for WhatsApp re-authentication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:40:48 +05:30
maaz519 759b49159e feat: add Next.js proxy routes for accounts list and QR endpoints 2026-05-29 11:37:48 +05:30
maaz519 1dba77959d feat: add AccountsModule with list and QR endpoints
Implements GET /accounts (list all accounts) and GET /accounts/:id/qr
(returns QR code as base64 data URL) using the qrcode package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:15:42 +05:30
maaz519 02dad1347c feat: thread QR/status callbacks through session pool; persist to DB in main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:08:51 +05:30
413 changed files with 31102 additions and 788 deletions
+38 -1
View File
@@ -73,7 +73,44 @@
"Bash(grep -v \"^$\")", "Bash(grep -v \"^$\")",
"Bash(npm info *)", "Bash(npm info *)",
"Bash(pnpm --filter @tower/search test)", "Bash(pnpm --filter @tower/search test)",
"Bash(pnpm --filter @tower/search build)" "Bash(pnpm --filter @tower/search build)",
"Bash(pnpm --filter @tower/worker test -- --testPathPattern approval)",
"Bash(pnpm --filter @tower/worker test -- approval)",
"Bash(xargs ls -la)",
"Bash(xargs ls)",
"Bash(pnpm --filter @tower/worker test -- --testPathPattern=index.processor)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('dependencies',{}\\), indent=2\\)\\)\")",
"Bash(pnpm --filter @tower/worker test index.processor)",
"Bash(pnpm --filter @tower/api test)",
"Bash(pnpm --filter @tower/api build)",
"Bash(pnpm --filter @tower/api test -- search.controller.spec.ts)",
"Bash(pnpm --filter @tower/api test -- search.service.spec.ts)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=groups.service)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=groups)",
"Bash(pnpm --filter @tower/api exec jest --testPathPattern=groups)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=routes.service)",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=\"routes/routes.service\")",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=\"routes\")",
"Bash(pnpm --filter @tower/api test -- --testPathPattern=routes)",
"Bash(pnpm --filter @tower/api exec jest --testPathPattern=routes)",
"Bash(pnpm --filter @tower/web test -- --testPathPattern=app/page)",
"Bash(pnpm --filter @tower/web test -- --testPathPattern 'app/page')",
"Bash(pnpm --filter @tower/web test -- --testPathPattern=search/page)",
"Bash(pnpm --filter @tower/web test -- apps/web/app/search/page.test.tsx)",
"Bash(pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager)",
"Bash(git -C /Users/maaz/Documents/insignia-work/tower status)",
"Bash(git -C /Users/maaz/Documents/insignia-work/tower show --stat HEAD)",
"Bash(pnpm -r build)",
"Bash(pnpm exec *)",
"Bash(pnpm add *)",
"Bash(mkdir -p /Users/maaz/Documents/insignia-work/tower/apps/web/app/api/accounts/\\\\[id\\\\]/qr)",
"Bash(pnpm jest *)",
"Bash(cd /Users/maaz/Documents/insignia-work/tower/apps/api && pnpm test --no-coverage 2>&1 | tail -15 && cd ../worker && pnpm test --no-coverage 2>&1 | tail -15 && cd ../web && pnpm test --no-coverage 2>&1 | tail -15)",
"Read(//Users/maaz/Documents/insignia-work/**)",
"Bash(psql postgresql://tower:tower_dev@localhost:5433/tower_dev -c \"SELECT id, name, platform, \\\\\"accountId\\\\\" FROM \\\\\"Group\\\\\" LIMIT 10;\")"
],
"additionalDirectories": [
"/Users/maaz/Documents/insignia-work/tower/apps/web/app/api/accounts/[id]"
] ]
} }
} }
+12
View File
@@ -0,0 +1,12 @@
node_modules
.git
.gitignore
*.md
.env
.env.local
.env.*.local
dist
.next
coverage
.turbo
sessions
+22 -1
View File
@@ -20,4 +20,25 @@ LOG_LEVEL=debug
# WhatsApp # WhatsApp
WHATSAPP_SESSION_PATH=./sessions WHATSAPP_SESSION_PATH=./sessions
TOWER_ADMIN_JIDS=
# TOWER Portal (used by worker command-handler to construct onboarding links)
TOWER_PORTAL_BASE_URL=http://localhost:3000
# Auth
BCRYPT_ROUNDS=10
JWT_EXPIRES_IN=7d
MEMBER_JWT_EXPIRES_IN=30d
# Default seed admin (only used in dev)
SEED_ADMIN_EMAIL=admin@tower.local
SEED_ADMIN_PASSWORD=tower_dev_password
# SMTP (optional — leave SMTP_HOST blank to skip email notifications).
# Defaults shown are for Ethereal (https://ethereal.email), a fake SMTP for testing.
# Generate fresh creds at https://ethereal.email/create and paste them below.
SMTP_HOST=smtp.ethereal.email
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=garrett.padberg@ethereal.email
SMTP_PASS=c93RRyQMb9WFysYZ6q
SMTP_FROM=TOWER <noreply@tower.local>
+3
View File
@@ -5,4 +5,7 @@ dist
coverage coverage
.env .env
*.env.local *.env.local
.env.production
sessions/ sessions/
*.tsbuildinfo
.vercel
+14
View File
@@ -0,0 +1,14 @@
node_modules
.turbo
.next
dist
build
coverage
*.tsbuildinfo
.git
apps/worker/sessions
apps/api/sessions
sessions
.env
.env.*
!.env.example
+171
View File
@@ -0,0 +1,171 @@
# TOWER — Claude Code Rules
## Project Overview
TOWER is a multi-tenant WhatsApp community management platform. It has four portals served by a single Next.js app and a single NestJS API:
| Portal | Path prefix | Auth cookie | Guard |
|--------|------------|-------------|-------|
| Portal selector | `/` | — | public |
| Chapter admin | `/`, `/search`, `/groups`, `/messages`, `/drafts`, `/settings`, `/threads`, `/claim-group` | `tower_token` | `JwtAuthGuard` |
| Organisation admin | `/org/*` | `tower_org_token` | `OrgAdminGuard` |
| Super admin | `/admin/*` | `tower_super_token` | `SuperAdminGuard` |
| Member | `/my/*`, `/onboard`, `/member-login` | `tower_member_token` | `MemberAuthGuard` |
## Tech Stack
- **Next.js 16** (App Router, TypeScript) — `apps/web`
- **NestJS** (TypeScript) — `apps/api`
- **Prisma** — ORM, PostgreSQL
- **BullMQ** + **Redis** — job queues
- **Tailwind CSS v4** — utility-first CSS
- **shadcn/ui** — component library (new-york style, CSS variables)
- **TanStack React Query** — all data fetching and mutations, no raw `fetch` in components
- **Zod** — all schema validation (forms, API response shapes)
- **react-hook-form** + **@hookform/resolvers/zod** — all forms
- **lucide-react** — icons
## File & Folder Conventions
```
apps/web/app/
_lib/ # Shared utilities and contexts
api.ts # getApiBaseUrl, jsonResponse, cookie helpers
query-client.tsx # QueryClientProvider wrapper
auth-context.tsx # Chapter admin auth (tower_token)
super-admin-context.tsx # Super admin auth (tower_super_token)
org-admin-context.tsx # Org admin auth (tower_org_token)
utils.ts # cn() helper
_components/ # Shared UI components (shadcn + custom)
ui/ # Raw shadcn components (never edit directly)
portal-shell.tsx # Shared portal layout wrapper
nav-user.tsx # User dropdown in nav
data-table.tsx # Shared table component
api/ # Next.js BFF route handlers only
auth/…
admin/…
org/…
my/…
(public)/ # Portal selector + all login pages (no auth)
admin/ # Super admin portal
org/ # Org admin portal
my/ # Member portal
[chapter pages]/ # Chapter admin pages at root level
```
## Coding Rules
### General
- **No raw `fetch` in components.** All API calls go through TanStack Query hooks. Define queries in a co-located `_queries.ts` or inline `useQuery`/`useMutation`.
- **No `useState` for server data.** Use React Query. `useState` is for UI-only state (modal open, tab selection, etc.).
- **All forms use react-hook-form + Zod.** Schema first: define a `z.object({...})` schema, derive the type with `z.infer`, pass resolver to `useForm`.
- **Shared components live in `app/_components/`.** Never duplicate a button, input, or card across portals.
- **Each portal has its own layout** with nav/sidebar. Do not put portal UI logic in the root layout.
- **No `any` types.** Use proper types or `unknown` + type narrowing.
### TanStack React Query
```tsx
// Define query key factories
export const tenantKeys = {
all: ['tenants'] as const,
list: () => [...tenantKeys.all, 'list'] as const,
detail: (id: string) => [...tenantKeys.all, 'detail', id] as const,
};
// In component
const { data, isPending } = useQuery({
queryKey: tenantKeys.list(),
queryFn: () => api.get<Tenant[]>('/api/admin/tenants'),
});
// Mutations always invalidate related queries
const mutation = useMutation({
mutationFn: (body: CreateTenantInput) => api.post('/api/admin/tenants', body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: tenantKeys.all }),
});
```
### Forms
```tsx
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormValues = z.infer<typeof schema>;
function MyForm() {
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</form>
</Form>
);
}
```
### API Helper
Use the typed `api` helper from `_lib/api-client.ts` — never call `fetch` directly in components or hooks:
```ts
// app/_lib/api-client.ts
export const api = {
get: <T>(url: string) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
post: <T>(url: string, body: unknown) => fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
patch: <T>(url: string, body: unknown) => fetch(url, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
del: (url: string) => fetch(url, { method: 'DELETE' }).then(r => { if (!r.ok) return Promise.reject(r); }),
};
```
### Auth Contexts
Each portal's auth context provides:
- `user` / `admin` — the authenticated entity (undefined when loading)
- `loading` — boolean
- `login(...)` — sets cookie via BFF, updates context
- `logout()` — clears cookie, redirects to portal login
Auth guard pattern (in portal layout, server component):
```tsx
// In a portal layout — always server component
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function PortalLayout({ children }) {
const token = (await cookies()).get('tower_token')?.value;
if (!token) redirect('/login');
return <PortalShell>{children}</PortalShell>;
}
```
### Styling
- Use shadcn/ui components as the base. Extend with Tailwind utilities.
- **No inline styles.** All styles via Tailwind classes.
- **No magic numbers in class names** — use design tokens (`text-sm`, `p-4`, `gap-2` — not arbitrary `p-[13px]`).
- Colors: use semantic tokens (`text-foreground`, `bg-muted`, `border`) not raw colors.
- Dark mode: set up via `class` strategy but only if explicitly requested.
### NestJS API Rules
- All protected endpoints use a Guard decorator — never trust the request without it.
- DTOs use `class-validator` decorators. `ValidationPipe` with `whitelist: true, forbidNonWhitelisted: true` is global.
- Always use `select` in Prisma queries to avoid leaking `passwordHash` or other sensitive fields.
- Org-scoped queries always include `organizationId` in the `where` clause.
- Tenant-scoped queries always include `tenantId` in the `where` clause.
### Redirect Rules (IMPORTANT)
After successful login, each portal redirects to its own home:
- Chapter admin: `/search` (or `next` param if set)
- Org admin: `/org`
- Super admin: `/admin`
- Member: `/my`
Never redirect to `/` after login — that is the public portal selector.
### Commit Style
Conventional commits: `feat:`, `fix:`, `refactor:`, `chore:`
Co-author every commit with Claude.
+63
View File
@@ -0,0 +1,63 @@
# ─── Prune: extract only @tower/api + its workspace deps ───
FROM node:22-alpine AS pruner
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json tsconfig.base.json ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm exec turbo prune --scope=@tower/api --docker
# ─── Install ALL deps (layer cached by lockfile) ───
FROM node:22-alpine AS installer
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN pnpm install --frozen-lockfile
# ─── Build ───
FROM installer AS builder
COPY --from=pruner /app/out/full/ .
COPY tsconfig.base.json ./
# Generate Prisma client
RUN pnpm exec prisma generate --schema=apps/api/prisma/schema.prisma
# Build internal workspace packages that api depends on (in dependency order)
RUN pnpm --filter @tower/types run build
RUN pnpm --filter @tower/config run build
RUN pnpm --filter @tower/logger run build
RUN pnpm --filter @tower/search run build
# Build the API via its package script (runs nest build from apps/api/)
RUN pnpm --filter @tower/api run build
# Hard verify: fail the Docker build if dist wasn't produced
RUN test -f apps/api/dist/main.js || (echo "ERROR: apps/api/dist/main.js not found after build!" && exit 1)
# ─── Production runner ───
FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN pnpm install --frozen-lockfile
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/packages/config/dist ./packages/config/dist
COPY --from=builder /app/packages/logger/dist ./packages/logger/dist
COPY --from=builder /app/packages/search/dist ./packages/search/dist
COPY --from=builder /app/packages/types/dist ./packages/types/dist
COPY --from=pruner /app/apps/api/prisma ./apps/api/prisma
RUN pnpm exec prisma generate --schema=apps/api/prisma/schema.prisma
COPY apps/api/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 3001
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "apps/api/dist/main"]
+51
View File
@@ -0,0 +1,51 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const groups = await prisma.group.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
select: {
id: true,
name: true,
platformId: true,
claimStatus: true,
accountId: true,
tenantId: true,
claimExpiresAt: true,
createdAt: true,
},
});
console.log(`Found ${groups.length} groups:`);
for (const g of groups) {
console.log(JSON.stringify(g, null, 2));
}
const accounts = await prisma.account.findMany({
where: { isBot: true },
select: { id: true, jid: true, status: true, displayName: true, createdAt: true },
});
console.log(`\nFound ${accounts.length} bot accounts:`);
for (const a of accounts) {
console.log(JSON.stringify(a, null, 2));
}
const audits = await prisma.auditEvent.findMany({
where: { action: { in: ['GROUP_PENDING_CLAIM', 'BOT_PAIRED', 'BOT_INITIATED'] } },
orderBy: { createdAt: 'desc' },
take: 10,
select: { action: true, resourceId: true, createdAt: true, payload: true, tenantId: true },
});
console.log(`\nFound ${audits.length} relevant audit events:`);
for (const a of audits) {
console.log(JSON.stringify(a, null, 2));
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+28
View File
@@ -0,0 +1,28 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const total = await prisma.group.count({ where: { claimStatus: 'PENDING_CLAIM' } });
const perTenant = await prisma.tenant.findMany({
where: { groups: { some: { claimStatus: 'PENDING_CLAIM' } } },
select: {
id: true,
name: true,
slug: true,
_count: { select: { groups: { where: { claimStatus: 'PENDING_CLAIM' } } } },
},
});
const sample = await prisma.group.findMany({
where: { claimStatus: 'PENDING_CLAIM' },
take: 3,
select: { id: true, name: true, platform: true, platformId: true, createdAt: true },
});
console.log('TOTAL PENDING_CLAIM:', total);
console.log('PER TENANT:', JSON.stringify(perTenant, null, 2));
console.log('SAMPLE:', JSON.stringify(sample, null, 2));
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
echo "Running database migrations..."
for i in $(seq 1 30); do
if pnpm exec prisma migrate deploy --schema=apps/api/prisma/schema.prisma; then
echo "Migrations complete"
break
fi
echo "Migration attempt $i failed, retrying in 2s..."
sleep 2
done
echo "Starting TOWER API..."
exec "$@"
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const p = new PrismaClient();
(async () => {
const tenants = await p.tenant.findMany({ select: { id: true, slug: true, name: true } });
const admins = await p.admin.findMany({ select: { id: true, email: true, role: true, tenant: { select: { slug: true } } } });
console.log('TENANTS:', JSON.stringify(tenants, null, 2));
console.log('ADMINS:', JSON.stringify(admins, null, 2));
await p.$disconnect();
})();
+19 -1
View File
@@ -6,18 +6,32 @@
"dev": "nest start --watch", "dev": "nest start --watch",
"start": "node dist/main", "start": "node dist/main",
"test": "jest", "test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json",
"db:seed": "DOTENV_CONFIG_PATH=.env ts-node -r dotenv/config prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",
"@tower/config": "workspace:*", "@tower/config": "workspace:*",
"@tower/logger": "workspace:*", "@tower/logger": "workspace:*",
"@tower/search": "workspace:*", "@tower/search": "workspace:*",
"@tower/types": "workspace:*", "@tower/types": "workspace:*",
"bcryptjs": "^2.4.3",
"bullmq": "^5.0.0",
"ioredis": "^5.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0" "rxjs": "^7.8.0"
}, },
@@ -25,12 +39,16 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0", "@nestjs/testing": "^11.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/jest": "^29.0.0", "@types/jest": "^29.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"jest": "^29.0.0", "jest": "^29.0.0",
"prisma": "^6.0.0", "prisma": "^6.0.0",
"ts-jest": "^29.0.0", "ts-jest": "^29.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }
@@ -0,0 +1,101 @@
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('OWNER', 'ADMIN', 'VIEWER');
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('ADMIN', 'SYSTEM', 'ADAPTER');
-- CreateTable: Tenant (must come first — referenced by everything else)
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
-- Insert default tenant so existing rows can be backfilled
INSERT INTO "Tenant" ("id", "slug", "name", "settings", "updatedAt")
VALUES ('default', 'default', 'Default Tenant', '{}', CURRENT_TIMESTAMP);
-- Add tenantId columns as nullable first
ALTER TABLE "Account" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "Approval" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "ConsentRecord" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "Group" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "Message" ADD COLUMN "tenantId" TEXT;
ALTER TABLE "SyncRoute" ADD COLUMN "tenantId" TEXT;
-- Backfill all existing rows to the default tenant
UPDATE "Account" SET "tenantId" = 'default';
UPDATE "Approval" SET "tenantId" = 'default';
UPDATE "ConsentRecord" SET "tenantId" = 'default';
UPDATE "Group" SET "tenantId" = 'default';
UPDATE "Message" SET "tenantId" = 'default';
UPDATE "SyncRoute" SET "tenantId" = 'default';
-- Now enforce NOT NULL
ALTER TABLE "Account" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "Approval" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "ConsentRecord" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "Group" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "Message" ALTER COLUMN "tenantId" SET NOT NULL;
ALTER TABLE "SyncRoute" ALTER COLUMN "tenantId" SET NOT NULL;
-- CreateTable: Admin (new — NOT NULL tenantId is fine, we'll seed default admin later)
CREATE TABLE "Admin" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'ADMIN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
);
-- CreateTable: AuditEvent (new — NOT NULL tenantId is fine)
CREATE TABLE "AuditEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"resourceType" TEXT NOT NULL,
"resourceId" TEXT NOT NULL,
"payload" JSONB NOT NULL DEFAULT '{}',
"traceId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Admin_tenantId_idx" ON "Admin"("tenantId");
CREATE UNIQUE INDEX "Admin_tenantId_email_key" ON "Admin"("tenantId", "email");
CREATE INDEX "AuditEvent_tenantId_createdAt_idx" ON "AuditEvent"("tenantId", "createdAt");
CREATE INDEX "AuditEvent_resourceType_resourceId_idx" ON "AuditEvent"("resourceType", "resourceId");
CREATE INDEX "Account_tenantId_idx" ON "Account"("tenantId");
CREATE INDEX "Approval_tenantId_idx" ON "Approval"("tenantId");
CREATE INDEX "ConsentRecord_tenantId_idx" ON "ConsentRecord"("tenantId");
CREATE INDEX "Group_tenantId_idx" ON "Group"("tenantId");
CREATE INDEX "Message_tenantId_idx" ON "Message"("tenantId");
CREATE INDEX "SyncRoute_tenantId_idx" ON "SyncRoute"("tenantId");
-- AddForeignKey
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Message" ADD CONSTRAINT "Message_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Approval" ADD CONSTRAINT "Approval_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SyncRoute" ADD CONSTRAINT "SyncRoute_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ConsentRecord" ADD CONSTRAINT "ConsentRecord_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Account" ADD CONSTRAINT "Account_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,201 @@
-- CreateEnum
CREATE TYPE "GroupClaimStatus" AS ENUM ('PENDING_CLAIM', 'CLAIMED', 'RELEASED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "ConsentScope" AS ENUM ('INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY');
-- CreateEnum
CREATE TYPE "ConsentStatus" AS ENUM ('GRANTED', 'REVOKED');
-- CreateEnum
CREATE TYPE "MemberOptOutReason" AS ENUM ('STOP_KEYWORD', 'SELF_PORTAL', 'ADMIN_ACTION');
-- AlterEnum
ALTER TYPE "AccountStatus" ADD VALUE 'PAIRING';
-- AlterEnum
ALTER TYPE "ActorType" ADD VALUE 'MEMBER';
-- DropForeignKey
ALTER TABLE "Account" DROP CONSTRAINT "Account_tenantId_fkey";
-- DropForeignKey
ALTER TABLE "Group" DROP CONSTRAINT "Group_tenantId_fkey";
-- DropIndex
DROP INDEX "Account_tenantId_idx";
-- DropIndex
DROP INDEX "ConsentRecord_groupId_memberJid_consentType_key";
-- DropIndex
DROP INDEX "ConsentRecord_tenantId_idx";
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "tenantId",
ADD COLUMN "isBot" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "pairingExpiresAt" TIMESTAMP(3),
ADD COLUMN "pairingToken" TEXT;
-- AlterTable
ALTER TABLE "ConsentRecord" DROP COLUMN "consentType",
DROP COLUMN "grantedAt",
DROP COLUMN "memberJid",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "effectiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "policyVersion" TEXT NOT NULL,
ADD COLUMN "proofEventId" TEXT NOT NULL,
ADD COLUMN "retentionDays" INTEGER NOT NULL DEFAULT 90,
ADD COLUMN "scopes" "ConsentScope"[],
ADD COLUMN "status" "ConsentStatus" NOT NULL DEFAULT 'GRANTED',
ADD COLUMN "userId" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "claimExpiresAt" TIMESTAMP(3),
ADD COLUMN "claimStatus" "GroupClaimStatus" NOT NULL DEFAULT 'PENDING_CLAIM',
ADD COLUMN "claimedAt" TIMESTAMP(3),
ADD COLUMN "claimedByAdminId" TEXT,
ALTER COLUMN "tenantId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Message" ADD COLUMN "senderTowerUserId" TEXT;
-- CreateTable
CREATE TABLE "TenantBot" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TenantBot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TowerUser" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"phoneHash" TEXT NOT NULL,
"jid" TEXT NOT NULL,
"displayName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TowerUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TowerSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TowerSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberOptOut" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"groupId" TEXT,
"reason" "MemberOptOutReason" NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MemberOptOut_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TenantBot_accountId_idx" ON "TenantBot"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "TenantBot_tenantId_accountId_key" ON "TenantBot"("tenantId", "accountId");
-- CreateIndex
CREATE INDEX "TowerUser_phoneHash_idx" ON "TowerUser"("phoneHash");
-- CreateIndex
CREATE INDEX "TowerUser_tenantId_idx" ON "TowerUser"("tenantId");
-- CreateIndex
CREATE INDEX "TowerUser_jid_idx" ON "TowerUser"("jid");
-- CreateIndex
CREATE UNIQUE INDEX "TowerUser_tenantId_phoneHash_key" ON "TowerUser"("tenantId", "phoneHash");
-- CreateIndex
CREATE UNIQUE INDEX "TowerSession_tokenHash_key" ON "TowerSession"("tokenHash");
-- CreateIndex
CREATE INDEX "TowerSession_userId_idx" ON "TowerSession"("userId");
-- CreateIndex
CREATE INDEX "TowerSession_expiresAt_idx" ON "TowerSession"("expiresAt");
-- CreateIndex
CREATE INDEX "MemberOptOut_tenantId_userId_idx" ON "MemberOptOut"("tenantId", "userId");
-- CreateIndex
CREATE INDEX "MemberOptOut_groupId_idx" ON "MemberOptOut"("groupId");
-- CreateIndex
CREATE INDEX "MemberOptOut_userId_idx" ON "MemberOptOut"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_pairingToken_key" ON "Account"("pairingToken");
-- CreateIndex
CREATE INDEX "Account_isBot_idx" ON "Account"("isBot");
-- CreateIndex
CREATE INDEX "Account_status_idx" ON "Account"("status");
-- CreateIndex
CREATE INDEX "ConsentRecord_tenantId_groupId_userId_idx" ON "ConsentRecord"("tenantId", "groupId", "userId");
-- CreateIndex
CREATE INDEX "ConsentRecord_status_idx" ON "ConsentRecord"("status");
-- CreateIndex
CREATE INDEX "ConsentRecord_userId_idx" ON "ConsentRecord"("userId");
-- CreateIndex
CREATE INDEX "Group_claimStatus_idx" ON "Group"("claimStatus");
-- CreateIndex
CREATE INDEX "Message_senderTowerUserId_idx" ON "Message"("senderTowerUserId");
-- AddForeignKey
ALTER TABLE "TenantBot" ADD CONSTRAINT "TenantBot_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TenantBot" ADD CONSTRAINT "TenantBot_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_claimedByAdminId_fkey" FOREIGN KEY ("claimedByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderTowerUserId_fkey" FOREIGN KEY ("senderTowerUserId") REFERENCES "TowerUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TowerUser" ADD CONSTRAINT "TowerUser_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TowerSession" ADD CONSTRAINT "TowerSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentRecord" ADD CONSTRAINT "ConsentRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberOptOut" ADD CONSTRAINT "MemberOptOut_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberOptOut" ADD CONSTRAINT "MemberOptOut_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "OtpChallenge" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"jid" TEXT NOT NULL,
"phoneHash" TEXT NOT NULL,
"code" TEXT NOT NULL,
"scopes" "ConsentScope"[],
"retentionDays" INTEGER NOT NULL DEFAULT 90,
"policyVersion" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"sentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OtpChallenge_tenantId_jid_idx" ON "OtpChallenge"("tenantId", "jid");
-- CreateIndex
CREATE INDEX "OtpChallenge_expiresAt_idx" ON "OtpChallenge"("expiresAt");
-- CreateIndex
CREATE INDEX "OtpChallenge_sentAt_idx" ON "OtpChallenge"("sentAt");
@@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "isForwardingPaused" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "SuperAdmin" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SuperAdmin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SuperAdmin_email_key" ON "SuperAdmin"("email");
@@ -0,0 +1,32 @@
-- CreateEnum
CREATE TYPE "RuleMatchType" AS ENUM ('HASHTAG', 'PREFIX', 'REACTION_EMOJI');
-- CreateEnum
CREATE TYPE "RuleAction" AS ENUM ('FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT');
-- CreateTable
CREATE TABLE "TenantRule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"matchType" "RuleMatchType" NOT NULL,
"matchValue" TEXT NOT NULL,
"action" "RuleAction" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TenantRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TenantRule_tenantId_isActive_idx" ON "TenantRule"("tenantId", "isActive");
-- CreateIndex
CREATE INDEX "TenantRule_tenantId_matchType_idx" ON "TenantRule"("tenantId", "matchType");
-- CreateIndex
CREATE UNIQUE INDEX "TenantRule_tenantId_matchType_matchValue_key" ON "TenantRule"("tenantId", "matchType", "matchValue");
-- AddForeignKey
ALTER TABLE "TenantRule" ADD CONSTRAINT "TenantRule_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- CreateTable: GroupClaimToken
CREATE TABLE "GroupClaimToken" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"creatorJid" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GroupClaimToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable: GroupAccess
CREATE TABLE "GroupAccess" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"grantedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GroupAccess_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GroupClaimToken_token_key" ON "GroupClaimToken"("token");
CREATE INDEX "GroupClaimToken_expiresAt_idx" ON "GroupClaimToken"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "GroupAccess_groupId_tenantId_key" ON "GroupAccess"("groupId", "tenantId");
CREATE INDEX "GroupAccess_tenantId_idx" ON "GroupAccess"("tenantId");
-- AddForeignKey
ALTER TABLE "GroupClaimToken" ADD CONSTRAINT "GroupClaimToken_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GroupAccess" ADD CONSTRAINT "GroupAccess_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GroupAccess" ADD CONSTRAINT "GroupAccess_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,45 @@
-- AlterEnum
ALTER TYPE "RuleAction" ADD VALUE 'P1';
-- CreateTable
CREATE TABLE "DigestConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"targetGroupJid" TEXT NOT NULL,
"targetAccountId" TEXT NOT NULL,
"scheduleHour" INTEGER NOT NULL DEFAULT 20,
"scheduleMinute" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DigestConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Digest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"messageIds" TEXT[],
"digestDate" TIMESTAMP(3) NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Digest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DigestConfig_tenantId_key" ON "DigestConfig"("tenantId");
-- CreateIndex
CREATE INDEX "Digest_tenantId_idx" ON "Digest"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Digest_tenantId_digestDate_key" ON "Digest"("tenantId", "digestDate");
-- AddForeignKey
ALTER TABLE "DigestConfig" ADD CONSTRAINT "DigestConfig_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Digest" ADD CONSTRAINT "Digest_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,41 @@
-- AlterEnum
-- Adding RAW and DNC values to MessageStatus enum.
-- NOTE: SET DEFAULT 'RAW' intentionally omitted — PostgreSQL cannot use new enum values
-- as column defaults in the same transaction. Status is always set explicitly in code.
ALTER TYPE "MessageStatus" ADD VALUE IF NOT EXISTS 'RAW';
ALTER TYPE "MessageStatus" ADD VALUE IF NOT EXISTS 'DNC';
-- AlterTable: add new columns to Message
ALTER TABLE "Message" ADD COLUMN IF NOT EXISTS "expiresAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "quotedPlatformMsgId" TEXT,
ADD COLUMN IF NOT EXISTS "threadId" TEXT;
-- CreateTable: Thread
CREATE TABLE IF NOT EXISTS "Thread" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"sourceGroupId" TEXT NOT NULL,
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"messageCount" INTEGER NOT NULL DEFAULT 0,
"topic" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Thread_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Thread_tenantId_idx" ON "Thread"("tenantId");
CREATE INDEX IF NOT EXISTS "Thread_sourceGroupId_lastActivityAt_idx" ON "Thread"("sourceGroupId", "lastActivityAt");
CREATE INDEX IF NOT EXISTS "Message_status_expiresAt_idx" ON "Message"("status", "expiresAt");
CREATE INDEX IF NOT EXISTS "Message_threadId_idx" ON "Message"("threadId");
-- AddForeignKey
ALTER TABLE "Message" DROP CONSTRAINT IF EXISTS "Message_threadId_fkey";
ALTER TABLE "Message" ADD CONSTRAINT "Message_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "Thread"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Thread" DROP CONSTRAINT IF EXISTS "Thread_tenantId_fkey";
ALTER TABLE "Thread" ADD CONSTRAINT "Thread_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Thread" DROP CONSTRAINT IF EXISTS "Thread_sourceGroupId_fkey";
ALTER TABLE "Thread" ADD CONSTRAINT "Thread_sourceGroupId_fkey" FOREIGN KEY ("sourceGroupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "OutboxEvent" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OutboxEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OutboxEvent_attempts_createdAt_idx" ON "OutboxEvent"("attempts", "createdAt");
-- LISTEN/NOTIFY trigger: fires pg_notify('outbox_event', NEW.id) after each INSERT
-- The worker holds an open pg.Client with LISTEN outbox_event to receive these events.
-- AFTER INSERT ensures NOTIFY fires only after the transaction commits.
CREATE OR REPLACE FUNCTION notify_outbox_event()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('outbox_event', NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER outbox_event_notify
AFTER INSERT ON "OutboxEvent"
FOR EACH ROW EXECUTE FUNCTION notify_outbox_event();
@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "OrgAdminRole" AS ENUM ('ORG_OWNER', 'ORG_ADMIN');
-- CreateTable: Organization
CREATE TABLE "Organization" (
"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 "Organization_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
-- CreateTable: OrgAdmin
CREATE TABLE "OrgAdmin" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"role" "OrgAdminRole" NOT NULL DEFAULT 'ORG_ADMIN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrgAdmin_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrgAdmin_organizationId_email_key" ON "OrgAdmin"("organizationId", "email");
CREATE INDEX "OrgAdmin_organizationId_idx" ON "OrgAdmin"("organizationId");
ALTER TABLE "OrgAdmin" ADD CONSTRAINT "OrgAdmin_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable: OrgRule
CREATE TABLE "OrgRule" (
"id" TEXT NOT NULL,
"organizationId" 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 "OrgRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrgRule_organizationId_matchType_matchValue_key" ON "OrgRule"("organizationId", "matchType", "matchValue");
CREATE INDEX "OrgRule_organizationId_isActive_idx" ON "OrgRule"("organizationId", "isActive");
ALTER TABLE "OrgRule" ADD CONSTRAINT "OrgRule_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AlterTable: Tenant — add nullable organizationId FK
ALTER TABLE "Tenant" ADD COLUMN "organizationId" TEXT;
ALTER TABLE "Tenant" ADD CONSTRAINT "Tenant_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,7 @@
ALTER TABLE "TowerUser" ADD COLUMN "avatar" TEXT;
ALTER TABLE "TowerUser" ADD COLUMN "hometown" TEXT;
ALTER TABLE "TowerUser" ADD COLUMN "currentLocation" TEXT;
ALTER TABLE "TowerUser" ADD COLUMN "interests" TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE "TowerUser" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en';
ALTER TABLE "TowerUser" ADD COLUMN "digestPreference" TEXT NOT NULL DEFAULT 'daily';
ALTER TABLE "TowerUser" ADD COLUMN "directoryVisible" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,36 @@
CREATE TYPE "RsvpStatus" AS ENUM ('GOING', 'NOT_GOING', 'MAYBE');
CREATE TABLE "Event" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "EventRsvp" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "RsvpStatus" NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventRsvp_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Event_tenantId_startsAt_idx" ON "Event"("tenantId", "startsAt");
CREATE INDEX "Event_tenantId_isPublished_idx" ON "Event"("tenantId", "isPublished");
CREATE UNIQUE INDEX "EventRsvp_eventId_userId_key" ON "EventRsvp"("eventId", "userId");
CREATE INDEX "EventRsvp_userId_idx" ON "EventRsvp"("userId");
ALTER TABLE "Event" ADD CONSTRAINT "Event_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "EventRsvp" ADD CONSTRAINT "EventRsvp_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "EventRsvp" ADD CONSTRAINT "EventRsvp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,59 @@
CREATE TYPE "SevaStatus" AS ENUM ('OPEN', 'CLOSED');
CREATE TYPE "SevaEntryStatus" AS ENUM ('SIGNED_UP', 'COMPLETED', 'CANCELLED');
CREATE TYPE "GamificationEventType" AS ENUM ('HELPFUL_ANSWER', 'SEVA_COMPLETED', 'EVENT_ATTENDANCE', 'FAQ_APPROVED', 'WELCOME', 'PROFILE_COMPLETED', 'BUSINESS_ADDED');
CREATE TABLE "SevaOpportunity" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"slots" INTEGER,
"startsAt" TIMESTAMP(3),
"pointsAward" INTEGER NOT NULL DEFAULT 20,
"status" "SevaStatus" NOT NULL DEFAULT 'OPEN',
"createdBy" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SevaOpportunity_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SevaEntry" (
"id" TEXT NOT NULL,
"opportunityId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "SevaEntryStatus" NOT NULL DEFAULT 'SIGNED_UP',
"hours" DOUBLE PRECISION,
"note" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SevaEntry_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GamificationEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "GamificationEventType" NOT NULL,
"points" INTEGER NOT NULL,
"refType" TEXT,
"refId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GamificationEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SevaOpportunity_tenantId_status_idx" ON "SevaOpportunity"("tenantId", "status");
CREATE INDEX "SevaOpportunity_tenantId_isPublished_idx" ON "SevaOpportunity"("tenantId", "isPublished");
CREATE UNIQUE INDEX "SevaEntry_opportunityId_userId_key" ON "SevaEntry"("opportunityId", "userId");
CREATE INDEX "SevaEntry_userId_idx" ON "SevaEntry"("userId");
CREATE INDEX "GamificationEvent_tenantId_userId_idx" ON "GamificationEvent"("tenantId", "userId");
CREATE INDEX "GamificationEvent_userId_createdAt_idx" ON "GamificationEvent"("userId", "createdAt");
CREATE UNIQUE INDEX "GamificationEvent_userId_type_refId_key" ON "GamificationEvent"("userId", "type", "refId");
ALTER TABLE "SevaOpportunity" ADD CONSTRAINT "SevaOpportunity_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SevaEntry" ADD CONSTRAINT "SevaEntry_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "SevaOpportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SevaEntry" ADD CONSTRAINT "SevaEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GamificationEvent" ADD CONSTRAINT "GamificationEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GamificationEvent" ADD CONSTRAINT "GamificationEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
CREATE TYPE "CircleRole" AS ENUM ('MEMBER', 'LEAD');
CREATE TABLE "Circle" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Circle_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "CircleMembership" (
"id" TEXT NOT NULL,
"circleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "CircleRole" NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CircleMembership_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Circle_tenantId_name_key" ON "Circle"("tenantId", "name");
CREATE INDEX "Circle_tenantId_isPublic_idx" ON "Circle"("tenantId", "isPublic");
CREATE UNIQUE INDEX "CircleMembership_circleId_userId_key" ON "CircleMembership"("circleId", "userId");
CREATE INDEX "CircleMembership_userId_idx" ON "CircleMembership"("userId");
ALTER TABLE "Circle" ADD CONSTRAINT "Circle_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CircleMembership" ADD CONSTRAINT "CircleMembership_circleId_fkey" FOREIGN KEY ("circleId") REFERENCES "Circle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CircleMembership" ADD CONSTRAINT "CircleMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
CREATE TABLE "AskAIQuery" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL,
"citations" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AskAIQuery_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AskAIQuery_tenantId_createdAt_idx" ON "AskAIQuery"("tenantId", "createdAt");
CREATE INDEX "AskAIQuery_userId_createdAt_idx" ON "AskAIQuery"("userId", "createdAt");
ALTER TABLE "AskAIQuery" ADD CONSTRAINT "AskAIQuery_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AskAIQuery" ADD CONSTRAINT "AskAIQuery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,37 @@
CREATE TYPE "KnowledgeItemStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
CREATE TABLE "KnowledgeBoard" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"slug" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeBoard_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "KnowledgeItem" (
"id" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL,
"tags" TEXT[],
"status" "KnowledgeItemStatus" NOT NULL DEFAULT 'DRAFT',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "KnowledgeBoard_tenantId_slug_key" ON "KnowledgeBoard"("tenantId", "slug");
CREATE INDEX "KnowledgeBoard_tenantId_idx" ON "KnowledgeBoard"("tenantId");
CREATE INDEX "KnowledgeItem_tenantId_status_idx" ON "KnowledgeItem"("tenantId", "status");
CREATE INDEX "KnowledgeItem_boardId_status_idx" ON "KnowledgeItem"("boardId", "status");
ALTER TABLE "KnowledgeBoard" ADD CONSTRAINT "KnowledgeBoard_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "KnowledgeItem" ADD CONSTRAINT "KnowledgeItem_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "KnowledgeBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "KnowledgeItem" ADD CONSTRAINT "KnowledgeItem_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
CREATE TABLE "GalleryAlbum" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"coverUrl" TEXT,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GalleryAlbum_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GalleryItem" (
"id" TEXT NOT NULL,
"albumId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"mediaUrl" TEXT NOT NULL,
"caption" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GalleryItem_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "GalleryAlbum_tenantId_isPublished_idx" ON "GalleryAlbum"("tenantId", "isPublished");
CREATE INDEX "GalleryItem_albumId_idx" ON "GalleryItem"("albumId");
CREATE INDEX "GalleryItem_tenantId_idx" ON "GalleryItem"("tenantId");
ALTER TABLE "GalleryAlbum" ADD CONSTRAINT "GalleryAlbum_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "GalleryItem" ADD CONSTRAINT "GalleryItem_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "GalleryAlbum"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "GalleryItem" ADD CONSTRAINT "GalleryItem_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "DraftType" AS ENUM ('EVENT', 'SEVA_TASK', 'FAQ_ITEM');
-- CreateEnum
CREATE TYPE "DraftStatus" AS ENUM ('PENDING_REVIEW', 'APPROVED', 'REJECTED');
-- CreateTable
CREATE TABLE "ContentDraft" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"type" "DraftType" NOT NULL,
"status" "DraftStatus" NOT NULL DEFAULT 'PENDING_REVIEW',
"sourceMessageId" TEXT NOT NULL,
"proposedJson" JSONB NOT NULL,
"confidence" DOUBLE PRECISION,
"reviewedBy" TEXT,
"reviewedAt" TIMESTAMP(3),
"publishedId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContentDraft_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ContentDraft_tenantId_status_idx" ON "ContentDraft"("tenantId", "status");
-- CreateIndex
CREATE INDEX "ContentDraft_tenantId_type_status_idx" ON "ContentDraft"("tenantId", "type", "status");
-- CreateIndex
CREATE INDEX "ContentDraft_sourceMessageId_idx" ON "ContentDraft"("sourceMessageId");
-- AddForeignKey
ALTER TABLE "ContentDraft" ADD CONSTRAINT "ContentDraft_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContentDraft" ADD CONSTRAINT "ContentDraft_sourceMessageId_fkey" FOREIGN KEY ("sourceMessageId") REFERENCES "Message"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Make groupId optional on OtpChallenge so member re-login challenges
-- (which have no group context) can be created without a groupId.
ALTER TABLE "OtpChallenge" ALTER COLUMN "groupId" DROP NOT NULL;
@@ -0,0 +1,45 @@
-- CreateEnum
CREATE TYPE "RuleIntent" AS ENUM ('POSITIVE', 'NEGATIVE');
CREATE TYPE "ForwardFormat" AS ENUM ('THREADED', 'DIGEST', 'SUMMARY');
CREATE TYPE "ForwardFrequency" AS ENUM ('INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS');
CREATE TYPE "ApprovalMode" AS ENUM ('MANUAL', 'AUTO');
-- AlterEnum: Add INTEREST to RuleMatchType
ALTER TYPE "RuleMatchType" ADD VALUE IF NOT EXISTS 'INTEREST';
-- AlterTable: TenantRule - add ruleIntent
ALTER TABLE "TenantRule" ADD COLUMN IF NOT EXISTS "ruleIntent" "RuleIntent" NOT NULL DEFAULT 'POSITIVE';
-- AlterTable: SyncRoute - add new columns (updatedAt with default NOW() for existing rows)
ALTER TABLE "SyncRoute"
ADD COLUMN IF NOT EXISTS "forwardFormat" "ForwardFormat" NOT NULL DEFAULT 'THREADED',
ADD COLUMN IF NOT EXISTS "withAttachment" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "frequency" "ForwardFrequency" NOT NULL DEFAULT 'INSTANT',
ADD COLUMN IF NOT EXISTS "approvalMode" "ApprovalMode" NOT NULL DEFAULT 'MANUAL',
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
-- CreateTable: SyncRouteRuleMap
CREATE TABLE IF NOT EXISTS "SyncRouteRuleMap" (
"id" TEXT NOT NULL,
"syncRouteId" TEXT NOT NULL,
"tenantRuleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SyncRouteRuleMap_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_tenantRuleId_key"
ON "SyncRouteRuleMap"("syncRouteId", "tenantRuleId");
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_idx"
ON "SyncRouteRuleMap"("syncRouteId");
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_tenantRuleId_idx"
ON "SyncRouteRuleMap"("tenantRuleId");
-- AddForeignKey
ALTER TABLE "SyncRouteRuleMap"
ADD CONSTRAINT "SyncRouteRuleMap_syncRouteId_fkey"
FOREIGN KEY ("syncRouteId") REFERENCES "SyncRoute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SyncRouteRuleMap"
ADD CONSTRAINT "SyncRouteRuleMap_tenantRuleId_fkey"
FOREIGN KEY ("tenantRuleId") REFERENCES "TenantRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+829 -19
View File
@@ -7,8 +7,223 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// ============================================================================
// Organization layer (above Tenancy)
// ============================================================================
enum OrgAdminRole {
ORG_OWNER
ORG_ADMIN
}
model Organization {
id String @id @default(cuid())
slug String @unique
name String
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenants Tenant[]
orgAdmins OrgAdmin[]
orgRules OrgRule[]
}
model OrgAdmin {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
email String
passwordHash String
name String?
role OrgAdminRole @default(ORG_ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, email])
@@index([organizationId])
}
model OrgRule {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, matchType, matchValue])
@@index([organizationId, isActive])
}
// ============================================================================
// Tenancy
// ============================================================================
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
isActive Boolean @default(true)
isForwardingPaused Boolean @default(false)
settings Json @default("{}")
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
admins Admin[]
tenantBots TenantBot[]
groups Group[]
messages Message[]
approvals Approval[]
syncRoutes SyncRoute[]
consentRecords ConsentRecord[]
memberOptOuts MemberOptOut[]
towerUsers TowerUser[]
auditEvents AuditEvent[]
rules TenantRule[]
groupAccesses GroupAccess[]
digestConfig DigestConfig?
digests Digest[]
events Event[]
threads Thread[]
sevaOpportunities SevaOpportunity[]
gamificationEvents GamificationEvent[]
circles Circle[]
askAIQueries AskAIQuery[]
knowledgeBoards KnowledgeBoard[]
knowledgeItems KnowledgeItem[]
galleryAlbums GalleryAlbum[]
galleryItems GalleryItem[]
contentDrafts ContentDraft[]
}
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 { model Group {
id String @id @default(cuid()) 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 platform String
platformId String platformId String
name String name String
@@ -16,54 +231,104 @@ model Group {
isActive Boolean @default(true) isActive Boolean @default(true)
accountId String? accountId String?
account Account? @relation(fields: [accountId], references: [id]) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
messages Message[] messages Message[]
syncRoutesFrom SyncRoute[] @relation("sourceGroup") syncRoutesFrom SyncRoute[] @relation("sourceGroup")
syncRoutesTo SyncRoute[] @relation("targetGroup") syncRoutesTo SyncRoute[] @relation("targetGroup")
consentRecords ConsentRecord[] consents ConsentRecord[]
claimTokens GroupClaimToken[]
groupAccesses GroupAccess[]
threads Thread[]
@@unique([platform, platformId]) @@unique([platform, platformId])
@@index([accountId]) @@index([accountId])
@@index([tenantId])
@@index([claimStatus])
} }
// ============================================================================
// Message ingest + approval
// ============================================================================
model Message { model Message {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
platform String platform String
platformMsgId String platformMsgId String
sourceGroupId String sourceGroupId String
sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
senderJid String senderJid String
senderName String? senderName String?
senderTowerUserId String?
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
content String content String
mediaUrl String? mediaUrl String?
tags String[] tags String[]
status MessageStatus @default(PENDING) status MessageStatus @default(PENDING)
expiresAt DateTime?
quotedPlatformMsgId String?
threadId String?
thread Thread? @relation(fields: [threadId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
approval Approval? approval Approval?
contentDrafts ContentDraft[]
@@unique([platform, platformMsgId]) @@unique([platform, platformMsgId])
@@index([tenantId])
@@index([senderTowerUserId])
@@index([status, expiresAt])
@@index([threadId])
} }
enum MessageStatus { enum MessageStatus {
RAW
PENDING PENDING
APPROVED APPROVED
REJECTED REJECTED
DISTRIBUTED DISTRIBUTED
ARCHIVED ARCHIVED
DNC
}
model Thread {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
lastActivityAt DateTime @default(now())
messageCount Int @default(0)
topic String?
createdAt DateTime @default(now())
messages Message[]
@@index([tenantId])
@@index([sourceGroupId, lastActivityAt])
} }
model Approval { model Approval {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
messageId String @unique messageId String @unique
message Message @relation(fields: [messageId], references: [id]) message Message @relation(fields: [messageId], references: [id])
adminId String adminId String
decision ApprovalDecision decision ApprovalDecision
notes String? notes String?
decidedAt DateTime @default(now()) decidedAt DateTime @default(now())
@@index([tenantId])
} }
enum ApprovalDecision { enum ApprovalDecision {
@@ -73,45 +338,590 @@ enum ApprovalDecision {
model SyncRoute { model SyncRoute {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String sourceGroupId String
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
targetGroupId String targetGroupId String
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
isActive Boolean @default(true) isActive Boolean @default(true)
forwardFormat ForwardFormat @default(THREADED)
withAttachment Boolean @default(true)
frequency ForwardFrequency @default(INSTANT)
approvalMode ApprovalMode @default(MANUAL)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedRules SyncRouteRuleMap[]
@@unique([sourceGroupId, targetGroupId]) @@unique([sourceGroupId, targetGroupId])
@@index([tenantId])
}
model SyncRouteRuleMap {
id String @id @default(cuid())
syncRouteId String
syncRoute SyncRoute @relation(fields: [syncRouteId], references: [id], onDelete: Cascade)
tenantRuleId String
tenantRule TenantRule @relation(fields: [tenantRuleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([syncRouteId, tenantRuleId])
@@index([syncRouteId])
@@index([tenantRuleId])
}
// ============================================================================
// 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?
avatar String?
hometown String?
currentLocation String?
interests String[]
language String @default("en")
digestPreference String @default("daily")
directoryVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
consents ConsentRecord[]
optOuts MemberOptOut[]
sessions TowerSession[]
messages Message[] @relation("senderTowerUser")
rsvps EventRsvp[]
sevaEntries SevaEntry[]
gamificationEvents GamificationEvent[]
circleMemberships CircleMembership[]
askAIQueries AskAIQuery[]
@@unique([tenantId, phoneHash])
@@index([phoneHash])
@@index([tenantId])
@@index([jid])
}
model TowerSession {
id String @id @default(cuid())
userId String
user TowerUser @relation(fields: [userId], references: [id])
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
} }
model ConsentRecord { model ConsentRecord {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
groupId String groupId String
group Group @relation(fields: [groupId], references: [id]) group Group @relation(fields: [groupId], references: [id])
memberJid String userId String
consentType String user TowerUser @relation(fields: [userId], references: [id])
grantedAt DateTime @default(now()) scopes ConsentScope[]
retentionDays Int @default(90)
policyVersion String
status ConsentStatus @default(GRANTED)
proofEventId String
effectiveAt DateTime @default(now())
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now())
@@unique([groupId, memberJid, consentType]) @@index([tenantId, groupId, userId])
@@index([status])
@@index([userId])
} }
enum AccountStatus { model MemberOptOut {
ACTIVE
DISCONNECTED
BANNED
}
model Account {
id String @id @default(cuid()) id String @id @default(cuid())
platform String 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 OtpChallenge {
id String @id @default(cuid())
tenantId String
jid String jid String
sessionPath String phoneHash String
displayName String? code String
status AccountStatus @default(ACTIVE) scopes ConsentScope[]
qrCode String? retentionDays Int @default(90)
groups Group[] policyVersion String
groupId String?
expiresAt DateTime
consumedAt DateTime?
sentAt DateTime?
createdAt DateTime @default(now())
@@index([tenantId, jid])
@@index([expiresAt])
@@index([sentAt])
}
// ============================================================================
// Tenant Rules Engine
// ============================================================================
enum RuleMatchType {
HASHTAG
PREFIX
REACTION_EMOJI
INTEREST
}
enum RuleAction {
FLAG
AUTO_APPROVE
SKIP
REJECT
}
enum RuleIntent {
POSITIVE
NEGATIVE
}
enum ForwardFormat {
THREADED
DIGEST
SUMMARY
}
enum ForwardFrequency {
INSTANT
FIVE_MIN
FIFTEEN_MIN
ONE_HOUR
ONE_DAY
SEVEN_DAYS
}
enum ApprovalMode {
MANUAL
AUTO
}
model TenantRule {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType
matchValue String
action RuleAction
ruleIntent RuleIntent @default(POSITIVE)
priority Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([platform, jid]) syncRouteRuleMaps SyncRouteRuleMap[]
@@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive])
@@index([tenantId, matchType])
}
// ============================================================================
// Digest
// ============================================================================
model DigestConfig {
id String @id @default(cuid())
tenantId String @unique
tenant Tenant @relation(fields: [tenantId], references: [id])
targetGroupJid String
targetAccountId String
scheduleHour Int @default(20)
scheduleMinute Int @default(0)
isActive Boolean @default(true)
lastSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Digest {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
content String
messageIds String[]
digestDate DateTime
sentAt DateTime @default(now())
@@unique([tenantId, digestDate])
@@index([tenantId])
}
// ============================================================================
// Events + RSVP
// ============================================================================
enum RsvpStatus {
GOING
NOT_GOING
MAYBE
}
model Event {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
location String?
startsAt DateTime
endsAt DateTime?
createdBy String
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rsvps EventRsvp[]
@@index([tenantId, startsAt])
@@index([tenantId, isPublished])
}
model EventRsvp {
id String @id @default(cuid())
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
status RsvpStatus
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, userId])
@@index([userId])
}
// ============================================================================
// Seva (volunteering) + Gamification
// ============================================================================
enum SevaStatus {
OPEN
CLOSED
}
enum SevaEntryStatus {
SIGNED_UP
COMPLETED
CANCELLED
}
model SevaOpportunity {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
location String?
slots Int?
startsAt DateTime?
pointsAward Int @default(20)
status SevaStatus @default(OPEN)
createdBy String
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
entries SevaEntry[]
@@index([tenantId, status])
@@index([tenantId, isPublished])
}
model SevaEntry {
id String @id @default(cuid())
opportunityId String
opportunity SevaOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
status SevaEntryStatus @default(SIGNED_UP)
hours Float?
note String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([opportunityId, userId])
@@index([userId])
}
// Multi-signal gamification — points come from many event types, not just seva.
enum GamificationEventType {
HELPFUL_ANSWER
SEVA_COMPLETED
EVENT_ATTENDANCE
FAQ_APPROVED
WELCOME
PROFILE_COMPLETED
BUSINESS_ADDED
}
model GamificationEvent {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
type GamificationEventType
points Int
refType String?
refId String?
createdAt DateTime @default(now())
@@index([tenantId, userId])
@@index([userId, createdAt])
// One award per (user, type, ref) — prevents double-awarding the same source.
@@unique([userId, type, refId])
}
// ============================================================================
// Circles (interest / affinity sub-groups within a chapter)
// ============================================================================
enum CircleRole {
MEMBER
LEAD
}
model Circle {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
description String?
isPublic Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships CircleMembership[]
@@unique([tenantId, name])
@@index([tenantId, isPublic])
}
model CircleMembership {
id String @id @default(cuid())
circleId String
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
userId String
user TowerUser @relation(fields: [userId], references: [id])
role CircleRole @default(MEMBER)
createdAt DateTime @default(now())
@@unique([circleId, userId])
@@index([userId])
}
// ============================================================================
// Ask AI (RAG over the message archive)
// ============================================================================
model AskAIQuery {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user TowerUser @relation(fields: [userId], references: [id])
question String
answer String
citations Json @default("[]")
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
@@index([userId, createdAt])
}
// ============================================================================
// Knowledge Base (admin-curated FAQ boards)
// ============================================================================
enum KnowledgeItemStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model KnowledgeBoard {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
description String?
slug String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items KnowledgeItem[]
@@unique([tenantId, slug])
@@index([tenantId])
}
model KnowledgeItem {
id String @id @default(cuid())
boardId String
board KnowledgeBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
question String
answer String
tags String[]
status KnowledgeItemStatus @default(DRAFT)
sortOrder Int @default(0)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, status])
@@index([boardId, status])
}
// ============================================================================
// Memories / Gallery
// ============================================================================
model GalleryAlbum {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
title String
description String?
coverUrl String?
isPublished Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items GalleryItem[]
@@index([tenantId, isPublished])
}
model GalleryItem {
id String @id @default(cuid())
albumId String
album GalleryAlbum @relation(fields: [albumId], references: [id], onDelete: Cascade)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
mediaUrl String
caption String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@index([albumId])
@@index([tenantId])
}
// ============================================================================
// Content Drafts — AI-proposed Event/Seva/FAQ pending admin review
// ============================================================================
enum DraftType {
EVENT
SEVA_TASK
FAQ_ITEM
}
enum DraftStatus {
PENDING_REVIEW
APPROVED
REJECTED
}
model ContentDraft {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
type DraftType
status DraftStatus @default(PENDING_REVIEW)
sourceMessageId String
sourceMessage Message @relation(fields: [sourceMessageId], references: [id])
proposedJson Json
confidence Float?
reviewedBy String?
reviewedAt DateTime?
publishedId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, status])
@@index([tenantId, type, status])
@@index([sourceMessageId])
} }
+59
View File
@@ -0,0 +1,59 @@
/* eslint-disable no-console */
import { PrismaClient, AdminRole } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
const DEFAULT_TENANT_SLUG = 'default';
const SEED_ADMIN_EMAIL = process.env['SEED_ADMIN_EMAIL'] ?? 'admin@tower.local';
const SEED_ADMIN_PASSWORD = process.env['SEED_ADMIN_PASSWORD'] ?? 'tower_dev_password';
const SUPER_ADMIN_EMAIL = process.env['SUPER_ADMIN_EMAIL'] ?? 'super@tower.local';
const SUPER_ADMIN_PASSWORD = process.env['SUPER_ADMIN_PASSWORD'] ?? 'super_dev_password';
const BCRYPT_ROUNDS = Number(process.env['BCRYPT_ROUNDS'] ?? '10');
async function main(): Promise<void> {
const tenant = await prisma.tenant.upsert({
where: { slug: DEFAULT_TENANT_SLUG },
update: {},
create: { slug: DEFAULT_TENANT_SLUG, name: 'Default Tenant' },
});
const passwordHash = await bcrypt.hash(SEED_ADMIN_PASSWORD, BCRYPT_ROUNDS);
const admin = await prisma.admin.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: SEED_ADMIN_EMAIL } },
update: { passwordHash, role: AdminRole.OWNER },
create: {
tenantId: tenant.id,
email: SEED_ADMIN_EMAIL,
passwordHash,
role: AdminRole.OWNER,
},
});
const superPasswordHash = await bcrypt.hash(SUPER_ADMIN_PASSWORD, BCRYPT_ROUNDS);
const superAdmin = await prisma.superAdmin.upsert({
where: { email: SUPER_ADMIN_EMAIL },
update: { passwordHash: superPasswordHash },
create: {
email: SUPER_ADMIN_EMAIL,
passwordHash: superPasswordHash,
name: 'Super Admin',
},
});
console.log('Seed complete:');
console.log(` Tenant: ${tenant.slug} (${tenant.id})`);
console.log(` Admin: ${admin.email} (${admin.id}) role=${admin.role}`);
console.log(` SuperAdmin: ${superAdmin.email} (${superAdmin.id})`);
console.log(` Password: ${SEED_ADMIN_PASSWORD} (dev only — change for production)`);
console.log(` Super pwd: ${SUPER_ADMIN_PASSWORD} (dev only — change for production)`);
}
main()
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+40
View File
@@ -1,19 +1,59 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { AuditModule } from './modules/audit/audit.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { SearchModule } from './modules/search/search.module'; import { SearchModule } from './modules/search/search.module';
import { GroupsModule } from './modules/groups/groups.module'; import { GroupsModule } from './modules/groups/groups.module';
import { RoutesModule } from './modules/routes/routes.module'; import { RoutesModule } from './modules/routes/routes.module';
import { 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';
import { DigestModule } from './modules/digest/digest.module';
import { OrgModule } from './modules/org/org.module';
import { ThreadsModule } from './modules/threads/threads.module';
import { EventsModule } from './modules/events/events.module';
import { GamificationModule } from './modules/gamification/gamification.module';
import { SevaModule } from './modules/seva/seva.module';
import { CirclesModule } from './modules/circles/circles.module';
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
import { GalleryModule } from './modules/gallery/gallery.module';
import { DraftsModule } from './modules/drafts/drafts.module';
import { AiStreamModule } from './modules/ai-stream/ai-stream.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, PrismaModule,
AuthModule,
AuditModule,
HealthModule, HealthModule,
SearchModule, SearchModule,
GroupsModule, GroupsModule,
RoutesModule, RoutesModule,
BotModule,
OnboardingModule,
MyModule,
MessagesModule,
RulesModule,
SuperAdminModule,
TenantModule,
DigestModule,
OrgModule,
ThreadsModule,
EventsModule,
GamificationModule,
SevaModule,
CirclesModule,
KnowledgeModule,
GalleryModule,
DraftsModule,
AiStreamModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+37
View File
@@ -0,0 +1,37 @@
const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
const DEFAULT_MODEL = 'google/gemini-3.5-flash';
/**
* Minimal OpenRouter chat completion. Mirrors the worker's llm-client so digest
* and Ask AI share one calling convention. Returns the assistant message text.
*/
export async function callLLM(
prompt: string,
apiKey: string,
model = DEFAULT_MODEL,
): Promise<string> {
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
'HTTP-Referer': 'https://tower.insignia.app',
'X-Title': 'TOWER Community Platform',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`OpenRouter error ${res.status}: ${body}`);
}
const data = (await res.json()) as { choices: Array<{ message: { content: string } }> };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error('OpenRouter returned empty content');
return content;
}
+13
View File
@@ -0,0 +1,13 @@
import { AdminRole } from '@tower/types';
export interface TenantContext {
tenantId: string;
adminId: string | null;
role: AdminRole | null;
}
export const emptyTenantContext: TenantContext = {
tenantId: '',
adminId: null,
role: null,
};
+17 -1
View File
@@ -1,9 +1,25 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory, Reflector } from '@nestjs/core';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { JwtAuthGuard } from './modules/auth/jwt-auth.guard';
import { validateEnv } from '@tower/config';
async function bootstrap() { async function bootstrap() {
validateEnv();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)));
const port = process.env['API_PORT'] ?? 3001; const port = process.env['API_PORT'] ?? 3001;
await app.listen(port); await app.listen(port);
console.log(`TOWER API running on port ${port}`); console.log(`TOWER API running on port ${port}`);
@@ -0,0 +1,42 @@
import { Controller, Get, Post, Body, Query, Headers, Sse, UseGuards, MessageEvent } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Public } from '../auth/public.decorator';
import { AiStreamGuard } from './ai-stream.guard';
import { AiStreamService } from './ai-stream.service';
import { CreateDraftDto } from './dto';
/**
* External AI-developer integration surface.
*
* GET /ai/stream SSE feed of every ingested message (read)
* POST /ai/drafts write back extracted content drafts (write)
*
* All endpoints are @Public() to bypass the global JWT guard, then protected by
* AiStreamGuard (static bearer token). Runs over the existing public HTTPS API,
* so no Redis client, Tailscale, or firewall changes are needed on the dev side.
*/
@Controller('ai')
@Public()
@UseGuards(AiStreamGuard)
export class AiStreamController {
constructor(private readonly service: AiStreamService) {}
@Sse('stream')
stream(
@Query('after') after?: string,
@Query('tenant') tenant?: string,
@Query('source') source?: string,
@Headers('last-event-id') lastEventId?: string,
): Observable<MessageEvent> {
// Last-Event-ID (set automatically by EventSource on reconnect) wins so the
// client resumes exactly where it dropped. Otherwise honour ?after=, default
// to '$' = only new messages from now on.
const cursor = lastEventId || after || '$';
return this.service.streamMessages(cursor, { tenantId: tenant, sourceGroupId: source });
}
@Post('drafts')
createDraft(@Body() dto: CreateDraftDto): Promise<{ draftId: string }> {
return this.service.createDraft(dto);
}
}
@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'crypto';
/**
* Guard for the AI-dev stream + writeback endpoints.
*
* Authenticates with a single static bearer token (AI_STREAM_TOKEN), accepted
* either as `Authorization: Bearer <token>` (POST / header-capable clients) or
* as a `?token=<token>` query param (browser EventSource cannot set headers).
*
* This is a separate auth realm from the admin/member JWT the AI dev never
* gets a login, just one long-lived token you rotate by changing the env var.
*/
@Injectable()
export class AiStreamGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const expected = this.config.get<string>('AI_STREAM_TOKEN');
if (!expected) {
throw new UnauthorizedException('AI stream is not configured');
}
const req = context.switchToHttp().getRequest();
const header: string | undefined = req.headers?.authorization;
const fromHeader = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
const fromQuery: string | undefined = req.query?.token;
const provided = fromHeader ?? fromQuery;
if (!provided || !safeEqual(provided, expected)) {
throw new UnauthorizedException('Invalid AI stream token');
}
return true;
}
}
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AiStreamController } from './ai-stream.controller';
import { AiStreamService } from './ai-stream.service';
@Module({
controllers: [AiStreamController],
providers: [AiStreamService],
})
export class AiStreamModule {}
@@ -0,0 +1,159 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MessageEvent } from '@nestjs/common';
import { Observable } from 'rxjs';
import Redis from 'ioredis';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateDraftDto } from './dto';
const STREAM_KEY = 'tower:stream:messages';
// Block this long per XREAD; on timeout we emit a heartbeat to keep the
// connection warm through proxies, then loop again.
const BLOCK_MS = 15000;
interface StreamRecord {
messageId: string;
tenantId: string;
content: string;
senderJid: string;
senderName: string | null;
sourceGroupId: string;
sourceGroupName: string | null;
organizationId: string | null;
replyToMessageId: string | null;
tags: string[];
effectiveAction: string | null;
traceId: string | null;
timestamp: string;
}
@Injectable()
export class AiStreamService {
private readonly logger = new Logger(AiStreamService.name);
private readonly redisUrl: string;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {
this.redisUrl = this.config.get<string>('REDIS_URL', 'redis://localhost:6379');
}
/**
* SSE source. Reads the Redis stream from `afterId` and emits one MessageEvent
* per message, with the stream entry id set as the SSE event id so the client's
* Last-Event-ID resumes exactly where it dropped off.
*
* afterId semantics (Redis stream cursor):
* '$' only messages that arrive after connect (default for a fresh client)
* '0' replay the entire retained stream (~50k messages) then follow live
* '<id>' resume strictly after that entry (used on reconnect)
*/
streamMessages(afterId: string, filters?: { tenantId?: string; sourceGroupId?: string }): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => {
const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null });
let active = true;
let cursor = afterId;
const loop = async () => {
while (active) {
try {
const res = (await redis.xread(
'COUNT', 100,
'BLOCK', BLOCK_MS,
'STREAMS', STREAM_KEY, cursor,
)) as [string, [string, string[]][]][] | null;
if (!res) {
// Idle timeout — heartbeat keeps the pipe alive. EventSource.onmessage
// ignores typed events, so this won't reach the dev's message handler.
if (active) subscriber.next({ type: 'heartbeat', data: '' } as MessageEvent);
continue;
}
for (const [, entries] of res) {
for (const [id, fields] of entries) {
const record = parseFields(fields);
cursor = id;
if (filters?.tenantId && record.tenantId !== filters.tenantId) continue;
if (filters?.sourceGroupId && record.sourceGroupId !== filters.sourceGroupId) continue;
subscriber.next({ id, data: record } as MessageEvent);
}
}
} catch (err) {
if (active) subscriber.error(err);
break;
}
}
};
loop();
return () => {
active = false;
redis.disconnect();
};
});
}
/**
* Writeback: the AI dev posts an extracted draft. Creates a ContentDraft in
* PENDING_REVIEW status it then shows up in the admin /admin/drafts UI for
* approve/reject, which publishes the real Event/SevaOpportunity/KnowledgeItem.
*/
async createDraft(dto: CreateDraftDto): Promise<{ draftId: string }> {
const message = await this.prisma.message.findUnique({
where: { id: dto.sourceMessageId },
select: { id: true, tenantId: true },
});
if (!message) {
throw new NotFoundException('sourceMessageId does not exist');
}
if (message.tenantId !== dto.tenantId) {
throw new BadRequestException('tenantId does not match the source message');
}
const draft = await this.prisma.contentDraft.create({
data: {
tenantId: dto.tenantId,
type: dto.type,
status: 'PENDING_REVIEW',
sourceMessageId: dto.sourceMessageId,
proposedJson: dto.proposedJson as object,
confidence: dto.confidence ?? null,
},
select: { id: true },
});
this.logger.log(`AI draft created: ${draft.id} (${dto.type}) from message ${dto.sourceMessageId}`);
return { draftId: draft.id };
}
}
function parseFields(fields: string[]): StreamRecord {
const obj: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) {
obj[fields[i]] = fields[i + 1];
}
let tags: string[] = [];
try {
tags = obj.tags ? JSON.parse(obj.tags) : [];
} catch {
tags = [];
}
return {
messageId: obj.messageId ?? '',
tenantId: obj.tenantId ?? '',
content: obj.content ?? '',
senderJid: obj.senderJid ?? '',
senderName: obj.senderName || null,
sourceGroupId: obj.sourceGroupId ?? '',
sourceGroupName: obj.sourceGroupName || null,
organizationId: obj.organizationId || null,
replyToMessageId: obj.replyToMessageId || null,
tags,
effectiveAction: obj.effectiveAction || null,
traceId: obj.traceId || null,
timestamp: obj.timestamp ?? '',
};
}
+24
View File
@@ -0,0 +1,24 @@
import { IsString, IsIn, IsOptional, IsNumber, IsObject, Min, Max } from 'class-validator';
import { DraftType } from '@prisma/client';
const DRAFT_TYPES: DraftType[] = ['EVENT', 'SEVA_TASK', 'FAQ_ITEM'];
export class CreateDraftDto {
@IsString()
tenantId!: string;
@IsString()
sourceMessageId!: string;
@IsIn(DRAFT_TYPES)
type!: DraftType;
@IsObject()
proposedJson!: Record<string, unknown>;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
confidence?: number;
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AskAIService } from './ask-ai.service';
import { SearchModule } from '../search/search.module';
@Module({
imports: [SearchModule],
providers: [AskAIService],
exports: [AskAIService],
})
export class AskAIModule {}
@@ -0,0 +1,107 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
import { SearchService } from '../search/search.service';
import { callLLM } from '../../common/llm-client';
export interface Citation {
messageId: string;
snippet: string;
senderName: string;
sourceGroupName: string;
approvedAt: number;
}
@Injectable()
export class AskAIService {
private readonly logger = new Logger(AskAIService.name);
constructor(
private readonly prisma: PrismaService,
private readonly search: SearchService,
private readonly config: ConfigService,
) {}
async ask(userId: string, tenantId: string, question: string) {
// 1. Retrieve: pull the most relevant approved messages from the tenant's index.
const { hits } = await this.search.search(tenantId, question, undefined, undefined, 1, 8);
const citations: Citation[] = hits.map((h) => ({
messageId: h.id,
snippet: h.content.slice(0, 240),
senderName: h.senderName || 'Unknown',
sourceGroupName: h.sourceGroupName,
approvedAt: h.approvedAt,
}));
// 2. Generate: ground the LLM in the retrieved snippets. Degrade gracefully
// if no AI key is configured or no context was found.
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
let answer: string;
if (citations.length === 0) {
answer = "I couldn't find anything in your community's messages about that yet. Try rephrasing, or ask an admin.";
} else if (!apiKey) {
answer = this.fallbackAnswer(citations);
} else {
try {
answer = await callLLM(this.buildPrompt(question, citations), apiKey);
} catch (err) {
this.logger.warn({ err }, 'Ask AI LLM call failed — returning snippet fallback');
answer = this.fallbackAnswer(citations);
}
}
// 3. Log the query for history + future tuning.
const record = await this.prisma.askAIQuery.create({
data: { tenantId, userId, question, answer, citations: citations as unknown as object },
});
return {
id: record.id,
question,
answer,
citations,
createdAt: record.createdAt.toISOString(),
};
}
async history(userId: string, tenantId: string) {
const queries = await this.prisma.askAIQuery.findMany({
where: { userId, tenantId },
orderBy: { createdAt: 'desc' },
take: 20,
});
return queries.map((q) => ({
id: q.id,
question: q.question,
answer: q.answer,
citations: q.citations as unknown as Citation[],
createdAt: q.createdAt.toISOString(),
}));
}
private buildPrompt(question: string, citations: Citation[]): string {
const context = citations
.map((c, i) => `[${i + 1}] (${c.sourceGroupName}, by ${c.senderName}): ${c.snippet}`)
.join('\n');
return [
'You are a helpful assistant for a community group. Answer the member\'s question',
'using ONLY the message excerpts below. If the excerpts do not contain the answer,',
'say you don\'t have that information. Cite sources inline as [1], [2], etc.',
'Keep the answer concise and friendly.',
'',
'Message excerpts:',
context,
'',
`Question: ${question}`,
'',
'Answer:',
].join('\n');
}
private fallbackAnswer(citations: Citation[]): string {
const top = citations.slice(0, 3).map((c, i) => `[${i + 1}] ${c.snippet}`).join('\n\n');
return `Here are the most relevant messages I found:\n\n${top}`;
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,61 @@
import { Test } from '@nestjs/testing';
import { ActorType } from '@prisma/client';
import { AuditService } from './audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditAction } from './audit.types';
describe('AuditService', () => {
let service: AuditService;
let prisma: { auditEvent: { create: jest.Mock } };
beforeEach(async () => {
prisma = { auditEvent: { create: jest.fn().mockResolvedValue({}) } };
const moduleRef = await Test.createTestingModule({
providers: [
AuditService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = moduleRef.get(AuditService);
});
it('writes an audit event with the explicit tenantId and admin actor by default', async () => {
await service.log({
tenantId: 'tnt-1',
actorId: 'adm-1',
action: AuditAction.ROUTE_CREATED,
resourceType: 'SyncRoute',
resourceId: 'r-1',
payload: { foo: 'bar' },
});
expect(prisma.auditEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tnt-1',
actorType: ActorType.ADMIN,
actorId: 'adm-1',
action: 'ROUTE_CREATED',
resourceType: 'SyncRoute',
resourceId: 'r-1',
payload: { foo: 'bar' },
}),
});
});
it('allows explicit tenantId override (e.g. system actor)', async () => {
await service.log({
tenantId: 'tnt-override',
actorType: ActorType.SYSTEM,
actorId: null,
action: AuditAction.AUTH_LOGIN,
resourceType: 'Admin',
resourceId: 'a-1',
});
expect(prisma.auditEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tnt-override',
actorType: ActorType.SYSTEM,
actorId: null,
}),
});
});
});
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ActorType } from '@prisma/client';
import { AuditActionValue } from './audit.types';
export interface AuditLogInput {
action: AuditActionValue;
resourceType: string;
resourceId: string;
actorType?: ActorType;
actorId?: string | null;
payload?: Record<string, unknown>;
traceId?: string | null;
tenantId: string;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(input: AuditLogInput): Promise<void> {
await this.prisma.auditEvent.create({
data: {
tenantId: input.tenantId,
actorType: input.actorType ?? ActorType.ADMIN,
actorId: input.actorId ?? null,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
payload: (input.payload ?? {}) as object,
traceId: input.traceId ?? null,
},
});
}
}
+53
View File
@@ -0,0 +1,53 @@
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',
EVENT_CREATED: 'EVENT_CREATED',
EVENT_UPDATED: 'EVENT_UPDATED',
EVENT_DELETED: 'EVENT_DELETED',
DIGEST_SENT: 'DIGEST_SENT',
SEVA_CREATED: 'SEVA_CREATED',
SEVA_DELETED: 'SEVA_DELETED',
SEVA_COMPLETED: 'SEVA_COMPLETED',
CIRCLE_CREATED: 'CIRCLE_CREATED',
CIRCLE_DELETED: 'CIRCLE_DELETED',
DRAFT_APPROVED: 'DRAFT_APPROVED',
DRAFT_REJECTED: 'DRAFT_REJECTED',
} as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
@@ -0,0 +1,39 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { SignupDto } from './dto/signup.dto';
import { Public } from './public.decorator';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentAdmin } from './current-admin.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Public()
@Post('signup')
@HttpCode(HttpStatus.OK)
async signup(@Body() dto: SignupDto) {
return this.authService.signup(dto);
}
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@CurrentAdmin() admin: any) {
return this.authService.logout(admin.sub, admin.tenantId);
}
@UseGuards(JwtAuthGuard)
@Get('me')
async me(@CurrentAdmin() admin: any) {
return this.authService.me(admin.sub, admin.tenantId);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';
import { AuditModule } from '../audit/audit.module';
import { BotModule } from '../bot/bot.module';
@Module({
imports: [
AuditModule,
forwardRef(() => BotModule),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? '',
signOptions: {
expiresIn: (config.get<string>('JWT_EXPIRES_IN') ?? '7d') as `${number}d` | `${number}h` | `${number}m` | `${number}s`,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
})
export class AuthModule {}
@@ -0,0 +1,254 @@
import { Test } from '@nestjs/testing';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { BotService } from '../bot/bot.service';
import { verifyPassword, hashPassword } from './password.util';
jest.mock('./password.util', () => ({
verifyPassword: jest.fn(),
hashPassword: jest.fn(),
}));
describe('AuthService', () => {
let service: AuthService;
let prisma: any;
let jwt: { signAsync: jest.Mock };
let audit: { log: jest.Mock };
let bot: { assignBotToTenant: jest.Mock };
beforeEach(async () => {
prisma = {
tenant: { findUnique: jest.fn(), create: jest.fn() },
admin: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn() },
$transaction: jest.fn(),
};
jwt = { signAsync: jest.fn().mockResolvedValue('signed-jwt-token') };
audit = { log: jest.fn().mockResolvedValue(undefined) };
bot = { assignBotToTenant: jest.fn().mockResolvedValue({ id: 'bot-1', status: 'ACTIVE' }) };
(verifyPassword as jest.Mock).mockReset();
(hashPassword as jest.Mock).mockReset().mockResolvedValue('hashed-pw');
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: prisma },
{ provide: JwtService, useValue: jwt },
{ provide: AuditService, useValue: audit },
{ provide: BotService, useValue: bot },
],
}).compile();
service = moduleRef.get(AuthService);
});
describe('login', () => {
it('returns token + admin on valid credentials', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
prisma.admin.findUnique.mockResolvedValue({
id: 'adm-1',
email: 'admin@tower.local',
passwordHash: 'hash',
role: 'OWNER',
tenantId: 'tnt-1',
});
(verifyPassword as jest.Mock).mockResolvedValue(true);
const res = await service.login({
tenantSlug: 'default',
email: 'admin@tower.local',
password: 'secret123',
});
expect(res.token).toBe('signed-jwt-token');
expect(res.admin).toEqual({
id: 'adm-1',
email: 'admin@tower.local',
role: 'OWNER',
tenantId: 'tnt-1',
tenantSlug: 'default',
});
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'AUTH_LOGIN', resourceId: 'adm-1' }),
);
});
it('rejects unknown tenant', async () => {
prisma.tenant.findUnique.mockResolvedValue(null);
await expect(
service.login({ tenantSlug: 'nope', email: 'a@b.c', password: 'secret123' }),
).rejects.toThrow(UnauthorizedException);
});
it('rejects unknown admin', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
prisma.admin.findUnique.mockResolvedValue(null);
await expect(
service.login({ tenantSlug: 'default', email: 'a@b.c', password: 'secret123' }),
).rejects.toThrow(UnauthorizedException);
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'AUTH_LOGIN_FAILED' }),
);
});
it('rejects bad password', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
prisma.admin.findUnique.mockResolvedValue({
id: 'adm-1',
email: 'a@b.c',
passwordHash: 'hash',
role: 'OWNER',
tenantId: 'tnt-1',
});
(verifyPassword as jest.Mock).mockResolvedValue(false);
await expect(
service.login({ tenantSlug: 'default', email: 'a@b.c', password: 'wrong1234' }),
).rejects.toThrow(UnauthorizedException);
});
it('logs in by email alone when no tenantSlug is given and the email is unique', async () => {
prisma.admin.findMany.mockResolvedValue([{ tenantId: 'tnt-2' }]);
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-2', slug: 'other' });
prisma.admin.findUnique.mockResolvedValue({
id: 'adm-2',
email: 'a@b.c',
passwordHash: 'hash',
role: 'OWNER',
tenantId: 'tnt-2',
});
(verifyPassword as jest.Mock).mockResolvedValue(true);
const res = await service.login({ email: 'a@b.c', password: 'secret123' });
expect(res.token).toBe('signed-jwt-token');
expect(res.admin.tenantSlug).toBe('other');
expect(res.admin.tenantId).toBe('tnt-2');
});
it('rejects when email belongs to multiple tenants', async () => {
prisma.admin.findMany.mockResolvedValue([{ tenantId: 'tnt-1' }, { tenantId: 'tnt-2' }]);
await expect(
service.login({ email: 'shared@x.com', password: 'secret123' }),
).rejects.toThrow(/multiple tenants/);
});
it('rejects when email matches no admin', async () => {
prisma.admin.findMany.mockResolvedValue([]);
await expect(
service.login({ email: 'nobody@x.com', password: 'secret123' }),
).rejects.toThrow(UnauthorizedException);
});
});
describe('me', () => {
it('returns admin profile', async () => {
prisma.admin.findFirst.mockResolvedValue({
id: 'adm-1',
email: 'a@b.c',
role: 'OWNER',
tenantId: 'tnt-1',
tenant: { slug: 'default' },
});
const res = await service.me('adm-1', 'tnt-1');
expect(res).toEqual({
admin: {
id: 'adm-1',
email: 'a@b.c',
role: 'OWNER',
tenantId: 'tnt-1',
tenantSlug: 'default',
},
});
});
it('throws when admin not found', async () => {
prisma.admin.findFirst.mockResolvedValue(null);
await expect(service.me('x', 'y')).rejects.toThrow(UnauthorizedException);
});
});
describe('logout', () => {
it('writes audit and returns ok', async () => {
const res = await service.logout('adm-1', 'tnt-1');
expect(res).toEqual({ ok: true });
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'AUTH_LOGOUT', resourceId: 'adm-1' }),
);
});
});
describe('signup', () => {
const baseReq = {
tenantName: 'Delhi Traders',
tenantSlug: 'delhi',
email: 'priya@delhi.test',
password: 'strongpass1',
};
it('creates tenant + owner admin atomically and returns token', async () => {
prisma.tenant.findUnique.mockResolvedValue(null);
prisma.$transaction.mockImplementation(async (cb: any) =>
cb({
tenant: {
create: jest.fn().mockResolvedValue({ id: 'tnt-new', slug: 'delhi', name: 'Delhi Traders' }),
},
admin: {
create: jest.fn().mockResolvedValue({
id: 'adm-new',
tenantId: 'tnt-new',
email: 'priya@delhi.test',
role: 'OWNER',
}),
},
tenantRule: { create: jest.fn().mockResolvedValue({}) },
}),
);
const res = await service.signup(baseReq);
expect(res.token).toBe('signed-jwt-token');
expect(res.admin).toEqual({
id: 'adm-new',
email: 'priya@delhi.test',
role: 'OWNER',
tenantId: 'tnt-new',
tenantSlug: 'delhi',
});
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'AUTH_SIGNUP',
tenantId: 'tnt-new',
resourceId: 'tnt-new',
}),
);
});
it('rejects a taken slug with 409', async () => {
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-existing', slug: 'delhi' });
await expect(service.signup(baseReq)).rejects.toBeInstanceOf(ConflictException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('hashes the password before storing', async () => {
prisma.tenant.findUnique.mockResolvedValue(null);
prisma.$transaction.mockImplementation(async (cb: any) =>
cb({
tenant: { create: jest.fn().mockResolvedValue({ id: 'tnt-x', slug: 'x', name: 'X' }) },
admin: {
create: jest.fn().mockImplementation(async ({ data }: any) => ({
id: 'adm-x',
tenantId: 'tnt-x',
email: data.email,
role: data.role,
})),
},
tenantRule: { create: jest.fn().mockResolvedValue({}) },
}),
);
await service.signup({ ...baseReq, password: 'plaintext' });
expect(hashPassword).toHaveBeenCalledWith('plaintext');
});
});
});
+227
View File
@@ -0,0 +1,227 @@
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
AdminRole,
JwtPayload,
LoginRequest,
LoginResponse,
SignupRequest,
SignupResponse,
} from '@tower/types';
import { AdminRole as PrismaAdminRole } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { verifyPassword } from './password.util';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { hashPassword } from './password.util';
import { BotService } from '../bot/bot.service';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly audit: AuditService,
private readonly bot: BotService,
) {}
async login(req: LoginRequest): Promise<LoginResponse> {
let tenant:
| { id: string; slug: string; name: string; organization: { id: string; slug: string; name: string } | null }
| null = null;
const tenantInclude = { organization: { select: { id: true, slug: true, name: true } } } as const;
if (req.tenantSlug) {
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
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 }, include: tenantInclude });
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,
tenantName: tenant.name,
organization: tenant.organization,
},
};
}
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
const admin = await this.prisma.admin.findFirst({
where: { id: adminId, tenantId },
include: { tenant: { include: { organization: { select: { id: true, slug: true, name: 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,
tenantName: admin.tenant.name,
organization: admin.tenant.organization,
},
};
}
async logout(adminId: string, tenantId: string): Promise<{ ok: true }> {
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.AUTH_LOGOUT,
resourceType: 'Admin',
resourceId: adminId,
});
return { ok: true };
}
async signup(req: SignupRequest): Promise<SignupResponse> {
const existingSlug = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
if (existingSlug) {
throw new ConflictException('That tenant slug is already taken');
}
const passwordHash = await hashPassword(req.password);
const { tenant, admin } = await this.prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: { slug: req.tenantSlug, name: req.tenantName },
});
const admin = await tx.admin.create({
data: {
tenantId: tenant.id,
email: req.email,
passwordHash,
role: PrismaAdminRole.OWNER,
},
});
// Seed default rules: FLAG for #important and #event hashtags, PREFIX for /tower
const defaults: Array<{ matchType: any; matchValue: string; action: any; priority: number }> = [
{ matchType: 'HASHTAG' as const, matchValue: '#important', action: 'FLAG' as const, priority: 0 },
{ matchType: 'HASHTAG' as const, matchValue: '#event', action: 'FLAG' as const, priority: 1 },
{ matchType: 'PREFIX' as const, matchValue: '/tower', action: 'FLAG' as const, priority: 2 },
];
for (const rule of defaults) {
await tx.tenantRule.create({
data: { tenantId: tenant.id, ...rule },
}).catch(() => {
// Ignore duplicate errors; rules are best-effort during signup
});
}
return { tenant, admin };
});
const payload: JwtPayload = {
kind: 'admin',
sub: admin.id,
tenantId: tenant.id,
role: PrismaAdminRole.OWNER,
email: admin.email,
};
const token = await this.jwt.signAsync(payload);
await this.audit.log({
tenantId: tenant.id,
actorId: admin.id,
action: AuditAction.AUTH_SIGNUP,
resourceType: 'Tenant',
resourceId: tenant.id,
payload: { email: admin.email, tenantSlug: tenant.slug },
});
// Auto-assign the least-loaded bot
await this.bot.assignBotToTenant(tenant.id);
return {
token,
admin: {
id: admin.id,
email: admin.email,
role: PrismaAdminRole.OWNER,
tenantId: tenant.id,
tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: null,
},
};
}
private async recordFailedLogin(
email: string,
reason: string,
tenantId?: string,
adminId?: string,
): Promise<void> {
if (!tenantId) return; // cannot audit without a tenant
await this.audit.log({
tenantId,
actorId: adminId ?? null,
action: AuditAction.AUTH_LOGIN_FAILED,
resourceType: 'Admin',
resourceId: adminId ?? email,
payload: { email, reason },
});
}
// Helper used by the seed script
static hashForSeed = hashPassword;
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '@tower/types';
export const CurrentAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload | null => {
const request = ctx.switchToHttp().getRequest();
return (request.user as JwtPayload | undefined) ?? null;
},
);
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { MemberJwtPayload } from '@tower/types';
export const CurrentMember = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): MemberJwtPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user as MemberJwtPayload;
},
);
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { TenantContext } from '../../common/tenant-context';
export const CurrentTenantContext = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): TenantContext => {
const request = ctx.switchToHttp().getRequest();
return request.tenantContext as TenantContext;
},
);
@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsOptional()
@IsString()
tenantSlug?: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
@@ -0,0 +1,24 @@
import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class SignupDto {
@IsString()
@MinLength(2)
@MaxLength(80)
tenantName!: string;
// Lowercase, alphanumeric + dashes, 2-40 chars, must start with a letter
@IsString()
@Matches(/^[a-z][a-z0-9-]{1,39}$/, {
message:
'tenantSlug must be 2-40 chars, start with a letter, and contain only lowercase letters, digits, and dashes',
})
tenantSlug!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
}
@@ -0,0 +1,48 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AdminJwtPayload, JwtPayload, MemberJwtPayload } from '@tower/types';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
// After JWT validation, attach a TenantContext to the request so that
// controllers, services, and the AuditService can read tenantId/adminId/role
// without re-parsing the token.
handleRequest<TUser = JwtPayload>(err: unknown, user: TUser, info: unknown, context: ExecutionContext): TUser {
if (err || !user) {
throw err ?? new UnauthorizedException(info instanceof Error ? info.message : 'Invalid or missing token');
}
const payload = user as unknown as JwtPayload;
const request = context.switchToHttp().getRequest();
if (payload.kind === 'admin') {
const admin = payload as AdminJwtPayload;
request.tenantContext = {
tenantId: admin.tenantId,
adminId: admin.sub,
role: admin.role,
};
} else {
const member = payload as MemberJwtPayload;
request.tenantContext = {
tenantId: member.tenantId,
adminId: null,
role: null,
};
}
return user;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '@tower/types';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') ?? '',
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
return payload;
}
}
@@ -0,0 +1,8 @@
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { MemberAuthGuard } from './member-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';
export const MEMBER_AUTH_KEY = 'member-auth';
export const MemberAuth = () =>
applyDecorators(SetMetadata(MEMBER_AUTH_KEY, true), UseGuards(JwtAuthGuard, MemberAuthGuard));
@@ -0,0 +1,14 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { MemberJwtPayload } from '@tower/types';
@Injectable()
export class MemberAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user as MemberJwtPayload | undefined;
if (!user || user.kind !== 'member') {
throw new UnauthorizedException('Member authentication required');
}
return true;
}
}
@@ -0,0 +1,34 @@
jest.mock('bcryptjs', () => ({
__esModule: true,
hash: jest.fn(),
compare: jest.fn(),
}));
import * as bcrypt from 'bcryptjs';
import { hashPassword, verifyPassword } from './password.util';
const mockedBcrypt = bcrypt as unknown as { hash: jest.Mock; compare: jest.Mock };
describe('password util', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('hashes and verifies a password roundtrip', async () => {
mockedBcrypt.hash.mockResolvedValue('$2a$10$hashedvalue');
mockedBcrypt.compare.mockResolvedValue(true);
const hash = await hashPassword('secret', 4);
expect(hash).toBe('$2a$10$hashedvalue');
expect(mockedBcrypt.hash).toHaveBeenCalledWith('secret', 4);
const ok = await verifyPassword('secret', hash);
expect(ok).toBe(true);
});
it('rejects wrong password', async () => {
mockedBcrypt.compare.mockResolvedValue(false);
const ok = await verifyPassword('wrong', '$2a$10$hashedvalue');
expect(ok).toBe(false);
});
});
@@ -0,0 +1,9 @@
import * as bcrypt from 'bcryptjs';
export async function hashPassword(plain: string, rounds: number = 10): Promise<string> {
return bcrypt.hash(plain, rounds);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { AdminRole } from '@tower/types';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: AdminRole[]) => SetMetadata(ROLES_KEY, roles);
+23
View File
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AdminRole } from '@tower/types';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<AdminRole[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const request = context.switchToHttp().getRequest();
const role = request.tenantContext?.role as AdminRole | null | undefined;
if (!role || !required.includes(role)) {
throw new ForbiddenException(`Role ${role ?? 'none'} not in required: ${required.join(', ')}`);
}
return true;
}
}
@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { BotService } from './bot.service';
import { SuperAdminGuard } from '../super-admin/super-admin.guard';
import { IsOptional, IsString } from 'class-validator';
class InitiateBotDto {
@IsOptional() @IsString() displayName?: string;
}
@Controller('admin/bots')
@UseGuards(SuperAdminGuard)
export class BotAdminController {
constructor(private readonly service: BotService) {}
@Get()
list() {
return this.service.listAll();
}
@Post('initiate')
initiate(@Body() body: InitiateBotDto) {
return this.service.superInitiate(body.displayName);
}
@Get('qr/:token')
getQr(@Param('token') token: string) {
return this.service.superGetQr(token);
}
@Post(':id/assign')
assign(@Param('id') id: string, @Body() body: { tenantId: string }) {
return this.service.assignTenant(body.tenantId, id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.superRemove(id);
}
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { BotService } from './bot.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@Controller('admin/bot')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class BotController {
constructor(private readonly service: BotService) {}
@Get()
get(@CurrentTenantContext() ctx: TenantContext) {
return this.service.get(ctx.tenantId);
}
@Post('reveal')
reveal(@CurrentTenantContext() ctx: TenantContext) {
return this.service.reveal(ctx.tenantId, ctx.adminId ?? '');
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BotController } from './bot.controller';
import { BotAdminController } from './bot-admin.controller';
import { BotService } from './bot.service';
import { AuthModule } from '../auth/auth.module';
import { SuperAdminModule } from '../super-admin/super-admin.module';
@Module({
imports: [ConfigModule, forwardRef(() => AuthModule), SuperAdminModule],
controllers: [BotController, BotAdminController],
providers: [BotService],
exports: [BotService],
})
export class BotModule {}
@@ -0,0 +1,189 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BotService } from './bot.service';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { ConfigService } from '@nestjs/config';
describe('BotService', () => {
let service: BotService;
const mockPrisma: any = {
account: {
findFirst: jest.fn(),
create: jest.fn(),
findUnique: jest.fn(),
},
tenantBot: {
create: jest.fn(),
deleteMany: jest.fn(),
count: jest.fn(),
findUnique: jest.fn(),
},
};
const mockAudit = { log: jest.fn() };
const mockConfig = { get: jest.fn().mockReturnValue('./sessions') };
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BotService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<BotService>(BotService);
});
describe('initiate', () => {
it('creates Account + TenantBot and returns pairingToken', async () => {
mockPrisma.account.findFirst.mockResolvedValue(null);
const created = { id: 'acc-1', jid: 'pending_x@placeholder', displayName: null };
mockPrisma.account.create.mockResolvedValue(created);
mockPrisma.tenantBot.create.mockResolvedValue({});
const res = await service.initiate('tnt-1', 'adm-1', 'MyBot');
expect(res.pairingToken).toBeTruthy();
expect(res.expiresAt).toBeTruthy();
expect(mockPrisma.account.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isBot: true,
status: 'PAIRING',
displayName: 'MyBot',
}),
}),
);
expect(mockPrisma.tenantBot.create).toHaveBeenCalledWith({
data: { tenantId: 'tnt-1', accountId: 'acc-1', isActive: true },
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'BOT_INITIATED', resourceId: 'acc-1' }),
);
});
it('rejects if any account already exists', async () => {
mockPrisma.account.findFirst.mockResolvedValue({ id: 'acc-existing' });
await expect(service.initiate('tnt-1', 'adm-1')).rejects.toThrow(/already configured/);
});
});
describe('get', () => {
it('returns { bot: null, shared: false } when no bot paired', async () => {
mockPrisma.account.findFirst.mockResolvedValue(null);
expect(await service.get('tnt-1')).toEqual({ bot: null, shared: false });
});
it('hides jid when bot is not ACTIVE', async () => {
mockPrisma.account.findFirst.mockResolvedValue({
id: 'acc-1',
platform: 'whatsapp',
jid: 'pending_x@placeholder',
displayName: null,
status: 'PAIRING',
isBot: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.get('tnt-1');
expect(res.bot?.jid).toBeNull();
expect(res.bot?.status).toBe('PAIRING');
});
it('shows jid when bot is ACTIVE', async () => {
mockPrisma.account.findFirst.mockResolvedValue({
id: 'acc-1',
platform: 'whatsapp',
jid: '1234567890:12@s.whatsapp.net',
displayName: null,
status: 'ACTIVE',
isBot: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.get('tnt-1');
expect(res.bot?.jid).toBe('1234567890:12@s.whatsapp.net');
});
it('reports shared=true when caller has no own bot but another tenant does', async () => {
mockPrisma.account.findFirst
.mockResolvedValueOnce(null) // own bot lookup: none
.mockResolvedValueOnce({
id: 'acc-shared',
platform: 'whatsapp',
jid: 'shared:1@s.whatsapp.net',
displayName: null,
status: 'ACTIVE',
isBot: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.get('tnt-new');
expect(res.shared).toBe(true);
expect(res.bot).toBeNull();
expect(res.sharedBotId).toBe('acc-shared');
});
});
const fullAccount = (overrides: Partial<any> = {}) => ({
id: 'acc-1',
platform: 'whatsapp',
jid: 'shared:1@s.whatsapp.net',
displayName: null,
status: 'ACTIVE',
isBot: true,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
});
describe('attach', () => {
it('creates TenantBot link and writes audit', async () => {
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-shared' }));
mockPrisma.tenantBot.findUnique.mockResolvedValue(null);
const res = await service.attach('tnt-new', 'adm-new', 'acc-shared');
expect(res.bot.id).toBe('acc-shared');
expect(mockPrisma.tenantBot.create).toHaveBeenCalledWith({
data: { tenantId: 'tnt-new', accountId: 'acc-shared', isActive: true },
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'BOT_ACCESS_GRANTED' }),
);
});
it('is idempotent when TenantBot link already exists', async () => {
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-shared' }));
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-new', accountId: 'acc-shared' });
await service.attach('tnt-new', 'adm-new', 'acc-shared');
expect(mockPrisma.tenantBot.create).not.toHaveBeenCalled();
});
it('rejects banned bots', async () => {
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-banned', status: 'BANNED' }));
await expect(service.attach('tnt-new', 'adm-new', 'acc-banned')).rejects.toThrow(/banned/);
});
});
describe('reveal', () => {
it('throws when no active bot', async () => {
mockPrisma.account.findFirst.mockResolvedValue({ id: 'acc-1', status: 'PAIRING', jid: 'x' });
await expect(service.reveal('tnt-1', 'adm-1')).rejects.toThrow(/No active bot/);
});
it('returns jid and writes audit event', async () => {
mockPrisma.account.findFirst.mockResolvedValue({
id: 'acc-1',
status: 'ACTIVE',
jid: '1234567890:12@s.whatsapp.net',
});
const res = await service.reveal('tnt-1', 'adm-1');
expect(res.jid).toBe('1234567890:12@s.whatsapp.net');
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({ action: 'BOT_REVEALED', payload: { jid: '1234567890:12@s.whatsapp.net' } }),
);
});
});
});
+304
View File
@@ -0,0 +1,304 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import * as QRCode from 'qrcode';
import type { BotInitiateResponse, BotQrResponse, BotRevealResponse, BotStatus, BotSummary } from '@tower/types';
const PAIRING_TTL_MS = 5 * 60 * 1000;
@Injectable()
export class BotService {
private readonly logger = new Logger(BotService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
async initiate(tenantId: string, adminId: string, displayName?: string): Promise<BotInitiateResponse> {
const existing = await this.prisma.account.findFirst({
where: { isBot: true, status: { in: ['PAIRING', 'ACTIVE', 'DISCONNECTED'] } },
});
if (existing) {
throw new ConflictException('A bot is already configured. Remove it before pairing a new one.');
}
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
const uid = randomUUID();
const pairingToken = randomUUID();
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
const account = await this.prisma.account.create({
data: {
platform: 'whatsapp',
jid: `pending_${uid}@placeholder`,
sessionPath: `${sessionBase}/${uid}`,
displayName: displayName ?? null,
status: 'PAIRING',
isBot: true,
pairingToken,
pairingExpiresAt: expiresAt,
},
});
await this.prisma.tenantBot.create({
data: { tenantId, accountId: account.id, isActive: true },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.BOT_INITIATED,
resourceType: 'Account',
resourceId: account.id,
payload: { displayName: account.displayName },
});
return {
pairingToken,
expiresAt: expiresAt.toISOString(),
qrDataUrl: null,
};
}
async getQr(tenantId: string, pairingToken: string): Promise<BotQrResponse> {
const account = await this.prisma.account.findFirst({
where: { pairingToken, tenants: { some: { tenantId } } },
});
if (!account) {
throw new NotFoundException('Pairing token not found for this tenant');
}
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
return {
status: account.status as BotStatus,
qrDataUrl: null,
pairingToken: account.pairingToken ?? '',
expiresAt: account.pairingExpiresAt.toISOString(),
};
}
if (!account.qrCode) {
return {
status: account.status as BotStatus,
qrDataUrl: null,
pairingToken: account.pairingToken ?? '',
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
};
}
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
return {
status: account.status as BotStatus,
qrDataUrl,
pairingToken: account.pairingToken ?? '',
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
};
}
async get(tenantId: string): Promise<{ bot: BotSummary | null; shared: boolean; sharedBotId?: string }> {
const own = await this.prisma.account.findFirst({
where: { tenants: { some: { tenantId } } },
orderBy: { createdAt: 'asc' },
});
if (own) {
return { bot: this.toSummary(own), shared: false };
}
// No own bot — is there a shared bot we could attach to?
const shared = await this.prisma.account.findFirst({
where: {
isBot: true,
status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] },
tenants: { some: {} },
},
orderBy: { createdAt: 'asc' },
});
if (shared) {
return { bot: null, shared: true, sharedBotId: shared.id };
}
return { bot: null, shared: false };
}
async attach(tenantId: string, adminId: string, accountId: string): Promise<{ bot: BotSummary }> {
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
if (account.status === 'BANNED') {
throw new ConflictException('Bot is banned and cannot be shared');
}
const existing = await this.prisma.tenantBot.findUnique({
where: { tenantId_accountId: { tenantId, accountId } },
});
if (!existing) {
await this.prisma.tenantBot.create({
data: { tenantId, accountId, isActive: true },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.BOT_ACCESS_GRANTED,
resourceType: 'Account',
resourceId: accountId,
payload: { reason: 'tenant attached to shared bot' },
});
}
return { bot: this.toSummary(account) };
}
private toSummary(account: any): BotSummary {
return {
id: account.id,
platform: account.platform,
jid: account.status === 'ACTIVE' ? account.jid : null,
displayName: account.displayName,
status: account.status as BotStatus,
isBot: account.isBot,
createdAt: account.createdAt.toISOString(),
updatedAt: account.updatedAt.toISOString(),
};
}
/**
* Find the least-loaded ACTIVE bot and assign it to the tenant.
* Returns null if no bot is available in the pool.
* Idempotent skips if the tenant already has a TenantBot.
*/
async assignBotToTenant(tenantId: string): Promise<BotSummary | null> {
const existing = await this.prisma.tenantBot.findFirst({
where: { tenantId },
include: { account: true },
});
if (existing) {
return this.toSummary(existing.account);
}
const candidates = await this.prisma.account.findMany({
where: { isBot: true, status: 'ACTIVE' },
include: { _count: { select: { tenants: true } } },
});
if (candidates.length === 0) {
this.logger.warn({ tenantId }, 'No ACTIVE bot available to assign');
return null;
}
const best = candidates.reduce((a, b) =>
a._count.tenants <= b._count.tenants ? a : b,
);
await this.prisma.tenantBot.create({
data: { tenantId, accountId: best.id, isActive: true },
});
this.logger.log({ tenantId, accountId: best.id, tenantCount: best._count.tenants }, 'Bot auto-assigned');
return this.toSummary(best);
}
async reveal(tenantId: string, adminId: string): Promise<BotRevealResponse> {
const account = await this.prisma.account.findFirst({
where: { tenants: { some: { tenantId } } },
});
if (!account || account.status !== 'ACTIVE') {
throw new NotFoundException('No active bot to reveal');
}
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.BOT_REVEALED,
resourceType: 'Account',
resourceId: account.id,
payload: { jid: account.jid },
});
return { jid: account.jid, revealedAt: new Date().toISOString() };
}
// ---------------------------------------------------------------------------
// Super admin bot management
// ---------------------------------------------------------------------------
async listAll(): Promise<any[]> {
const bots = await this.prisma.account.findMany({
where: { isBot: true },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { tenants: true } } },
});
return bots.map((b) => ({
id: b.id,
jid: b.status === 'ACTIVE' ? b.jid : null,
displayName: b.displayName,
status: b.status,
platform: b.platform,
tenantCount: b._count.tenants,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}));
}
async superInitiate(displayName?: string): Promise<{ pairingToken: string; expiresAt: string }> {
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
const uid = randomUUID();
const pairingToken = randomUUID();
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
await this.prisma.account.create({
data: {
platform: 'whatsapp',
jid: `pending_${uid}@placeholder`,
sessionPath: `${sessionBase}/${uid}`,
displayName: displayName ?? null,
status: 'PAIRING',
isBot: true,
pairingToken,
pairingExpiresAt: expiresAt,
},
});
return { pairingToken, expiresAt: expiresAt.toISOString() };
}
async superGetQr(pairingToken: string): Promise<any> {
const account = await this.prisma.account.findFirst({
where: { pairingToken },
});
if (!account) throw new NotFoundException('Pairing token not found');
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt.toISOString() };
}
if (!account.qrCode) {
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
}
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
return { status: account.status, qrDataUrl, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
}
async assignTenant(tenantId: string, accountId: string): Promise<any> {
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
if (account.status !== 'ACTIVE') throw new ConflictException('Bot is not ACTIVE');
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
const existing = await this.prisma.tenantBot.findFirst({ where: { tenantId } });
if (existing) throw new ConflictException('Tenant already has a bot assigned');
await this.prisma.tenantBot.create({
data: { tenantId, accountId: account.id, isActive: true },
});
return { ok: true, accountId: account.id, jid: account.jid };
}
async superRemove(accountId: string): Promise<{ ok: true }> {
const account = await this.prisma.account.findUnique({
where: { id: accountId },
include: { _count: { select: { tenants: true } } },
});
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
if (account._count.tenants > 0) {
throw new ConflictException(`Cannot remove bot — ${account._count.tenants} tenant(s) still assigned. Reassign them first.`);
}
await this.prisma.account.delete({ where: { id: accountId } });
return { ok: true };
}
}
@@ -0,0 +1,46 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { CirclesService } from './circles.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/circles')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class CirclesController {
constructor(private readonly service: CirclesService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listAdmin(ctx.tenantId);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: { name: string; description?: string; isPublic?: boolean },
) {
return this.service.create(ctx.tenantId, ctx.adminId!, body);
}
@Patch(':id')
update(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { name?: string; description?: string; isPublic?: boolean },
) {
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
}
@Delete(':id')
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
}
@Get(':id/members')
members(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.listMembers(ctx.tenantId, id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CirclesController } from './circles.controller';
import { CirclesService } from './circles.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [CirclesController],
providers: [CirclesService],
exports: [CirclesService],
})
export class CirclesModule {}
@@ -0,0 +1,145 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@Injectable()
export class CirclesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
// ── Admin ────────────────────────────────────────────────────────────────
async listAdmin(tenantId: string) {
return this.prisma.circle.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { memberships: true } } },
});
}
async create(tenantId: string, adminId: string, dto: {
name: string;
description?: string;
isPublic?: boolean;
}) {
const existing = await this.prisma.circle.findFirst({ where: { tenantId, name: dto.name } });
if (existing) throw new BadRequestException('A circle with this name already exists');
const circle = await this.prisma.circle.create({
data: {
tenantId,
name: dto.name,
description: dto.description ?? null,
isPublic: dto.isPublic ?? true,
createdBy: adminId,
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.CIRCLE_CREATED,
resourceType: 'Circle',
resourceId: circle.id,
payload: { name: circle.name },
});
return circle;
}
async update(tenantId: string, adminId: string, id: string, dto: {
name?: string;
description?: string;
isPublic?: boolean;
}) {
const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
return this.prisma.circle.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.isPublic !== undefined && { isPublic: dto.isPublic }),
},
});
}
async remove(tenantId: string, adminId: string, id: string) {
const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
await this.prisma.circle.delete({ where: { id } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.CIRCLE_DELETED,
resourceType: 'Circle',
resourceId: id,
});
return { ok: true };
}
async listMembers(tenantId: string, circleId: string) {
const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
return this.prisma.circleMembership.findMany({
where: { circleId },
include: { user: { select: { id: true, displayName: true, jid: true } } },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
});
}
// ── Member ─────────────────────────────────────────────────────────────────
async listForMember(userId: string, tenantId: string) {
const circles = await this.prisma.circle.findMany({
where: {
tenantId,
// public circles, plus any private circle the member already belongs to
OR: [{ isPublic: true }, { memberships: { some: { userId } } }],
},
orderBy: { name: 'asc' },
include: {
memberships: { where: { userId }, select: { role: true } },
_count: { select: { memberships: true } },
},
});
return circles.map((c) => ({
id: c.id,
name: c.name,
description: c.description,
isPublic: c.isPublic,
memberCount: c._count.memberships,
isMember: c.memberships.length > 0,
myRole: c.memberships[0]?.role ?? null,
}));
}
async join(userId: string, tenantId: string, circleId: string) {
const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } });
if (!circle) throw new NotFoundException('Circle not found');
if (!circle.isPublic) {
const existing = await this.prisma.circleMembership.findUnique({
where: { circleId_userId: { circleId, userId } },
});
if (!existing) throw new ForbiddenException('This circle is invite-only');
}
const membership = await this.prisma.circleMembership.upsert({
where: { circleId_userId: { circleId, userId } },
create: { circleId, userId, role: 'MEMBER' },
update: {},
});
return { ok: true, membershipId: membership.id };
}
async leave(userId: string, tenantId: string, circleId: string) {
const membership = await this.prisma.circleMembership.findFirst({
where: { circleId, userId, circle: { tenantId } },
});
if (!membership) throw new NotFoundException('You are not a member of this circle');
await this.prisma.circleMembership.delete({ where: { id: membership.id } });
return { ok: true };
}
}
@@ -0,0 +1,43 @@
import { Body, Controller, Delete, Get, Post, Put, UseGuards } from '@nestjs/common';
import { DigestService } from './digest.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 { UpdateDigestConfigDto } from './digest.dto';
@Controller('admin/digest')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class DigestController {
constructor(private readonly digestService: DigestService) {}
@Get('config')
getConfig(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.getConfig(ctx.tenantId);
}
@Put('config')
upsertConfig(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: UpdateDigestConfigDto,
) {
return this.digestService.upsertConfig(ctx.tenantId, body, ctx.adminId ?? 'unknown');
}
@Delete('config')
disableConfig(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.disableConfig(ctx.tenantId);
}
@Get('history')
getHistory(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.getHistory(ctx.tenantId);
}
@Post('send-now')
sendNow(@CurrentTenantContext() ctx: TenantContext) {
return this.digestService.sendNow(ctx.tenantId, ctx.adminId ?? 'unknown');
}
}
+23
View File
@@ -0,0 +1,23 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateDigestConfigDto {
@IsString()
targetGroupJid: string;
@IsString()
targetAccountId: string;
@IsInt()
@Min(0)
@Max(23)
scheduleHour: number;
@IsInt()
@Min(0)
@Max(59)
scheduleMinute: number;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DigestController } from './digest.controller';
import { DigestService } from './digest.service';
import { digestQueueProvider } from '../../queues/digest.queue';
@Module({
controllers: [DigestController],
providers: [DigestService, digestQueueProvider],
})
export class DigestModule {}
@@ -0,0 +1,100 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { Queue } from 'bullmq';
import { DigestJobData } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { DIGEST_QUEUE } from '../../queues/digest.queue';
import { UpdateDigestConfigDto } from './digest.dto';
function todayMidnightUtc(): Date {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
@Injectable()
export class DigestService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
@Inject(DIGEST_QUEUE) private readonly digestQueue: Queue<DigestJobData>,
) {}
async getConfig(tenantId: string) {
return this.prisma.digestConfig.findUnique({ where: { tenantId } });
}
async upsertConfig(tenantId: string, dto: UpdateDigestConfigDto, adminId: string) {
const config = await this.prisma.digestConfig.upsert({
where: { tenantId },
create: {
tenantId,
targetGroupJid: dto.targetGroupJid,
targetAccountId: dto.targetAccountId,
scheduleHour: dto.scheduleHour,
scheduleMinute: dto.scheduleMinute,
isActive: dto.isActive ?? true,
},
update: {
targetGroupJid: dto.targetGroupJid,
targetAccountId: dto.targetAccountId,
scheduleHour: dto.scheduleHour,
scheduleMinute: dto.scheduleMinute,
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.DIGEST_SENT,
resourceType: 'DigestConfig',
resourceId: config.id,
payload: { targetGroupJid: dto.targetGroupJid, scheduleHour: dto.scheduleHour, scheduleMinute: dto.scheduleMinute },
});
return config;
}
async disableConfig(tenantId: string): Promise<void> {
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
if (!config) throw new NotFoundException('Digest config not found');
await this.prisma.digestConfig.update({ where: { tenantId }, data: { isActive: false } });
}
async getHistory(tenantId: string) {
const digests = await this.prisma.digest.findMany({
where: { tenantId },
orderBy: { digestDate: 'desc' },
take: 30,
});
return digests.map((d) => ({
id: d.id,
digestDate: d.digestDate.toISOString(),
messageCount: (d.messageIds as string[]).length,
sentAt: d.sentAt.toISOString(),
}));
}
async sendNow(tenantId: string, adminId: string): Promise<{ status: string }> {
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
if (!config) throw new NotFoundException('Digest config not found — configure via PUT /admin/digest/config first');
await this.digestQueue.add('digest', {
tenantId,
digestDate: todayMidnightUtc().toISOString(),
triggeredBy: 'manual',
}, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.DIGEST_SENT,
resourceType: 'Digest',
resourceId: tenantId,
payload: { triggeredBy: 'manual' },
});
return { status: 'queued' };
}
}
@@ -0,0 +1,29 @@
import { Controller, Get, Post, Param, Query, UseGuards, Request } from '@nestjs/common';
import { DraftsService } from './drafts.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { DraftStatus, DraftType } from '@prisma/client';
@Controller('admin/drafts')
@UseGuards(JwtAuthGuard)
export class DraftsController {
constructor(private readonly service: DraftsService) {}
@Get()
list(
@Request() req: any,
@Query('type') type?: DraftType,
@Query('status') status?: DraftStatus,
) {
return this.service.list(req.user.tenantId, type, status ?? 'PENDING_REVIEW');
}
@Post(':id/approve')
approve(@Param('id') id: string, @Request() req: any) {
return this.service.approve(id, req.user.tenantId, req.user.sub);
}
@Post(':id/reject')
reject(@Param('id') id: string, @Request() req: any) {
return this.service.reject(id, req.user.tenantId, req.user.sub);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DraftsController } from './drafts.controller';
import { DraftsService } from './drafts.service';
import { AuthModule } from '../auth/auth.module';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [AuthModule, AuditModule],
controllers: [DraftsController],
providers: [DraftsService],
})
export class DraftsModule {}
@@ -0,0 +1,151 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { DraftStatus, DraftType } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
export interface ApproveDraftResult {
publishedId: string;
type: DraftType;
}
@Injectable()
export class DraftsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list(tenantId: string, type?: DraftType, status: DraftStatus = 'PENDING_REVIEW') {
return this.prisma.contentDraft.findMany({
where: { tenantId, ...(type ? { type } : {}), status },
include: {
sourceMessage: {
select: { id: true, content: true, senderName: true, createdAt: true },
},
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async approve(draftId: string, tenantId: string, adminId: string): Promise<ApproveDraftResult> {
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
if (draft.status !== 'PENDING_REVIEW') {
throw new BadRequestException('Draft has already been reviewed');
}
const proposed = draft.proposedJson as Record<string, any>;
let publishedId: string;
if (draft.type === 'EVENT') {
const startsAt = proposed.date
? new Date(`${proposed.date}T${proposed.time ?? '00:00'}:00`)
: new Date();
const event = await this.prisma.event.create({
data: {
tenantId,
title: proposed.title ?? 'Untitled Event',
description: proposed.description ?? null,
location: proposed.location ?? null,
startsAt,
createdBy: adminId,
isPublished: true,
},
});
publishedId = event.id;
} else if (draft.type === 'SEVA_TASK') {
const startsAt = proposed.date ? new Date(`${proposed.date}T00:00:00`) : undefined;
const seva = await this.prisma.sevaOpportunity.create({
data: {
tenantId,
title: proposed.title ?? 'Seva Opportunity',
description: proposed.parentEventTitle
? `Part of: ${proposed.parentEventTitle}`
: (proposed.description ?? null),
slots: proposed.slots ?? null,
startsAt: startsAt ?? null,
createdBy: adminId,
isPublished: true,
},
});
publishedId = seva.id;
} else {
// FAQ_ITEM — find or create a default board
let board = await this.prisma.knowledgeBoard.findFirst({
where: { tenantId },
orderBy: { sortOrder: 'asc' },
});
if (!board) {
board = await this.prisma.knowledgeBoard.create({
data: {
tenantId,
name: 'Community FAQs',
slug: 'community-faqs',
},
});
}
const item = await this.prisma.knowledgeItem.create({
data: {
boardId: board.id,
tenantId,
question: proposed.question ?? 'Untitled question',
answer: proposed.answer ?? '',
tags: proposed.tags ?? [],
status: 'PUBLISHED',
createdBy: adminId,
},
});
publishedId = item.id;
}
await this.prisma.contentDraft.update({
where: { id: draftId },
data: {
status: 'APPROVED',
reviewedBy: adminId,
reviewedAt: new Date(),
publishedId,
},
});
await this.audit.log({
tenantId,
actorType: 'ADMIN',
actorId: adminId,
action: AuditAction.DRAFT_APPROVED,
resourceType: 'ContentDraft',
resourceId: draftId,
payload: { type: draft.type, publishedId },
});
return { publishedId, type: draft.type };
}
async reject(draftId: string, tenantId: string, adminId: string): Promise<void> {
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
if (draft.status !== 'PENDING_REVIEW') {
throw new BadRequestException('Draft has already been reviewed');
}
await this.prisma.contentDraft.update({
where: { id: draftId },
data: { status: 'REJECTED', reviewedBy: adminId, reviewedAt: new Date() },
});
await this.audit.log({
tenantId,
actorType: 'ADMIN',
actorId: adminId,
action: AuditAction.DRAFT_REJECTED,
resourceType: 'ContentDraft',
resourceId: draftId,
payload: { type: draft.type },
});
}
}
@@ -0,0 +1,60 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { EventsService } from './events.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/events')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class EventsController {
constructor(private readonly service: EventsService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.service.list(ctx.tenantId, true);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: {
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt?: string;
isPublished?: boolean;
},
) {
return this.service.create(ctx.tenantId, ctx.adminId!, body);
}
@Patch(':id')
update(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: {
title?: string;
description?: string;
location?: string;
startsAt?: string;
endsAt?: string;
isPublished?: boolean;
},
) {
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
}
@Delete(':id')
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
}
@Get(':id/rsvps')
rsvps(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.getRsvps(ctx.tenantId, id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [EventsController],
providers: [EventsService],
})
export class EventsModule {}
@@ -0,0 +1,108 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@Injectable()
export class EventsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async list(tenantId: string, includeUnpublished = false) {
return this.prisma.event.findMany({
where: {
tenantId,
...(includeUnpublished ? {} : { isPublished: true }),
},
orderBy: { startsAt: 'asc' },
include: { _count: { select: { rsvps: true } } },
});
}
async create(tenantId: string, adminId: string, dto: {
title: string;
description?: string;
location?: string;
startsAt: string;
endsAt?: string;
isPublished?: boolean;
}) {
const event = await this.prisma.event.create({
data: {
tenantId,
title: dto.title,
description: dto.description ?? null,
location: dto.location ?? null,
startsAt: new Date(dto.startsAt),
endsAt: dto.endsAt ? new Date(dto.endsAt) : null,
createdBy: adminId,
isPublished: dto.isPublished ?? false,
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.EVENT_CREATED,
resourceType: 'Event',
resourceId: event.id,
payload: { title: event.title, isPublished: event.isPublished },
});
return event;
}
async update(tenantId: string, adminId: string, eventId: string, dto: {
title?: string;
description?: string;
location?: string;
startsAt?: string;
endsAt?: string;
isPublished?: boolean;
}) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
if (!event) throw new NotFoundException('Event not found');
return this.prisma.event.update({
where: { id: eventId },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.location !== undefined && { location: dto.location }),
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
...(dto.endsAt !== undefined && { endsAt: new Date(dto.endsAt) }),
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
},
});
}
async remove(tenantId: string, adminId: string, eventId: string) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
if (!event) throw new NotFoundException('Event not found');
await this.prisma.event.delete({ where: { id: eventId } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.EVENT_DELETED,
resourceType: 'Event',
resourceId: eventId,
payload: { deleted: true },
});
return { ok: true };
}
async getRsvps(tenantId: string, eventId: string) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
if (!event) throw new NotFoundException('Event not found');
return this.prisma.eventRsvp.findMany({
where: { eventId },
include: { user: { select: { id: true, displayName: true, jid: true } } },
orderBy: { createdAt: 'asc' },
});
}
}
@@ -0,0 +1,55 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { GalleryService } from './gallery.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/gallery')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class GalleryController {
constructor(private readonly service: GalleryService) {}
@Get('albums')
listAlbums(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listAlbumsAdmin(ctx.tenantId);
}
@Post('albums')
createAlbum(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: { title: string; description?: string; coverUrl?: string; isPublished?: boolean },
) {
return this.service.createAlbum(ctx.tenantId, ctx.adminId!, body);
}
@Patch('albums/:id')
updateAlbum(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { title?: string; description?: string; coverUrl?: string; isPublished?: boolean },
) {
return this.service.updateAlbum(ctx.tenantId, id, body);
}
@Delete('albums/:id')
removeAlbum(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.removeAlbum(ctx.tenantId, id);
}
@Post('albums/:id/items')
addItem(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { mediaUrl: string; caption?: string; sortOrder?: number },
) {
return this.service.addItem(ctx.tenantId, id, body);
}
@Delete('items/:itemId')
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
return this.service.removeItem(ctx.tenantId, itemId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { GalleryController } from './gallery.controller';
import { GalleryService } from './gallery.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [GalleryController],
providers: [GalleryService],
exports: [GalleryService],
})
export class GalleryModule {}
@@ -0,0 +1,120 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class GalleryService {
constructor(private readonly prisma: PrismaService) {}
// ── Admin: albums ──────────────────────────────────────────────────────────
async listAlbumsAdmin(tenantId: string) {
return this.prisma.galleryAlbum.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { items: true } } },
});
}
async createAlbum(tenantId: string, adminId: string, dto: {
title: string;
description?: string;
coverUrl?: string;
isPublished?: boolean;
}) {
return this.prisma.galleryAlbum.create({
data: {
tenantId,
title: dto.title,
description: dto.description ?? null,
coverUrl: dto.coverUrl ?? null,
isPublished: dto.isPublished ?? false,
createdBy: adminId,
},
});
}
async updateAlbum(tenantId: string, id: string, dto: {
title?: string;
description?: string;
coverUrl?: string;
isPublished?: boolean;
}) {
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
if (!album) throw new NotFoundException('Album not found');
return this.prisma.galleryAlbum.update({
where: { id },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.coverUrl !== undefined && { coverUrl: dto.coverUrl }),
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
},
});
}
async removeAlbum(tenantId: string, id: string) {
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
if (!album) throw new NotFoundException('Album not found');
await this.prisma.galleryAlbum.delete({ where: { id } });
return { ok: true };
}
// ── Admin: items ─────────────────────────────────────────────────────────────
async addItem(tenantId: string, albumId: string, dto: { mediaUrl: string; caption?: string; sortOrder?: number }) {
const album = await this.prisma.galleryAlbum.findFirst({ where: { id: albumId, tenantId } });
if (!album) throw new NotFoundException('Album not found');
return this.prisma.galleryItem.create({
data: {
albumId,
tenantId,
mediaUrl: dto.mediaUrl,
caption: dto.caption ?? null,
sortOrder: dto.sortOrder ?? 0,
},
});
}
async removeItem(tenantId: string, itemId: string) {
const item = await this.prisma.galleryItem.findFirst({ where: { id: itemId, tenantId } });
if (!item) throw new NotFoundException('Item not found');
await this.prisma.galleryItem.delete({ where: { id: itemId } });
return { ok: true };
}
// ── Member: read published albums ────────────────────────────────────────────
async listForMember(tenantId: string) {
const albums = await this.prisma.galleryAlbum.findMany({
where: { tenantId, isPublished: true },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { items: true } } },
});
return albums.map((a) => ({
id: a.id,
title: a.title,
description: a.description,
coverUrl: a.coverUrl,
itemCount: a._count.items,
createdAt: a.createdAt.toISOString(),
}));
}
async getAlbumForMember(tenantId: string, albumId: string) {
const album = await this.prisma.galleryAlbum.findFirst({
where: { id: albumId, tenantId, isPublished: true },
include: { items: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] } },
});
if (!album) throw new NotFoundException('Album not found');
return {
id: album.id,
title: album.title,
description: album.description,
items: album.items.map((i) => ({
id: i.id,
mediaUrl: i.mediaUrl,
caption: i.caption,
})),
};
}
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GamificationService } from './gamification.service';
@Module({
providers: [GamificationService],
exports: [GamificationService],
})
export class GamificationModule {}
@@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, GamificationEventType } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { POINTS, decayFactor } from './gamification.types';
interface AwardOptions {
points?: number;
refType?: string;
refId?: string;
}
@Injectable()
export class GamificationService {
private readonly logger = new Logger(GamificationService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Award points for an event. Idempotent per (userId, type, refId): if an award
* for the same source already exists, this is a no-op (returns null).
*/
async award(
tenantId: string,
userId: string,
type: GamificationEventType,
opts: AwardOptions = {},
) {
const points = opts.points ?? POINTS[type];
try {
return await this.prisma.gamificationEvent.create({
data: {
tenantId,
userId,
type,
points,
refType: opts.refType ?? null,
refId: opts.refId ?? null,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
// Already awarded for this source — idempotent skip.
return null;
}
throw err;
}
}
async getUserScore(userId: string) {
const events = await this.prisma.gamificationEvent.findMany({
where: { userId },
select: { type: true, points: true, createdAt: true },
});
const now = Date.now();
let lifetime = 0;
let recent = 0;
const byType: Record<string, number> = {};
for (const e of events) {
lifetime += e.points;
const ageDays = (now - e.createdAt.getTime()) / 86_400_000;
recent += e.points * decayFactor(ageDays);
byType[e.type] = (byType[e.type] ?? 0) + e.points;
}
return {
lifetime,
recent: Math.round(recent),
byType,
eventCount: events.length,
};
}
async leaderboard(tenantId: string, limit = 10) {
const grouped = await this.prisma.gamificationEvent.groupBy({
by: ['userId'],
where: { tenantId },
_sum: { points: true },
orderBy: { _sum: { points: 'desc' } },
take: limit,
});
if (grouped.length === 0) return [];
const users = await this.prisma.towerUser.findMany({
where: { id: { in: grouped.map((g) => g.userId) } },
select: { id: true, displayName: true, directoryVisible: true },
});
const userMap = new Map(users.map((u) => [u.id, u]));
return grouped.map((g, i) => {
const u = userMap.get(g.userId);
return {
rank: i + 1,
userId: g.userId,
displayName: u?.directoryVisible ? (u.displayName ?? 'Member') : 'Member',
points: g._sum.points ?? 0,
};
});
}
}
@@ -0,0 +1,19 @@
import { GamificationEventType } from '@prisma/client';
// Default point award per event type. Multi-signal — seva is one of many sources.
export const POINTS: Record<GamificationEventType, number> = {
HELPFUL_ANSWER: 10,
SEVA_COMPLETED: 20,
EVENT_ATTENDANCE: 5,
FAQ_APPROVED: 15,
WELCOME: 3,
PROFILE_COMPLETED: 5,
BUSINESS_ADDED: 5,
};
// Time-decay weighting for the "current standing" score (lifetime points are never decayed).
export function decayFactor(ageDays: number): number {
if (ageDays <= 30) return 1;
if (ageDays <= 90) return 0.5;
return 0.25;
}
@@ -1,11 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { GroupsController } from './groups.controller'; import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import type { TenantContext } from '../../common/tenant-context';
const ctx: TenantContext = { tenantId: 'tnt-A', adminId: 'adm_1', role: 'OWNER' };
const mockGroups = [ const mockGroups = [
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' }, { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' },
]; ];
const mockService = { list: jest.fn().mockResolvedValue(mockGroups) };
const mockService = {
list: jest.fn().mockResolvedValue(mockGroups),
listShared: jest.fn().mockResolvedValue([]),
listSharedByMe: jest.fn().mockResolvedValue([]),
getClaimTokenInfo: jest.fn(),
claimWithToken: jest.fn(),
share: jest.fn(),
unshare: jest.fn(),
regenerateToken: jest.fn(),
listUnclaimed: jest.fn().mockResolvedValue([]),
};
describe('GroupsController', () => { describe('GroupsController', () => {
let controller: GroupsController; let controller: GroupsController;
@@ -22,9 +36,29 @@ describe('GroupsController', () => {
controller = module.get<GroupsController>(GroupsController); controller = module.get<GroupsController>(GroupsController);
}); });
it('returns groups from service', async () => { it('list() delegates to service', async () => {
const result = await controller.list(); const result = await controller.list(ctx);
expect(result).toEqual(mockGroups); expect(result).toEqual(mockGroups);
expect(mockService.list).toHaveBeenCalled(); expect(mockService.list).toHaveBeenCalledWith('tnt-A');
});
it('listShared() delegates to service', async () => {
await controller.listShared(ctx);
expect(mockService.listShared).toHaveBeenCalledWith('tnt-A');
});
it('claimWithToken() delegates to service', async () => {
await controller.claimWithToken(ctx, { token: 'abc123' });
expect(mockService.claimWithToken).toHaveBeenCalledWith('abc123', 'adm_1');
});
it('share() delegates to service', async () => {
await controller.share(ctx, 'grp_1', { targetTenantId: 'tnt-B' });
expect(mockService.share).toHaveBeenCalledWith('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
});
it('listUnclaimed() delegates to service', async () => {
await controller.listUnclaimed();
expect(mockService.listUnclaimed).toHaveBeenCalledWith();
}); });
}); });
@@ -1,12 +1,86 @@
import { Controller, Get } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { IsString } from 'class-validator';
@Controller('groups') class ClaimWithTokenDto {
@IsString() token!: string;
}
class ShareDto {
@IsString() targetTenantId!: string;
}
@Controller()
@UseGuards(JwtAuthGuard, RolesGuard)
export class GroupsController { export class GroupsController {
constructor(private readonly groupsService: GroupsService) {} constructor(private readonly groupsService: GroupsService) {}
@Get() @Get('groups')
list() { list(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.list(); return this.groupsService.list(ctx.tenantId);
}
@Get('groups/shared')
listShared(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.listShared(ctx.tenantId);
}
@Get('groups/shared-by-me')
listSharedByMe(@CurrentTenantContext() ctx: TenantContext) {
return this.groupsService.listSharedByMe(ctx.tenantId);
}
@Get('admin/groups/claim-token-info')
getClaimTokenInfo(@Query('token') token: string) {
return this.groupsService.getClaimTokenInfo(token);
}
@Post('admin/groups/claim-with-token')
@Roles('OWNER')
claimWithToken(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: ClaimWithTokenDto,
) {
return this.groupsService.claimWithToken(body.token, ctx.adminId ?? '');
}
@Post('admin/groups/:id/share')
@Roles('OWNER')
share(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: ShareDto,
) {
return this.groupsService.share(ctx.tenantId, ctx.adminId ?? '', id, body.targetTenantId);
}
@Delete('admin/groups/:id/share/:targetTenantId')
@Roles('OWNER')
unshare(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('targetTenantId') targetTenantId: string,
) {
return this.groupsService.unshare(ctx.tenantId, id, targetTenantId);
}
@Post('admin/groups/:id/regenerate-token')
@Roles('OWNER')
regenerateToken(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.groupsService.regenerateToken(ctx.tenantId, ctx.adminId ?? '', id);
}
@Get('admin/groups/unclaimed')
@Roles('OWNER')
listUnclaimed() {
return this.groupsService.listUnclaimed();
} }
} }
@@ -1,37 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
const mockGroups = [ const mockGroup = { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' };
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', platformId: '222@g.us', isActive: true, accountId: null },
];
describe('GroupsService', () => { describe('GroupsService', () => {
let service: GroupsService; let service: GroupsService;
const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } }; const mockPrisma: any = {
group: {
beforeEach(() => { findMany: jest.fn().mockResolvedValue([mockGroup]),
jest.clearAllMocks(); findUnique: jest.fn().mockResolvedValue(mockGroup),
}); findFirst: jest.fn(),
update: jest.fn().mockResolvedValue(mockGroup),
},
groupClaimToken: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
groupAccess: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
tenantBot: { findUnique: jest.fn(), count: jest.fn(), create: jest.fn() },
admin: { findUnique: jest.fn() },
tenant: { findMany: jest.fn() },
$transaction: jest.fn(),
};
const mockAudit = { log: jest.fn() };
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
GroupsService, GroupsService,
{ provide: PrismaService, useValue: mockPrisma }, { provide: PrismaService, useValue: mockPrisma },
{ provide: AuditService, useValue: mockAudit },
], ],
}).compile(); }).compile();
service = module.get<GroupsService>(GroupsService); service = module.get<GroupsService>(GroupsService);
}); });
it('returns all groups ordered by name', async () => { describe('list', () => {
const result = await service.list(); it('returns groups for the given tenant including shared groups', async () => {
expect(result).toHaveLength(2); const result = await service.list('tnt-A');
expect(result[0].name).toBe('Alpha'); expect(result).toHaveLength(1);
expect(mockPrisma.group.findMany).toHaveBeenCalledWith({ expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
orderBy: { name: 'asc' }, expect.objectContaining({
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true }, where: expect.objectContaining({ OR: expect.any(Array) }),
}),
);
});
});
describe('listUnclaimed', () => {
it('returns groups with no tenantId', async () => {
await service.listUnclaimed();
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: null } }),
);
});
});
describe('getClaimTokenInfo', () => {
it('throws NotFound for invalid token', async () => {
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce(null);
await expect(service.getClaimTokenInfo('bad')).rejects.toThrow(NotFoundException);
});
});
describe('claimWithToken', () => {
const mockToken = {
id: 'tok_1', groupId: 'grp_1', token: 'abc123', creatorJid: 'creator@jid',
expiresAt: new Date(Date.now() + 3600000), consumedAt: null,
};
beforeEach(() => {
mockPrisma.admin.findUnique.mockResolvedValue({ id: 'adm_1', tenantId: 'tnt-A' });
mockPrisma.groupClaimToken.findUnique.mockResolvedValue(mockToken);
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: null, accountId: 'acc_1' });
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-A', accountId: 'acc_1' });
mockPrisma.$transaction.mockResolvedValue([mockGroup]);
});
it('throws NotFound when admin does not exist', async () => {
mockPrisma.admin.findUnique.mockResolvedValueOnce(null);
await expect(service.claimWithToken('abc123', 'bad_admin')).rejects.toThrow(NotFoundException);
});
it('throws Conflict when token is consumed', async () => {
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, consumedAt: new Date() });
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
});
it('throws Conflict when token is expired', async () => {
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, expiresAt: new Date(Date.now() - 1000) });
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
});
it('throws Conflict when group is already claimed', async () => {
mockPrisma.group.findUnique.mockResolvedValueOnce({ id: 'grp_1', tenantId: 'tnt-B', accountId: 'acc_1' });
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
});
});
describe('share / unshare', () => {
const sharedAccess = { id: 'acc_1', groupId: 'grp_1', tenantId: 'tnt-B', grantedBy: 'adm_1' };
it('share creates a GroupAccess record', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
mockPrisma.groupAccess.create.mockResolvedValue(sharedAccess);
const result = await service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
expect(result).toEqual(sharedAccess);
});
it('share throws Conflict if already shared', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
await expect(service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B')).rejects.toThrow(ConflictException);
});
it('unshare deletes the GroupAccess record', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
mockPrisma.groupAccess.delete.mockResolvedValue(sharedAccess);
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).resolves.not.toThrow();
});
it('unshare throws NotFound if no share exists', async () => {
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).rejects.toThrow(NotFoundException);
}); });
}); });
}); });
+248 -4
View File
@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { randomBytes } from 'crypto';
export interface GroupSummary { export interface GroupSummary {
id: string; id: string;
@@ -8,16 +11,257 @@ export interface GroupSummary {
platformId: string; platformId: string;
isActive: boolean; isActive: boolean;
accountId: string | null; accountId: string | null;
tenantId: string | null;
} }
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
@Injectable() @Injectable()
export class GroupsService { export class GroupsService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list(): Promise<GroupSummary[]> { list(tenantId: string): Promise<GroupSummary[]> {
return this.prisma.group.findMany({ return this.prisma.group.findMany({
where: {
OR: [
{ tenantId },
{ groupAccesses: { some: { tenantId } } },
],
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true }, select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
});
}
async listUnclaimed(): Promise<GroupSummary[]> {
return this.prisma.group.findMany({
where: { tenantId: null },
orderBy: { name: 'asc' },
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
});
}
async listShared(tenantId: string): Promise<(GroupSummary & { sharedByTenantName: string })[]> {
const accesses = await this.prisma.groupAccess.findMany({
where: { tenantId },
include: {
group: {
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
},
},
});
const ownerTenantIds: string[] = [...new Set(accesses.map((a) => a.group.tenantId).filter((id): id is string => !!id))];
const tenants = ownerTenantIds.length > 0
? await this.prisma.tenant.findMany({
where: { id: { in: ownerTenantIds } },
select: { id: true, name: true },
})
: [];
const tenantMap = new Map(tenants.map((t) => [t.id, t.name]));
return accesses.map((a) => ({
...a.group,
sharedByTenantName: a.group.tenantId ? tenantMap.get(a.group.tenantId) ?? 'Unknown' : 'Unknown',
}));
}
async listSharedByMe(tenantId: string) {
const accesses = await this.prisma.groupAccess.findMany({
where: { group: { tenantId } },
include: {
group: { select: { id: true, name: true } },
tenant: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
});
// Group by group
const grouped = new Map<string, { groupId: string; groupName: string; sharedWith: { tenantId: string; tenantName: string; grantedAt: Date }[] }>();
for (const a of accesses) {
const key = a.group.id;
if (!grouped.has(key)) {
grouped.set(key, { groupId: a.group.id, groupName: a.group.name, sharedWith: [] });
}
grouped.get(key)!.sharedWith.push({
tenantId: a.tenantId,
tenantName: a.tenant.name,
grantedAt: a.createdAt,
});
}
return [...grouped.values()];
}
async getClaimTokenInfo(token: string) {
const record = await this.prisma.groupClaimToken.findUnique({
where: { token },
include: { group: { select: { name: true } } },
});
if (!record) throw new NotFoundException('Invalid token');
return {
groupName: record.group.name,
expiresAt: record.expiresAt.toISOString(),
isConsumed: record.consumedAt !== null,
isExpired: record.expiresAt < new Date(),
};
}
async claimWithToken(token: string, adminId: string): Promise<GroupSummary> {
const admin = await this.prisma.admin.findUnique({
where: { id: adminId },
select: { tenantId: true },
});
if (!admin) throw new NotFoundException('Admin not found');
const record = await this.prisma.groupClaimToken.findUnique({
where: { token },
});
if (!record) throw new NotFoundException('Invalid token');
if (record.consumedAt) throw new ConflictException('Token has already been used');
if (record.expiresAt < new Date()) throw new ConflictException('Token has expired');
const group = await this.prisma.group.findUnique({
where: { id: record.groupId },
});
if (!group) throw new NotFoundException('Group not found');
if (group.tenantId) throw new ConflictException('Group is already claimed');
// Account-binding: ensure the claiming tenant has a TenantBot link
if (group.accountId) {
const myLink = await this.prisma.tenantBot.findUnique({
where: { tenantId_accountId: { tenantId: admin.tenantId, accountId: group.accountId } },
});
if (!myLink) {
const anyLinks = await this.prisma.tenantBot.count({
where: { accountId: group.accountId },
});
if (anyLinks === 0) {
throw new ConflictException('Bot account has no tenant binding — cannot claim');
}
await this.prisma.tenantBot.create({
data: { tenantId: admin.tenantId, accountId: group.accountId, isActive: true },
});
await this.audit.log({
tenantId: admin.tenantId,
actorId: adminId,
action: AuditAction.BOT_ACCESS_GRANTED,
resourceType: 'Account',
resourceId: group.accountId,
payload: { reason: 'auto-grant on token claim', groupId: group.id },
}); });
} }
} }
const [updated] = await this.prisma.$transaction([
this.prisma.group.update({
where: { id: group.id },
data: { tenantId: admin.tenantId, claimStatus: 'CLAIMED' },
select: {
id: true, name: true, platform: true, platformId: true,
isActive: true, accountId: true, tenantId: true,
},
}),
this.prisma.groupClaimToken.update({
where: { id: record.id },
data: { consumedAt: new Date() },
}),
]);
await this.audit.log({
tenantId: admin.tenantId,
actorId: adminId,
action: AuditAction.GROUP_CLAIMED_WITH_TOKEN,
resourceType: 'Group',
resourceId: group.id,
payload: { groupName: group.name },
});
return updated;
}
async share(tenantId: string, adminId: string, groupId: string, targetTenantId: string) {
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
const existing = await this.prisma.groupAccess.findUnique({
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
});
if (existing) throw new ConflictException('Group already shared with this tenant');
const access = await this.prisma.groupAccess.create({
data: { groupId, tenantId: targetTenantId, grantedBy: adminId },
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.GROUP_SHARED,
resourceType: 'Group',
resourceId: groupId,
payload: { targetTenantId, groupName: group.name },
});
return access;
}
async unshare(tenantId: string, groupId: string, targetTenantId: string) {
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
const existing = await this.prisma.groupAccess.findUnique({
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
});
if (!existing) throw new NotFoundException('Share not found');
await this.prisma.groupAccess.delete({
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
});
await this.audit.log({
tenantId,
action: AuditAction.GROUP_UNSHARED,
resourceType: 'Group',
resourceId: groupId,
payload: { targetTenantId },
});
}
async regenerateToken(tenantId: string, adminId: string, groupId: string) {
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
// Allow regenerate for owned groups OR unclaimed groups (support case)
if (group.tenantId && group.tenantId !== tenantId) throw new NotFoundException('Group not found');
const token = randomBytes(32).toString('hex');
const record = await this.prisma.groupClaimToken.create({
data: {
groupId,
token,
creatorJid: token, // placeholder — support will need to extract jid from group metadata
expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
},
});
await this.audit.log({
tenantId: group.tenantId ?? tenantId,
actorId: adminId,
action: AuditAction.GROUP_CLAIM_TOKEN_REGENERATED,
resourceType: 'Group',
resourceId: groupId,
payload: { tokenId: record.id },
});
return { token, expiresAt: record.expiresAt.toISOString() };
}
}
@@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/public.decorator';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@Public()
@Get() @Get()
check() { check() {
return { return {
@@ -0,0 +1,71 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { KnowledgeService } from './knowledge.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';
type ItemStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
@Controller('admin/knowledge')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class KnowledgeController {
constructor(private readonly service: KnowledgeService) {}
@Get('boards')
listBoards(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listBoardsAdmin(ctx.tenantId);
}
@Post('boards')
createBoard(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: { name: string; description?: string; sortOrder?: number },
) {
return this.service.createBoard(ctx.tenantId, body);
}
@Patch('boards/:id')
updateBoard(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { name?: string; description?: string; sortOrder?: number },
) {
return this.service.updateBoard(ctx.tenantId, id, body);
}
@Delete('boards/:id')
removeBoard(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.removeBoard(ctx.tenantId, id);
}
@Get('boards/:id/items')
listItems(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.listItemsAdmin(ctx.tenantId, id);
}
@Post('boards/:id/items')
createItem(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { question: string; answer: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
) {
return this.service.createItem(ctx.tenantId, ctx.adminId!, id, body);
}
@Patch('items/:itemId')
updateItem(
@CurrentTenantContext() ctx: TenantContext,
@Param('itemId') itemId: string,
@Body() body: { question?: string; answer?: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
) {
return this.service.updateItem(ctx.tenantId, itemId, body);
}
@Delete('items/:itemId')
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
return this.service.removeItem(ctx.tenantId, itemId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { KnowledgeController } from './knowledge.controller';
import { KnowledgeService } from './knowledge.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [KnowledgeController],
providers: [KnowledgeService],
exports: [KnowledgeService],
})
export class KnowledgeModule {}
@@ -0,0 +1,154 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
function slugify(name: string): string {
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'board';
}
@Injectable()
export class KnowledgeService {
constructor(private readonly prisma: PrismaService) {}
// ── Admin: boards ──────────────────────────────────────────────────────────
async listBoardsAdmin(tenantId: string) {
return this.prisma.knowledgeBoard.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: { _count: { select: { items: true } } },
});
}
async createBoard(tenantId: string, dto: { name: string; description?: string; sortOrder?: number }) {
let slug = slugify(dto.name);
const existing = await this.prisma.knowledgeBoard.findFirst({ where: { tenantId, slug } });
if (existing) slug = `${slug}-${Date.now().toString(36)}`;
return this.prisma.knowledgeBoard.create({
data: {
tenantId,
name: dto.name,
description: dto.description ?? null,
slug,
sortOrder: dto.sortOrder ?? 0,
},
});
}
async updateBoard(tenantId: string, id: string, dto: { name?: string; description?: string; sortOrder?: number }) {
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
if (!board) throw new NotFoundException('Board not found');
return this.prisma.knowledgeBoard.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
},
});
}
async removeBoard(tenantId: string, id: string) {
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
if (!board) throw new NotFoundException('Board not found');
await this.prisma.knowledgeBoard.delete({ where: { id } });
return { ok: true };
}
// ── Admin: items ─────────────────────────────────────────────────────────────
async listItemsAdmin(tenantId: string, boardId: string) {
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
if (!board) throw new NotFoundException('Board not found');
return this.prisma.knowledgeItem.findMany({
where: { boardId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
async createItem(tenantId: string, adminId: string, boardId: string, dto: {
question: string;
answer: string;
tags?: string[];
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
sortOrder?: number;
}) {
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
if (!board) throw new NotFoundException('Board not found');
return this.prisma.knowledgeItem.create({
data: {
boardId,
tenantId,
question: dto.question,
answer: dto.answer,
tags: dto.tags ?? [],
status: dto.status ?? 'DRAFT',
sortOrder: dto.sortOrder ?? 0,
createdBy: adminId,
},
});
}
async updateItem(tenantId: string, itemId: string, dto: {
question?: string;
answer?: string;
tags?: string[];
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
sortOrder?: number;
}) {
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
if (!item) throw new NotFoundException('Item not found');
return this.prisma.knowledgeItem.update({
where: { id: itemId },
data: {
...(dto.question !== undefined && { question: dto.question }),
...(dto.answer !== undefined && { answer: dto.answer }),
...(dto.tags !== undefined && { tags: dto.tags }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
},
});
}
async removeItem(tenantId: string, itemId: string) {
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
if (!item) throw new NotFoundException('Item not found');
await this.prisma.knowledgeItem.delete({ where: { id: itemId } });
return { ok: true };
}
// ── Member: read published knowledge grouped by board ────────────────────────
async listForMember(tenantId: string, q?: string) {
const boards = await this.prisma.knowledgeBoard.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: {
items: {
where: {
status: 'PUBLISHED',
...(q && q.trim()
? {
OR: [
{ question: { contains: q.trim(), mode: 'insensitive' } },
{ answer: { contains: q.trim(), mode: 'insensitive' } },
],
}
: {}),
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
select: { id: true, question: true, answer: true, tags: true },
},
},
});
return boards
.filter((b) => b.items.length > 0)
.map((b) => ({
id: b.id,
name: b.name,
description: b.description,
items: b.items,
}));
}
}
@@ -0,0 +1,56 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
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';
class ApproveMessageDto {
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
}
@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/routes')
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
}
@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,
@Body() body: ApproveMessageDto,
) {
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
}
@Post('reindex')
reindex(@CurrentTenantContext() ctx: TenantContext) {
return this.messagesService.reindexApproved(ctx.tenantId);
}
}

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