Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d21bd6bcb4 | |||
| 6de78fe270 | |||
| 39ca91f07a | |||
| 3e86e0ffc0 | |||
| 5801cbf85b | |||
| f61c070428 | |||
| 6d89d8a609 | |||
| 57fe9d9bf1 | |||
| cd1eef0098 | |||
| a0dc94ce79 | |||
| 0ffdc362b5 | |||
| f7922454ca | |||
| bc9fe7c145 | |||
| a685f8b4d5 | |||
| 566690bd04 | |||
| 622737fdca | |||
| 435b0e7806 | |||
| 47f345c4bf | |||
| 6aec093516 | |||
| 978f7effc0 |
+45
@@ -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;
|
||||
@@ -7,6 +7,59 @@ datasource db {
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -18,6 +71,8 @@ model Tenant {
|
||||
isActive Boolean @default(true)
|
||||
isForwardingPaused Boolean @default(false)
|
||||
settings Json @default("{}")
|
||||
organizationId String?
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -33,6 +88,19 @@ model Tenant {
|
||||
auditEvents AuditEvent[]
|
||||
rules TenantRule[]
|
||||
groupAccesses GroupAccess[]
|
||||
digestConfig DigestConfig?
|
||||
digests Digest[]
|
||||
events Event[]
|
||||
threads Thread[]
|
||||
sevaOpportunities SevaOpportunity[]
|
||||
gamificationEvents GamificationEvent[]
|
||||
circles Circle[]
|
||||
askAIQueries AskAIQuery[]
|
||||
knowledgeBoards KnowledgeBoard[]
|
||||
knowledgeItems KnowledgeItem[]
|
||||
galleryAlbums GalleryAlbum[]
|
||||
galleryItems GalleryItem[]
|
||||
contentDrafts ContentDraft[]
|
||||
}
|
||||
|
||||
enum AdminRole {
|
||||
@@ -177,6 +245,7 @@ model Group {
|
||||
consents ConsentRecord[]
|
||||
claimTokens GroupClaimToken[]
|
||||
groupAccesses GroupAccess[]
|
||||
threads Thread[]
|
||||
|
||||
@@unique([platform, platformId])
|
||||
@@index([accountId])
|
||||
@@ -204,22 +273,48 @@ model Message {
|
||||
mediaUrl String?
|
||||
tags String[]
|
||||
status MessageStatus @default(PENDING)
|
||||
expiresAt DateTime?
|
||||
quotedPlatformMsgId String?
|
||||
threadId String?
|
||||
thread Thread? @relation(fields: [threadId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
approval Approval?
|
||||
contentDrafts ContentDraft[]
|
||||
|
||||
@@unique([platform, platformMsgId])
|
||||
@@index([tenantId])
|
||||
@@index([senderTowerUserId])
|
||||
@@index([status, expiresAt])
|
||||
@@index([threadId])
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
RAW
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
DISTRIBUTED
|
||||
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 {
|
||||
@@ -316,6 +411,13 @@ model TowerUser {
|
||||
phoneHash String
|
||||
jid String
|
||||
displayName String?
|
||||
avatar String?
|
||||
hometown String?
|
||||
currentLocation String?
|
||||
interests String[]
|
||||
language String @default("en")
|
||||
digestPreference String @default("daily")
|
||||
directoryVisible Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -323,6 +425,11 @@ model TowerUser {
|
||||
optOuts MemberOptOut[]
|
||||
sessions TowerSession[]
|
||||
messages Message[] @relation("senderTowerUser")
|
||||
rsvps EventRsvp[]
|
||||
sevaEntries SevaEntry[]
|
||||
gamificationEvents GamificationEvent[]
|
||||
circleMemberships CircleMembership[]
|
||||
askAIQueries AskAIQuery[]
|
||||
|
||||
@@unique([tenantId, phoneHash])
|
||||
@@index([phoneHash])
|
||||
@@ -433,3 +540,339 @@ model TenantRule {
|
||||
@@index([tenantId, isActive])
|
||||
@@index([tenantId, matchType])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Digest
|
||||
// ============================================================================
|
||||
|
||||
model DigestConfig {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
targetGroupJid String
|
||||
targetAccountId String
|
||||
scheduleHour Int @default(20)
|
||||
scheduleMinute Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
lastSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Digest {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
content String
|
||||
messageIds String[]
|
||||
digestDate DateTime
|
||||
sentAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, digestDate])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events + RSVP
|
||||
// ============================================================================
|
||||
|
||||
enum RsvpStatus {
|
||||
GOING
|
||||
NOT_GOING
|
||||
MAYBE
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime?
|
||||
createdBy String
|
||||
isPublished Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
rsvps EventRsvp[]
|
||||
|
||||
@@index([tenantId, startsAt])
|
||||
@@index([tenantId, isPublished])
|
||||
}
|
||||
|
||||
model EventRsvp {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
status RsvpStatus
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([eventId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seva (volunteering) + Gamification
|
||||
// ============================================================================
|
||||
|
||||
enum SevaStatus {
|
||||
OPEN
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum SevaEntryStatus {
|
||||
SIGNED_UP
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model SevaOpportunity {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
slots Int?
|
||||
startsAt DateTime?
|
||||
pointsAward Int @default(20)
|
||||
status SevaStatus @default(OPEN)
|
||||
createdBy String
|
||||
isPublished Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
entries SevaEntry[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, isPublished])
|
||||
}
|
||||
|
||||
model SevaEntry {
|
||||
id String @id @default(cuid())
|
||||
opportunityId String
|
||||
opportunity SevaOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
status SevaEntryStatus @default(SIGNED_UP)
|
||||
hours Float?
|
||||
note String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([opportunityId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Multi-signal gamification — points come from many event types, not just seva.
|
||||
enum GamificationEventType {
|
||||
HELPFUL_ANSWER
|
||||
SEVA_COMPLETED
|
||||
EVENT_ATTENDANCE
|
||||
FAQ_APPROVED
|
||||
WELCOME
|
||||
PROFILE_COMPLETED
|
||||
BUSINESS_ADDED
|
||||
}
|
||||
|
||||
model GamificationEvent {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
type GamificationEventType
|
||||
points Int
|
||||
refType String?
|
||||
refId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId, userId])
|
||||
@@index([userId, createdAt])
|
||||
// One award per (user, type, ref) — prevents double-awarding the same source.
|
||||
@@unique([userId, type, refId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Circles (interest / affinity sub-groups within a chapter)
|
||||
// ============================================================================
|
||||
|
||||
enum CircleRole {
|
||||
MEMBER
|
||||
LEAD
|
||||
}
|
||||
|
||||
model Circle {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
isPublic Boolean @default(true)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships CircleMembership[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId, isPublic])
|
||||
}
|
||||
|
||||
model CircleMembership {
|
||||
id String @id @default(cuid())
|
||||
circleId String
|
||||
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
role CircleRole @default(MEMBER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([circleId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ask AI (RAG over the message archive)
|
||||
// ============================================================================
|
||||
|
||||
model AskAIQuery {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
question String
|
||||
answer String
|
||||
citations Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Knowledge Base (admin-curated FAQ boards)
|
||||
// ============================================================================
|
||||
|
||||
enum KnowledgeItemStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model KnowledgeBoard {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
slug String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items KnowledgeItem[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model KnowledgeItem {
|
||||
id String @id @default(cuid())
|
||||
boardId String
|
||||
board KnowledgeBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
question String
|
||||
answer String
|
||||
tags String[]
|
||||
status KnowledgeItemStatus @default(DRAFT)
|
||||
sortOrder Int @default(0)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([boardId, status])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memories / Gallery
|
||||
// ============================================================================
|
||||
|
||||
model GalleryAlbum {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
title String
|
||||
description String?
|
||||
coverUrl String?
|
||||
isPublished Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items GalleryItem[]
|
||||
|
||||
@@index([tenantId, isPublished])
|
||||
}
|
||||
|
||||
model GalleryItem {
|
||||
id String @id @default(cuid())
|
||||
albumId String
|
||||
album GalleryAlbum @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
mediaUrl String
|
||||
caption String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([albumId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Drafts — AI-proposed Event/Seva/FAQ pending admin review
|
||||
// ============================================================================
|
||||
|
||||
enum DraftType {
|
||||
EVENT
|
||||
SEVA_TASK
|
||||
FAQ_ITEM
|
||||
}
|
||||
|
||||
enum DraftStatus {
|
||||
PENDING_REVIEW
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model ContentDraft {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
type DraftType
|
||||
status DraftStatus @default(PENDING_REVIEW)
|
||||
sourceMessageId String
|
||||
sourceMessage Message @relation(fields: [sourceMessageId], references: [id])
|
||||
proposedJson Json
|
||||
confidence Float?
|
||||
reviewedBy String?
|
||||
reviewedAt DateTime?
|
||||
publishedId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, type, status])
|
||||
@@index([sourceMessageId])
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@ import { MessagesModule } from './modules/messages/messages.module';
|
||||
import { RulesModule } from './modules/rules/rules.module';
|
||||
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
|
||||
import { TenantModule } from './modules/tenant/tenant.module';
|
||||
import { DigestModule } from './modules/digest/digest.module';
|
||||
import { OrgModule } from './modules/org/org.module';
|
||||
import { ThreadsModule } from './modules/threads/threads.module';
|
||||
import { EventsModule } from './modules/events/events.module';
|
||||
import { GamificationModule } from './modules/gamification/gamification.module';
|
||||
import { SevaModule } from './modules/seva/seva.module';
|
||||
import { CirclesModule } from './modules/circles/circles.module';
|
||||
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
|
||||
import { GalleryModule } from './modules/gallery/gallery.module';
|
||||
import { DraftsModule } from './modules/drafts/drafts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +42,16 @@ import { TenantModule } from './modules/tenant/tenant.module';
|
||||
RulesModule,
|
||||
SuperAdminModule,
|
||||
TenantModule,
|
||||
DigestModule,
|
||||
OrgModule,
|
||||
ThreadsModule,
|
||||
EventsModule,
|
||||
GamificationModule,
|
||||
SevaModule,
|
||||
CirclesModule,
|
||||
KnowledgeModule,
|
||||
GalleryModule,
|
||||
DraftsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,17 @@ export const AuditAction = {
|
||||
MEMBER_DELETED: 'MEMBER_DELETED',
|
||||
OTP_REQUESTED: 'OTP_REQUESTED',
|
||||
OTP_VERIFIED: 'OTP_VERIFIED',
|
||||
EVENT_CREATED: 'EVENT_CREATED',
|
||||
EVENT_UPDATED: 'EVENT_UPDATED',
|
||||
EVENT_DELETED: 'EVENT_DELETED',
|
||||
DIGEST_SENT: 'DIGEST_SENT',
|
||||
SEVA_CREATED: 'SEVA_CREATED',
|
||||
SEVA_DELETED: 'SEVA_DELETED',
|
||||
SEVA_COMPLETED: 'SEVA_COMPLETED',
|
||||
CIRCLE_CREATED: 'CIRCLE_CREATED',
|
||||
CIRCLE_DELETED: 'CIRCLE_DELETED',
|
||||
DRAFT_APPROVED: 'DRAFT_APPROVED',
|
||||
DRAFT_REJECTED: 'DRAFT_REJECTED',
|
||||
} as const;
|
||||
|
||||
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
||||
|
||||
@@ -0,0 +1,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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpdateDigestConfigDto {
|
||||
@IsString()
|
||||
targetGroupJid: string;
|
||||
|
||||
@IsString()
|
||||
targetAccountId: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
scheduleHour: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(59)
|
||||
scheduleMinute: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DigestController } from './digest.controller';
|
||||
import { DigestService } from './digest.service';
|
||||
import { digestQueueProvider } from '../../queues/digest.queue';
|
||||
|
||||
@Module({
|
||||
controllers: [DigestController],
|
||||
providers: [DigestService, digestQueueProvider],
|
||||
})
|
||||
export class DigestModule {}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import { DigestJobData } from '@tower/types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { DIGEST_QUEUE } from '../../queues/digest.queue';
|
||||
import { UpdateDigestConfigDto } from './digest.dto';
|
||||
|
||||
function todayMidnightUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DigestService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@Inject(DIGEST_QUEUE) private readonly digestQueue: Queue<DigestJobData>,
|
||||
) {}
|
||||
|
||||
async getConfig(tenantId: string) {
|
||||
return this.prisma.digestConfig.findUnique({ where: { tenantId } });
|
||||
}
|
||||
|
||||
async upsertConfig(tenantId: string, dto: UpdateDigestConfigDto, adminId: string) {
|
||||
const config = await this.prisma.digestConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: {
|
||||
tenantId,
|
||||
targetGroupJid: dto.targetGroupJid,
|
||||
targetAccountId: dto.targetAccountId,
|
||||
scheduleHour: dto.scheduleHour,
|
||||
scheduleMinute: dto.scheduleMinute,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
update: {
|
||||
targetGroupJid: dto.targetGroupJid,
|
||||
targetAccountId: dto.targetAccountId,
|
||||
scheduleHour: dto.scheduleHour,
|
||||
scheduleMinute: dto.scheduleMinute,
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.DIGEST_SENT,
|
||||
resourceType: 'DigestConfig',
|
||||
resourceId: config.id,
|
||||
payload: { targetGroupJid: dto.targetGroupJid, scheduleHour: dto.scheduleHour, scheduleMinute: dto.scheduleMinute },
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async disableConfig(tenantId: string): Promise<void> {
|
||||
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
|
||||
if (!config) throw new NotFoundException('Digest config not found');
|
||||
await this.prisma.digestConfig.update({ where: { tenantId }, data: { isActive: false } });
|
||||
}
|
||||
|
||||
async getHistory(tenantId: string) {
|
||||
const digests = await this.prisma.digest.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { digestDate: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
return digests.map((d) => ({
|
||||
id: d.id,
|
||||
digestDate: d.digestDate.toISOString(),
|
||||
messageCount: (d.messageIds as string[]).length,
|
||||
sentAt: d.sentAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async sendNow(tenantId: string, adminId: string): Promise<{ status: string }> {
|
||||
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
|
||||
if (!config) throw new NotFoundException('Digest config not found — configure via PUT /admin/digest/config first');
|
||||
|
||||
await this.digestQueue.add('digest', {
|
||||
tenantId,
|
||||
digestDate: todayMidnightUtc().toISOString(),
|
||||
triggeredBy: 'manual',
|
||||
}, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.DIGEST_SENT,
|
||||
resourceType: 'Digest',
|
||||
resourceId: tenantId,
|
||||
payload: { triggeredBy: 'manual' },
|
||||
});
|
||||
|
||||
return { status: 'queued' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Post, Param, Query, UseGuards, Request } from '@nestjs/common';
|
||||
import { DraftsService } from './drafts.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { DraftStatus, DraftType } from '@prisma/client';
|
||||
|
||||
@Controller('admin/drafts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DraftsController {
|
||||
constructor(private readonly service: DraftsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Request() req: any,
|
||||
@Query('type') type?: DraftType,
|
||||
@Query('status') status?: DraftStatus,
|
||||
) {
|
||||
return this.service.list(req.user.tenantId, type, status ?? 'PENDING_REVIEW');
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Request() req: any) {
|
||||
return this.service.approve(id, req.user.tenantId, req.user.sub);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Request() req: any) {
|
||||
return this.service.reject(id, req.user.tenantId, req.user.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DraftsController } from './drafts.controller';
|
||||
import { DraftsService } from './drafts.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, AuditModule],
|
||||
controllers: [DraftsController],
|
||||
providers: [DraftsService],
|
||||
})
|
||||
export class DraftsModule {}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { DraftStatus, DraftType } from '@prisma/client';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
|
||||
export interface ApproveDraftResult {
|
||||
publishedId: string;
|
||||
type: DraftType;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DraftsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
list(tenantId: string, type?: DraftType, status: DraftStatus = 'PENDING_REVIEW') {
|
||||
return this.prisma.contentDraft.findMany({
|
||||
where: { tenantId, ...(type ? { type } : {}), status },
|
||||
include: {
|
||||
sourceMessage: {
|
||||
select: { id: true, content: true, senderName: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async approve(draftId: string, tenantId: string, adminId: string): Promise<ApproveDraftResult> {
|
||||
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
|
||||
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
|
||||
if (draft.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('Draft has already been reviewed');
|
||||
}
|
||||
|
||||
const proposed = draft.proposedJson as Record<string, any>;
|
||||
let publishedId: string;
|
||||
|
||||
if (draft.type === 'EVENT') {
|
||||
const startsAt = proposed.date
|
||||
? new Date(`${proposed.date}T${proposed.time ?? '00:00'}:00`)
|
||||
: new Date();
|
||||
|
||||
const event = await this.prisma.event.create({
|
||||
data: {
|
||||
tenantId,
|
||||
title: proposed.title ?? 'Untitled Event',
|
||||
description: proposed.description ?? null,
|
||||
location: proposed.location ?? null,
|
||||
startsAt,
|
||||
createdBy: adminId,
|
||||
isPublished: true,
|
||||
},
|
||||
});
|
||||
publishedId = event.id;
|
||||
|
||||
} else if (draft.type === 'SEVA_TASK') {
|
||||
const startsAt = proposed.date ? new Date(`${proposed.date}T00:00:00`) : undefined;
|
||||
const seva = await this.prisma.sevaOpportunity.create({
|
||||
data: {
|
||||
tenantId,
|
||||
title: proposed.title ?? 'Seva Opportunity',
|
||||
description: proposed.parentEventTitle
|
||||
? `Part of: ${proposed.parentEventTitle}`
|
||||
: (proposed.description ?? null),
|
||||
slots: proposed.slots ?? null,
|
||||
startsAt: startsAt ?? null,
|
||||
createdBy: adminId,
|
||||
isPublished: true,
|
||||
},
|
||||
});
|
||||
publishedId = seva.id;
|
||||
|
||||
} else {
|
||||
// FAQ_ITEM — find or create a default board
|
||||
let board = await this.prisma.knowledgeBoard.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
if (!board) {
|
||||
board = await this.prisma.knowledgeBoard.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: 'Community FAQs',
|
||||
slug: 'community-faqs',
|
||||
},
|
||||
});
|
||||
}
|
||||
const item = await this.prisma.knowledgeItem.create({
|
||||
data: {
|
||||
boardId: board.id,
|
||||
tenantId,
|
||||
question: proposed.question ?? 'Untitled question',
|
||||
answer: proposed.answer ?? '',
|
||||
tags: proposed.tags ?? [],
|
||||
status: 'PUBLISHED',
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
publishedId = item.id;
|
||||
}
|
||||
|
||||
await this.prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
reviewedBy: adminId,
|
||||
reviewedAt: new Date(),
|
||||
publishedId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorType: 'ADMIN',
|
||||
actorId: adminId,
|
||||
action: AuditAction.DRAFT_APPROVED,
|
||||
resourceType: 'ContentDraft',
|
||||
resourceId: draftId,
|
||||
payload: { type: draft.type, publishedId },
|
||||
});
|
||||
|
||||
return { publishedId, type: draft.type };
|
||||
}
|
||||
|
||||
async reject(draftId: string, tenantId: string, adminId: string): Promise<void> {
|
||||
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
|
||||
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
|
||||
if (draft.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('Draft has already been reviewed');
|
||||
}
|
||||
|
||||
await this.prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: { status: 'REJECTED', reviewedBy: adminId, reviewedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorType: 'ADMIN',
|
||||
actorId: adminId,
|
||||
action: AuditAction.DRAFT_REJECTED,
|
||||
resourceType: 'ContentDraft',
|
||||
resourceId: draftId,
|
||||
payload: { type: draft.type },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { EventsService } from './events.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
@Controller('admin/events')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class EventsController {
|
||||
constructor(private readonly service: EventsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.service.list(ctx.tenantId, true);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startsAt: string;
|
||||
endsAt?: string;
|
||||
isPublished?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.service.create(ctx.tenantId, ctx.adminId!, body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startsAt?: string;
|
||||
endsAt?: string;
|
||||
isPublished?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
|
||||
}
|
||||
|
||||
@Get(':id/rsvps')
|
||||
rsvps(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.getRsvps(ctx.tenantId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EventsController } from './events.controller';
|
||||
import { EventsService } from './events.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [EventsController],
|
||||
providers: [EventsService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(tenantId: string, includeUnpublished = false) {
|
||||
return this.prisma.event.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(includeUnpublished ? {} : { isPublished: true }),
|
||||
},
|
||||
orderBy: { startsAt: 'asc' },
|
||||
include: { _count: { select: { rsvps: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, adminId: string, dto: {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startsAt: string;
|
||||
endsAt?: string;
|
||||
isPublished?: boolean;
|
||||
}) {
|
||||
const event = await this.prisma.event.create({
|
||||
data: {
|
||||
tenantId,
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
location: dto.location ?? null,
|
||||
startsAt: new Date(dto.startsAt),
|
||||
endsAt: dto.endsAt ? new Date(dto.endsAt) : null,
|
||||
createdBy: adminId,
|
||||
isPublished: dto.isPublished ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.EVENT_CREATED,
|
||||
resourceType: 'Event',
|
||||
resourceId: event.id,
|
||||
payload: { title: event.title, isPublished: event.isPublished },
|
||||
});
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
async update(tenantId: string, adminId: string, eventId: string, dto: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startsAt?: string;
|
||||
endsAt?: string;
|
||||
isPublished?: boolean;
|
||||
}) {
|
||||
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
|
||||
if (!event) throw new NotFoundException('Event not found');
|
||||
|
||||
return this.prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.location !== undefined && { location: dto.location }),
|
||||
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
|
||||
...(dto.endsAt !== undefined && { endsAt: new Date(dto.endsAt) }),
|
||||
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, adminId: string, eventId: string) {
|
||||
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
|
||||
if (!event) throw new NotFoundException('Event not found');
|
||||
await this.prisma.event.delete({ where: { id: eventId } });
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.EVENT_DELETED,
|
||||
resourceType: 'Event',
|
||||
resourceId: eventId,
|
||||
payload: { deleted: true },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async getRsvps(tenantId: string, eventId: string) {
|
||||
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
|
||||
if (!event) throw new NotFoundException('Event not found');
|
||||
|
||||
return this.prisma.eventRsvp.findMany({
|
||||
where: { eventId },
|
||||
include: { user: { select: { id: true, displayName: true, jid: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { GalleryService } from './gallery.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
@Controller('admin/gallery')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class GalleryController {
|
||||
constructor(private readonly service: GalleryService) {}
|
||||
|
||||
@Get('albums')
|
||||
listAlbums(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.service.listAlbumsAdmin(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Post('albums')
|
||||
createAlbum(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: { title: string; description?: string; coverUrl?: string; isPublished?: boolean },
|
||||
) {
|
||||
return this.service.createAlbum(ctx.tenantId, ctx.adminId!, body);
|
||||
}
|
||||
|
||||
@Patch('albums/:id')
|
||||
updateAlbum(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { title?: string; description?: string; coverUrl?: string; isPublished?: boolean },
|
||||
) {
|
||||
return this.service.updateAlbum(ctx.tenantId, id, body);
|
||||
}
|
||||
|
||||
@Delete('albums/:id')
|
||||
removeAlbum(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.removeAlbum(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('albums/:id/items')
|
||||
addItem(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { mediaUrl: string; caption?: string; sortOrder?: number },
|
||||
) {
|
||||
return this.service.addItem(ctx.tenantId, id, body);
|
||||
}
|
||||
|
||||
@Delete('items/:itemId')
|
||||
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
|
||||
return this.service.removeItem(ctx.tenantId, itemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GalleryController } from './gallery.controller';
|
||||
import { GalleryService } from './gallery.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [GalleryController],
|
||||
providers: [GalleryService],
|
||||
exports: [GalleryService],
|
||||
})
|
||||
export class GalleryModule {}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class GalleryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ── Admin: albums ──────────────────────────────────────────────────────────
|
||||
|
||||
async listAlbumsAdmin(tenantId: string) {
|
||||
return this.prisma.galleryAlbum.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { items: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async createAlbum(tenantId: string, adminId: string, dto: {
|
||||
title: string;
|
||||
description?: string;
|
||||
coverUrl?: string;
|
||||
isPublished?: boolean;
|
||||
}) {
|
||||
return this.prisma.galleryAlbum.create({
|
||||
data: {
|
||||
tenantId,
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
coverUrl: dto.coverUrl ?? null,
|
||||
isPublished: dto.isPublished ?? false,
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateAlbum(tenantId: string, id: string, dto: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
coverUrl?: string;
|
||||
isPublished?: boolean;
|
||||
}) {
|
||||
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
|
||||
if (!album) throw new NotFoundException('Album not found');
|
||||
return this.prisma.galleryAlbum.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.coverUrl !== undefined && { coverUrl: dto.coverUrl }),
|
||||
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeAlbum(tenantId: string, id: string) {
|
||||
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
|
||||
if (!album) throw new NotFoundException('Album not found');
|
||||
await this.prisma.galleryAlbum.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Admin: items ─────────────────────────────────────────────────────────────
|
||||
|
||||
async addItem(tenantId: string, albumId: string, dto: { mediaUrl: string; caption?: string; sortOrder?: number }) {
|
||||
const album = await this.prisma.galleryAlbum.findFirst({ where: { id: albumId, tenantId } });
|
||||
if (!album) throw new NotFoundException('Album not found');
|
||||
return this.prisma.galleryItem.create({
|
||||
data: {
|
||||
albumId,
|
||||
tenantId,
|
||||
mediaUrl: dto.mediaUrl,
|
||||
caption: dto.caption ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(tenantId: string, itemId: string) {
|
||||
const item = await this.prisma.galleryItem.findFirst({ where: { id: itemId, tenantId } });
|
||||
if (!item) throw new NotFoundException('Item not found');
|
||||
await this.prisma.galleryItem.delete({ where: { id: itemId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Member: read published albums ────────────────────────────────────────────
|
||||
|
||||
async listForMember(tenantId: string) {
|
||||
const albums = await this.prisma.galleryAlbum.findMany({
|
||||
where: { tenantId, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { items: true } } },
|
||||
});
|
||||
return albums.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
coverUrl: a.coverUrl,
|
||||
itemCount: a._count.items,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async getAlbumForMember(tenantId: string, albumId: string) {
|
||||
const album = await this.prisma.galleryAlbum.findFirst({
|
||||
where: { id: albumId, tenantId, isPublished: true },
|
||||
include: { items: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] } },
|
||||
});
|
||||
if (!album) throw new NotFoundException('Album not found');
|
||||
return {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
description: album.description,
|
||||
items: album.items.map((i) => ({
|
||||
id: i.id,
|
||||
mediaUrl: i.mediaUrl,
|
||||
caption: i.caption,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GamificationService } from './gamification.service';
|
||||
|
||||
@Module({
|
||||
providers: [GamificationService],
|
||||
exports: [GamificationService],
|
||||
})
|
||||
export class GamificationModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, GamificationEventType } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { POINTS, decayFactor } from './gamification.types';
|
||||
|
||||
interface AwardOptions {
|
||||
points?: number;
|
||||
refType?: string;
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GamificationService {
|
||||
private readonly logger = new Logger(GamificationService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Award points for an event. Idempotent per (userId, type, refId): if an award
|
||||
* for the same source already exists, this is a no-op (returns null).
|
||||
*/
|
||||
async award(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
type: GamificationEventType,
|
||||
opts: AwardOptions = {},
|
||||
) {
|
||||
const points = opts.points ?? POINTS[type];
|
||||
try {
|
||||
return await this.prisma.gamificationEvent.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
type,
|
||||
points,
|
||||
refType: opts.refType ?? null,
|
||||
refId: opts.refId ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
// Already awarded for this source — idempotent skip.
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserScore(userId: string) {
|
||||
const events = await this.prisma.gamificationEvent.findMany({
|
||||
where: { userId },
|
||||
select: { type: true, points: true, createdAt: true },
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
let lifetime = 0;
|
||||
let recent = 0;
|
||||
const byType: Record<string, number> = {};
|
||||
|
||||
for (const e of events) {
|
||||
lifetime += e.points;
|
||||
const ageDays = (now - e.createdAt.getTime()) / 86_400_000;
|
||||
recent += e.points * decayFactor(ageDays);
|
||||
byType[e.type] = (byType[e.type] ?? 0) + e.points;
|
||||
}
|
||||
|
||||
return {
|
||||
lifetime,
|
||||
recent: Math.round(recent),
|
||||
byType,
|
||||
eventCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
async leaderboard(tenantId: string, limit = 10) {
|
||||
const grouped = await this.prisma.gamificationEvent.groupBy({
|
||||
by: ['userId'],
|
||||
where: { tenantId },
|
||||
_sum: { points: true },
|
||||
orderBy: { _sum: { points: 'desc' } },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
if (grouped.length === 0) return [];
|
||||
|
||||
const users = await this.prisma.towerUser.findMany({
|
||||
where: { id: { in: grouped.map((g) => g.userId) } },
|
||||
select: { id: true, displayName: true, directoryVisible: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return grouped.map((g, i) => {
|
||||
const u = userMap.get(g.userId);
|
||||
return {
|
||||
rank: i + 1,
|
||||
userId: g.userId,
|
||||
displayName: u?.directoryVisible ? (u.displayName ?? 'Member') : 'Member',
|
||||
points: g._sum.points ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { GamificationEventType } from '@prisma/client';
|
||||
|
||||
// Default point award per event type. Multi-signal — seva is one of many sources.
|
||||
export const POINTS: Record<GamificationEventType, number> = {
|
||||
HELPFUL_ANSWER: 10,
|
||||
SEVA_COMPLETED: 20,
|
||||
EVENT_ATTENDANCE: 5,
|
||||
FAQ_APPROVED: 15,
|
||||
WELCOME: 3,
|
||||
PROFILE_COMPLETED: 5,
|
||||
BUSINESS_ADDED: 5,
|
||||
};
|
||||
|
||||
// Time-decay weighting for the "current standing" score (lifetime points are never decayed).
|
||||
export function decayFactor(ageDays: number): number {
|
||||
if (ageDays <= 30) return 1;
|
||||
if (ageDays <= 90) return 0.5;
|
||||
return 0.25;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
type ItemStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
|
||||
@Controller('admin/knowledge')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class KnowledgeController {
|
||||
constructor(private readonly service: KnowledgeService) {}
|
||||
|
||||
@Get('boards')
|
||||
listBoards(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.service.listBoardsAdmin(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Post('boards')
|
||||
createBoard(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: { name: string; description?: string; sortOrder?: number },
|
||||
) {
|
||||
return this.service.createBoard(ctx.tenantId, body);
|
||||
}
|
||||
|
||||
@Patch('boards/:id')
|
||||
updateBoard(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; description?: string; sortOrder?: number },
|
||||
) {
|
||||
return this.service.updateBoard(ctx.tenantId, id, body);
|
||||
}
|
||||
|
||||
@Delete('boards/:id')
|
||||
removeBoard(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.removeBoard(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('boards/:id/items')
|
||||
listItems(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.listItemsAdmin(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('boards/:id/items')
|
||||
createItem(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { question: string; answer: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
|
||||
) {
|
||||
return this.service.createItem(ctx.tenantId, ctx.adminId!, id, body);
|
||||
}
|
||||
|
||||
@Patch('items/:itemId')
|
||||
updateItem(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('itemId') itemId: string,
|
||||
@Body() body: { question?: string; answer?: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
|
||||
) {
|
||||
return this.service.updateItem(ctx.tenantId, itemId, body);
|
||||
}
|
||||
|
||||
@Delete('items/:itemId')
|
||||
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
|
||||
return this.service.removeItem(ctx.tenantId, itemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KnowledgeController } from './knowledge.controller';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [KnowledgeController],
|
||||
providers: [KnowledgeService],
|
||||
exports: [KnowledgeService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'board';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ── Admin: boards ──────────────────────────────────────────────────────────
|
||||
|
||||
async listBoardsAdmin(tenantId: string) {
|
||||
return this.prisma.knowledgeBoard.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: { _count: { select: { items: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async createBoard(tenantId: string, dto: { name: string; description?: string; sortOrder?: number }) {
|
||||
let slug = slugify(dto.name);
|
||||
const existing = await this.prisma.knowledgeBoard.findFirst({ where: { tenantId, slug } });
|
||||
if (existing) slug = `${slug}-${Date.now().toString(36)}`;
|
||||
|
||||
return this.prisma.knowledgeBoard.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
slug,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateBoard(tenantId: string, id: string, dto: { name?: string; description?: string; sortOrder?: number }) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
return this.prisma.knowledgeBoard.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeBoard(tenantId: string, id: string) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
await this.prisma.knowledgeBoard.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Admin: items ─────────────────────────────────────────────────────────────
|
||||
|
||||
async listItemsAdmin(tenantId: string, boardId: string) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
return this.prisma.knowledgeItem.findMany({
|
||||
where: { boardId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createItem(tenantId: string, adminId: string, boardId: string, dto: {
|
||||
question: string;
|
||||
answer: string;
|
||||
tags?: string[];
|
||||
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
return this.prisma.knowledgeItem.create({
|
||||
data: {
|
||||
boardId,
|
||||
tenantId,
|
||||
question: dto.question,
|
||||
answer: dto.answer,
|
||||
tags: dto.tags ?? [],
|
||||
status: dto.status ?? 'DRAFT',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateItem(tenantId: string, itemId: string, dto: {
|
||||
question?: string;
|
||||
answer?: string;
|
||||
tags?: string[];
|
||||
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
|
||||
if (!item) throw new NotFoundException('Item not found');
|
||||
return this.prisma.knowledgeItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
...(dto.question !== undefined && { question: dto.question }),
|
||||
...(dto.answer !== undefined && { answer: dto.answer }),
|
||||
...(dto.tags !== undefined && { tags: dto.tags }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(tenantId: string, itemId: string) {
|
||||
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
|
||||
if (!item) throw new NotFoundException('Item not found');
|
||||
await this.prisma.knowledgeItem.delete({ where: { id: itemId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Member: read published knowledge grouped by board ────────────────────────
|
||||
|
||||
async listForMember(tenantId: string, q?: string) {
|
||||
const boards = await this.prisma.knowledgeBoard.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
items: {
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
...(q && q.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ question: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ answer: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { id: true, question: true, answer: true, tags: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return boards
|
||||
.filter((b) => b.items.length > 0)
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
items: b.items,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,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 { 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 { CurrentMember } from '../auth/current-member.decorator';
|
||||
import type { MemberJwtPayload } from '@tower/types';
|
||||
@@ -22,13 +27,121 @@ class OptInDto {
|
||||
@Controller('my')
|
||||
@MemberAuth()
|
||||
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')
|
||||
profile(@CurrentMember() member: MemberJwtPayload) {
|
||||
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')
|
||||
listGroups(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.service.listGroups(member.sub, member.tenantId);
|
||||
|
||||
@@ -2,9 +2,15 @@ import { Module } from '@nestjs/common';
|
||||
import { MyController } from './my.controller';
|
||||
import { MyService } from './my.service';
|
||||
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({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule, GalleryModule],
|
||||
controllers: [MyController],
|
||||
providers: [MyService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Unauthorize
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
|
||||
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
|
||||
|
||||
@@ -12,6 +13,7 @@ export class MyService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly gamification: GamificationService,
|
||||
) {}
|
||||
|
||||
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[]> {
|
||||
const consents = await this.prisma.consentRecord.findMany({
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const NAV_LINKS = [
|
||||
{ href: '/search', label: 'Search' },
|
||||
{ href: '/groups', label: 'Groups & Routes' },
|
||||
{ href: '/messages/pending', label: 'Pending messages' },
|
||||
{ href: '/drafts', label: 'AI Drafts' },
|
||||
{ href: '/settings/rules', label: 'Rules' },
|
||||
{ href: '/settings/bot', label: 'Bot' },
|
||||
];
|
||||
@@ -18,6 +19,7 @@ const SUPER_ADMIN_LINKS = [
|
||||
{ href: '/admin', label: 'Dashboard' },
|
||||
{ href: '/admin/tenants', label: 'Tenants' },
|
||||
{ href: '/admin/bots', label: 'Bot Pool' },
|
||||
{ href: '/admin/drafts', label: 'AI Drafts' },
|
||||
];
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/signup', '/onboard'];
|
||||
@@ -85,25 +87,7 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
if (MEMBER_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return (
|
||||
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col">
|
||||
<span className="font-bold text-base mb-4">TOWER</span>
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<Link href="/my" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||||
Profile
|
||||
</Link>
|
||||
<Link href="/my/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||||
Groups
|
||||
</Link>
|
||||
<Link href="/my/settings" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-3 mt-3 text-xs text-gray-500 px-3">
|
||||
Member portal
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface SourceMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
senderName: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
type: 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
|
||||
status: string;
|
||||
proposedJson: Record<string, any>;
|
||||
confidence: number | null;
|
||||
createdAt: string;
|
||||
sourceMessage: SourceMessage;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<Draft['type'], string> = {
|
||||
EVENT: 'Event',
|
||||
SEVA_TASK: 'Seva Task',
|
||||
FAQ_ITEM: 'FAQ',
|
||||
};
|
||||
|
||||
const TYPE_COLOR: Record<Draft['type'], string> = {
|
||||
EVENT: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
SEVA_TASK: 'bg-green-50 text-green-700 border-green-200',
|
||||
FAQ_ITEM: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
};
|
||||
|
||||
function ProposedPreview({ type, data }: { type: Draft['type']; data: Record<string, any> }) {
|
||||
if (type === 'EVENT') {
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><span className="font-medium text-gray-700">Title:</span> {data.title ?? '—'}</p>
|
||||
{data.date && <p><span className="font-medium text-gray-700">Date:</span> {data.date} {data.time ?? ''}</p>}
|
||||
{data.location && <p><span className="font-medium text-gray-700">Location:</span> {data.location}</p>}
|
||||
{data.description && <p><span className="font-medium text-gray-700">Description:</span> {data.description}</p>}
|
||||
{data.rsvpNeeded && <p className="text-indigo-600 text-xs">RSVP required</p>}
|
||||
{data.sevaActionItems?.length > 0 && (
|
||||
<p className="text-green-700 text-xs">+{data.sevaActionItems.length} seva task(s) will also be created</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'SEVA_TASK') {
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><span className="font-medium text-gray-700">Title:</span> {data.title ?? '—'}</p>
|
||||
{data.parentEventTitle && <p><span className="font-medium text-gray-700">Event:</span> {data.parentEventTitle}</p>}
|
||||
{data.slots && <p><span className="font-medium text-gray-700">Slots:</span> {data.slots}</p>}
|
||||
{data.skills?.length > 0 && <p><span className="font-medium text-gray-700">Skills:</span> {data.skills.join(', ')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><span className="font-medium text-gray-700">Q:</span> {data.question ?? '—'}</p>
|
||||
<p><span className="font-medium text-gray-700">A:</span> {data.answer ?? '—'}</p>
|
||||
{data.tags?.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap mt-1">
|
||||
{data.tags.map((t: string) => (
|
||||
<span key={t} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraftCard({ draft }: { draft: Draft }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
|
||||
|
||||
async function act(action: 'approve' | 'reject') {
|
||||
setLoading(action);
|
||||
try {
|
||||
await fetch(`/api/admin/drafts/${draft.id}/${action}`, { method: 'POST' });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const confidence = draft.confidence != null ? Math.round(draft.confidence * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${TYPE_COLOR[draft.type]}`}>
|
||||
{TYPE_LABEL[draft.type]}
|
||||
</span>
|
||||
{confidence != null && (
|
||||
<span className="text-xs text-gray-400">{confidence}% confidence</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source message */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">
|
||||
Source message{draft.sourceMessage.senderName ? ` — ${draft.sourceMessage.senderName}` : ''}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 line-clamp-3">{draft.sourceMessage.content}</p>
|
||||
</div>
|
||||
|
||||
{/* AI proposed content */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">AI proposed</p>
|
||||
<ProposedPreview type={draft.type} data={draft.proposedJson} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => act('approve')}
|
||||
disabled={loading !== null}
|
||||
className="flex-1 py-2 text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading === 'approve' ? 'Approving…' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => act('reject')}
|
||||
disabled={loading !== null}
|
||||
className="flex-1 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading === 'reject' ? 'Rejecting…' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -49,9 +49,10 @@ export default function AdminDashboard() {
|
||||
<div className="text-2xl font-bold mt-1">{totalBots ? Math.round(totalTenants / totalBots) : 0}</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/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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,3 +7,14 @@ export async function GET(): Promise<Response> {
|
||||
const body = await res.json();
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user