Compare commits

..

20 Commits

Author SHA1 Message Date
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
176 changed files with 11388 additions and 229 deletions
@@ -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;
+476 -33
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 {
@@ -310,19 +405,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])
@@ -433,3 +540,339 @@ model TenantRule {
@@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])
}
+20
View File
@@ -14,6 +14,16 @@ 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';
@Module({ @Module({
imports: [ imports: [
@@ -32,6 +42,16 @@ import { TenantModule } from './modules/tenant/tenant.module';
RulesModule, RulesModule,
SuperAdminModule, SuperAdminModule,
TenantModule, TenantModule,
DigestModule,
OrgModule,
ThreadsModule,
EventsModule,
GamificationModule,
SevaModule,
CirclesModule,
KnowledgeModule,
GalleryModule,
DraftsModule,
], ],
}) })
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,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];
@@ -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,
}));
}
}
+115 -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,121 @@ 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('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],
}) })
+200
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,204 @@ 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 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 },
@@ -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,61 @@
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);
}
@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 {}
+165
View File
@@ -0,0 +1,165 @@
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 } });
}
}
@@ -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,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],
};
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
interface OrgProfile {
id: string;
email: string;
name: string | null;
role: 'ORG_OWNER' | 'ORG_ADMIN';
organization: { id: string; slug: string; name: string };
}
interface OrgAdminState {
orgAdmin: OrgProfile | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const OrgAdminContext = createContext<OrgAdminState | null>(null);
export function OrgAdminProvider({ children }: { children: React.ReactNode }) {
const [orgAdmin, setOrgAdmin] = useState<OrgProfile | null>(null);
const [loading, setLoading] = useState(true);
const checkSession = useCallback(async () => {
try {
const res = await fetch('/api/auth/org/me', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setOrgAdmin(data);
} else {
setOrgAdmin(null);
}
} catch {
setOrgAdmin(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void checkSession(); }, [checkSession]);
const login = useCallback(async (email: string, password: string) => {
const res = await fetch('/api/auth/org/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Login failed' }));
throw new Error(err.message ?? 'Login failed');
}
const data = await res.json();
setOrgAdmin(data.orgAdmin);
// Refresh full profile (includes organization object)
void checkSession();
}, [checkSession]);
const logout = useCallback(async () => {
await fetch('/api/auth/org/logout', { method: 'POST', credentials: 'include' });
setOrgAdmin(null);
}, []);
return (
<OrgAdminContext.Provider value={{ orgAdmin, loading, login, logout }}>
{children}
</OrgAdminContext.Provider>
);
}
export function useOrgAdmin(): OrgAdminState {
const ctx = useContext(OrgAdminContext);
if (!ctx) throw new Error('useOrgAdmin must be used within <OrgAdminProvider>');
return ctx;
}
+3 -19
View File
@@ -10,6 +10,7 @@ const NAV_LINKS = [
{ href: '/search', label: 'Search' }, { href: '/search', label: 'Search' },
{ href: '/groups', label: 'Groups & Routes' }, { href: '/groups', label: 'Groups & Routes' },
{ href: '/messages/pending', label: 'Pending messages' }, { href: '/messages/pending', label: 'Pending messages' },
{ href: '/drafts', label: 'AI Drafts' },
{ href: '/settings/rules', label: 'Rules' }, { href: '/settings/rules', label: 'Rules' },
{ href: '/settings/bot', label: 'Bot' }, { href: '/settings/bot', label: 'Bot' },
]; ];
@@ -18,6 +19,7 @@ const SUPER_ADMIN_LINKS = [
{ href: '/admin', label: 'Dashboard' }, { href: '/admin', label: 'Dashboard' },
{ href: '/admin/tenants', label: 'Tenants' }, { href: '/admin/tenants', label: 'Tenants' },
{ href: '/admin/bots', label: 'Bot Pool' }, { href: '/admin/bots', label: 'Bot Pool' },
{ href: '/admin/drafts', label: 'AI Drafts' },
]; ];
const PUBLIC_PATHS = ['/login', '/signup', '/onboard']; const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
@@ -85,25 +87,7 @@ export function Sidebar() {
} }
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) { if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
return ( return null;
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
<span className="font-bold text-base mb-4">TOWER</span>
<div className="flex flex-col gap-1 flex-1">
<Link href="/my" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Profile
</Link>
<Link href="/my/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Groups
</Link>
<Link href="/my/settings" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
Settings
</Link>
</div>
<div className="border-t border-gray-200 pt-3 mt-3 text-xs text-gray-500 px-3">
Member portal
</div>
</nav>
);
} }
return ( return (
+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>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { apiFetch } from '../../_lib/api';
import { redirect } from 'next/navigation';
import { getToken } from '../../_lib/api';
import { DraftCard } from './DraftCard';
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 DraftsPage({
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="p-6 max-w-5xl mx-auto 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 WhatsApp messages
</p>
</div>
<Link href="/admin" className="text-sm text-indigo-600 hover:underline"> Admin</Link>
</div>
{/* Type filter tabs */}
<div className="flex gap-1 border-b border-gray-200">
{TABS.map((tab) => {
const href = tab.value === 'ALL' ? '/admin/drafts' : `/admin/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 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>
);
}
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSuperAdmin } from '../../../_lib/super-admin-context';
export default function AdminOrgDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const [org, setOrg] = useState<any>(null);
const [showAdminForm, setShowAdminForm] = useState(false);
const [adminForm, setAdminForm] = useState({ email: '', password: '', name: '', role: 'ORG_OWNER' });
const [tenantId, setTenantId] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function load() {
const res = await fetch(`/api/admin/orgs/${id}`);
if (res.ok) setOrg(await res.json());
}
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
void load();
}, [admin, loading, router, id]);
async function handleCreateAdmin(e: React.FormEvent) {
e.preventDefault();
setError('');
setSaving(true);
try {
const res = await fetch(`/api/admin/orgs/${id}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(adminForm),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message ?? 'Failed');
}
setShowAdminForm(false);
setAdminForm({ email: '', password: '', name: '', role: 'ORG_OWNER' });
void load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setSaving(false);
}
}
async function handleAssignTenant(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
const res = await fetch(`/api/admin/tenants/${tenantId}/org`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: id }),
});
if (!res.ok) throw new Error('Failed to assign');
setTenantId('');
void load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setSaving(false);
}
}
if (loading || !admin) return <p className="text-gray-500">Loading...</p>;
if (!org) return <p className="text-gray-400">Organization not found.</p>;
return (
<div>
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-3"> Back</button>
<h1 className="text-2xl font-bold mb-1">{org.name}</h1>
<p className="text-sm text-gray-500 mb-6">{org.slug}</p>
{/* Chapters */}
<div className="bg-white rounded-xl border mb-6">
<div className="px-5 py-4 border-b flex items-center justify-between">
<h2 className="font-semibold">Chapters</h2>
<form onSubmit={(e) => void handleAssignTenant(e)} className="flex gap-2">
<input
type="text"
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
className="border rounded-lg px-3 py-1.5 text-sm"
placeholder="Tenant ID to assign"
/>
<button type="submit" disabled={!tenantId || saving} className="bg-blue-600 text-white rounded-lg px-3 py-1.5 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
Assign
</button>
</form>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Slug</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{(org.tenants ?? []).map((t: any) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{t.name}</td>
<td className="px-4 py-3 text-gray-500">{t.slug}</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${t.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{t.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{org.tenants?.length === 0 && <p className="p-4 text-gray-400">No chapters assigned.</p>}
</div>
{/* Org Admins */}
<div className="bg-white rounded-xl border">
<div className="px-5 py-4 border-b flex items-center justify-between">
<h2 className="font-semibold">Org Admins</h2>
<button
onClick={() => setShowAdminForm(!showAdminForm)}
className="bg-blue-600 text-white rounded-lg px-3 py-1.5 text-sm font-medium hover:bg-blue-700"
>
Add Admin
</button>
</div>
{showAdminForm && (
<form onSubmit={(e) => void handleCreateAdmin(e)} className="p-5 border-b flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={adminForm.email} onChange={(e) => setAdminForm({ ...adminForm, email: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required />
</div>
<div>
<label className="block text-xs font-medium mb-1">Password</label>
<input type="password" value={adminForm.password} onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required />
</div>
<div>
<label className="block text-xs font-medium mb-1">Name</label>
<input type="text" value={adminForm.name} onChange={(e) => setAdminForm({ ...adminForm, name: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Role</label>
<select value={adminForm.role} onChange={(e) => setAdminForm({ ...adminForm, role: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
<option value="ORG_OWNER">ORG_OWNER</option>
<option value="ORG_ADMIN">ORG_ADMIN</option>
</select>
</div>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={saving} className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">{saving ? 'Saving...' : 'Create'}</button>
<button type="button" onClick={() => setShowAdminForm(false)} className="border rounded-lg px-4 py-2 text-sm hover:bg-gray-50">Cancel</button>
</div>
</form>
)}
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Created</th>
</tr>
</thead>
<tbody className="divide-y">
{(org.orgAdmins ?? []).map((a: any) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="px-4 py-3">{a.email}</td>
<td className="px-4 py-3 text-gray-500">{a.name ?? '—'}</td>
<td className="px-4 py-3">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-purple-100 text-purple-700">{a.role}</span>
</td>
<td className="px-4 py-3 text-gray-500">{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{org.orgAdmins?.length === 0 && <p className="p-4 text-gray-400">No org admins yet.</p>}
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useEffect, useState } from 'react';
import { useSuperAdmin } from '../../_lib/super-admin-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function AdminOrgsPage() {
const { admin, loading } = useSuperAdmin();
const router = useRouter();
const [orgs, setOrgs] = useState<any[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ slug: '', name: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function load() {
const res = await fetch('/api/admin/orgs');
if (res.ok) setOrgs(await res.json());
}
useEffect(() => {
if (loading) return;
if (!admin) { router.replace('/admin/login'); return; }
void load();
}, [admin, loading, router]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setError('');
setSaving(true);
try {
const res = await fetch('/api/admin/orgs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message ?? 'Failed');
}
setShowAdd(false);
setForm({ slug: '', name: '' });
void load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setSaving(false);
}
}
if (loading) return <p className="text-gray-500">Loading...</p>;
if (!admin) return null;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Organizations</h1>
<button
onClick={() => setShowAdd(!showAdd)}
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700"
>
Create Org
</button>
</div>
{showAdd && (
<form onSubmit={(e) => void handleAdd(e)} className="bg-white rounded-xl border p-5 mb-6 flex flex-col gap-4">
<h2 className="font-semibold">New Organization</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full border rounded-lg px-3 py-2 text-sm"
placeholder="UP Parivaar"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Slug</label>
<input
type="text"
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="w-full border rounded-lg px-3 py-2 text-sm"
placeholder="org_up_parivaar"
required
/>
</div>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={saving} className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Creating...' : 'Create'}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="border rounded-lg px-4 py-2 text-sm hover:bg-gray-50">
Cancel
</button>
</div>
</form>
)}
<div className="bg-white rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Slug</th>
<th className="px-4 py-3 font-medium">Chapters</th>
<th className="px-4 py-3 font-medium">Org Admins</th>
</tr>
</thead>
<tbody className="divide-y">
{orgs.map((org: any) => (
<tr key={org.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/admin/orgs/${org.id}`} className="text-blue-600 hover:underline font-medium">
{org.name}
</Link>
</td>
<td className="px-4 py-3 text-gray-500">{org.slug}</td>
<td className="px-4 py-3">{org._count?.tenants ?? 0}</td>
<td className="px-4 py-3">{org._count?.orgAdmins ?? 0}</td>
</tr>
))}
</tbody>
</table>
{orgs.length === 0 && <p className="p-4 text-gray-400">No organizations yet.</p>}
</div>
</div>
);
}
+2 -1
View File
@@ -49,9 +49,10 @@ export default function AdminDashboard() {
<div className="text-2xl font-bold mt-1">{totalBots ? Math.round(totalTenants / totalBots) : 0}</div> <div className="text-2xl font-bold mt-1">{totalBots ? Math.round(totalTenants / totalBots) : 0}</div>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4 flex-wrap">
<Link href="/admin/tenants" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Tenants</Link> <Link href="/admin/tenants" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Tenants</Link>
<Link href="/admin/bots" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Bots</Link> <Link href="/admin/bots" className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm">Manage Bots</Link>
<Link href="/admin/drafts" className="bg-indigo-600 text-white rounded-lg px-4 py-2 text-sm">AI Content Drafts</Link>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/circles/${id}/members`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,28 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await apiFetch(`/admin/circles/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/circles/${id}`, { method: 'DELETE' });
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/circles');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await apiFetch('/admin/circles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, apiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/drafts/${id}/approve`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, apiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/drafts/${id}/reject`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
+14
View File
@@ -0,0 +1,14 @@
import { jsonResponse, apiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const status = searchParams.get('status') ?? 'PENDING_REVIEW';
const query = new URLSearchParams({ status });
if (type) query.set('type', type);
const res = await apiFetch(`/admin/drafts?${query}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,28 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await apiFetch(`/admin/events/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/events/${id}`, { method: 'DELETE' });
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/events');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await apiFetch('/admin/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,21 @@
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getSuperToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_super_token')?.value;
}
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const token = await getSuperToken();
const body = await req.json().catch(() => ({}));
const res = await fetch(`${getApiBaseUrl()}/admin/orgs/${id}/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
+18
View File
@@ -0,0 +1,18 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getSuperToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_super_token')?.value;
}
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const token = await getSuperToken();
const res = await fetch(`${getApiBaseUrl()}/admin/orgs/${id}`, {
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
+29
View File
@@ -0,0 +1,29 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getSuperToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_super_token')?.value;
}
export async function GET(): Promise<Response> {
const token = await getSuperToken();
const res = await fetch(`${getApiBaseUrl()}/admin/orgs`, {
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
export async function POST(req: Request): Promise<Response> {
const token = await getSuperToken();
const body = await req.json().catch(() => ({}));
const res = await fetch(`${getApiBaseUrl()}/admin/orgs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
@@ -0,0 +1,18 @@
import { apiFetch, jsonResponse } from '../../../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string; entryId: string }> },
): Promise<Response> {
const { id, entryId } = await params;
const body = await request.json().catch(() => ({}));
const res = await apiFetch(`/admin/seva/${id}/entries/${entryId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,13 @@
import { apiFetch, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/seva/${id}/entries`);
const body = await res.json();
return jsonResponse(body, res.status);
}
+28
View File
@@ -0,0 +1,28 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await apiFetch(`/admin/seva/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/seva/${id}`, { method: 'DELETE' });
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await apiFetch('/admin/seva');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await apiFetch('/admin/seva', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,21 @@
import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getSuperToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_super_token')?.value;
}
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await params;
const token = await getSuperToken();
const body = await req.json().catch(() => ({}));
const res = await fetch(`${getApiBaseUrl()}/admin/tenants/${id}/org`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}
+19
View File
@@ -0,0 +1,19 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(req: Request): Promise<Response> {
const body = await req.json().catch(() => ({}));
const res = await fetch(`${getApiBaseUrl()}/auth/org/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const payload = await res.json();
const headers: Record<string, string> = {};
if (res.ok && payload.token) {
headers['Set-Cookie'] = `tower_org_token=${payload.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`;
}
return jsonResponse(payload, res.status, headers);
}
@@ -0,0 +1,9 @@
import { jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(): Promise<Response> {
return jsonResponse({ ok: true }, 200, {
'Set-Cookie': 'tower_org_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
});
}
+16
View File
@@ -0,0 +1,16 @@
import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const token = await (await import('next/headers')).cookies().then(c => c.get('tower_org_token')?.value);
const res = await fetch(`${getApiBaseUrl()}/auth/org/me`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: 'no-store',
});
const body = await res.json();
return jsonResponse(body, res.status);
}
+20
View File
@@ -0,0 +1,20 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/ask');
const body = await res.json();
return jsonResponse(body, res.status);
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const res = await memberApiFetch('/my/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/circles/${id}/join`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/circles/${id}/leave`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/circles');
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/dashboard');
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/digest');
const body = await res.json();
return jsonResponse(body, res.status);
}
+11
View File
@@ -0,0 +1,11 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const qs = url.searchParams.toString();
const res = await memberApiFetch(`/my/directory${qs ? `?${qs}` : ''}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,18 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await request.json();
const res = await memberApiFetch(`/my/events/${id}/rsvp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/events');
const body = await res.json();
return jsonResponse(body, res.status);
}
+11
View File
@@ -0,0 +1,11 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const qs = url.searchParams.toString();
const res = await memberApiFetch(`/my/knowledge${qs ? `?${qs}` : ''}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/memories/${id}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/memories');
const body = await res.json();
return jsonResponse(body, res.status);
}
+11
View File
@@ -7,3 +7,14 @@ export async function GET(): Promise<Response> {
const body = await res.json(); const body = await res.json();
return jsonResponse(body, res.status); return jsonResponse(body, res.status);
} }
export async function PATCH(request: Request): Promise<Response> {
const body = await request.json();
const res = await memberApiFetch('/my/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const resBody = await res.json();
return jsonResponse(resBody, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/seva/${id}/cancel`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,13 @@
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await memberApiFetch(`/my/seva/${id}/signup`, { method: 'POST' });
const body = await res.json();
return jsonResponse(body, res.status);
}
+9
View File
@@ -0,0 +1,9 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(): Promise<Response> {
const res = await memberApiFetch('/my/seva');
const body = await res.json();
return jsonResponse(body, res.status);
}
+17
View File
@@ -0,0 +1,17 @@
import { getApiBaseUrl, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
async function getOrgToken(): Promise<string | undefined> {
const { cookies } = await import('next/headers');
return (await cookies()).get('tower_org_token')?.value;
}
export async function GET(): Promise<Response> {
const token = await getOrgToken();
const res = await fetch(`${getApiBaseUrl()}/org/dashboard`, {
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
cache: 'no-store',
});
return jsonResponse(await res.json(), res.status);
}

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