Compare commits

..

41 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
268 changed files with 16447 additions and 1155 deletions
+2
View File
@@ -7,3 +7,5 @@ coverage
*.env.local *.env.local
.env.production .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.
@@ -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;
+545 -53
View File
@@ -7,19 +7,74 @@ 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 // Tenancy
// ============================================================================ // ============================================================================
model Tenant { model Tenant {
id String @id @default(cuid()) id String @id @default(cuid())
slug String @unique slug String @unique
name String name String
isActive Boolean @default(true) isActive Boolean @default(true)
isForwardingPaused Boolean @default(false) isForwardingPaused Boolean @default(false)
settings Json @default("{}") settings Json @default("{}")
createdAt DateTime @default(now()) organizationId String?
updatedAt DateTime @updatedAt organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
admins Admin[] admins Admin[]
tenantBots TenantBot[] tenantBots TenantBot[]
@@ -33,6 +88,19 @@ model Tenant {
auditEvents AuditEvent[] auditEvents AuditEvent[]
rules TenantRule[] rules TenantRule[]
groupAccesses GroupAccess[] 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 { enum AdminRole {
@@ -177,6 +245,7 @@ model Group {
consents ConsentRecord[] consents ConsentRecord[]
claimTokens GroupClaimToken[] claimTokens GroupClaimToken[]
groupAccesses GroupAccess[] groupAccesses GroupAccess[]
threads Thread[]
@@unique([platform, platformId]) @@unique([platform, platformId])
@@index([accountId]) @@index([accountId])
@@ -189,37 +258,63 @@ model Group {
// ============================================================================ // ============================================================================
model Message { model Message {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) 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? senderTowerUserId String?
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id]) 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)
createdAt DateTime @default(now()) expiresAt DateTime?
updatedAt DateTime @updatedAt quotedPlatformMsgId String?
threadId String?
thread Thread? @relation(fields: [threadId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
approval Approval? approval Approval?
contentDrafts ContentDraft[]
@@unique([platform, platformMsgId]) @@unique([platform, platformMsgId])
@@index([tenantId]) @@index([tenantId])
@@index([senderTowerUserId]) @@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 {
@@ -242,20 +337,40 @@ enum ApprovalDecision {
} }
model SyncRoute { model SyncRoute {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) 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)
createdAt DateTime @default(now()) forwardFormat ForwardFormat @default(THREADED)
withAttachment Boolean @default(true)
frequency ForwardFrequency @default(INSTANT)
approvalMode ApprovalMode @default(MANUAL)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedRules SyncRouteRuleMap[]
@@unique([sourceGroupId, targetGroupId]) @@unique([sourceGroupId, targetGroupId])
@@index([tenantId]) @@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 // Group claiming + sharing
// ============================================================================ // ============================================================================
@@ -310,19 +425,31 @@ enum MemberOptOutReason {
// Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET). // Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET).
model TowerUser { model TowerUser {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
phoneHash String phoneHash String
jid String jid String
displayName String? displayName String?
createdAt DateTime @default(now()) avatar String?
updatedAt DateTime @updatedAt 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[] consents ConsentRecord[]
optOuts MemberOptOut[] optOuts MemberOptOut[]
sessions TowerSession[] sessions TowerSession[]
messages Message[] @relation("senderTowerUser") messages Message[] @relation("senderTowerUser")
rsvps EventRsvp[]
sevaEntries SevaEntry[]
gamificationEvents GamificationEvent[]
circleMemberships CircleMembership[]
askAIQueries AskAIQuery[]
@@unique([tenantId, phoneHash]) @@unique([tenantId, phoneHash])
@@index([phoneHash]) @@index([phoneHash])
@@ -389,7 +516,7 @@ model OtpChallenge {
scopes ConsentScope[] scopes ConsentScope[]
retentionDays Int @default(90) retentionDays Int @default(90)
policyVersion String policyVersion String
groupId String groupId String?
expiresAt DateTime expiresAt DateTime
consumedAt DateTime? consumedAt DateTime?
sentAt DateTime? sentAt DateTime?
@@ -408,6 +535,7 @@ enum RuleMatchType {
HASHTAG HASHTAG
PREFIX PREFIX
REACTION_EMOJI REACTION_EMOJI
INTEREST
} }
enum RuleAction { enum RuleAction {
@@ -417,19 +545,383 @@ enum RuleAction {
REJECT 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 { model TenantRule {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType matchType RuleMatchType
matchValue String matchValue String
action RuleAction action RuleAction
priority Int @default(0) ruleIntent RuleIntent @default(POSITIVE)
isActive Boolean @default(true) priority Int @default(0)
createdAt DateTime @default(now()) isActive Boolean @default(true)
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRouteRuleMaps SyncRouteRuleMap[]
@@unique([tenantId, matchType, matchValue]) @@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive]) @@index([tenantId, isActive])
@@index([tenantId, matchType]) @@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])
}
+22
View File
@@ -14,6 +14,17 @@ import { MessagesModule } from './modules/messages/messages.module';
import { RulesModule } from './modules/rules/rules.module'; import { RulesModule } from './modules/rules/rules.module';
import { SuperAdminModule } from './modules/super-admin/super-admin.module'; import { SuperAdminModule } from './modules/super-admin/super-admin.module';
import { TenantModule } from './modules/tenant/tenant.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: [
@@ -32,6 +43,17 @@ import { TenantModule } from './modules/tenant/tenant.module';
RulesModule, RulesModule,
SuperAdminModule, SuperAdminModule,
TenantModule, 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;
}
@@ -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}`;
}
}
+11
View File
@@ -37,6 +37,17 @@ export const AuditAction = {
MEMBER_DELETED: 'MEMBER_DELETED', MEMBER_DELETED: 'MEMBER_DELETED',
OTP_REQUESTED: 'OTP_REQUESTED', OTP_REQUESTED: 'OTP_REQUESTED',
OTP_VERIFIED: 'OTP_VERIFIED', 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; } as const;
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction]; export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
+13 -4
View File
@@ -26,10 +26,13 @@ export class AuthService {
) {} ) {}
async login(req: LoginRequest): Promise<LoginResponse> { async login(req: LoginRequest): Promise<LoginResponse> {
let tenant: { id: string; slug: string } | null = null; 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) { if (req.tenantSlug) {
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } }); tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
if (!tenant) { if (!tenant) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
@@ -48,7 +51,7 @@ export class AuthService {
'Email is registered in multiple tenants — please specify tenantSlug', 'Email is registered in multiple tenants — please specify tenantSlug',
); );
} }
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId } }); const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId }, include: tenantInclude });
if (!found) { if (!found) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
@@ -93,6 +96,8 @@ export class AuthService {
role: admin.role as AdminRole, role: admin.role as AdminRole,
tenantId: admin.tenantId, tenantId: admin.tenantId,
tenantSlug: tenant.slug, tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: tenant.organization,
}, },
}; };
} }
@@ -100,7 +105,7 @@ export class AuthService {
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> { async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
const admin = await this.prisma.admin.findFirst({ const admin = await this.prisma.admin.findFirst({
where: { id: adminId, tenantId }, where: { id: adminId, tenantId },
include: { tenant: true }, include: { tenant: { include: { organization: { select: { id: true, slug: true, name: true } } } } },
}); });
if (!admin) throw new UnauthorizedException('Admin not found'); if (!admin) throw new UnauthorizedException('Admin not found');
return { return {
@@ -110,6 +115,8 @@ export class AuthService {
role: admin.role as AdminRole, role: admin.role as AdminRole,
tenantId: admin.tenantId, tenantId: admin.tenantId,
tenantSlug: admin.tenant.slug, tenantSlug: admin.tenant.slug,
tenantName: admin.tenant.name,
organization: admin.tenant.organization,
}, },
}; };
} }
@@ -192,6 +199,8 @@ export class AuthService {
role: PrismaAdminRole.OWNER, role: PrismaAdminRole.OWNER,
tenantId: tenant.id, tenantId: tenant.id,
tenantSlug: tenant.slug, tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: null,
}, },
}; };
} }
@@ -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;
}
@@ -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,
}));
}
}
@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { MessagesService } from './messages.service'; import { MessagesService } from './messages.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -6,6 +7,10 @@ import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context'; import { TenantContext } from '../../common/tenant-context';
class ApproveMessageDto {
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
}
@Controller('admin/messages') @Controller('admin/messages')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN') @Roles('OWNER', 'ADMIN')
@@ -22,6 +27,11 @@ export class MessagesController {
return this.messagesService.pendingCount(ctx.tenantId); 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(':id')
get( get(
@CurrentTenantContext() ctx: TenantContext, @CurrentTenantContext() ctx: TenantContext,
@@ -34,8 +44,9 @@ export class MessagesController {
approve( approve(
@CurrentTenantContext() ctx: TenantContext, @CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string, @Param('id') id: string,
@Body() body: ApproveMessageDto,
) { ) {
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id); return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
} }
@Post('reindex') @Post('reindex')
@@ -1,7 +1,7 @@
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { ForwardJobData, IndexJobData } from '@tower/types'; import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types'; import { AuditAction } from '../audit/audit.types';
@@ -112,7 +112,76 @@ export class MessagesService {
})); }));
} }
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> { async getRoutesForMessage(tenantId: string, messageId: string): Promise<RouteForMessage[]> {
const message = await this.prisma.message.findUnique({
where: { id: messageId },
select: { id: true, tenantId: true, sourceGroupId: true, content: true },
});
if (!message) throw new NotFoundException('Message not found');
if (message.tenantId !== tenantId) throw new NotFoundException('Message not found');
const syncRoutes = await this.prisma.syncRoute.findMany({
where: { sourceGroupId: message.sourceGroupId, isActive: true },
include: {
targetGroup: { select: { id: true, name: true, isActive: true } },
assignedRules: {
include: {
tenantRule: {
select: { matchType: true, matchValue: true, ruleIntent: true, isActive: true },
},
},
},
},
});
return syncRoutes
.filter((r: any) => r.targetGroup?.isActive)
.map((route: any) => {
const activeRules = route.assignedRules
.filter((m: any) => m.tenantRule.isActive)
.map((m: any) => m.tenantRule);
let ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE' = 'NONE';
if (activeRules.length > 0) {
const negativeRules = activeRules.filter((r: any) => r.ruleIntent === 'NEGATIVE');
const positiveRules = activeRules.filter((r: any) => r.ruleIntent === 'POSITIVE');
if (negativeRules.length > 0 && this.matchesAny(message.content, negativeRules)) {
ruleDecision = 'BLOCK';
} else if (positiveRules.length > 0 && this.matchesAny(message.content, positiveRules)) {
ruleDecision = 'FORWARD';
}
}
return {
routeId: route.id,
targetGroupId: route.targetGroup.id,
targetGroupName: route.targetGroup.name,
approvalMode: route.approvalMode,
forwardFormat: route.forwardFormat,
withAttachment: route.withAttachment,
frequency: route.frequency,
ruleDecision,
} as RouteForMessage;
});
}
private matchesAny(content: string, rules: Array<{ matchType: string; matchValue: string }>): boolean {
for (const rule of rules) {
if (rule.matchType === 'HASHTAG') {
const escaped = rule.matchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i').test(content)) return true;
} else if (rule.matchType === 'PREFIX') {
if (content.trimStart().startsWith(rule.matchValue)) return true;
} else if (rule.matchType === 'REACTION_EMOJI') {
if (content.includes(rule.matchValue)) return true;
}
}
return false;
}
async approve(tenantId: string, adminId: string, messageId: string, targetGroupIds?: string[]): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
const message = await this.prisma.message.findUnique({ const message = await this.prisma.message.findUnique({
where: { id: messageId }, where: { id: messageId },
include: { include: {
@@ -160,10 +229,14 @@ export class MessagesService {
throw new ConflictException('Message could not be approved (concurrent update)'); throw new ConflictException('Message could not be approved (concurrent update)');
} }
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter( const allRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
(r: any) => r.targetGroup != null, (r: any) => r.targetGroup != null,
); );
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({ const selectedRoutes = targetGroupIds
? allRoutes.filter((r: any) => targetGroupIds.includes(r.targetGroupId))
: allRoutes;
const forwardJobs: ForwardJobData[] = selectedRoutes.map((route: any) => ({
tenantId: message.tenantId, tenantId: message.tenantId,
messageId: message.id, messageId: message.id,
content: message.content, content: message.content,
@@ -204,6 +277,7 @@ export class MessagesService {
resourceId: message.id, resourceId: message.id,
payload: { payload: {
routesForwarded: forwardJobs.length, routesForwarded: forwardJobs.length,
targetGroupIds: targetGroupIds ?? null,
contentPreview: message.content.slice(0, 80), contentPreview: message.content.slice(0, 80),
}, },
}); });
+120 -2
View File
@@ -1,5 +1,10 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { MyService } from './my.service'; import { MyService } from './my.service';
import { SevaService } from '../seva/seva.service';
import { CirclesService } from '../circles/circles.service';
import { AskAIService } from '../ask-ai/ask-ai.service';
import { KnowledgeService } from '../knowledge/knowledge.service';
import { GalleryService } from '../gallery/gallery.service';
import { MemberAuth } from '../auth/member-auth.decorator'; import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator'; import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types'; import type { MemberJwtPayload } from '@tower/types';
@@ -22,13 +27,126 @@ class OptInDto {
@Controller('my') @Controller('my')
@MemberAuth() @MemberAuth()
export class MyController { export class MyController {
constructor(private readonly service: MyService) {} constructor(
private readonly service: MyService,
private readonly seva: SevaService,
private readonly circles: CirclesService,
private readonly askAI: AskAIService,
private readonly knowledge: KnowledgeService,
private readonly gallery: GalleryService,
) {}
@Get('knowledge')
knowledgeList(@CurrentMember() member: MemberJwtPayload, @Query('q') q?: string) {
return this.knowledge.listForMember(member.tenantId, q);
}
@Get('memories')
memoriesList(@CurrentMember() member: MemberJwtPayload) {
return this.gallery.listForMember(member.tenantId);
}
@Get('memories/:id')
memoriesAlbum(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.gallery.getAlbumForMember(member.tenantId, id);
}
@Get('ask')
askHistory(@CurrentMember() member: MemberJwtPayload) {
return this.askAI.history(member.sub, member.tenantId);
}
@Post('ask')
ask(@CurrentMember() member: MemberJwtPayload, @Body() body: { question: string }) {
return this.askAI.ask(member.sub, member.tenantId, body.question);
}
@Get('seva')
sevaList(@CurrentMember() member: MemberJwtPayload) {
return this.seva.listForMember(member.sub, member.tenantId);
}
@Post('seva/:id/signup')
sevaSignup(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.seva.signup(member.sub, member.tenantId, id);
}
@Post('seva/:id/cancel')
sevaCancel(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.seva.cancel(member.sub, member.tenantId, id);
}
@Get('directory')
directory(
@CurrentMember() member: MemberJwtPayload,
@Query('q') q?: string,
@Query('interest') interest?: string,
) {
return this.service.getDirectory(member.sub, member.tenantId, q, interest);
}
@Get('circles')
circlesList(@CurrentMember() member: MemberJwtPayload) {
return this.circles.listForMember(member.sub, member.tenantId);
}
@Post('circles/:id/join')
circleJoin(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.circles.join(member.sub, member.tenantId, id);
}
@Post('circles/:id/leave')
circleLeave(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.circles.leave(member.sub, member.tenantId, id);
}
@Get('dashboard')
dashboard(@CurrentMember() member: MemberJwtPayload) {
return this.service.getDashboard(member.sub, member.tenantId);
}
@Get('scope')
scope(@CurrentMember() member: MemberJwtPayload) {
return this.service.getScope(member.sub, member.tenantId);
}
@Get('digest')
digests(@CurrentMember() member: MemberJwtPayload) {
return this.service.getDigests(member.tenantId);
}
@Get('events')
listEvents(@CurrentMember() member: MemberJwtPayload) {
return this.service.listEvents(member.sub, member.tenantId);
}
@Post('events/:id/rsvp')
rsvp(
@CurrentMember() member: MemberJwtPayload,
@Param('id') eventId: string,
@Body() body: { status: 'GOING' | 'NOT_GOING' | 'MAYBE'; note?: string },
) {
return this.service.upsertRsvp(member.sub, member.tenantId, eventId, body.status, body.note);
}
@Get('profile') @Get('profile')
profile(@CurrentMember() member: MemberJwtPayload) { profile(@CurrentMember() member: MemberJwtPayload) {
return this.service.getProfile(member.sub, member.tenantId); return this.service.getProfile(member.sub, member.tenantId);
} }
@Patch('profile')
updateProfile(@CurrentMember() member: MemberJwtPayload, @Body() body: {
displayName?: string;
hometown?: string;
currentLocation?: string;
interests?: string[];
language?: string;
digestPreference?: string;
directoryVisible?: boolean;
}) {
return this.service.updateProfile(member.sub, member.tenantId, body);
}
@Get('groups') @Get('groups')
listGroups(@CurrentMember() member: MemberJwtPayload) { listGroups(@CurrentMember() member: MemberJwtPayload) {
return this.service.listGroups(member.sub, member.tenantId); return this.service.listGroups(member.sub, member.tenantId);
+7 -1
View File
@@ -2,9 +2,15 @@ import { Module } from '@nestjs/common';
import { MyController } from './my.controller'; import { MyController } from './my.controller';
import { MyService } from './my.service'; import { MyService } from './my.service';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SevaModule } from '../seva/seva.module';
import { GamificationModule } from '../gamification/gamification.module';
import { CirclesModule } from '../circles/circles.module';
import { AskAIModule } from '../ask-ai/ask-ai.module';
import { KnowledgeModule } from '../knowledge/knowledge.module';
import { GalleryModule } from '../gallery/gallery.module';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule, GalleryModule],
controllers: [MyController], controllers: [MyController],
providers: [MyService], providers: [MyService],
}) })
+223
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Unauthorize
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types'; import { AuditAction } from '../audit/audit.types';
import { GamificationService } from '../gamification/gamification.service';
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types'; import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client'; import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
@@ -12,6 +13,7 @@ export class MyService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly gamification: GamificationService,
) {} ) {}
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> { async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
@@ -26,6 +28,227 @@ export class MyService {
}; };
} }
async listEvents(userId: string, tenantId: string) {
const now = new Date();
const events = await this.prisma.event.findMany({
where: { tenantId, isPublished: true, startsAt: { gte: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) } },
orderBy: { startsAt: 'asc' },
take: 20,
include: {
rsvps: {
where: { userId },
select: { status: true },
},
_count: { select: { rsvps: true } },
},
});
return events.map((e) => ({
id: e.id,
title: e.title,
description: e.description,
location: e.location,
startsAt: e.startsAt.toISOString(),
endsAt: e.endsAt?.toISOString() ?? null,
rsvpCount: e._count.rsvps,
myRsvp: e.rsvps[0]?.status ?? null,
}));
}
async upsertRsvp(userId: string, tenantId: string, eventId: string, status: 'GOING' | 'NOT_GOING' | 'MAYBE', note?: string) {
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId, isPublished: true } });
if (!event) throw new NotFoundException('Event not found');
const rsvp = await this.prisma.eventRsvp.upsert({
where: { eventId_userId: { eventId, userId } },
create: { eventId, userId, status, note: note ?? null },
update: { status, note: note ?? null },
});
return { ok: true, rsvpId: rsvp.id, status: rsvp.status };
}
async updateProfile(userId: string, tenantId: string, body: {
displayName?: string;
hometown?: string;
currentLocation?: string;
interests?: string[];
language?: string;
digestPreference?: string;
directoryVisible?: boolean;
}) {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
const updated = await this.prisma.towerUser.update({
where: { id: userId },
data: {
...(body.displayName !== undefined && { displayName: body.displayName }),
...(body.hometown !== undefined && { hometown: body.hometown }),
...(body.currentLocation !== undefined && { currentLocation: body.currentLocation }),
...(body.interests !== undefined && { interests: body.interests }),
...(body.language !== undefined && { language: body.language }),
...(body.digestPreference !== undefined && { digestPreference: body.digestPreference }),
...(body.directoryVisible !== undefined && { directoryVisible: body.directoryVisible }),
},
select: {
id: true, jid: true, displayName: true, avatar: true,
hometown: true, currentLocation: true, interests: true,
language: true, digestPreference: true, directoryVisible: true,
},
});
// Award PROFILE_COMPLETED once the profile carries real signal (name + hometown + an interest).
// Idempotent via refId=userId, so re-saving never double-awards.
if (updated.displayName && updated.hometown && updated.interests.length > 0) {
await this.gamification.award(tenantId, userId, 'PROFILE_COMPLETED', {
refType: 'TowerUser',
refId: userId,
});
}
return updated;
}
async getDigests(tenantId: string) {
const digests = await this.prisma.digest.findMany({
where: { tenantId },
orderBy: { digestDate: 'desc' },
take: 20,
select: { id: true, digestDate: true, content: true, messageIds: true, sentAt: true },
});
return digests.map((d) => ({
id: d.id,
digestDate: d.digestDate.toISOString(),
summary: d.content.slice(0, 300),
messageCount: (d.messageIds as string[]).length,
sentAt: d.sentAt.toISOString(),
}));
}
async getDirectory(userId: string, tenantId: string, q?: string, interest?: string) {
const where: any = {
tenantId,
directoryVisible: true,
id: { not: userId },
};
if (interest) where.interests = { has: interest };
if (q && q.trim()) {
const term = q.trim();
where.OR = [
{ displayName: { contains: term, mode: 'insensitive' } },
{ hometown: { contains: term, mode: 'insensitive' } },
{ currentLocation: { contains: term, mode: 'insensitive' } },
];
}
const [members, allVisible] = await Promise.all([
this.prisma.towerUser.findMany({
where,
take: 100,
orderBy: { displayName: 'asc' },
select: {
id: true,
displayName: true,
avatar: true,
hometown: true,
currentLocation: true,
interests: true,
},
}),
// Aggregate interest filter chips across the whole visible directory.
this.prisma.towerUser.findMany({
where: { tenantId, directoryVisible: true, id: { not: userId } },
select: { interests: true },
}),
]);
const interestCounts = new Map<string, number>();
for (const u of allVisible) {
for (const i of u.interests) interestCounts.set(i, (interestCounts.get(i) ?? 0) + 1);
}
const topInterests = [...interestCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.map(([name, count]) => ({ name, count }));
return {
total: members.length,
members: members.map((m) => ({
id: m.id,
displayName: m.displayName ?? 'Member',
avatar: m.avatar,
hometown: m.hometown,
currentLocation: m.currentLocation,
interests: m.interests,
})),
interests: topInterests,
};
}
async getScope(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({
where: { id: userId, tenantId },
select: {
displayName: true,
tenant: {
select: {
id: true,
slug: true,
name: true,
organization: { select: { id: true, slug: true, name: true } },
},
},
},
});
if (!user) throw new NotFoundException('User not found');
return {
member: { displayName: user.displayName },
chapter: { id: user.tenant.id, slug: user.tenant.slug, name: user.tenant.name },
organization: user.tenant.organization,
};
}
async getDashboard(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
const [activeConsents, messagesCount] = await Promise.all([
this.prisma.consentRecord.findMany({
where: { userId, tenantId, status: 'GRANTED' },
include: { group: { select: { id: true, name: true } } },
}),
this.prisma.message.count({ where: { senderTowerUserId: userId, tenantId } }),
]);
return {
profile: {
id: user.id,
jid: user.jid,
displayName: user.displayName,
avatar: user.avatar,
hometown: user.hometown,
currentLocation: user.currentLocation,
interests: user.interests,
language: user.language,
digestPreference: user.digestPreference,
directoryVisible: user.directoryVisible,
createdAt: user.createdAt.toISOString(),
},
stats: {
groupsJoined: activeConsents.length,
messagesContributed: messagesCount,
activeConsents: activeConsents.length,
},
groups: activeConsents.map((c) => ({
id: c.group.id,
name: c.group.name,
joinedAt: c.effectiveAt.toISOString(),
consentStatus: c.status,
})),
};
}
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> { async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
const consents = await this.prisma.consentRecord.findMany({ const consents = await this.prisma.consentRecord.findMany({
where: { userId, tenantId }, where: { userId, tenantId },
@@ -224,6 +224,89 @@ export class OnboardingService {
}; };
} }
async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> {
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const user = await this.prisma.towerUser.findFirst({
where: { phoneHash },
select: { id: true, tenantId: true, jid: true },
});
if (!user) throw new NotFoundException('No portal account found for this number. Use your original invite link to register first.');
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
const challengeId = randomBytes(16).toString('hex');
await this.prisma.otpChallenge.create({
data: {
id: challengeId,
tenantId: user.tenantId,
jid: user.jid,
phoneHash,
code,
scopes: DEFAULT_SCOPES,
retentionDays: DEFAULT_RETENTION_DAYS,
policyVersion: this.policyVersion,
expiresAt,
},
});
await this.audit.log({
tenantId: user.tenantId,
action: AuditAction.OTP_REQUESTED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: { jid: user.jid, source: 'member-login' },
});
return { challengeId, expiresInSeconds: OTP_TTL_MIN * 60 };
}
async memberVerify(challengeId: string, phone: string, code: string): Promise<{
memberToken: string;
user: { id: string; tenantId: string; jid: string; displayName: string | null };
}> {
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
if (!challenge) throw new NotFoundException('Challenge not found');
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Code expired');
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
await this.prisma.otpChallenge.update({
where: { id: challengeId },
data: { consumedAt: new Date() },
});
const user = await this.prisma.towerUser.findFirst({
where: { phoneHash, tenantId: challenge.tenantId },
select: { id: true, tenantId: true, jid: true, displayName: true, phoneHash: true },
});
if (!user) throw new NotFoundException('User not found');
await this.audit.log({
tenantId: user.tenantId,
action: AuditAction.OTP_VERIFIED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: { jid: user.jid, source: 'member-login' },
});
const memberToken = await this.jwt.signAsync({
kind: 'member',
sub: user.id,
tenantId: user.tenantId,
jid: user.jid,
phoneHash: user.phoneHash,
} as const);
return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName } };
}
private normalizeJid(jid: string): string { private normalizeJid(jid: string): string {
return jid.trim(); return jid.trim();
} }
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { OnboardingService } from './onboarding.service'; import { OnboardingService } from './onboarding.service';
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator'; import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
import { ConsentScope } from '@tower/types'; import { ConsentScope } from '@tower/types';
@@ -18,6 +18,16 @@ class VerifyOtpDto {
@IsInt() @Min(1) @IsOptional() retentionDays?: number; @IsInt() @Min(1) @IsOptional() retentionDays?: number;
} }
class MemberLoginDto {
@IsString() @MinLength(6) phone!: string;
}
class MemberVerifyDto {
@IsString() challengeId!: string;
@IsString() @MinLength(6) phone!: string;
@IsString() @MinLength(6) code!: string;
}
@Controller('public') @Controller('public')
export class PublicOnboardingController { export class PublicOnboardingController {
constructor(private readonly service: OnboardingService) {} constructor(private readonly service: OnboardingService) {}
@@ -46,4 +56,16 @@ export class PublicOnboardingController {
body.retentionDays, body.retentionDays,
); );
} }
@Post('auth/member-login')
@Public()
memberLogin(@Body() body: MemberLoginDto) {
return this.service.memberLogin(body.phone);
}
@Post('auth/member-verify')
@Public()
memberVerify(@Body() body: MemberVerifyDto) {
return this.service.memberVerify(body.challengeId, body.phone, body.code);
}
} }
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { OrgAdminJwtPayload } from '@tower/types';
export const CurrentOrgAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): OrgAdminJwtPayload => {
return ctx.switchToHttp().getRequest().user;
},
);
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class OrgAdminGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) throw new UnauthorizedException();
const token = authHeader.slice(7);
try {
const payload = this.jwtService.verify(token);
if (payload.kind !== 'orgadmin') throw new UnauthorizedException('Not an org admin');
req.user = payload;
return true;
} catch {
throw new UnauthorizedException();
}
}
}
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { OrgService } from './org.service';
import { OrgAdminGuard } from './org-admin.guard';
import { Public } from '../auth/public.decorator';
@Controller('auth/org')
export class OrgAuthController {
constructor(private readonly orgService: OrgService) {}
@Public()
@Post('login')
login(@Body() body: { email: string; password: string }) {
return this.orgService.login(body.email, body.password);
}
@UseGuards(OrgAdminGuard)
@Get('me')
me(@Req() req: any) {
return this.orgService.me(req.user.sub);
}
}
@@ -0,0 +1,70 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { OrgService } from './org.service';
import { OrgAdminGuard } from './org-admin.guard';
import { CurrentOrgAdmin } from './org-admin.decorator';
import { OrgAdminJwtPayload } from '@tower/types';
@Controller('org')
@UseGuards(OrgAdminGuard)
export class OrgController {
constructor(private readonly orgService: OrgService) {}
@Get('dashboard')
getDashboard(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getDashboard(admin.organizationId);
}
@Get('tenants')
getTenants(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getTenants(admin.organizationId);
}
@Post('tenants')
createTenant(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Body() body: { name: string; slug: string },
) {
return this.orgService.createTenant(admin.organizationId, body, admin.role);
}
@Get('tenants/:id')
getTenant(@CurrentOrgAdmin() admin: OrgAdminJwtPayload, @Param('id') id: string) {
return this.orgService.getTenant(admin.organizationId, id);
}
@Post('tenants/:id/admins')
createTenantAdmin(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Param('id') id: string,
@Body() body: { email: string; password: string; role?: string },
) {
return this.orgService.createTenantAdmin(admin.organizationId, id, body.email, body.password, body.role);
}
@Get('rules')
getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
return this.orgService.getRules(admin.organizationId);
}
@Post('rules')
createRule(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Body() body: { matchType: string; matchValue: string; action: string; priority?: number },
) {
return this.orgService.createRule(admin.organizationId, body);
}
@Patch('rules/:id')
updateRule(
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
@Param('id') id: string,
@Body() body: { matchValue?: string; action?: string; priority?: number; isActive?: boolean },
) {
return this.orgService.updateRule(admin.organizationId, id, body);
}
@Delete('rules/:id')
deleteRule(@CurrentOrgAdmin() admin: OrgAdminJwtPayload, @Param('id') id: string) {
return this.orgService.deleteRule(admin.organizationId, id);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OrgAuthController } from './org-auth.controller';
import { OrgController } from './org.controller';
import { OrgService } from './org.service';
import { OrgAdminGuard } from './org-admin.guard';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [
PrismaModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET') ?? '',
}),
}),
],
controllers: [OrgAuthController, OrgController],
providers: [OrgService, OrgAdminGuard],
exports: [OrgAdminGuard],
})
export class OrgModule {}
+187
View File
@@ -0,0 +1,187 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { OrgAdminJwtPayload } from '@tower/types';
@Injectable()
export class OrgService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
async login(email: string, password: string) {
const admin = await this.prisma.orgAdmin.findFirst({ where: { email } });
if (!admin) throw new UnauthorizedException('Invalid credentials');
const valid = await bcrypt.compare(password, admin.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid credentials');
const payload: OrgAdminJwtPayload = {
kind: 'orgadmin',
sub: admin.id,
organizationId: admin.organizationId,
email: admin.email,
role: admin.role as 'ORG_OWNER' | 'ORG_ADMIN',
};
const token = this.jwtService.sign(payload, {
secret: this.config.get<string>('JWT_SECRET'),
expiresIn: '7d',
});
return {
token,
orgAdmin: { id: admin.id, email: admin.email, name: admin.name, role: admin.role, organizationId: admin.organizationId },
};
}
async me(adminId: string) {
const admin = await this.prisma.orgAdmin.findUnique({
where: { id: adminId },
include: { organization: { select: { id: true, slug: true, name: true } } },
});
if (!admin) throw new UnauthorizedException('Org admin not found');
return { id: admin.id, email: admin.email, name: admin.name, role: admin.role, organization: admin.organization };
}
async getDashboard(organizationId: string) {
const tenants = await this.prisma.tenant.findMany({
where: { organizationId },
select: {
id: true,
name: true,
slug: true,
isActive: true,
_count: { select: { messages: true, towerUsers: true, admins: true } },
},
});
return {
chapterCount: tenants.length,
chapters: tenants.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
messageCount: t._count.messages,
memberCount: t._count.towerUsers,
adminCount: t._count.admins,
})),
};
}
async getTenants(organizationId: string) {
return this.prisma.tenant.findMany({
where: { organizationId },
select: {
id: true,
name: true,
slug: true,
isActive: true,
createdAt: true,
_count: { select: { messages: true, towerUsers: true } },
},
orderBy: { name: 'asc' },
});
}
async getTenant(organizationId: string, tenantId: string) {
const tenant = await this.prisma.tenant.findFirst({
where: { id: tenantId, organizationId },
include: {
admins: { select: { id: true, email: true, role: true, createdAt: true } },
_count: { select: { messages: true, towerUsers: true, groups: true } },
},
});
if (!tenant) throw new NotFoundException('Chapter not found');
return tenant;
}
async createTenant(organizationId: string, body: { name: string; slug: string }, role: string) {
if (role !== 'ORG_OWNER') throw new ForbiddenException('Only ORG_OWNER can create chapters');
const existing = await this.prisma.tenant.findUnique({ where: { slug: body.slug } });
if (existing) throw new ConflictException('Tenant slug already taken');
return this.prisma.tenant.create({
data: { name: body.name, slug: body.slug, organizationId },
});
}
async getRules(organizationId: string) {
return this.prisma.orgRule.findMany({
where: { organizationId },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
}
async createRule(organizationId: string, body: { matchType: string; matchValue: string; action: string; priority?: number }) {
const existing = await this.prisma.orgRule.findUnique({
where: { organizationId_matchType_matchValue: { organizationId, matchType: body.matchType as any, matchValue: body.matchValue } },
});
if (existing) throw new ConflictException('Rule already exists');
return this.prisma.orgRule.create({
data: {
organizationId,
matchType: body.matchType as any,
matchValue: body.matchValue,
action: body.action as any,
priority: body.priority ?? 0,
},
});
}
async updateRule(organizationId: string, ruleId: string, body: { matchValue?: string; action?: string; priority?: number; isActive?: boolean }) {
const rule = await this.prisma.orgRule.findFirst({ where: { id: ruleId, organizationId } });
if (!rule) throw new NotFoundException('Rule not found');
return this.prisma.orgRule.update({
where: { id: ruleId },
data: {
...(body.matchValue !== undefined && { matchValue: body.matchValue }),
...(body.action !== undefined && { action: body.action as any }),
...(body.priority !== undefined && { priority: body.priority }),
...(body.isActive !== undefined && { isActive: body.isActive }),
},
});
}
async deleteRule(organizationId: string, ruleId: string) {
const rule = await this.prisma.orgRule.findFirst({ where: { id: ruleId, organizationId } });
if (!rule) throw new NotFoundException('Rule not found');
await this.prisma.orgRule.delete({ where: { id: ruleId } });
}
async createTenantAdmin(
organizationId: string,
tenantId: string,
email: string,
password: string,
role?: string,
) {
const tenant = await this.prisma.tenant.findFirst({ where: { id: tenantId, organizationId } });
if (!tenant) throw new NotFoundException('Chapter not found or does not belong to this org');
const existing = await this.prisma.admin.findUnique({
where: { tenantId_email: { tenantId, email } },
});
if (existing) throw new ConflictException('An admin with this email already exists for this chapter');
const passwordHash = await bcrypt.hash(password, 10);
return this.prisma.admin.create({
data: { tenantId, email, passwordHash, ...(role ? { role: role as any } : {}) },
select: { id: true, email: true, role: true, createdAt: true },
});
}
}
@@ -1,8 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context'; import { TenantContext } from '../../common/tenant-context';
import { IsString } from 'class-validator';
class CreateRouteDto { class CreateRouteDto {
@IsString() sourceGroupId!: string; @IsString() sourceGroupId!: string;
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
@IsString({ each: true }) targetGroupIds!: string[]; @IsString({ each: true }) targetGroupIds!: string[];
} }
class UpdateRouteDto {
@IsEnum(['THREADED', 'DIGEST', 'SUMMARY']) @IsOptional() forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
@IsBoolean() @IsOptional() withAttachment?: boolean;
@IsEnum(['INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS']) @IsOptional() frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
@IsEnum(['MANUAL', 'AUTO']) @IsOptional() approvalMode?: 'MANUAL' | 'AUTO';
@IsBoolean() @IsOptional() isActive?: boolean;
}
@Controller('routes') @Controller('routes')
export class RoutesController { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}
@@ -36,6 +44,35 @@ export class RoutesController {
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds); return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
} }
@Patch(':id')
update(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string, @Body() body: UpdateRouteDto) {
return this.routesService.update(ctx.tenantId, id, body);
}
@Get(':id/rules')
listRules(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.routesService.listAssignedRules(ctx.tenantId, id);
}
@Post(':id/rules/:tenantRuleId')
assignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
return this.routesService.assignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id/rules/:tenantRuleId')
@HttpCode(204)
async unassignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
await this.routesService.unassignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id') @Delete(':id')
@HttpCode(204) @HttpCode(204)
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
skipped: string[]; skipped: string[];
} }
export interface UpdateRouteInput {
forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment?: boolean;
frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode?: 'MANUAL' | 'AUTO';
isActive?: boolean;
}
const routeInclude = { const routeInclude = {
sourceGroup: { select: { name: true, tenantId: true } }, sourceGroup: { select: { name: true, tenantId: true } },
targetGroup: { select: { name: true, tenantId: true } }, targetGroup: { select: { name: true, tenantId: true } },
@@ -30,11 +38,70 @@ export class RoutesService {
include: { include: {
sourceGroup: { select: { name: true } }, sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } }, targetGroup: { select: { name: true } },
assignedRules: {
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true },
},
},
},
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async update(tenantId: string, id: string, dto: UpdateRouteInput) {
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException(`Route ${id} not found`);
return this.prisma.syncRoute.update({ where: { id }, data: dto as any });
}
async assignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const rule = await this.prisma.tenantRule.findFirst({ where: { id: tenantRuleId, tenantId } });
if (!rule) throw new NotFoundException(`Rule ${tenantRuleId} not found`);
try {
return await this.prisma.syncRouteRuleMap.create({
data: { id: `${routeId}_${tenantRuleId}`.slice(0, 25) + Math.random().toString(36).slice(2, 8), syncRouteId: routeId, tenantRuleId },
include: { tenantRule: { select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true } } },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Rule is already assigned to this route');
}
throw e;
}
}
async unassignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const map = await this.prisma.syncRouteRuleMap.findUnique({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
if (!map) throw new NotFoundException('Assignment not found');
await this.prisma.syncRouteRuleMap.delete({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
}
async listAssignedRules(tenantId: string, routeId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const maps = await this.prisma.syncRouteRuleMap.findMany({
where: { syncRouteId: routeId },
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
});
return maps.map((m: any) => ({ ...m.tenantRule, assignedAt: m.createdAt }));
}
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) { async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
if (!sourceGroupId || !targetGroupId) { if (!sourceGroupId || !targetGroupId) {
throw new BadRequestException('sourceGroupId and targetGroupId are required'); throw new BadRequestException('sourceGroupId and targetGroupId are required');
+19 -32
View File
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
export class RulesService { export class RulesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string): Promise<TenantRuleData[]> { private toData(r: any): TenantRuleData {
const rows = await this.prisma.tenantRule.findMany({ return {
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => ({
id: r.id, id: r.id,
tenantId: r.tenantId, tenantId: r.tenantId,
matchType: r.matchType, matchType: r.matchType,
matchValue: r.matchValue, matchValue: r.matchValue,
action: r.action, action: r.action,
ruleIntent: r.ruleIntent,
priority: r.priority, priority: r.priority,
isActive: r.isActive, isActive: r.isActive,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(), updatedAt: r.updatedAt.toISOString(),
})); };
}
async list(tenantId: string): Promise<TenantRuleData[]> {
const rows = await this.prisma.tenantRule.findMany({
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => this.toData(r));
} }
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> { async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
const existing = await this.prisma.tenantRule.findUnique({ const existing = await this.prisma.tenantRule.findUnique({
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } }, where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType as any, matchValue: req.matchValue } },
}); });
if (existing) { if (existing) {
throw new ConflictException('A rule with this matchType + matchValue already exists'); throw new ConflictException('A rule with this matchType + matchValue already exists');
@@ -35,25 +40,16 @@ export class RulesService {
const row = await this.prisma.tenantRule.create({ const row = await this.prisma.tenantRule.create({
data: { data: {
tenantId, tenantId,
matchType: req.matchType, matchType: req.matchType as any,
matchValue: req.matchValue, matchValue: req.matchValue,
action: req.action, action: req.action as any,
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
priority: req.priority ?? 0, priority: req.priority ?? 0,
isActive: req.isActive ?? true, isActive: req.isActive ?? true,
}, },
}); });
return { return this.toData(row);
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
} }
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> { async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
@@ -64,22 +60,13 @@ export class RulesService {
if (req.matchType !== undefined) data.matchType = req.matchType; if (req.matchType !== undefined) data.matchType = req.matchType;
if (req.matchValue !== undefined) data.matchValue = req.matchValue; if (req.matchValue !== undefined) data.matchValue = req.matchValue;
if (req.action !== undefined) data.action = req.action; if (req.action !== undefined) data.action = req.action;
if (req.ruleIntent !== undefined) data.ruleIntent = req.ruleIntent;
if (req.priority !== undefined) data.priority = req.priority; if (req.priority !== undefined) data.priority = req.priority;
if (req.isActive !== undefined) data.isActive = req.isActive; if (req.isActive !== undefined) data.isActive = req.isActive;
const row = await this.prisma.tenantRule.update({ where: { id }, data }); const row = await this.prisma.tenantRule.update({ where: { id }, data });
return { return this.toData(row);
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
} }
async remove(tenantId: string, id: string): Promise<void> { async remove(tenantId: string, id: string): Promise<void> {
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
@Module({ @Module({
controllers: [SearchController], controllers: [SearchController],
providers: [SearchService], providers: [SearchService],
exports: [SearchService],
}) })
export class SearchModule {} export class SearchModule {}
@@ -0,0 +1,73 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { SevaService } from './seva.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/seva')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class SevaController {
constructor(private readonly service: SevaService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listAdmin(ctx.tenantId);
}
@Post()
create(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: {
title: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
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;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
status?: 'OPEN' | 'CLOSED';
},
) {
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/entries')
entries(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.listEntries(ctx.tenantId, id);
}
@Post(':id/entries/:entryId/complete')
complete(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('entryId') entryId: string,
@Body() body: { hours?: number },
) {
return this.service.completeEntry(ctx.tenantId, ctx.adminId!, id, entryId, body.hours);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SevaController } from './seva.controller';
import { SevaService } from './seva.service';
import { AuthModule } from '../auth/auth.module';
import { GamificationModule } from '../gamification/gamification.module';
@Module({
imports: [AuthModule, GamificationModule],
controllers: [SevaController],
providers: [SevaService],
exports: [SevaService],
})
export class SevaModule {}
+224
View File
@@ -0,0 +1,224 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { GamificationService } from '../gamification/gamification.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
@Injectable()
export class SevaService {
constructor(
private readonly prisma: PrismaService,
private readonly gamification: GamificationService,
private readonly audit: AuditService,
) {}
// ── Admin ────────────────────────────────────────────────────────────────
async listAdmin(tenantId: string) {
return this.prisma.sevaOpportunity.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { entries: true } } },
});
}
async create(tenantId: string, adminId: string, dto: {
title: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
}) {
const opp = await this.prisma.sevaOpportunity.create({
data: {
tenantId,
title: dto.title,
description: dto.description ?? null,
location: dto.location ?? null,
slots: dto.slots ?? null,
startsAt: dto.startsAt ? new Date(dto.startsAt) : null,
pointsAward: dto.pointsAward ?? 20,
isPublished: dto.isPublished ?? false,
createdBy: adminId,
},
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.SEVA_CREATED,
resourceType: 'SevaOpportunity',
resourceId: opp.id,
payload: { title: opp.title },
});
return opp;
}
async update(tenantId: string, adminId: string, id: string, dto: {
title?: string;
description?: string;
location?: string;
slots?: number;
startsAt?: string;
pointsAward?: number;
isPublished?: boolean;
status?: 'OPEN' | 'CLOSED';
}) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
return this.prisma.sevaOpportunity.update({
where: { id },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.location !== undefined && { location: dto.location }),
...(dto.slots !== undefined && { slots: dto.slots }),
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
...(dto.pointsAward !== undefined && { pointsAward: dto.pointsAward }),
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
...(dto.status !== undefined && { status: dto.status }),
},
});
}
async remove(tenantId: string, adminId: string, id: string) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
await this.prisma.sevaOpportunity.delete({ where: { id } });
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.SEVA_DELETED,
resourceType: 'SevaOpportunity',
resourceId: id,
});
return { ok: true };
}
async listEntries(tenantId: string, opportunityId: string) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id: opportunityId, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
return this.prisma.sevaEntry.findMany({
where: { opportunityId },
include: { user: { select: { id: true, displayName: true, jid: true } } },
orderBy: { createdAt: 'asc' },
});
}
/** Admin marks an entry completed → awards SEVA_COMPLETED points (idempotent). */
async completeEntry(tenantId: string, adminId: string, opportunityId: string, entryId: string, hours?: number) {
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id: opportunityId, tenantId } });
if (!opp) throw new NotFoundException('Opportunity not found');
const entry = await this.prisma.sevaEntry.findFirst({ where: { id: entryId, opportunityId } });
if (!entry) throw new NotFoundException('Entry not found');
const updated = await this.prisma.sevaEntry.update({
where: { id: entryId },
data: { status: 'COMPLETED', completedAt: new Date(), ...(hours !== undefined && { hours }) },
});
await this.gamification.award(tenantId, entry.userId, 'SEVA_COMPLETED', {
points: opp.pointsAward,
refType: 'SevaEntry',
refId: entry.id,
});
await this.audit.log({
tenantId,
actorId: adminId,
action: AuditAction.SEVA_COMPLETED,
resourceType: 'SevaEntry',
resourceId: entry.id,
payload: { opportunityId, userId: entry.userId, points: opp.pointsAward },
});
return updated;
}
// ── Member ─────────────────────────────────────────────────────────────────
async listForMember(userId: string, tenantId: string) {
const opportunities = await this.prisma.sevaOpportunity.findMany({
where: { tenantId, isPublished: true, status: 'OPEN' },
orderBy: [{ startsAt: 'asc' }, { createdAt: 'desc' }],
include: {
entries: { where: { userId }, select: { id: true, status: true } },
_count: { select: { entries: true } },
},
});
const myEntries = await this.prisma.sevaEntry.findMany({
where: { userId, opportunity: { tenantId } },
include: { opportunity: { select: { title: true, pointsAward: true } } },
orderBy: { createdAt: 'desc' },
});
const [score, leaderboard] = await Promise.all([
this.gamification.getUserScore(userId),
this.gamification.leaderboard(tenantId, 5),
]);
return {
score,
leaderboard,
opportunities: opportunities.map((o) => ({
id: o.id,
title: o.title,
description: o.description,
location: o.location,
slots: o.slots,
startsAt: o.startsAt?.toISOString() ?? null,
pointsAward: o.pointsAward,
signupCount: o._count.entries,
myEntryStatus: o.entries[0]?.status ?? null,
})),
myEntries: myEntries.map((e) => ({
id: e.id,
opportunityTitle: e.opportunity.title,
status: e.status,
hours: e.hours,
pointsAward: e.opportunity.pointsAward,
completedAt: e.completedAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(),
})),
};
}
async signup(userId: string, tenantId: string, opportunityId: string) {
const opp = await this.prisma.sevaOpportunity.findFirst({
where: { id: opportunityId, tenantId, isPublished: true, status: 'OPEN' },
include: { _count: { select: { entries: true } } },
});
if (!opp) throw new NotFoundException('Opportunity not available');
if (opp.slots != null && opp._count.entries >= opp.slots) {
const existing = await this.prisma.sevaEntry.findUnique({
where: { opportunityId_userId: { opportunityId, userId } },
});
if (!existing) throw new BadRequestException('This opportunity is full');
}
const entry = await this.prisma.sevaEntry.upsert({
where: { opportunityId_userId: { opportunityId, userId } },
create: { opportunityId, userId, status: 'SIGNED_UP' },
update: { status: 'SIGNED_UP' },
});
return { ok: true, entryId: entry.id, status: entry.status };
}
async cancel(userId: string, tenantId: string, opportunityId: string) {
const entry = await this.prisma.sevaEntry.findFirst({
where: { opportunityId, userId, opportunity: { tenantId } },
});
if (!entry) throw new NotFoundException('You are not signed up for this');
if (entry.status === 'COMPLETED') throw new BadRequestException('Cannot cancel a completed seva');
await this.prisma.sevaEntry.update({
where: { id: entry.id },
data: { status: 'CANCELLED' },
});
return { ok: true };
}
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { OrgAdminRole } from '@prisma/client';
import { SuperAdminGuard } from './super-admin.guard';
import { SuperAdminService } from './super-admin.service';
class CreateOrgDto {
@IsString() @MinLength(2) slug!: string;
@IsString() @MinLength(1) name!: string;
}
class CreateOrgAdminDto {
@IsString() email!: string;
@IsString() @IsOptional() name?: string;
@IsString() @MinLength(8) password!: string;
@IsEnum(OrgAdminRole) @IsOptional() role?: OrgAdminRole;
}
class AssignTenantOrgDto {
@IsString() @IsOptional() organizationId?: string | null;
}
@UseGuards(SuperAdminGuard)
@Controller('admin')
export class AdminOrgsController {
constructor(private readonly service: SuperAdminService) {}
@Get('orgs')
listOrgs() {
return this.service.listOrgs();
}
@Post('orgs')
createOrg(@Body() body: CreateOrgDto) {
return this.service.createOrg(body.slug, body.name);
}
@Get('orgs/:id')
getOrg(@Param('id') id: string) {
return this.service.getOrg(id);
}
@Post('orgs/:id/admins')
createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) {
return this.service.createOrgAdmin(id, body.email, body.name, body.password, body.role);
}
@Patch('tenants/:id/org')
assignTenantToOrg(@Param('id') id: string, @Body() body: AssignTenantOrgDto) {
return this.service.assignTenantToOrg(id, body.organizationId ?? null);
}
}
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { SuperAdminController } from './super-admin.controller'; import { SuperAdminController } from './super-admin.controller';
import { AdminOrgsController } from './admin-orgs.controller';
import { SuperAdminService } from './super-admin.service'; import { SuperAdminService } from './super-admin.service';
import { SuperAdminGuard } from './super-admin.guard'; import { SuperAdminGuard } from './super-admin.guard';
import { PrismaModule } from '../../prisma/prisma.module'; import { PrismaModule } from '../../prisma/prisma.module';
@@ -18,7 +19,7 @@ import { PrismaModule } from '../../prisma/prisma.module';
}), }),
}), }),
], ],
controllers: [SuperAdminController], controllers: [SuperAdminController, AdminOrgsController],
providers: [SuperAdminService, SuperAdminGuard], providers: [SuperAdminService, SuperAdminGuard],
exports: [SuperAdminGuard, JwtModule], exports: [SuperAdminGuard, JwtModule],
}) })
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
@@ -32,4 +32,81 @@ export class SuperAdminService {
if (!admin) throw new UnauthorizedException('Super admin not found'); if (!admin) throw new UnauthorizedException('Super admin not found');
return { id: admin.id, email: admin.email, name: admin.name }; return { id: admin.id, email: admin.email, name: admin.name };
} }
async listOrgs() {
return this.prisma.organization.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
slug: true,
name: true,
createdAt: true,
_count: { select: { tenants: true, orgAdmins: true } },
},
});
}
async createOrg(slug: string, name: string) {
const existing = await this.prisma.organization.findUnique({ where: { slug } });
if (existing) throw new ConflictException(`Organization with slug "${slug}" already exists`);
return this.prisma.organization.create({
data: { slug, name },
select: { id: true, slug: true, name: true, createdAt: true },
});
}
async getOrg(id: string) {
const org = await this.prisma.organization.findUnique({
where: { id },
select: {
id: true,
slug: true,
name: true,
createdAt: true,
tenants: {
select: { id: true, slug: true, name: true, isActive: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
orgAdmins: {
select: { id: true, email: true, name: true, role: true, createdAt: true },
orderBy: { createdAt: 'desc' },
},
},
});
if (!org) throw new NotFoundException('Organization not found');
return org;
}
async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string, role?: string) {
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
if (!org) throw new NotFoundException('Organization not found');
const existing = await this.prisma.orgAdmin.findUnique({
where: { organizationId_email: { organizationId: orgId, email } },
});
if (existing) throw new ConflictException('An admin with this email already exists in this organization');
const passwordHash = await bcrypt.hash(password, 10);
const admin = await this.prisma.orgAdmin.create({
data: { organizationId: orgId, email, name, passwordHash, ...(role ? { role: role as any } : {}) },
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return admin;
}
async assignTenantToOrg(tenantId: string, organizationId: string | null) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
if (organizationId !== null) {
const org = await this.prisma.organization.findUnique({ where: { id: organizationId } });
if (!org) throw new NotFoundException('Organization not found');
}
return this.prisma.tenant.update({
where: { id: tenantId },
data: { organizationId },
select: { id: true, slug: true, name: true, organizationId: true },
});
}
} }
@@ -0,0 +1,27 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ThreadsService } from './threads.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/threads')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class ThreadsController {
constructor(private readonly threads: ThreadsService) {}
@Get()
list(@CurrentTenantContext() ctx: TenantContext) {
return this.threads.listThreads(ctx.tenantId);
}
@Get(':id')
get(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
) {
return this.threads.getThread(ctx.tenantId, id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ThreadsController } from './threads.controller';
import { ThreadsService } from './threads.service';
@Module({
controllers: [ThreadsController],
providers: [ThreadsService],
})
export class ThreadsModule {}
@@ -0,0 +1,70 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ThreadsService {
constructor(private readonly prisma: PrismaService) {}
async listThreads(tenantId: string) {
const threads = await this.prisma.thread.findMany({
where: { tenantId },
orderBy: { lastActivityAt: 'desc' },
include: {
_count: { select: { messages: true } },
sourceGroup: { select: { name: true, platformId: true } },
},
});
return threads.map((t) => ({
id: t.id,
topic: t.topic,
sourceGroupName: t.sourceGroup.name,
messageCount: t._count.messages,
lastActivityAt: t.lastActivityAt.toISOString(),
createdAt: t.createdAt.toISOString(),
}));
}
async getThread(tenantId: string, threadId: string) {
const thread = await this.prisma.thread.findUnique({
where: { id: threadId },
include: {
messages: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
content: true,
senderJid: true,
senderName: true,
tags: true,
status: true,
createdAt: true,
},
},
sourceGroup: { select: { name: true, platformId: true } },
},
});
if (!thread || thread.tenantId !== tenantId) {
throw new NotFoundException('Thread not found');
}
return {
id: thread.id,
topic: thread.topic,
sourceGroupName: thread.sourceGroup.name,
messageCount: thread.messageCount,
lastActivityAt: thread.lastActivityAt.toISOString(),
createdAt: thread.createdAt.toISOString(),
messages: thread.messages.map((m) => ({
id: m.id,
content: m.content,
senderJid: m.senderJid,
senderName: m.senderName,
tags: m.tags,
status: m.status,
createdAt: m.createdAt.toISOString(),
})),
};
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Provider } from '@nestjs/common';
import { Queue } from 'bullmq';
import { ConfigService } from '@nestjs/config';
import { DigestJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection';
export const DIGEST_QUEUE = 'DIGEST_QUEUE';
export function createDigestQueue(redisUrl: string): Queue<DigestJobData> {
return new Queue<DigestJobData>('tower.digest.generate.v1', {
connection: parseRedisUrl(redisUrl),
});
}
export const digestQueueProvider: Provider = {
provide: DIGEST_QUEUE,
useFactory: (config: ConfigService) =>
createDigestQueue(config.get<string>('REDIS_URL', 'redis://localhost:6379')),
inject: [ConfigService],
};
+1
View File
@@ -0,0 +1 @@
.vercel
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch } from '@/app/_lib/api';
import { redirect } from 'next/navigation';
import { getToken } from '@/app/_lib/api';
import { DraftCard } from '@/app/_components/draft-card';
import Link from 'next/link';
type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
interface Draft {
id: string;
type: DraftType;
status: string;
proposedJson: Record<string, any>;
confidence: number | null;
createdAt: string;
sourceMessage: {
id: string;
content: string;
senderName: string | null;
createdAt: string;
};
}
const TABS: { label: string; value: DraftType | 'ALL' }[] = [
{ label: 'All', value: 'ALL' },
{ label: 'Events', value: 'EVENT' },
{ label: 'Seva Tasks', value: 'SEVA_TASK' },
{ label: 'FAQs', value: 'FAQ_ITEM' },
];
async function fetchDrafts(type?: DraftType): Promise<Draft[]> {
const query = new URLSearchParams({ status: 'PENDING_REVIEW' });
if (type) query.set('type', type);
const res = await apiFetch(`/admin/drafts?${query}`);
if (!res.ok) return [];
return (await res.json()) as Draft[];
}
export default async function TenantDraftsPage({
searchParams,
}: {
searchParams: Promise<{ type?: string }>;
}) {
const token = await getToken();
if (!token) redirect('/login');
const { type } = await searchParams;
const activeType = (type as DraftType) ?? undefined;
const drafts = await fetchDrafts(activeType);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">AI Content Drafts</h1>
<p className="text-sm text-gray-500 mt-0.5">
Events, seva tasks, and FAQs auto-detected from your group messages
</p>
</div>
</div>
<div className="flex gap-1 border-b border-gray-200">
{TABS.map((tab) => {
const href = tab.value === 'ALL' ? '/drafts' : `/drafts?type=${tab.value}`;
const isActive = (tab.value === 'ALL' && !activeType) || tab.value === activeType;
return (
<Link
key={tab.value}
href={href}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
isActive
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</Link>
);
})}
</div>
{drafts.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<p className="text-sm font-medium">No pending drafts</p>
<p className="text-xs mt-1">
Drafts appear here when the AI detects events, volunteer asks, or FAQ patterns in your group messages.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{drafts.map((d) => (
<DraftCard key={d.id} draft={d} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,250 @@
'use client';
import { useState } from 'react';
import { RouteSettingsDialog } from './RouteSettingsDialog';
import { RouteRulesDialog } from './RouteRulesDialog';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
}
interface Route {
id: string;
sourceGroupId: string;
targetGroupId: string;
sourceGroup: { name: string };
targetGroup: { name: string };
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
assignedRules?: Array<{ tenantRule: AssignedRule }>;
}
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
const FREQ_LABELS: Record<ForwardFrequency, string> = {
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
};
const FORMAT_LABELS: Record<ForwardFormat, string> = {
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
};
export function RouteManager({
groups,
initialRoutes,
}: {
groups: Group[];
initialRoutes: Route[];
}) {
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
const [sourceId, setSourceId] = useState('');
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) {
const key = r.sourceGroup.name;
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
grouped.get(key)!.targets.push(r);
}
function toggleTarget(id: string) {
setTargetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
setError(null);
}
async function addRoutes() {
if (!sourceId || targetIds.size === 0) return;
setBusy(true); setError(null);
try {
const res = await fetch('/api/routes/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
});
if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
if (!res.ok) { setError('Failed to create routes'); return; }
const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]);
setSourceId(''); setTargetIds(new Set());
} finally { setBusy(false); }
}
async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
}
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
return (
<div className="flex flex-col gap-6">
<section>
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
{routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
<th className="px-4 py-2.5">Source</th>
<th className="px-4 py-2.5">Target</th>
<th className="px-4 py-2.5">Rules</th>
<th className="px-4 py-2.5">Frequency</th>
<th className="px-4 py-2.5">Format</th>
<th className="px-4 py-2.5">Approval</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{routes.map((r) => {
const ruleCount = r.assignedRules?.length ?? 0;
return (
<tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
<td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
<td className="px-4 py-2.5 text-gray-600">
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Edit settings"
>
</button>
<button
onClick={() => setRulesRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Manage rules"
>
📋
</button>
<button
onClick={() => void deleteRoute(r.id)}
className="text-xs text-gray-400 hover:text-red-600"
title="Delete route"
>
🗑
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="text-base font-semibold mb-3">Add routes</h2>
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center">
<select
value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
<option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
</div>
{sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => (
<label
key={g.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={targetIds.has(g.id)}
onChange={() => toggleTarget(g.id)}
className="rounded border-gray-300"
/>
{g.name}
</label>
))}
</div>
)}
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
)}
<button
onClick={() => void addRoutes()}
disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
</button>
</div>
</section>
{settingsRoute && (
<RouteSettingsDialog
route={settingsRoute}
onSaved={(updates) => {
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
}}
onClose={() => setSettingsRoute(null)}
/>
)}
{rulesRoute && (
<RouteRulesDialog
routeId={rulesRoute.id}
targetGroupName={rulesRoute.targetGroup.name}
onClose={() => setRulesRoute(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState } from 'react';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
assignedAt: string;
}
interface TenantRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
}
export function RouteRulesDialog({
routeId,
targetGroupName,
onClose,
}: {
routeId: string;
targetGroupName: string;
onClose: () => void;
}) {
const [assigned, setAssigned] = useState<AssignedRule[]>([]);
const [allRules, setAllRules] = useState<TenantRule[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/routes/${routeId}/rules`).then((r) => r.json()),
fetch('/api/rules').then((r) => r.json()),
]).then(([a, all]) => {
setAssigned(Array.isArray(a) ? a : []);
setAllRules(Array.isArray(all) ? all : []);
}).catch(() => {}).finally(() => setLoading(false));
}, [routeId]);
const assignedIds = new Set(assigned.map((r) => r.id));
const available = allRules.filter((r) => r.isActive && !assignedIds.has(r.id));
async function assign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'POST' });
if (res.ok) {
const rule = allRules.find((r) => r.id === tenantRuleId)!;
setAssigned((prev) => [...prev, { ...rule, assignedAt: new Date().toISOString() }]);
}
} finally {
setBusy(null);
}
}
async function unassign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.ok || res.status === 204) {
setAssigned((prev) => prev.filter((r) => r.id !== tenantRuleId));
}
} finally {
setBusy(null);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-5 flex flex-col gap-4 max-h-[80vh]">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">Rules for route</h2>
<p className="text-xs text-gray-500"> {targetGroupName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
{loading ? (
<p className="text-sm text-gray-400">Loading</p>
) : (
<div className="flex flex-col gap-4 overflow-y-auto">
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Assigned ({assigned.length})
</h3>
{assigned.length === 0 ? (
<p className="text-sm text-gray-400">No rules assigned route uses approval mode only.</p>
) : (
<ul className="flex flex-col gap-1.5">
{assigned.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void unassign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
</section>
{available.length > 0 && (
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Add from your rules
</h3>
<ul className="flex flex-col gap-1.5">
{available.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-dashed border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void assign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
+ Add
</button>
</li>
))}
</ul>
</section>
)}
{available.length === 0 && assigned.length > 0 && (
<p className="text-xs text-gray-400">All active rules are assigned to this route.</p>
)}
</div>
)}
<div className="flex justify-end pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Done
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface Route {
id: string;
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
}
const FORMAT_OPTIONS: { value: ForwardFormat; label: string }[] = [
{ value: 'THREADED', label: 'Threaded' },
{ value: 'DIGEST', label: 'Digest' },
{ value: 'SUMMARY', label: 'Summary' },
];
const FREQ_OPTIONS: { value: ForwardFrequency; label: string }[] = [
{ value: 'INSTANT', label: 'Instant' },
{ value: 'FIVE_MIN', label: '5 min' },
{ value: 'FIFTEEN_MIN', label: '15 min' },
{ value: 'ONE_HOUR', label: '1 hour' },
{ value: 'ONE_DAY', label: '1 day' },
{ value: 'SEVEN_DAYS', label: '7 days' },
];
export function RouteSettingsDialog({
route,
onSaved,
onClose,
}: {
route: Route;
onSaved: (updated: Partial<Route>) => void;
onClose: () => void;
}) {
const [forwardFormat, setForwardFormat] = useState<ForwardFormat>(route.forwardFormat);
const [withAttachment, setWithAttachment] = useState(route.withAttachment);
const [frequency, setFrequency] = useState<ForwardFrequency>(route.frequency);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(route.approvalMode);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function save() {
setBusy(true);
setError('');
try {
const res = await fetch(`/api/routes/${route.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ forwardFormat, withAttachment, frequency, approvalMode }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setError(err.message ?? 'Failed to save');
return;
}
onSaved({ forwardFormat, withAttachment, frequency, approvalMode });
onClose();
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Route settings</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Forward format</label>
<select
value={forwardFormat}
onChange={(e) => setForwardFormat(e.target.value as ForwardFormat)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FORMAT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withAttachment}
onChange={(e) => setWithAttachment(e.target.checked)}
className="rounded border-gray-300"
/>
Include attachments
</label>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as ForwardFrequency)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FREQ_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Approval mode</label>
<div className="flex gap-2">
{(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setApprovalMode(m)}
className={`flex-1 rounded px-3 py-2 text-sm font-medium border transition-colors ${
approvalMode === m
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{m === 'MANUAL' ? 'Manual' : 'Auto'}
</button>
))}
</div>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { RouteManager } from './RouteManager'; import { RouteManager } from './RouteManager';
import { GroupsTabs } from './GroupsTabs'; import { GroupsTabs } from './GroupsTabs';
import { apiFetch } from '../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface Group { interface Group {
id: string; id: string;
@@ -22,6 +22,12 @@ interface Route {
targetGroupId: string; targetGroupId: string;
sourceGroup: { name: string }; sourceGroup: { name: string };
targetGroup: { name: string }; targetGroup: { name: string };
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode: 'MANUAL' | 'AUTO';
isActive: boolean;
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
} }
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string }; type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/app/_lib/auth-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { ScopeCard } from '@/app/_components/scope-card';
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
const NAV = [
{ href: '/search', label: 'Search', icon: <Search /> },
{ href: '/groups', label: 'Groups & Routes', icon: <Users /> },
{ href: '/messages/pending', label: 'Pending', icon: <Inbox /> },
{ href: '/drafts', label: 'AI Drafts', icon: <FileText /> },
{ href: '/settings/rules', label: 'Rules', icon: <SlidersHorizontal /> },
{ href: '/settings/bot', label: 'Bot', icon: <Bot /> },
];
export default function ChapterLayout({ children }: { children: React.ReactNode }) {
const { admin, loading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading && !admin) router.replace(`/login?next=${encodeURIComponent(pathname)}`);
}, [admin, loading, pathname, router]);
return (
<PortalShell
portalName="Chapter"
theme="blue"
navItems={NAV}
loading={loading}
authed={!!admin}
user={admin ? { email: admin.email, name: admin.name, role: admin.role, subtitle: admin.tenantName } : undefined}
scope={admin ? <ScopeCard accent="text-blue-600" organization={admin.organization} chapter={admin.tenantName ? { name: admin.tenantName } : null} /> : undefined}
onLogout={() => { void logout(); router.replace('/login'); }}
>
{children}
</PortalShell>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
interface RouteForMessage {
routeId: string;
targetGroupId: string;
targetGroupName: string;
approvalMode: 'MANUAL' | 'AUTO';
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: string;
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
}
export function ForwardTargetDialog({
messageId,
onApproved,
onClose,
}: {
messageId: string;
onApproved: () => void;
onClose: () => void;
}) {
const [routes, setRoutes] = useState<RouteForMessage[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
useEffect(() => {
fetch(`/api/messages/${messageId}/routes`)
.then((r) => r.json())
.then((data: RouteForMessage[]) => {
setRoutes(Array.isArray(data) ? data : []);
// Pre-select: FORWARD decision, or AUTO approvalMode with no BLOCK
const preSelected = new Set(
(Array.isArray(data) ? data : [])
.filter((r) => r.ruleDecision === 'FORWARD' || (r.ruleDecision === 'NONE' && r.approvalMode === 'AUTO'))
.map((r) => r.targetGroupId),
);
setSelected(preSelected);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [messageId]);
function toggleGroup(groupId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
return next;
});
}
async function confirm() {
setBusy(true);
try {
const res = await fetch(`/api/messages/${messageId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetGroupIds: [...selected] }),
});
if (res.ok) {
onApproved();
onClose();
}
} finally {
setBusy(false);
}
}
const filtered = routes.filter((r) =>
r.targetGroupName.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100">
<h2 className="text-base font-semibold">Approve &amp; Forward</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="px-5 py-3 border-b border-gray-100">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{loading ? (
<p className="text-sm text-gray-400">Loading routes</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-400">No target groups found.</p>
) : (
<ul className="flex flex-col gap-1.5">
{filtered.map((r) => {
const isBlocked = r.ruleDecision === 'BLOCK';
const isChecked = selected.has(r.targetGroupId);
return (
<li key={r.targetGroupId}>
<label
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
isBlocked
? 'opacity-50 cursor-default'
: isChecked
? 'bg-blue-50'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => !isBlocked && toggleGroup(r.targetGroupId)}
disabled={isBlocked}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">{r.targetGroupName}</div>
{isBlocked && (
<div className="text-[10px] text-red-500">Blocked by assigned rule</div>
)}
{r.ruleDecision === 'FORWARD' && (
<div className="text-[10px] text-green-600">Auto-forward by rule</div>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-100">
<span className="text-xs text-gray-500">{selected.size} group{selected.size !== 1 ? 's' : ''} selected</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void confirm()}
disabled={busy || selected.size === 0}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Approving…' : 'Approve & Forward'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface MessageDetail { interface MessageDetail {
id: string; id: string;
@@ -0,0 +1,115 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { ForwardTargetDialog } from '../ForwardTargetDialog';
interface PendingMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
createdAt: string;
sourceGroupId: string;
sourceGroupName: string;
sourceGroupPlatformId: string;
}
export default function PendingMessagesPage() {
const [messages, setMessages] = useState<PendingMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
fetch('/api/messages/pending')
.then(async (res) => {
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json() as Promise<PendingMessage[]>;
})
.then((data) => { setMessages(data); setError(null); })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6">
Messages waiting for admin approval. Click "Approve &amp; Forward" to choose which groups to send to.
</p>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
Failed to load: {error}
</div>
)}
{!loading && !error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now.
</div>
)}
<ul className="space-y-3">
{messages.map((m) => (
<PendingMessageRow
key={m.id}
message={m}
onApproved={() => setMessages((prev) => prev.filter((x) => x.id !== m.id))}
/>
))}
</ul>
</div>
);
}
function PendingMessageRow({
message,
onApproved,
}: {
message: PendingMessage;
onApproved: () => void;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500">{new Date(message.createdAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => (
<span key={t} className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
{t}
</span>
))}
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">{message.content}</div>
<div className="flex justify-end pt-1">
<button
onClick={() => setDialogOpen(true)}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Approve &amp; Forward
</button>
</div>
{dialogOpen && (
<ForwardTargetDialog
messageId={message.id}
onApproved={onApproved}
onClose={() => setDialogOpen(false)}
/>
)}
</li>
);
}
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { apiFetch } from '../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface MeiliHit { interface MeiliHit {
id: string; id: string;
@@ -1,5 +1,5 @@
import { BotSettingsCard } from './BotSettingsCard'; import { BotSettingsCard } from './BotSettingsCard';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface BotSummary { interface BotSummary {
id: string; id: string;
@@ -2,48 +2,71 @@
import { useState } from 'react'; import { useState } from 'react';
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
interface RuleData { interface RuleData {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: RuleMatchType;
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; action: RuleAction;
ruleIntent: RuleIntent;
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
const MATCH_TYPE_LABELS: Record<string, string> = { const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
HASHTAG: 'Hashtag', HASHTAG: 'Hashtag',
PREFIX: 'Prefix', PREFIX: 'Prefix',
REACTION_EMOJI: 'Reaction Emoji', REACTION_EMOJI: 'Reaction Emoji',
INTEREST: 'Interest',
}; };
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<RuleAction, string> = {
FLAG: 'Flag (Pending)', FLAG: 'Flag (Pending)',
AUTO_APPROVE: 'Auto-approve', AUTO_APPROVE: 'Auto-approve',
SKIP: 'Skip (Silent Drop)', SKIP: 'Skip (Silent Drop)',
REJECT: 'Reject (Visible)', REJECT: 'Reject (Visible)',
}; };
const REACTION_OPTIONS = [
{ value: '👍', label: '👍 Thumbs Up' },
{ value: '👎', label: '👎 Thumbs Down' },
];
function getValuePlaceholder(matchType: RuleMatchType): string {
if (matchType === 'HASHTAG') return '#important';
if (matchType === 'PREFIX') return '!!';
if (matchType === 'INTEREST') return '#education';
return '';
}
export function RuleManager({ initial }: { initial: RuleData[] }) { export function RuleManager({ initial }: { initial: RuleData[] }) {
const [rules, setRules] = useState<RuleData[]>(initial); const [rules, setRules] = useState<RuleData[]>(initial);
const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG'); const [matchType, setMatchType] = useState<RuleMatchType>('HASHTAG');
const [matchValue, setMatchValue] = useState(''); const [matchValue, setMatchValue] = useState('');
const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG'); const [action, setAction] = useState<RuleAction>('FLAG');
const [ruleIntent, setRuleIntent] = useState<RuleIntent>('POSITIVE');
const [priority, setPriority] = useState(0); const [priority, setPriority] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
function handleMatchTypeChange(type: RuleMatchType) {
setMatchType(type);
setMatchValue('');
}
async function addRule() { async function addRule() {
if (!matchValue.trim()) return; if (!matchValue.trim()) return;
setBusy(true); setBusy(true); setError('');
setError('');
try { try {
const res = await fetch('/api/rules', { const res = await fetch('/api/rules', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, priority }), body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, ruleIntent, priority }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Failed to create rule' })); const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
@@ -52,12 +75,8 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
} }
const created: RuleData = await res.json(); const created: RuleData = await res.json();
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
setMatchValue(''); setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
setAction('FLAG'); } finally { setBusy(false); }
setPriority(0);
} finally {
setBusy(false);
}
} }
async function toggleRule(rule: RuleData) { async function toggleRule(rule: RuleData) {
@@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<label className="text-xs text-gray-500">Type</label> <label className="text-xs text-gray-500">Type</label>
<select <select
value={matchType} value={matchType}
onChange={(e) => setMatchType(e.target.value as any)} onChange={(e) => handleMatchTypeChange(e.target.value as RuleMatchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm" className="border border-gray-300 rounded px-3 py-2 text-sm"
> >
<option value="HASHTAG">Hashtag</option> <option value="HASHTAG">Hashtag</option>
<option value="PREFIX">Prefix</option> <option value="PREFIX">Prefix</option>
<option value="REACTION_EMOJI">Reaction Emoji</option> <option value="REACTION_EMOJI">Reaction Emoji</option>
<option value="INTEREST">Interest</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Value</label> <label className="text-xs text-gray-500">Value</label>
<input {matchType === 'REACTION_EMOJI' ? (
value={matchValue} <select
onChange={(e) => setMatchValue(e.target.value)} value={matchValue}
placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'} onChange={(e) => setMatchValue(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40" className="border border-gray-300 rounded px-3 py-2 text-sm w-44"
/> >
<option value="">Select emoji</option>
{REACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : (
<input
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
placeholder={getValuePlaceholder(matchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
/>
)}
{matchType === 'INTEREST' && (
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
)}
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Intent</label>
<div className="flex gap-1">
{(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => (
<button
key={i}
type="button"
onClick={() => setRuleIntent(i)}
className={`px-3 py-2 text-sm rounded border transition-colors ${
ruleIntent === i
? i === 'POSITIVE'
? 'bg-green-600 text-white border-green-600'
: 'bg-red-600 text-white border-red-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{i === 'POSITIVE' ? '+ Pos' : ' Neg'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Action</label> <label className="text-xs text-gray-500">Action</label>
<select <select
value={action} value={action}
onChange={(e) => setAction(e.target.value as any)} onChange={(e) => setAction(e.target.value as RuleAction)}
className="border border-gray-300 rounded px-3 py-2 text-sm" className="border border-gray-300 rounded px-3 py-2 text-sm"
> >
<option value="FLAG">Flag (Pending)</option> <option value="FLAG">Flag (Pending)</option>
@@ -116,6 +176,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<option value="REJECT">Reject (Visible)</option> <option value="REJECT">Reject (Visible)</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Priority</label> <label className="text-xs text-gray-500">Priority</label>
<input <input
@@ -125,6 +186,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
className="border border-gray-300 rounded px-3 py-2 text-sm w-20" className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => void addRule()} onClick={() => void addRule()}
@@ -140,13 +202,14 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<section> <section>
<h2 className="text-base font-semibold mb-3">Active Rules</h2> <h2 className="text-base font-semibold mb-3">Active Rules</h2>
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="text-sm text-gray-400">No rules configured. Messages without matching rules are ignored.</p> <p className="text-sm text-gray-400">No rules configured. Assign rules to sync routes to control auto-forwarding.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 text-left text-gray-500"> <tr className="border-b border-gray-200 text-left text-gray-500">
<th className="pb-2 pr-3">Type</th> <th className="pb-2 pr-3">Type</th>
<th className="pb-2 pr-3">Value</th> <th className="pb-2 pr-3">Value</th>
<th className="pb-2 pr-3">Intent</th>
<th className="pb-2 pr-3">Action</th> <th className="pb-2 pr-3">Action</th>
<th className="pb-2 pr-3">Priority</th> <th className="pb-2 pr-3">Priority</th>
<th className="pb-2 pr-3">Active</th> <th className="pb-2 pr-3">Active</th>
@@ -158,6 +221,13 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<tr key={rule.id} className="border-b border-gray-100"> <tr key={rule.id} className="border-b border-gray-100">
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td> <td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td> <td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
<td className="py-2 pr-3">
<span className={`text-xs rounded px-2 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
</td>
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td> <td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
<td className="py-2 pr-3">{rule.priority}</td> <td className="py-2 pr-3">{rule.priority}</td>
<td className="py-2 pr-3"> <td className="py-2 pr-3">
@@ -1,11 +1,12 @@
import { RuleManager } from './RuleManager'; import { RuleManager } from './RuleManager';
import { apiFetch } from '../../_lib/api'; import { apiFetch } from '@/app/_lib/api';
interface RuleData { interface RuleData {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
ruleIntent: 'POSITIVE' | 'NEGATIVE';
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
@@ -0,0 +1,107 @@
import Link from 'next/link';
import { apiFetch } from '@/app/_lib/api';
interface ThreadMessage {
id: string;
content: string;
senderJid: string;
senderName: string | null;
tags: string[];
status: string;
createdAt: string;
}
interface ThreadDetail {
id: string;
topic: string | null;
sourceGroupName: string;
messageCount: number;
lastActivityAt: string;
createdAt: string;
messages: ThreadMessage[];
}
const STATUS_COLORS: Record<string, string> = {
APPROVED: 'text-green-600',
PENDING: 'text-amber-600',
REJECTED: 'text-red-600',
RAW: 'text-gray-400',
DNC: 'text-gray-400',
DISTRIBUTED: 'text-blue-600',
};
export default async function ThreadDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let thread: ThreadDetail | null = null;
let error: string | null = null;
try {
const res = await apiFetch(`/admin/threads/${id}`);
if (res.ok) {
thread = await res.json();
} else {
error = `API returned ${res.status}`;
}
} catch {
error = 'Failed to load thread';
}
if (error || !thread) {
return (
<div className="max-w-2xl">
<Link href="/threads" className="text-sm text-blue-600 hover:underline mb-4 inline-block">&larr; Back to threads</Link>
<p className="text-red-600 text-sm">{error ?? 'Thread not found'}</p>
</div>
);
}
return (
<div className="max-w-2xl">
<Link href="/threads" className="text-sm text-blue-600 hover:underline mb-4 inline-block">&larr; Back to threads</Link>
<div className="mb-6">
<h1 className="text-xl font-semibold">
{thread.topic ?? `Thread ${thread.id.slice(0, 8)}`}
</h1>
<p className="text-sm text-gray-500 mt-1">
{thread.sourceGroupName} &middot; {thread.messageCount} messages &middot; last activity {new Date(thread.lastActivityAt).toLocaleDateString()}
</p>
</div>
<div className="flex flex-col gap-3">
{thread.messages.map((msg) => (
<div key={msg.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-700">{msg.senderName ?? msg.senderJid}</span>
<span className="mx-1">&middot;</span>
<span>{new Date(msg.createdAt).toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${STATUS_COLORS[msg.status] ?? 'text-gray-500'}`}>
{msg.status}
</span>
<Link href={`/messages/${msg.id}`} className="text-xs text-blue-600 hover:underline">
View
</Link>
</div>
</div>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{msg.content}</p>
{msg.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{msg.tags.map((tag) => (
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">{tag}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Link from 'next/link';
import { apiFetch } from '@/app/_lib/api';
interface ThreadSummary {
id: string;
topic: string | null;
sourceGroupName: string;
messageCount: number;
lastActivityAt: string;
createdAt: string;
}
export default async function ThreadsPage() {
let threads: ThreadSummary[] = [];
let error: string | null = null;
try {
const res = await apiFetch('/admin/threads');
if (res.ok) {
threads = await res.json();
} else {
error = `API returned ${res.status}`;
}
} catch {
error = 'Failed to load threads';
}
return (
<div className="max-w-4xl">
<h1 className="text-xl font-semibold mb-6">Threads</h1>
{error && (
<p className="text-red-600 text-sm mb-4">{error}</p>
)}
{!error && threads.length === 0 && (
<p className="text-gray-500 text-sm">No threads yet. Threads are created automatically when members reply to messages.</p>
)}
{threads.length > 0 && (
<div className="border border-gray-200 rounded-lg divide-y divide-gray-100">
{threads.map((thread) => (
<Link
key={thread.id}
href={`/threads/${thread.id}`}
className="flex items-start justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate">
{thread.topic ?? `Thread ${thread.id.slice(0, 8)}`}
</p>
<p className="text-xs text-gray-500 mt-0.5">{thread.sourceGroupName}</p>
</div>
<div className="ml-4 shrink-0 text-right">
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{thread.messageCount} {thread.messageCount === 1 ? 'message' : 'messages'}
</span>
<p className="text-xs text-gray-400 mt-1">
{new Date(thread.lastActivityAt).toLocaleDateString()}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface SourceMessage {
id: string;
content: string;
senderName: string | null;
createdAt: string;
}
interface Draft {
id: string;
type: 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
status: string;
proposedJson: Record<string, any>;
confidence: number | null;
createdAt: string;
sourceMessage: SourceMessage;
}
const TYPE_LABEL: Record<Draft['type'], string> = {
EVENT: 'Event',
SEVA_TASK: 'Seva Task',
FAQ_ITEM: 'FAQ',
};
const TYPE_COLOR: Record<Draft['type'], string> = {
EVENT: 'bg-indigo-50 text-indigo-700 border-indigo-200',
SEVA_TASK: 'bg-green-50 text-green-700 border-green-200',
FAQ_ITEM: 'bg-amber-50 text-amber-700 border-amber-200',
};
function ProposedPreview({ type, data }: { type: Draft['type']; data: Record<string, any> }) {
if (type === 'EVENT') {
return (
<div className="space-y-1 text-sm">
<p><span className="font-medium text-gray-700">Title:</span> {data.title ?? '—'}</p>
{data.date && <p><span className="font-medium text-gray-700">Date:</span> {data.date} {data.time ?? ''}</p>}
{data.location && <p><span className="font-medium text-gray-700">Location:</span> {data.location}</p>}
{data.description && <p><span className="font-medium text-gray-700">Description:</span> {data.description}</p>}
{data.rsvpNeeded && <p className="text-indigo-600 text-xs">RSVP required</p>}
{data.sevaActionItems?.length > 0 && (
<p className="text-green-700 text-xs">+{data.sevaActionItems.length} seva task(s) will also be created</p>
)}
</div>
);
}
if (type === 'SEVA_TASK') {
return (
<div className="space-y-1 text-sm">
<p><span className="font-medium text-gray-700">Title:</span> {data.title ?? '—'}</p>
{data.parentEventTitle && <p><span className="font-medium text-gray-700">Event:</span> {data.parentEventTitle}</p>}
{data.slots && <p><span className="font-medium text-gray-700">Slots:</span> {data.slots}</p>}
{data.skills?.length > 0 && <p><span className="font-medium text-gray-700">Skills:</span> {data.skills.join(', ')}</p>}
</div>
);
}
return (
<div className="space-y-1 text-sm">
<p><span className="font-medium text-gray-700">Q:</span> {data.question ?? '—'}</p>
<p><span className="font-medium text-gray-700">A:</span> {data.answer ?? '—'}</p>
{data.tags?.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{data.tags.map((t: string) => (
<span key={t} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{t}</span>
))}
</div>
)}
</div>
);
}
export function DraftCard({ draft }: { draft: Draft }) {
const router = useRouter();
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
async function act(action: 'approve' | 'reject') {
setLoading(action);
try {
await fetch(`/api/admin/drafts/${draft.id}/${action}`, { method: 'POST' });
router.refresh();
} finally {
setLoading(null);
}
}
const confidence = draft.confidence != null ? Math.round(draft.confidence * 100) : null;
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${TYPE_COLOR[draft.type]}`}>
{TYPE_LABEL[draft.type]}
</span>
{confidence != null && (
<span className="text-xs text-gray-400">{confidence}% confidence</span>
)}
</div>
{/* Source message */}
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<p className="text-xs font-medium text-gray-500 mb-1">
Source message{draft.sourceMessage.senderName ? `${draft.sourceMessage.senderName}` : ''}
</p>
<p className="text-sm text-gray-700 line-clamp-3">{draft.sourceMessage.content}</p>
</div>
{/* AI proposed content */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">AI proposed</p>
<ProposedPreview type={draft.type} data={draft.proposedJson} />
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={() => act('approve')}
disabled={loading !== null}
className="flex-1 py-2 text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{loading === 'approve' ? 'Approving…' : 'Approve'}
</button>
<button
onClick={() => act('reject')}
disabled={loading !== null}
className="flex-1 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
{loading === 'reject' ? 'Rejecting…' : 'Reject'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import Link from 'next/link';
import { cn } from '../_lib/utils';
import { PORTAL_THEMES, type PortalTheme } from './portal-theme';
import { Check, ArrowLeft } from 'lucide-react';
interface PortalLoginShellProps {
title: string;
subtitle: string;
theme: PortalTheme;
/** Short value-prop bullets shown on the branding panel */
highlights?: string[];
children: React.ReactNode;
footer?: React.ReactNode;
}
export function PortalLoginShell({ title, subtitle, theme, highlights = [], children, footer }: PortalLoginShellProps) {
const t = PORTAL_THEMES[theme];
return (
<div className="min-h-screen grid lg:grid-cols-2">
{/* Branding panel */}
<div className={cn('relative hidden lg:flex flex-col justify-between p-12 text-white overflow-hidden', t.gradient)}>
{/* decorative pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
backgroundSize: '24px 24px',
}}
/>
<div className="relative flex items-center gap-3">
<div className="size-9 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center font-bold">T</div>
<span className="font-semibold text-lg tracking-tight">TOWER</span>
</div>
<div className="relative space-y-6">
<h2 className="text-3xl font-bold leading-tight max-w-sm">{title}</h2>
{highlights.length > 0 && (
<ul className="space-y-3">
{highlights.map((h) => (
<li key={h} className="flex items-center gap-3 text-sm text-white/90">
<span className="size-5 rounded-full bg-white/15 flex items-center justify-center shrink-0">
<Check className="size-3" />
</span>
{h}
</li>
))}
</ul>
)}
</div>
<p className="relative text-sm text-white/60">Community Knowledge Infrastructure Platform</p>
</div>
{/* Form panel */}
<div className="flex items-center justify-center p-6 sm:p-8 bg-background">
<div className="w-full max-w-sm space-y-8">
{/* Mobile logo */}
<div className="flex items-center gap-2 lg:hidden">
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm', t.gradient)}>T</div>
<span className="font-semibold">TOWER</span>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{children}
<div className="space-y-3 text-center">
{footer}
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3" />
Back to portal selector
</Link>
</div>
</div>
</div>
</div>
);
}

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