Compare commits
41 Commits
4f1df96b34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73956fd1e3 | |||
| e598073dc7 | |||
| 5a0f7aa58f | |||
| a3798b3059 | |||
| 205418fc4e | |||
| d7b5988858 | |||
| f8b7afcc38 | |||
| 8d720278cb | |||
| 6bff55db64 | |||
| b26a635283 | |||
| 484ae7bf9d | |||
| 8a61908df1 | |||
| abb6799c3d | |||
| a1d2c6e6c2 | |||
| e61b0dfebd | |||
| c851bd9678 | |||
| 530f81416a | |||
| 7cafbc8ba6 | |||
| a0e5629f77 | |||
| 4fdd8160fd | |||
| 7fd425959f | |||
| d21bd6bcb4 | |||
| 6de78fe270 | |||
| 39ca91f07a | |||
| 3e86e0ffc0 | |||
| 5801cbf85b | |||
| f61c070428 | |||
| 6d89d8a609 | |||
| 57fe9d9bf1 | |||
| cd1eef0098 | |||
| a0dc94ce79 | |||
| 0ffdc362b5 | |||
| f7922454ca | |||
| bc9fe7c145 | |||
| a685f8b4d5 | |||
| 566690bd04 | |||
| 622737fdca | |||
| 435b0e7806 | |||
| 47f345c4bf | |||
| 6aec093516 | |||
| 978f7effc0 |
@@ -7,3 +7,5 @@ coverage
|
|||||||
*.env.local
|
*.env.local
|
||||||
.env.production
|
.env.production
|
||||||
sessions/
|
sessions/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.vercel
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.turbo
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
apps/worker/sessions
|
||||||
|
apps/api/sessions
|
||||||
|
sessions
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# TOWER — Claude Code Rules
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
TOWER is a multi-tenant WhatsApp community management platform. It has four portals served by a single Next.js app and a single NestJS API:
|
||||||
|
|
||||||
|
| Portal | Path prefix | Auth cookie | Guard |
|
||||||
|
|--------|------------|-------------|-------|
|
||||||
|
| Portal selector | `/` | — | public |
|
||||||
|
| Chapter admin | `/`, `/search`, `/groups`, `/messages`, `/drafts`, `/settings`, `/threads`, `/claim-group` | `tower_token` | `JwtAuthGuard` |
|
||||||
|
| Organisation admin | `/org/*` | `tower_org_token` | `OrgAdminGuard` |
|
||||||
|
| Super admin | `/admin/*` | `tower_super_token` | `SuperAdminGuard` |
|
||||||
|
| Member | `/my/*`, `/onboard`, `/member-login` | `tower_member_token` | `MemberAuthGuard` |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 16** (App Router, TypeScript) — `apps/web`
|
||||||
|
- **NestJS** (TypeScript) — `apps/api`
|
||||||
|
- **Prisma** — ORM, PostgreSQL
|
||||||
|
- **BullMQ** + **Redis** — job queues
|
||||||
|
- **Tailwind CSS v4** — utility-first CSS
|
||||||
|
- **shadcn/ui** — component library (new-york style, CSS variables)
|
||||||
|
- **TanStack React Query** — all data fetching and mutations, no raw `fetch` in components
|
||||||
|
- **Zod** — all schema validation (forms, API response shapes)
|
||||||
|
- **react-hook-form** + **@hookform/resolvers/zod** — all forms
|
||||||
|
- **lucide-react** — icons
|
||||||
|
|
||||||
|
## File & Folder Conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/app/
|
||||||
|
_lib/ # Shared utilities and contexts
|
||||||
|
api.ts # getApiBaseUrl, jsonResponse, cookie helpers
|
||||||
|
query-client.tsx # QueryClientProvider wrapper
|
||||||
|
auth-context.tsx # Chapter admin auth (tower_token)
|
||||||
|
super-admin-context.tsx # Super admin auth (tower_super_token)
|
||||||
|
org-admin-context.tsx # Org admin auth (tower_org_token)
|
||||||
|
utils.ts # cn() helper
|
||||||
|
_components/ # Shared UI components (shadcn + custom)
|
||||||
|
ui/ # Raw shadcn components (never edit directly)
|
||||||
|
portal-shell.tsx # Shared portal layout wrapper
|
||||||
|
nav-user.tsx # User dropdown in nav
|
||||||
|
data-table.tsx # Shared table component
|
||||||
|
api/ # Next.js BFF route handlers only
|
||||||
|
auth/…
|
||||||
|
admin/…
|
||||||
|
org/…
|
||||||
|
my/…
|
||||||
|
(public)/ # Portal selector + all login pages (no auth)
|
||||||
|
admin/ # Super admin portal
|
||||||
|
org/ # Org admin portal
|
||||||
|
my/ # Member portal
|
||||||
|
[chapter pages]/ # Chapter admin pages at root level
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Rules
|
||||||
|
|
||||||
|
### General
|
||||||
|
- **No raw `fetch` in components.** All API calls go through TanStack Query hooks. Define queries in a co-located `_queries.ts` or inline `useQuery`/`useMutation`.
|
||||||
|
- **No `useState` for server data.** Use React Query. `useState` is for UI-only state (modal open, tab selection, etc.).
|
||||||
|
- **All forms use react-hook-form + Zod.** Schema first: define a `z.object({...})` schema, derive the type with `z.infer`, pass resolver to `useForm`.
|
||||||
|
- **Shared components live in `app/_components/`.** Never duplicate a button, input, or card across portals.
|
||||||
|
- **Each portal has its own layout** with nav/sidebar. Do not put portal UI logic in the root layout.
|
||||||
|
- **No `any` types.** Use proper types or `unknown` + type narrowing.
|
||||||
|
|
||||||
|
### TanStack React Query
|
||||||
|
```tsx
|
||||||
|
// Define query key factories
|
||||||
|
export const tenantKeys = {
|
||||||
|
all: ['tenants'] as const,
|
||||||
|
list: () => [...tenantKeys.all, 'list'] as const,
|
||||||
|
detail: (id: string) => [...tenantKeys.all, 'detail', id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// In component
|
||||||
|
const { data, isPending } = useQuery({
|
||||||
|
queryKey: tenantKeys.list(),
|
||||||
|
queryFn: () => api.get<Tenant[]>('/api/admin/tenants'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations always invalidate related queries
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: CreateTenantInput) => api.post('/api/admin/tenants', body),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: tenantKeys.all }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
```tsx
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField control={form.control} name="email" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Helper
|
||||||
|
Use the typed `api` helper from `_lib/api-client.ts` — never call `fetch` directly in components or hooks:
|
||||||
|
```ts
|
||||||
|
// app/_lib/api-client.ts
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
|
||||||
|
post: <T>(url: string, body: unknown) => fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
|
||||||
|
patch: <T>(url: string, body: unknown) => fetch(url, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
|
||||||
|
del: (url: string) => fetch(url, { method: 'DELETE' }).then(r => { if (!r.ok) return Promise.reject(r); }),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Contexts
|
||||||
|
Each portal's auth context provides:
|
||||||
|
- `user` / `admin` — the authenticated entity (undefined when loading)
|
||||||
|
- `loading` — boolean
|
||||||
|
- `login(...)` — sets cookie via BFF, updates context
|
||||||
|
- `logout()` — clears cookie, redirects to portal login
|
||||||
|
|
||||||
|
Auth guard pattern (in portal layout, server component):
|
||||||
|
```tsx
|
||||||
|
// In a portal layout — always server component
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default async function PortalLayout({ children }) {
|
||||||
|
const token = (await cookies()).get('tower_token')?.value;
|
||||||
|
if (!token) redirect('/login');
|
||||||
|
return <PortalShell>{children}</PortalShell>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Use shadcn/ui components as the base. Extend with Tailwind utilities.
|
||||||
|
- **No inline styles.** All styles via Tailwind classes.
|
||||||
|
- **No magic numbers in class names** — use design tokens (`text-sm`, `p-4`, `gap-2` — not arbitrary `p-[13px]`).
|
||||||
|
- Colors: use semantic tokens (`text-foreground`, `bg-muted`, `border`) not raw colors.
|
||||||
|
- Dark mode: set up via `class` strategy but only if explicitly requested.
|
||||||
|
|
||||||
|
### NestJS API Rules
|
||||||
|
- All protected endpoints use a Guard decorator — never trust the request without it.
|
||||||
|
- DTOs use `class-validator` decorators. `ValidationPipe` with `whitelist: true, forbidNonWhitelisted: true` is global.
|
||||||
|
- Always use `select` in Prisma queries to avoid leaking `passwordHash` or other sensitive fields.
|
||||||
|
- Org-scoped queries always include `organizationId` in the `where` clause.
|
||||||
|
- Tenant-scoped queries always include `tenantId` in the `where` clause.
|
||||||
|
|
||||||
|
### Redirect Rules (IMPORTANT)
|
||||||
|
After successful login, each portal redirects to its own home:
|
||||||
|
- Chapter admin: `/search` (or `next` param if set)
|
||||||
|
- Org admin: `/org`
|
||||||
|
- Super admin: `/admin`
|
||||||
|
- Member: `/my`
|
||||||
|
|
||||||
|
Never redirect to `/` after login — that is the public portal selector.
|
||||||
|
|
||||||
|
### Commit Style
|
||||||
|
Conventional commits: `feat:`, `fix:`, `refactor:`, `chore:`
|
||||||
|
Co-author every commit with Claude.
|
||||||
+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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Make groupId optional on OtpChallenge so member re-login challenges
|
||||||
|
-- (which have no group context) can be created without a groupId.
|
||||||
|
ALTER TABLE "OtpChallenge" ALTER COLUMN "groupId" DROP NOT NULL;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RuleIntent" AS ENUM ('POSITIVE', 'NEGATIVE');
|
||||||
|
CREATE TYPE "ForwardFormat" AS ENUM ('THREADED', 'DIGEST', 'SUMMARY');
|
||||||
|
CREATE TYPE "ForwardFrequency" AS ENUM ('INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS');
|
||||||
|
CREATE TYPE "ApprovalMode" AS ENUM ('MANUAL', 'AUTO');
|
||||||
|
|
||||||
|
-- AlterEnum: Add INTEREST to RuleMatchType
|
||||||
|
ALTER TYPE "RuleMatchType" ADD VALUE IF NOT EXISTS 'INTEREST';
|
||||||
|
|
||||||
|
-- AlterTable: TenantRule - add ruleIntent
|
||||||
|
ALTER TABLE "TenantRule" ADD COLUMN IF NOT EXISTS "ruleIntent" "RuleIntent" NOT NULL DEFAULT 'POSITIVE';
|
||||||
|
|
||||||
|
-- AlterTable: SyncRoute - add new columns (updatedAt with default NOW() for existing rows)
|
||||||
|
ALTER TABLE "SyncRoute"
|
||||||
|
ADD COLUMN IF NOT EXISTS "forwardFormat" "ForwardFormat" NOT NULL DEFAULT 'THREADED',
|
||||||
|
ADD COLUMN IF NOT EXISTS "withAttachment" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN IF NOT EXISTS "frequency" "ForwardFrequency" NOT NULL DEFAULT 'INSTANT',
|
||||||
|
ADD COLUMN IF NOT EXISTS "approvalMode" "ApprovalMode" NOT NULL DEFAULT 'MANUAL',
|
||||||
|
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
|
||||||
|
|
||||||
|
-- CreateTable: SyncRouteRuleMap
|
||||||
|
CREATE TABLE IF NOT EXISTS "SyncRouteRuleMap" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"syncRouteId" TEXT NOT NULL,
|
||||||
|
"tenantRuleId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SyncRouteRuleMap_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_tenantRuleId_key"
|
||||||
|
ON "SyncRouteRuleMap"("syncRouteId", "tenantRuleId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_idx"
|
||||||
|
ON "SyncRouteRuleMap"("syncRouteId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_tenantRuleId_idx"
|
||||||
|
ON "SyncRouteRuleMap"("tenantRuleId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SyncRouteRuleMap"
|
||||||
|
ADD CONSTRAINT "SyncRouteRuleMap_syncRouteId_fkey"
|
||||||
|
FOREIGN KEY ("syncRouteId") REFERENCES "SyncRoute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "SyncRouteRuleMap"
|
||||||
|
ADD CONSTRAINT "SyncRouteRuleMap_tenantRuleId_fkey"
|
||||||
|
FOREIGN KEY ("tenantRuleId") REFERENCES "TenantRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -7,6 +7,59 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Organization layer (above Tenancy)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum OrgAdminRole {
|
||||||
|
ORG_OWNER
|
||||||
|
ORG_ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model Organization {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
settings Json @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenants Tenant[]
|
||||||
|
orgAdmins OrgAdmin[]
|
||||||
|
orgRules OrgRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrgAdmin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
email String
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
role OrgAdminRole @default(ORG_ADMIN)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([organizationId, email])
|
||||||
|
@@index([organizationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrgRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
matchType RuleMatchType
|
||||||
|
matchValue String
|
||||||
|
action RuleAction
|
||||||
|
priority Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([organizationId, matchType, matchValue])
|
||||||
|
@@index([organizationId, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tenancy
|
// Tenancy
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -18,6 +71,8 @@ model Tenant {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
isForwardingPaused Boolean @default(false)
|
isForwardingPaused Boolean @default(false)
|
||||||
settings Json @default("{}")
|
settings Json @default("{}")
|
||||||
|
organizationId String?
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -33,6 +88,19 @@ model Tenant {
|
|||||||
auditEvents AuditEvent[]
|
auditEvents AuditEvent[]
|
||||||
rules TenantRule[]
|
rules TenantRule[]
|
||||||
groupAccesses GroupAccess[]
|
groupAccesses GroupAccess[]
|
||||||
|
digestConfig DigestConfig?
|
||||||
|
digests Digest[]
|
||||||
|
events Event[]
|
||||||
|
threads Thread[]
|
||||||
|
sevaOpportunities SevaOpportunity[]
|
||||||
|
gamificationEvents GamificationEvent[]
|
||||||
|
circles Circle[]
|
||||||
|
askAIQueries AskAIQuery[]
|
||||||
|
knowledgeBoards KnowledgeBoard[]
|
||||||
|
knowledgeItems KnowledgeItem[]
|
||||||
|
galleryAlbums GalleryAlbum[]
|
||||||
|
galleryItems GalleryItem[]
|
||||||
|
contentDrafts ContentDraft[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdminRole {
|
enum AdminRole {
|
||||||
@@ -177,6 +245,7 @@ model Group {
|
|||||||
consents ConsentRecord[]
|
consents ConsentRecord[]
|
||||||
claimTokens GroupClaimToken[]
|
claimTokens GroupClaimToken[]
|
||||||
groupAccesses GroupAccess[]
|
groupAccesses GroupAccess[]
|
||||||
|
threads Thread[]
|
||||||
|
|
||||||
@@unique([platform, platformId])
|
@@unique([platform, platformId])
|
||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@ -204,22 +273,48 @@ model Message {
|
|||||||
mediaUrl String?
|
mediaUrl String?
|
||||||
tags String[]
|
tags String[]
|
||||||
status MessageStatus @default(PENDING)
|
status MessageStatus @default(PENDING)
|
||||||
|
expiresAt DateTime?
|
||||||
|
quotedPlatformMsgId String?
|
||||||
|
threadId String?
|
||||||
|
thread Thread? @relation(fields: [threadId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
approval Approval?
|
approval Approval?
|
||||||
|
contentDrafts ContentDraft[]
|
||||||
|
|
||||||
@@unique([platform, platformMsgId])
|
@@unique([platform, platformMsgId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([senderTowerUserId])
|
@@index([senderTowerUserId])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([threadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MessageStatus {
|
enum MessageStatus {
|
||||||
|
RAW
|
||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
REJECTED
|
REJECTED
|
||||||
DISTRIBUTED
|
DISTRIBUTED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
|
DNC
|
||||||
|
}
|
||||||
|
|
||||||
|
model Thread {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
sourceGroupId String
|
||||||
|
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||||
|
lastActivityAt DateTime @default(now())
|
||||||
|
messageCount Int @default(0)
|
||||||
|
topic String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
messages Message[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([sourceGroupId, lastActivityAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Approval {
|
model Approval {
|
||||||
@@ -250,12 +345,32 @@ model SyncRoute {
|
|||||||
targetGroupId String
|
targetGroupId String
|
||||||
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
forwardFormat ForwardFormat @default(THREADED)
|
||||||
|
withAttachment Boolean @default(true)
|
||||||
|
frequency ForwardFrequency @default(INSTANT)
|
||||||
|
approvalMode ApprovalMode @default(MANUAL)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
assignedRules SyncRouteRuleMap[]
|
||||||
|
|
||||||
@@unique([sourceGroupId, targetGroupId])
|
@@unique([sourceGroupId, targetGroupId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SyncRouteRuleMap {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
syncRouteId String
|
||||||
|
syncRoute SyncRoute @relation(fields: [syncRouteId], references: [id], onDelete: Cascade)
|
||||||
|
tenantRuleId String
|
||||||
|
tenantRule TenantRule @relation(fields: [tenantRuleId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([syncRouteId, tenantRuleId])
|
||||||
|
@@index([syncRouteId])
|
||||||
|
@@index([tenantRuleId])
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Group claiming + sharing
|
// Group claiming + sharing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -316,6 +431,13 @@ model TowerUser {
|
|||||||
phoneHash String
|
phoneHash String
|
||||||
jid String
|
jid String
|
||||||
displayName 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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -323,6 +445,11 @@ model TowerUser {
|
|||||||
optOuts MemberOptOut[]
|
optOuts MemberOptOut[]
|
||||||
sessions TowerSession[]
|
sessions TowerSession[]
|
||||||
messages Message[] @relation("senderTowerUser")
|
messages Message[] @relation("senderTowerUser")
|
||||||
|
rsvps EventRsvp[]
|
||||||
|
sevaEntries SevaEntry[]
|
||||||
|
gamificationEvents GamificationEvent[]
|
||||||
|
circleMemberships CircleMembership[]
|
||||||
|
askAIQueries AskAIQuery[]
|
||||||
|
|
||||||
@@unique([tenantId, phoneHash])
|
@@unique([tenantId, phoneHash])
|
||||||
@@index([phoneHash])
|
@@index([phoneHash])
|
||||||
@@ -389,7 +516,7 @@ model OtpChallenge {
|
|||||||
scopes ConsentScope[]
|
scopes ConsentScope[]
|
||||||
retentionDays Int @default(90)
|
retentionDays Int @default(90)
|
||||||
policyVersion String
|
policyVersion String
|
||||||
groupId String
|
groupId String?
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
consumedAt DateTime?
|
consumedAt DateTime?
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
@@ -408,6 +535,7 @@ enum RuleMatchType {
|
|||||||
HASHTAG
|
HASHTAG
|
||||||
PREFIX
|
PREFIX
|
||||||
REACTION_EMOJI
|
REACTION_EMOJI
|
||||||
|
INTEREST
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RuleAction {
|
enum RuleAction {
|
||||||
@@ -417,6 +545,31 @@ enum RuleAction {
|
|||||||
REJECT
|
REJECT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RuleIntent {
|
||||||
|
POSITIVE
|
||||||
|
NEGATIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ForwardFormat {
|
||||||
|
THREADED
|
||||||
|
DIGEST
|
||||||
|
SUMMARY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ForwardFrequency {
|
||||||
|
INSTANT
|
||||||
|
FIVE_MIN
|
||||||
|
FIFTEEN_MIN
|
||||||
|
ONE_HOUR
|
||||||
|
ONE_DAY
|
||||||
|
SEVEN_DAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApprovalMode {
|
||||||
|
MANUAL
|
||||||
|
AUTO
|
||||||
|
}
|
||||||
|
|
||||||
model TenantRule {
|
model TenantRule {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
@@ -424,12 +577,351 @@ model TenantRule {
|
|||||||
matchType RuleMatchType
|
matchType RuleMatchType
|
||||||
matchValue String
|
matchValue String
|
||||||
action RuleAction
|
action RuleAction
|
||||||
|
ruleIntent RuleIntent @default(POSITIVE)
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
syncRouteRuleMaps SyncRouteRuleMap[]
|
||||||
|
|
||||||
@@unique([tenantId, matchType, matchValue])
|
@@unique([tenantId, matchType, matchValue])
|
||||||
@@index([tenantId, isActive])
|
@@index([tenantId, isActive])
|
||||||
@@index([tenantId, matchType])
|
@@index([tenantId, matchType])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Digest
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model DigestConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @unique
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
targetGroupJid String
|
||||||
|
targetAccountId String
|
||||||
|
scheduleHour Int @default(20)
|
||||||
|
scheduleMinute Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
lastSentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Digest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
content String
|
||||||
|
messageIds String[]
|
||||||
|
digestDate DateTime
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([tenantId, digestDate])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Events + RSVP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum RsvpStatus {
|
||||||
|
GOING
|
||||||
|
NOT_GOING
|
||||||
|
MAYBE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
location String?
|
||||||
|
startsAt DateTime
|
||||||
|
endsAt DateTime?
|
||||||
|
createdBy String
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rsvps EventRsvp[]
|
||||||
|
|
||||||
|
@@index([tenantId, startsAt])
|
||||||
|
@@index([tenantId, isPublished])
|
||||||
|
}
|
||||||
|
|
||||||
|
model EventRsvp {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
eventId String
|
||||||
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
status RsvpStatus
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([eventId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Seva (volunteering) + Gamification
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum SevaStatus {
|
||||||
|
OPEN
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SevaEntryStatus {
|
||||||
|
SIGNED_UP
|
||||||
|
COMPLETED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model SevaOpportunity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
location String?
|
||||||
|
slots Int?
|
||||||
|
startsAt DateTime?
|
||||||
|
pointsAward Int @default(20)
|
||||||
|
status SevaStatus @default(OPEN)
|
||||||
|
createdBy String
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
entries SevaEntry[]
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, isPublished])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SevaEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
opportunityId String
|
||||||
|
opportunity SevaOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
status SevaEntryStatus @default(SIGNED_UP)
|
||||||
|
hours Float?
|
||||||
|
note String?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([opportunityId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-signal gamification — points come from many event types, not just seva.
|
||||||
|
enum GamificationEventType {
|
||||||
|
HELPFUL_ANSWER
|
||||||
|
SEVA_COMPLETED
|
||||||
|
EVENT_ATTENDANCE
|
||||||
|
FAQ_APPROVED
|
||||||
|
WELCOME
|
||||||
|
PROFILE_COMPLETED
|
||||||
|
BUSINESS_ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
model GamificationEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
type GamificationEventType
|
||||||
|
points Int
|
||||||
|
refType String?
|
||||||
|
refId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId, userId])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
// One award per (user, type, ref) — prevents double-awarding the same source.
|
||||||
|
@@unique([userId, type, refId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Circles (interest / affinity sub-groups within a chapter)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum CircleRole {
|
||||||
|
MEMBER
|
||||||
|
LEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
model Circle {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
isPublic Boolean @default(true)
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
memberships CircleMembership[]
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId, isPublic])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CircleMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
circleId String
|
||||||
|
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
role CircleRole @default(MEMBER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([circleId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Ask AI (RAG over the message archive)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model AskAIQuery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
question String
|
||||||
|
answer String
|
||||||
|
citations Json @default("[]")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Knowledge Base (admin-curated FAQ boards)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum KnowledgeItemStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
model KnowledgeBoard {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
slug String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
items KnowledgeItem[]
|
||||||
|
|
||||||
|
@@unique([tenantId, slug])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model KnowledgeItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
boardId String
|
||||||
|
board KnowledgeBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
question String
|
||||||
|
answer String
|
||||||
|
tags String[]
|
||||||
|
status KnowledgeItemStatus @default(DRAFT)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([boardId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Memories / Gallery
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model GalleryAlbum {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
coverUrl String?
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
items GalleryItem[]
|
||||||
|
|
||||||
|
@@index([tenantId, isPublished])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GalleryItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
albumId String
|
||||||
|
album GalleryAlbum @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
mediaUrl String
|
||||||
|
caption String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([albumId])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Content Drafts — AI-proposed Event/Seva/FAQ pending admin review
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum DraftType {
|
||||||
|
EVENT
|
||||||
|
SEVA_TASK
|
||||||
|
FAQ_ITEM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DraftStatus {
|
||||||
|
PENDING_REVIEW
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
model ContentDraft {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
type DraftType
|
||||||
|
status DraftStatus @default(PENDING_REVIEW)
|
||||||
|
sourceMessageId String
|
||||||
|
sourceMessage Message @relation(fields: [sourceMessageId], references: [id])
|
||||||
|
proposedJson Json
|
||||||
|
confidence Float?
|
||||||
|
reviewedBy String?
|
||||||
|
reviewedAt DateTime?
|
||||||
|
publishedId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, type, status])
|
||||||
|
@@index([sourceMessageId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ import { MessagesModule } from './modules/messages/messages.module';
|
|||||||
import { RulesModule } from './modules/rules/rules.module';
|
import { RulesModule } from './modules/rules/rules.module';
|
||||||
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
|
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
|
||||||
import { TenantModule } from './modules/tenant/tenant.module';
|
import { TenantModule } from './modules/tenant/tenant.module';
|
||||||
|
import { DigestModule } from './modules/digest/digest.module';
|
||||||
|
import { OrgModule } from './modules/org/org.module';
|
||||||
|
import { ThreadsModule } from './modules/threads/threads.module';
|
||||||
|
import { EventsModule } from './modules/events/events.module';
|
||||||
|
import { GamificationModule } from './modules/gamification/gamification.module';
|
||||||
|
import { SevaModule } from './modules/seva/seva.module';
|
||||||
|
import { CirclesModule } from './modules/circles/circles.module';
|
||||||
|
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
|
||||||
|
import { GalleryModule } from './modules/gallery/gallery.module';
|
||||||
|
import { DraftsModule } from './modules/drafts/drafts.module';
|
||||||
|
import { AiStreamModule } from './modules/ai-stream/ai-stream.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,6 +43,17 @@ import { TenantModule } from './modules/tenant/tenant.module';
|
|||||||
RulesModule,
|
RulesModule,
|
||||||
SuperAdminModule,
|
SuperAdminModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
|
DigestModule,
|
||||||
|
OrgModule,
|
||||||
|
ThreadsModule,
|
||||||
|
EventsModule,
|
||||||
|
GamificationModule,
|
||||||
|
SevaModule,
|
||||||
|
CirclesModule,
|
||||||
|
KnowledgeModule,
|
||||||
|
GalleryModule,
|
||||||
|
DraftsModule,
|
||||||
|
AiStreamModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||||
|
const DEFAULT_MODEL = 'google/gemini-3.5-flash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal OpenRouter chat completion. Mirrors the worker's llm-client so digest
|
||||||
|
* and Ask AI share one calling convention. Returns the assistant message text.
|
||||||
|
*/
|
||||||
|
export async function callLLM(
|
||||||
|
prompt: string,
|
||||||
|
apiKey: string,
|
||||||
|
model = DEFAULT_MODEL,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': 'https://tower.insignia.app',
|
||||||
|
'X-Title': 'TOWER Community Platform',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`OpenRouter error ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { choices: Array<{ message: { content: string } }> };
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
if (!content) throw new Error('OpenRouter returned empty content');
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, Headers, Sse, UseGuards, MessageEvent } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { AiStreamGuard } from './ai-stream.guard';
|
||||||
|
import { AiStreamService } from './ai-stream.service';
|
||||||
|
import { CreateDraftDto } from './dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External AI-developer integration surface.
|
||||||
|
*
|
||||||
|
* GET /ai/stream — SSE feed of every ingested message (read)
|
||||||
|
* POST /ai/drafts — write back extracted content drafts (write)
|
||||||
|
*
|
||||||
|
* All endpoints are @Public() to bypass the global JWT guard, then protected by
|
||||||
|
* AiStreamGuard (static bearer token). Runs over the existing public HTTPS API,
|
||||||
|
* so no Redis client, Tailscale, or firewall changes are needed on the dev side.
|
||||||
|
*/
|
||||||
|
@Controller('ai')
|
||||||
|
@Public()
|
||||||
|
@UseGuards(AiStreamGuard)
|
||||||
|
export class AiStreamController {
|
||||||
|
constructor(private readonly service: AiStreamService) {}
|
||||||
|
|
||||||
|
@Sse('stream')
|
||||||
|
stream(
|
||||||
|
@Query('after') after?: string,
|
||||||
|
@Query('tenant') tenant?: string,
|
||||||
|
@Query('source') source?: string,
|
||||||
|
@Headers('last-event-id') lastEventId?: string,
|
||||||
|
): Observable<MessageEvent> {
|
||||||
|
// Last-Event-ID (set automatically by EventSource on reconnect) wins so the
|
||||||
|
// client resumes exactly where it dropped. Otherwise honour ?after=, default
|
||||||
|
// to '$' = only new messages from now on.
|
||||||
|
const cursor = lastEventId || after || '$';
|
||||||
|
return this.service.streamMessages(cursor, { tenantId: tenant, sourceGroupId: source });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('drafts')
|
||||||
|
createDraft(@Body() dto: CreateDraftDto): Promise<{ draftId: string }> {
|
||||||
|
return this.service.createDraft(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard for the AI-dev stream + writeback endpoints.
|
||||||
|
*
|
||||||
|
* Authenticates with a single static bearer token (AI_STREAM_TOKEN), accepted
|
||||||
|
* either as `Authorization: Bearer <token>` (POST / header-capable clients) or
|
||||||
|
* as a `?token=<token>` query param (browser EventSource cannot set headers).
|
||||||
|
*
|
||||||
|
* This is a separate auth realm from the admin/member JWT — the AI dev never
|
||||||
|
* gets a login, just one long-lived token you rotate by changing the env var.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiStreamGuard implements CanActivate {
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const expected = this.config.get<string>('AI_STREAM_TOKEN');
|
||||||
|
if (!expected) {
|
||||||
|
throw new UnauthorizedException('AI stream is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const header: string | undefined = req.headers?.authorization;
|
||||||
|
const fromHeader = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
||||||
|
const fromQuery: string | undefined = req.query?.token;
|
||||||
|
const provided = fromHeader ?? fromQuery;
|
||||||
|
|
||||||
|
if (!provided || !safeEqual(provided, expected)) {
|
||||||
|
throw new UnauthorizedException('Invalid AI stream token');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AiStreamController } from './ai-stream.controller';
|
||||||
|
import { AiStreamService } from './ai-stream.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AiStreamController],
|
||||||
|
providers: [AiStreamService],
|
||||||
|
})
|
||||||
|
export class AiStreamModule {}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MessageEvent } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateDraftDto } from './dto';
|
||||||
|
|
||||||
|
const STREAM_KEY = 'tower:stream:messages';
|
||||||
|
// Block this long per XREAD; on timeout we emit a heartbeat to keep the
|
||||||
|
// connection warm through proxies, then loop again.
|
||||||
|
const BLOCK_MS = 15000;
|
||||||
|
|
||||||
|
interface StreamRecord {
|
||||||
|
messageId: string;
|
||||||
|
tenantId: string;
|
||||||
|
content: string;
|
||||||
|
senderJid: string;
|
||||||
|
senderName: string | null;
|
||||||
|
sourceGroupId: string;
|
||||||
|
sourceGroupName: string | null;
|
||||||
|
organizationId: string | null;
|
||||||
|
replyToMessageId: string | null;
|
||||||
|
tags: string[];
|
||||||
|
effectiveAction: string | null;
|
||||||
|
traceId: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiStreamService {
|
||||||
|
private readonly logger = new Logger(AiStreamService.name);
|
||||||
|
private readonly redisUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
this.redisUrl = this.config.get<string>('REDIS_URL', 'redis://localhost:6379');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE source. Reads the Redis stream from `afterId` and emits one MessageEvent
|
||||||
|
* per message, with the stream entry id set as the SSE event id so the client's
|
||||||
|
* Last-Event-ID resumes exactly where it dropped off.
|
||||||
|
*
|
||||||
|
* afterId semantics (Redis stream cursor):
|
||||||
|
* '$' → only messages that arrive after connect (default for a fresh client)
|
||||||
|
* '0' → replay the entire retained stream (~50k messages) then follow live
|
||||||
|
* '<id>' → resume strictly after that entry (used on reconnect)
|
||||||
|
*/
|
||||||
|
streamMessages(afterId: string, filters?: { tenantId?: string; sourceGroupId?: string }): Observable<MessageEvent> {
|
||||||
|
return new Observable<MessageEvent>((subscriber) => {
|
||||||
|
const redis = new Redis(this.redisUrl, { maxRetriesPerRequest: null });
|
||||||
|
let active = true;
|
||||||
|
let cursor = afterId;
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
while (active) {
|
||||||
|
try {
|
||||||
|
const res = (await redis.xread(
|
||||||
|
'COUNT', 100,
|
||||||
|
'BLOCK', BLOCK_MS,
|
||||||
|
'STREAMS', STREAM_KEY, cursor,
|
||||||
|
)) as [string, [string, string[]][]][] | null;
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
// Idle timeout — heartbeat keeps the pipe alive. EventSource.onmessage
|
||||||
|
// ignores typed events, so this won't reach the dev's message handler.
|
||||||
|
if (active) subscriber.next({ type: 'heartbeat', data: '' } as MessageEvent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, entries] of res) {
|
||||||
|
for (const [id, fields] of entries) {
|
||||||
|
const record = parseFields(fields);
|
||||||
|
cursor = id;
|
||||||
|
if (filters?.tenantId && record.tenantId !== filters.tenantId) continue;
|
||||||
|
if (filters?.sourceGroupId && record.sourceGroupId !== filters.sourceGroupId) continue;
|
||||||
|
subscriber.next({ id, data: record } as MessageEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (active) subscriber.error(err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
redis.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writeback: the AI dev posts an extracted draft. Creates a ContentDraft in
|
||||||
|
* PENDING_REVIEW status — it then shows up in the admin /admin/drafts UI for
|
||||||
|
* approve/reject, which publishes the real Event/SevaOpportunity/KnowledgeItem.
|
||||||
|
*/
|
||||||
|
async createDraft(dto: CreateDraftDto): Promise<{ draftId: string }> {
|
||||||
|
const message = await this.prisma.message.findUnique({
|
||||||
|
where: { id: dto.sourceMessageId },
|
||||||
|
select: { id: true, tenantId: true },
|
||||||
|
});
|
||||||
|
if (!message) {
|
||||||
|
throw new NotFoundException('sourceMessageId does not exist');
|
||||||
|
}
|
||||||
|
if (message.tenantId !== dto.tenantId) {
|
||||||
|
throw new BadRequestException('tenantId does not match the source message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = await this.prisma.contentDraft.create({
|
||||||
|
data: {
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
type: dto.type,
|
||||||
|
status: 'PENDING_REVIEW',
|
||||||
|
sourceMessageId: dto.sourceMessageId,
|
||||||
|
proposedJson: dto.proposedJson as object,
|
||||||
|
confidence: dto.confidence ?? null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`AI draft created: ${draft.id} (${dto.type}) from message ${dto.sourceMessageId}`);
|
||||||
|
return { draftId: draft.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFields(fields: string[]): StreamRecord {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < fields.length; i += 2) {
|
||||||
|
obj[fields[i]] = fields[i + 1];
|
||||||
|
}
|
||||||
|
let tags: string[] = [];
|
||||||
|
try {
|
||||||
|
tags = obj.tags ? JSON.parse(obj.tags) : [];
|
||||||
|
} catch {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageId: obj.messageId ?? '',
|
||||||
|
tenantId: obj.tenantId ?? '',
|
||||||
|
content: obj.content ?? '',
|
||||||
|
senderJid: obj.senderJid ?? '',
|
||||||
|
senderName: obj.senderName || null,
|
||||||
|
sourceGroupId: obj.sourceGroupId ?? '',
|
||||||
|
sourceGroupName: obj.sourceGroupName || null,
|
||||||
|
organizationId: obj.organizationId || null,
|
||||||
|
replyToMessageId: obj.replyToMessageId || null,
|
||||||
|
tags,
|
||||||
|
effectiveAction: obj.effectiveAction || null,
|
||||||
|
traceId: obj.traceId || null,
|
||||||
|
timestamp: obj.timestamp ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsString, IsIn, IsOptional, IsNumber, IsObject, Min, Max } from 'class-validator';
|
||||||
|
import { DraftType } from '@prisma/client';
|
||||||
|
|
||||||
|
const DRAFT_TYPES: DraftType[] = ['EVENT', 'SEVA_TASK', 'FAQ_ITEM'];
|
||||||
|
|
||||||
|
export class CreateDraftDto {
|
||||||
|
@IsString()
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
sourceMessageId!: string;
|
||||||
|
|
||||||
|
@IsIn(DRAFT_TYPES)
|
||||||
|
type!: DraftType;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
proposedJson!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AskAIService } from './ask-ai.service';
|
||||||
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SearchModule],
|
||||||
|
providers: [AskAIService],
|
||||||
|
exports: [AskAIService],
|
||||||
|
})
|
||||||
|
export class AskAIModule {}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { SearchService } from '../search/search.service';
|
||||||
|
import { callLLM } from '../../common/llm-client';
|
||||||
|
|
||||||
|
export interface Citation {
|
||||||
|
messageId: string;
|
||||||
|
snippet: string;
|
||||||
|
senderName: string;
|
||||||
|
sourceGroupName: string;
|
||||||
|
approvedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AskAIService {
|
||||||
|
private readonly logger = new Logger(AskAIService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly search: SearchService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ask(userId: string, tenantId: string, question: string) {
|
||||||
|
// 1. Retrieve: pull the most relevant approved messages from the tenant's index.
|
||||||
|
const { hits } = await this.search.search(tenantId, question, undefined, undefined, 1, 8);
|
||||||
|
|
||||||
|
const citations: Citation[] = hits.map((h) => ({
|
||||||
|
messageId: h.id,
|
||||||
|
snippet: h.content.slice(0, 240),
|
||||||
|
senderName: h.senderName || 'Unknown',
|
||||||
|
sourceGroupName: h.sourceGroupName,
|
||||||
|
approvedAt: h.approvedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Generate: ground the LLM in the retrieved snippets. Degrade gracefully
|
||||||
|
// if no AI key is configured or no context was found.
|
||||||
|
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
|
||||||
|
let answer: string;
|
||||||
|
|
||||||
|
if (citations.length === 0) {
|
||||||
|
answer = "I couldn't find anything in your community's messages about that yet. Try rephrasing, or ask an admin.";
|
||||||
|
} else if (!apiKey) {
|
||||||
|
answer = this.fallbackAnswer(citations);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
answer = await callLLM(this.buildPrompt(question, citations), apiKey);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, 'Ask AI LLM call failed — returning snippet fallback');
|
||||||
|
answer = this.fallbackAnswer(citations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Log the query for history + future tuning.
|
||||||
|
const record = await this.prisma.askAIQuery.create({
|
||||||
|
data: { tenantId, userId, question, answer, citations: citations as unknown as object },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
citations,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async history(userId: string, tenantId: string) {
|
||||||
|
const queries = await this.prisma.askAIQuery.findMany({
|
||||||
|
where: { userId, tenantId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
return queries.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
question: q.question,
|
||||||
|
answer: q.answer,
|
||||||
|
citations: q.citations as unknown as Citation[],
|
||||||
|
createdAt: q.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPrompt(question: string, citations: Citation[]): string {
|
||||||
|
const context = citations
|
||||||
|
.map((c, i) => `[${i + 1}] (${c.sourceGroupName}, by ${c.senderName}): ${c.snippet}`)
|
||||||
|
.join('\n');
|
||||||
|
return [
|
||||||
|
'You are a helpful assistant for a community group. Answer the member\'s question',
|
||||||
|
'using ONLY the message excerpts below. If the excerpts do not contain the answer,',
|
||||||
|
'say you don\'t have that information. Cite sources inline as [1], [2], etc.',
|
||||||
|
'Keep the answer concise and friendly.',
|
||||||
|
'',
|
||||||
|
'Message excerpts:',
|
||||||
|
context,
|
||||||
|
'',
|
||||||
|
`Question: ${question}`,
|
||||||
|
'',
|
||||||
|
'Answer:',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackAnswer(citations: Citation[]): string {
|
||||||
|
const top = citations.slice(0, 3).map((c, i) => `[${i + 1}] ${c.snippet}`).join('\n\n');
|
||||||
|
return `Here are the most relevant messages I found:\n\n${top}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,17 @@ export const AuditAction = {
|
|||||||
MEMBER_DELETED: 'MEMBER_DELETED',
|
MEMBER_DELETED: 'MEMBER_DELETED',
|
||||||
OTP_REQUESTED: 'OTP_REQUESTED',
|
OTP_REQUESTED: 'OTP_REQUESTED',
|
||||||
OTP_VERIFIED: 'OTP_VERIFIED',
|
OTP_VERIFIED: 'OTP_VERIFIED',
|
||||||
|
EVENT_CREATED: 'EVENT_CREATED',
|
||||||
|
EVENT_UPDATED: 'EVENT_UPDATED',
|
||||||
|
EVENT_DELETED: 'EVENT_DELETED',
|
||||||
|
DIGEST_SENT: 'DIGEST_SENT',
|
||||||
|
SEVA_CREATED: 'SEVA_CREATED',
|
||||||
|
SEVA_DELETED: 'SEVA_DELETED',
|
||||||
|
SEVA_COMPLETED: 'SEVA_COMPLETED',
|
||||||
|
CIRCLE_CREATED: 'CIRCLE_CREATED',
|
||||||
|
CIRCLE_DELETED: 'CIRCLE_DELETED',
|
||||||
|
DRAFT_APPROVED: 'DRAFT_APPROVED',
|
||||||
|
DRAFT_REJECTED: 'DRAFT_REJECTED',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(req: LoginRequest): Promise<LoginResponse> {
|
async login(req: LoginRequest): Promise<LoginResponse> {
|
||||||
let tenant: { id: string; slug: string } | null = null;
|
let tenant:
|
||||||
|
| { id: string; slug: string; name: string; organization: { id: string; slug: string; name: string } | null }
|
||||||
|
| null = null;
|
||||||
|
const tenantInclude = { organization: { select: { id: true, slug: true, name: true } } } as const;
|
||||||
|
|
||||||
if (req.tenantSlug) {
|
if (req.tenantSlug) {
|
||||||
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
|
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
@@ -48,7 +51,7 @@ export class AuthService {
|
|||||||
'Email is registered in multiple tenants — please specify tenantSlug',
|
'Email is registered in multiple tenants — please specify tenantSlug',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId } });
|
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId }, include: tenantInclude });
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,8 @@ export class AuthService {
|
|||||||
role: admin.role as AdminRole,
|
role: admin.role as AdminRole,
|
||||||
tenantId: admin.tenantId,
|
tenantId: admin.tenantId,
|
||||||
tenantSlug: tenant.slug,
|
tenantSlug: tenant.slug,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
organization: tenant.organization,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,7 +105,7 @@ export class AuthService {
|
|||||||
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
|
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
|
||||||
const admin = await this.prisma.admin.findFirst({
|
const admin = await this.prisma.admin.findFirst({
|
||||||
where: { id: adminId, tenantId },
|
where: { id: adminId, tenantId },
|
||||||
include: { tenant: true },
|
include: { tenant: { include: { organization: { select: { id: true, slug: true, name: true } } } } },
|
||||||
});
|
});
|
||||||
if (!admin) throw new UnauthorizedException('Admin not found');
|
if (!admin) throw new UnauthorizedException('Admin not found');
|
||||||
return {
|
return {
|
||||||
@@ -110,6 +115,8 @@ export class AuthService {
|
|||||||
role: admin.role as AdminRole,
|
role: admin.role as AdminRole,
|
||||||
tenantId: admin.tenantId,
|
tenantId: admin.tenantId,
|
||||||
tenantSlug: admin.tenant.slug,
|
tenantSlug: admin.tenant.slug,
|
||||||
|
tenantName: admin.tenant.name,
|
||||||
|
organization: admin.tenant.organization,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,6 +199,8 @@ export class AuthService {
|
|||||||
role: PrismaAdminRole.OWNER,
|
role: PrismaAdminRole.OWNER,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
tenantSlug: tenant.slug,
|
tenantSlug: tenant.slug,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
organization: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { CirclesService } from './circles.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
@Controller('admin/circles')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class CirclesController {
|
||||||
|
constructor(private readonly service: CirclesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.listAdmin(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: { name: string; description?: string; isPublic?: boolean },
|
||||||
|
) {
|
||||||
|
return this.service.create(ctx.tenantId, ctx.adminId!, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { name?: string; description?: string; isPublic?: boolean },
|
||||||
|
) {
|
||||||
|
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/members')
|
||||||
|
members(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.listMembers(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CirclesController } from './circles.controller';
|
||||||
|
import { CirclesService } from './circles.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [CirclesController],
|
||||||
|
providers: [CirclesService],
|
||||||
|
exports: [CirclesService],
|
||||||
|
})
|
||||||
|
export class CirclesModule {}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CirclesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Admin ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listAdmin(tenantId: string) {
|
||||||
|
return this.prisma.circle.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { _count: { select: { memberships: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, adminId: string, dto: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}) {
|
||||||
|
const existing = await this.prisma.circle.findFirst({ where: { tenantId, name: dto.name } });
|
||||||
|
if (existing) throw new BadRequestException('A circle with this name already exists');
|
||||||
|
|
||||||
|
const circle = await this.prisma.circle.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
isPublic: dto.isPublic ?? true,
|
||||||
|
createdBy: adminId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.CIRCLE_CREATED,
|
||||||
|
resourceType: 'Circle',
|
||||||
|
resourceId: circle.id,
|
||||||
|
payload: { name: circle.name },
|
||||||
|
});
|
||||||
|
return circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, adminId: string, id: string, dto: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}) {
|
||||||
|
const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!circle) throw new NotFoundException('Circle not found');
|
||||||
|
return this.prisma.circle.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
...(dto.isPublic !== undefined && { isPublic: dto.isPublic }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, adminId: string, id: string) {
|
||||||
|
const circle = await this.prisma.circle.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!circle) throw new NotFoundException('Circle not found');
|
||||||
|
await this.prisma.circle.delete({ where: { id } });
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.CIRCLE_DELETED,
|
||||||
|
resourceType: 'Circle',
|
||||||
|
resourceId: id,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMembers(tenantId: string, circleId: string) {
|
||||||
|
const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } });
|
||||||
|
if (!circle) throw new NotFoundException('Circle not found');
|
||||||
|
return this.prisma.circleMembership.findMany({
|
||||||
|
where: { circleId },
|
||||||
|
include: { user: { select: { id: true, displayName: true, jid: true } } },
|
||||||
|
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Member ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listForMember(userId: string, tenantId: string) {
|
||||||
|
const circles = await this.prisma.circle.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
// public circles, plus any private circle the member already belongs to
|
||||||
|
OR: [{ isPublic: true }, { memberships: { some: { userId } } }],
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: {
|
||||||
|
memberships: { where: { userId }, select: { role: true } },
|
||||||
|
_count: { select: { memberships: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return circles.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
description: c.description,
|
||||||
|
isPublic: c.isPublic,
|
||||||
|
memberCount: c._count.memberships,
|
||||||
|
isMember: c.memberships.length > 0,
|
||||||
|
myRole: c.memberships[0]?.role ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async join(userId: string, tenantId: string, circleId: string) {
|
||||||
|
const circle = await this.prisma.circle.findFirst({ where: { id: circleId, tenantId } });
|
||||||
|
if (!circle) throw new NotFoundException('Circle not found');
|
||||||
|
if (!circle.isPublic) {
|
||||||
|
const existing = await this.prisma.circleMembership.findUnique({
|
||||||
|
where: { circleId_userId: { circleId, userId } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new ForbiddenException('This circle is invite-only');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await this.prisma.circleMembership.upsert({
|
||||||
|
where: { circleId_userId: { circleId, userId } },
|
||||||
|
create: { circleId, userId, role: 'MEMBER' },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return { ok: true, membershipId: membership.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(userId: string, tenantId: string, circleId: string) {
|
||||||
|
const membership = await this.prisma.circleMembership.findFirst({
|
||||||
|
where: { circleId, userId, circle: { tenantId } },
|
||||||
|
});
|
||||||
|
if (!membership) throw new NotFoundException('You are not a member of this circle');
|
||||||
|
await this.prisma.circleMembership.delete({ where: { id: membership.id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
|
import { DigestService } from './digest.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
import { UpdateDigestConfigDto } from './digest.dto';
|
||||||
|
|
||||||
|
@Controller('admin/digest')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class DigestController {
|
||||||
|
constructor(private readonly digestService: DigestService) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
getConfig(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.digestService.getConfig(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('config')
|
||||||
|
upsertConfig(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: UpdateDigestConfigDto,
|
||||||
|
) {
|
||||||
|
return this.digestService.upsertConfig(ctx.tenantId, body, ctx.adminId ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('config')
|
||||||
|
disableConfig(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.digestService.disableConfig(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('history')
|
||||||
|
getHistory(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.digestService.getHistory(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('send-now')
|
||||||
|
sendNow(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.digestService.sendNow(ctx.tenantId, ctx.adminId ?? 'unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateDigestConfigDto {
|
||||||
|
@IsString()
|
||||||
|
targetGroupJid: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
targetAccountId: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(23)
|
||||||
|
scheduleHour: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(59)
|
||||||
|
scheduleMinute: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DigestController } from './digest.controller';
|
||||||
|
import { DigestService } from './digest.service';
|
||||||
|
import { digestQueueProvider } from '../../queues/digest.queue';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DigestController],
|
||||||
|
providers: [DigestService, digestQueueProvider],
|
||||||
|
})
|
||||||
|
export class DigestModule {}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { DigestJobData } from '@tower/types';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
import { DIGEST_QUEUE } from '../../queues/digest.queue';
|
||||||
|
import { UpdateDigestConfigDto } from './digest.dto';
|
||||||
|
|
||||||
|
function todayMidnightUtc(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DigestService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
@Inject(DIGEST_QUEUE) private readonly digestQueue: Queue<DigestJobData>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getConfig(tenantId: string) {
|
||||||
|
return this.prisma.digestConfig.findUnique({ where: { tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertConfig(tenantId: string, dto: UpdateDigestConfigDto, adminId: string) {
|
||||||
|
const config = await this.prisma.digestConfig.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
targetGroupJid: dto.targetGroupJid,
|
||||||
|
targetAccountId: dto.targetAccountId,
|
||||||
|
scheduleHour: dto.scheduleHour,
|
||||||
|
scheduleMinute: dto.scheduleMinute,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
targetGroupJid: dto.targetGroupJid,
|
||||||
|
targetAccountId: dto.targetAccountId,
|
||||||
|
scheduleHour: dto.scheduleHour,
|
||||||
|
scheduleMinute: dto.scheduleMinute,
|
||||||
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.DIGEST_SENT,
|
||||||
|
resourceType: 'DigestConfig',
|
||||||
|
resourceId: config.id,
|
||||||
|
payload: { targetGroupJid: dto.targetGroupJid, scheduleHour: dto.scheduleHour, scheduleMinute: dto.scheduleMinute },
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableConfig(tenantId: string): Promise<void> {
|
||||||
|
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
|
||||||
|
if (!config) throw new NotFoundException('Digest config not found');
|
||||||
|
await this.prisma.digestConfig.update({ where: { tenantId }, data: { isActive: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(tenantId: string) {
|
||||||
|
const digests = await this.prisma.digest.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { digestDate: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
return digests.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
digestDate: d.digestDate.toISOString(),
|
||||||
|
messageCount: (d.messageIds as string[]).length,
|
||||||
|
sentAt: d.sentAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNow(tenantId: string, adminId: string): Promise<{ status: string }> {
|
||||||
|
const config = await this.prisma.digestConfig.findUnique({ where: { tenantId } });
|
||||||
|
if (!config) throw new NotFoundException('Digest config not found — configure via PUT /admin/digest/config first');
|
||||||
|
|
||||||
|
await this.digestQueue.add('digest', {
|
||||||
|
tenantId,
|
||||||
|
digestDate: todayMidnightUtc().toISOString(),
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
}, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.DIGEST_SENT,
|
||||||
|
resourceType: 'Digest',
|
||||||
|
resourceId: tenantId,
|
||||||
|
payload: { triggeredBy: 'manual' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'queued' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Controller, Get, Post, Param, Query, UseGuards, Request } from '@nestjs/common';
|
||||||
|
import { DraftsService } from './drafts.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { DraftStatus, DraftType } from '@prisma/client';
|
||||||
|
|
||||||
|
@Controller('admin/drafts')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class DraftsController {
|
||||||
|
constructor(private readonly service: DraftsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('type') type?: DraftType,
|
||||||
|
@Query('status') status?: DraftStatus,
|
||||||
|
) {
|
||||||
|
return this.service.list(req.user.tenantId, type, status ?? 'PENDING_REVIEW');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
approve(@Param('id') id: string, @Request() req: any) {
|
||||||
|
return this.service.approve(id, req.user.tenantId, req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
reject(@Param('id') id: string, @Request() req: any) {
|
||||||
|
return this.service.reject(id, req.user.tenantId, req.user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DraftsController } from './drafts.controller';
|
||||||
|
import { DraftsService } from './drafts.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, AuditModule],
|
||||||
|
controllers: [DraftsController],
|
||||||
|
providers: [DraftsService],
|
||||||
|
})
|
||||||
|
export class DraftsModule {}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { DraftStatus, DraftType } from '@prisma/client';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
|
||||||
|
export interface ApproveDraftResult {
|
||||||
|
publishedId: string;
|
||||||
|
type: DraftType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DraftsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(tenantId: string, type?: DraftType, status: DraftStatus = 'PENDING_REVIEW') {
|
||||||
|
return this.prisma.contentDraft.findMany({
|
||||||
|
where: { tenantId, ...(type ? { type } : {}), status },
|
||||||
|
include: {
|
||||||
|
sourceMessage: {
|
||||||
|
select: { id: true, content: true, senderName: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(draftId: string, tenantId: string, adminId: string): Promise<ApproveDraftResult> {
|
||||||
|
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
|
||||||
|
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
|
||||||
|
if (draft.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new BadRequestException('Draft has already been reviewed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposed = draft.proposedJson as Record<string, any>;
|
||||||
|
let publishedId: string;
|
||||||
|
|
||||||
|
if (draft.type === 'EVENT') {
|
||||||
|
const startsAt = proposed.date
|
||||||
|
? new Date(`${proposed.date}T${proposed.time ?? '00:00'}:00`)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
const event = await this.prisma.event.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
title: proposed.title ?? 'Untitled Event',
|
||||||
|
description: proposed.description ?? null,
|
||||||
|
location: proposed.location ?? null,
|
||||||
|
startsAt,
|
||||||
|
createdBy: adminId,
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
publishedId = event.id;
|
||||||
|
|
||||||
|
} else if (draft.type === 'SEVA_TASK') {
|
||||||
|
const startsAt = proposed.date ? new Date(`${proposed.date}T00:00:00`) : undefined;
|
||||||
|
const seva = await this.prisma.sevaOpportunity.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
title: proposed.title ?? 'Seva Opportunity',
|
||||||
|
description: proposed.parentEventTitle
|
||||||
|
? `Part of: ${proposed.parentEventTitle}`
|
||||||
|
: (proposed.description ?? null),
|
||||||
|
slots: proposed.slots ?? null,
|
||||||
|
startsAt: startsAt ?? null,
|
||||||
|
createdBy: adminId,
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
publishedId = seva.id;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// FAQ_ITEM — find or create a default board
|
||||||
|
let board = await this.prisma.knowledgeBoard.findFirst({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
if (!board) {
|
||||||
|
board = await this.prisma.knowledgeBoard.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: 'Community FAQs',
|
||||||
|
slug: 'community-faqs',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const item = await this.prisma.knowledgeItem.create({
|
||||||
|
data: {
|
||||||
|
boardId: board.id,
|
||||||
|
tenantId,
|
||||||
|
question: proposed.question ?? 'Untitled question',
|
||||||
|
answer: proposed.answer ?? '',
|
||||||
|
tags: proposed.tags ?? [],
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
createdBy: adminId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
publishedId = item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.contentDraft.update({
|
||||||
|
where: { id: draftId },
|
||||||
|
data: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
reviewedBy: adminId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
publishedId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorType: 'ADMIN',
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.DRAFT_APPROVED,
|
||||||
|
resourceType: 'ContentDraft',
|
||||||
|
resourceId: draftId,
|
||||||
|
payload: { type: draft.type, publishedId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { publishedId, type: draft.type };
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(draftId: string, tenantId: string, adminId: string): Promise<void> {
|
||||||
|
const draft = await this.prisma.contentDraft.findUnique({ where: { id: draftId } });
|
||||||
|
if (!draft || draft.tenantId !== tenantId) throw new NotFoundException('Draft not found');
|
||||||
|
if (draft.status !== 'PENDING_REVIEW') {
|
||||||
|
throw new BadRequestException('Draft has already been reviewed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.contentDraft.update({
|
||||||
|
where: { id: draftId },
|
||||||
|
data: { status: 'REJECTED', reviewedBy: adminId, reviewedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorType: 'ADMIN',
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.DRAFT_REJECTED,
|
||||||
|
resourceType: 'ContentDraft',
|
||||||
|
resourceId: draftId,
|
||||||
|
payload: { type: draft.type },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { EventsService } from './events.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
@Controller('admin/events')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class EventsController {
|
||||||
|
constructor(private readonly service: EventsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.list(ctx.tenantId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.service.create(ctx.tenantId, ctx.adminId!, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
startsAt?: string;
|
||||||
|
endsAt?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/rsvps')
|
||||||
|
rsvps(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.getRsvps(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EventsController } from './events.controller';
|
||||||
|
import { EventsService } from './events.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [EventsController],
|
||||||
|
providers: [EventsService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(tenantId: string, includeUnpublished = false) {
|
||||||
|
return this.prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
...(includeUnpublished ? {} : { isPublished: true }),
|
||||||
|
},
|
||||||
|
orderBy: { startsAt: 'asc' },
|
||||||
|
include: { _count: { select: { rsvps: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, adminId: string, dto: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
}) {
|
||||||
|
const event = await this.prisma.event.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
location: dto.location ?? null,
|
||||||
|
startsAt: new Date(dto.startsAt),
|
||||||
|
endsAt: dto.endsAt ? new Date(dto.endsAt) : null,
|
||||||
|
createdBy: adminId,
|
||||||
|
isPublished: dto.isPublished ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.EVENT_CREATED,
|
||||||
|
resourceType: 'Event',
|
||||||
|
resourceId: event.id,
|
||||||
|
payload: { title: event.title, isPublished: event.isPublished },
|
||||||
|
});
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, adminId: string, eventId: string, dto: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
startsAt?: string;
|
||||||
|
endsAt?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
}) {
|
||||||
|
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
|
||||||
|
if (!event) throw new NotFoundException('Event not found');
|
||||||
|
|
||||||
|
return this.prisma.event.update({
|
||||||
|
where: { id: eventId },
|
||||||
|
data: {
|
||||||
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
...(dto.location !== undefined && { location: dto.location }),
|
||||||
|
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
|
||||||
|
...(dto.endsAt !== undefined && { endsAt: new Date(dto.endsAt) }),
|
||||||
|
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, adminId: string, eventId: string) {
|
||||||
|
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
|
||||||
|
if (!event) throw new NotFoundException('Event not found');
|
||||||
|
await this.prisma.event.delete({ where: { id: eventId } });
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.EVENT_DELETED,
|
||||||
|
resourceType: 'Event',
|
||||||
|
resourceId: eventId,
|
||||||
|
payload: { deleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRsvps(tenantId: string, eventId: string) {
|
||||||
|
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId } });
|
||||||
|
if (!event) throw new NotFoundException('Event not found');
|
||||||
|
|
||||||
|
return this.prisma.eventRsvp.findMany({
|
||||||
|
where: { eventId },
|
||||||
|
include: { user: { select: { id: true, displayName: true, jid: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { GalleryService } from './gallery.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
@Controller('admin/gallery')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class GalleryController {
|
||||||
|
constructor(private readonly service: GalleryService) {}
|
||||||
|
|
||||||
|
@Get('albums')
|
||||||
|
listAlbums(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.listAlbumsAdmin(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('albums')
|
||||||
|
createAlbum(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: { title: string; description?: string; coverUrl?: string; isPublished?: boolean },
|
||||||
|
) {
|
||||||
|
return this.service.createAlbum(ctx.tenantId, ctx.adminId!, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('albums/:id')
|
||||||
|
updateAlbum(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { title?: string; description?: string; coverUrl?: string; isPublished?: boolean },
|
||||||
|
) {
|
||||||
|
return this.service.updateAlbum(ctx.tenantId, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('albums/:id')
|
||||||
|
removeAlbum(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.removeAlbum(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('albums/:id/items')
|
||||||
|
addItem(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { mediaUrl: string; caption?: string; sortOrder?: number },
|
||||||
|
) {
|
||||||
|
return this.service.addItem(ctx.tenantId, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('items/:itemId')
|
||||||
|
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
|
||||||
|
return this.service.removeItem(ctx.tenantId, itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GalleryController } from './gallery.controller';
|
||||||
|
import { GalleryService } from './gallery.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [GalleryController],
|
||||||
|
providers: [GalleryService],
|
||||||
|
exports: [GalleryService],
|
||||||
|
})
|
||||||
|
export class GalleryModule {}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GalleryService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ── Admin: albums ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listAlbumsAdmin(tenantId: string) {
|
||||||
|
return this.prisma.galleryAlbum.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { _count: { select: { items: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlbum(tenantId: string, adminId: string, dto: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
}) {
|
||||||
|
return this.prisma.galleryAlbum.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
coverUrl: dto.coverUrl ?? null,
|
||||||
|
isPublished: dto.isPublished ?? false,
|
||||||
|
createdBy: adminId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAlbum(tenantId: string, id: string, dto: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
}) {
|
||||||
|
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!album) throw new NotFoundException('Album not found');
|
||||||
|
return this.prisma.galleryAlbum.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
...(dto.coverUrl !== undefined && { coverUrl: dto.coverUrl }),
|
||||||
|
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAlbum(tenantId: string, id: string) {
|
||||||
|
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!album) throw new NotFoundException('Album not found');
|
||||||
|
await this.prisma.galleryAlbum.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: items ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async addItem(tenantId: string, albumId: string, dto: { mediaUrl: string; caption?: string; sortOrder?: number }) {
|
||||||
|
const album = await this.prisma.galleryAlbum.findFirst({ where: { id: albumId, tenantId } });
|
||||||
|
if (!album) throw new NotFoundException('Album not found');
|
||||||
|
return this.prisma.galleryItem.create({
|
||||||
|
data: {
|
||||||
|
albumId,
|
||||||
|
tenantId,
|
||||||
|
mediaUrl: dto.mediaUrl,
|
||||||
|
caption: dto.caption ?? null,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(tenantId: string, itemId: string) {
|
||||||
|
const item = await this.prisma.galleryItem.findFirst({ where: { id: itemId, tenantId } });
|
||||||
|
if (!item) throw new NotFoundException('Item not found');
|
||||||
|
await this.prisma.galleryItem.delete({ where: { id: itemId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Member: read published albums ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listForMember(tenantId: string) {
|
||||||
|
const albums = await this.prisma.galleryAlbum.findMany({
|
||||||
|
where: { tenantId, isPublished: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { _count: { select: { items: true } } },
|
||||||
|
});
|
||||||
|
return albums.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
description: a.description,
|
||||||
|
coverUrl: a.coverUrl,
|
||||||
|
itemCount: a._count.items,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumForMember(tenantId: string, albumId: string) {
|
||||||
|
const album = await this.prisma.galleryAlbum.findFirst({
|
||||||
|
where: { id: albumId, tenantId, isPublished: true },
|
||||||
|
include: { items: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] } },
|
||||||
|
});
|
||||||
|
if (!album) throw new NotFoundException('Album not found');
|
||||||
|
return {
|
||||||
|
id: album.id,
|
||||||
|
title: album.title,
|
||||||
|
description: album.description,
|
||||||
|
items: album.items.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
mediaUrl: i.mediaUrl,
|
||||||
|
caption: i.caption,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GamificationService } from './gamification.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [GamificationService],
|
||||||
|
exports: [GamificationService],
|
||||||
|
})
|
||||||
|
export class GamificationModule {}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma, GamificationEventType } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { POINTS, decayFactor } from './gamification.types';
|
||||||
|
|
||||||
|
interface AwardOptions {
|
||||||
|
points?: number;
|
||||||
|
refType?: string;
|
||||||
|
refId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GamificationService {
|
||||||
|
private readonly logger = new Logger(GamificationService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award points for an event. Idempotent per (userId, type, refId): if an award
|
||||||
|
* for the same source already exists, this is a no-op (returns null).
|
||||||
|
*/
|
||||||
|
async award(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
type: GamificationEventType,
|
||||||
|
opts: AwardOptions = {},
|
||||||
|
) {
|
||||||
|
const points = opts.points ?? POINTS[type];
|
||||||
|
try {
|
||||||
|
return await this.prisma.gamificationEvent.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
points,
|
||||||
|
refType: opts.refType ?? null,
|
||||||
|
refId: opts.refId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||||
|
// Already awarded for this source — idempotent skip.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserScore(userId: string) {
|
||||||
|
const events = await this.prisma.gamificationEvent.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { type: true, points: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let lifetime = 0;
|
||||||
|
let recent = 0;
|
||||||
|
const byType: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
lifetime += e.points;
|
||||||
|
const ageDays = (now - e.createdAt.getTime()) / 86_400_000;
|
||||||
|
recent += e.points * decayFactor(ageDays);
|
||||||
|
byType[e.type] = (byType[e.type] ?? 0) + e.points;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lifetime,
|
||||||
|
recent: Math.round(recent),
|
||||||
|
byType,
|
||||||
|
eventCount: events.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaderboard(tenantId: string, limit = 10) {
|
||||||
|
const grouped = await this.prisma.gamificationEvent.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { tenantId },
|
||||||
|
_sum: { points: true },
|
||||||
|
orderBy: { _sum: { points: 'desc' } },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (grouped.length === 0) return [];
|
||||||
|
|
||||||
|
const users = await this.prisma.towerUser.findMany({
|
||||||
|
where: { id: { in: grouped.map((g) => g.userId) } },
|
||||||
|
select: { id: true, displayName: true, directoryVisible: true },
|
||||||
|
});
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
return grouped.map((g, i) => {
|
||||||
|
const u = userMap.get(g.userId);
|
||||||
|
return {
|
||||||
|
rank: i + 1,
|
||||||
|
userId: g.userId,
|
||||||
|
displayName: u?.directoryVisible ? (u.displayName ?? 'Member') : 'Member',
|
||||||
|
points: g._sum.points ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { GamificationEventType } from '@prisma/client';
|
||||||
|
|
||||||
|
// Default point award per event type. Multi-signal — seva is one of many sources.
|
||||||
|
export const POINTS: Record<GamificationEventType, number> = {
|
||||||
|
HELPFUL_ANSWER: 10,
|
||||||
|
SEVA_COMPLETED: 20,
|
||||||
|
EVENT_ATTENDANCE: 5,
|
||||||
|
FAQ_APPROVED: 15,
|
||||||
|
WELCOME: 3,
|
||||||
|
PROFILE_COMPLETED: 5,
|
||||||
|
BUSINESS_ADDED: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Time-decay weighting for the "current standing" score (lifetime points are never decayed).
|
||||||
|
export function decayFactor(ageDays: number): number {
|
||||||
|
if (ageDays <= 30) return 1;
|
||||||
|
if (ageDays <= 90) return 0.5;
|
||||||
|
return 0.25;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { KnowledgeService } from './knowledge.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
type ItemStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
|
||||||
|
@Controller('admin/knowledge')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class KnowledgeController {
|
||||||
|
constructor(private readonly service: KnowledgeService) {}
|
||||||
|
|
||||||
|
@Get('boards')
|
||||||
|
listBoards(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.listBoardsAdmin(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('boards')
|
||||||
|
createBoard(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: { name: string; description?: string; sortOrder?: number },
|
||||||
|
) {
|
||||||
|
return this.service.createBoard(ctx.tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('boards/:id')
|
||||||
|
updateBoard(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { name?: string; description?: string; sortOrder?: number },
|
||||||
|
) {
|
||||||
|
return this.service.updateBoard(ctx.tenantId, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('boards/:id')
|
||||||
|
removeBoard(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.removeBoard(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('boards/:id/items')
|
||||||
|
listItems(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.listItemsAdmin(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('boards/:id/items')
|
||||||
|
createItem(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { question: string; answer: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
|
||||||
|
) {
|
||||||
|
return this.service.createItem(ctx.tenantId, ctx.adminId!, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('items/:itemId')
|
||||||
|
updateItem(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('itemId') itemId: string,
|
||||||
|
@Body() body: { question?: string; answer?: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
|
||||||
|
) {
|
||||||
|
return this.service.updateItem(ctx.tenantId, itemId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('items/:itemId')
|
||||||
|
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
|
||||||
|
return this.service.removeItem(ctx.tenantId, itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { KnowledgeController } from './knowledge.controller';
|
||||||
|
import { KnowledgeService } from './knowledge.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [KnowledgeController],
|
||||||
|
providers: [KnowledgeService],
|
||||||
|
exports: [KnowledgeService],
|
||||||
|
})
|
||||||
|
export class KnowledgeModule {}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'board';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KnowledgeService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ── Admin: boards ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listBoardsAdmin(tenantId: string) {
|
||||||
|
return this.prisma.knowledgeBoard.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
include: { _count: { select: { items: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBoard(tenantId: string, dto: { name: string; description?: string; sortOrder?: number }) {
|
||||||
|
let slug = slugify(dto.name);
|
||||||
|
const existing = await this.prisma.knowledgeBoard.findFirst({ where: { tenantId, slug } });
|
||||||
|
if (existing) slug = `${slug}-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
return this.prisma.knowledgeBoard.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
slug,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBoard(tenantId: string, id: string, dto: { name?: string; description?: string; sortOrder?: number }) {
|
||||||
|
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!board) throw new NotFoundException('Board not found');
|
||||||
|
return this.prisma.knowledgeBoard.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBoard(tenantId: string, id: string) {
|
||||||
|
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!board) throw new NotFoundException('Board not found');
|
||||||
|
await this.prisma.knowledgeBoard.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: items ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listItemsAdmin(tenantId: string, boardId: string) {
|
||||||
|
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
|
||||||
|
if (!board) throw new NotFoundException('Board not found');
|
||||||
|
return this.prisma.knowledgeItem.findMany({
|
||||||
|
where: { boardId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createItem(tenantId: string, adminId: string, boardId: string, dto: {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
tags?: string[];
|
||||||
|
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
sortOrder?: number;
|
||||||
|
}) {
|
||||||
|
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
|
||||||
|
if (!board) throw new NotFoundException('Board not found');
|
||||||
|
return this.prisma.knowledgeItem.create({
|
||||||
|
data: {
|
||||||
|
boardId,
|
||||||
|
tenantId,
|
||||||
|
question: dto.question,
|
||||||
|
answer: dto.answer,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
status: dto.status ?? 'DRAFT',
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
createdBy: adminId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateItem(tenantId: string, itemId: string, dto: {
|
||||||
|
question?: string;
|
||||||
|
answer?: string;
|
||||||
|
tags?: string[];
|
||||||
|
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
sortOrder?: number;
|
||||||
|
}) {
|
||||||
|
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
|
||||||
|
if (!item) throw new NotFoundException('Item not found');
|
||||||
|
return this.prisma.knowledgeItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: {
|
||||||
|
...(dto.question !== undefined && { question: dto.question }),
|
||||||
|
...(dto.answer !== undefined && { answer: dto.answer }),
|
||||||
|
...(dto.tags !== undefined && { tags: dto.tags }),
|
||||||
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
|
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(tenantId: string, itemId: string) {
|
||||||
|
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
|
||||||
|
if (!item) throw new NotFoundException('Item not found');
|
||||||
|
await this.prisma.knowledgeItem.delete({ where: { id: itemId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Member: read published knowledge grouped by board ────────────────────────
|
||||||
|
|
||||||
|
async listForMember(tenantId: string, q?: string) {
|
||||||
|
const boards = await this.prisma.knowledgeBoard.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
...(q && q.trim()
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ question: { contains: q.trim(), mode: 'insensitive' } },
|
||||||
|
{ answer: { contains: q.trim(), mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
select: { id: true, question: true, answer: true, tags: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return boards
|
||||||
|
.filter((b) => b.items.length > 0)
|
||||||
|
.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
description: b.description,
|
||||||
|
items: b.items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { IsArray, IsOptional, IsString } from 'class-validator';
|
||||||
import { MessagesService } from './messages.service';
|
import { MessagesService } from './messages.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
@@ -6,6 +7,10 @@ import { Roles } from '../auth/roles.decorator';
|
|||||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
import { TenantContext } from '../../common/tenant-context';
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
class ApproveMessageDto {
|
||||||
|
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('admin/messages')
|
@Controller('admin/messages')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('OWNER', 'ADMIN')
|
@Roles('OWNER', 'ADMIN')
|
||||||
@@ -22,6 +27,11 @@ export class MessagesController {
|
|||||||
return this.messagesService.pendingCount(ctx.tenantId);
|
return this.messagesService.pendingCount(ctx.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/routes')
|
||||||
|
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
get(
|
get(
|
||||||
@CurrentTenantContext() ctx: TenantContext,
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
@@ -34,8 +44,9 @@ export class MessagesController {
|
|||||||
approve(
|
approve(
|
||||||
@CurrentTenantContext() ctx: TenantContext,
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
@Body() body: ApproveMessageDto,
|
||||||
) {
|
) {
|
||||||
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id);
|
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reindex')
|
@Post('reindex')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { ForwardJobData, IndexJobData } from '@tower/types';
|
import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AuditAction } from '../audit/audit.types';
|
import { AuditAction } from '../audit/audit.types';
|
||||||
@@ -112,7 +112,76 @@ export class MessagesService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
|
async getRoutesForMessage(tenantId: string, messageId: string): Promise<RouteForMessage[]> {
|
||||||
|
const message = await this.prisma.message.findUnique({
|
||||||
|
where: { id: messageId },
|
||||||
|
select: { id: true, tenantId: true, sourceGroupId: true, content: true },
|
||||||
|
});
|
||||||
|
if (!message) throw new NotFoundException('Message not found');
|
||||||
|
if (message.tenantId !== tenantId) throw new NotFoundException('Message not found');
|
||||||
|
|
||||||
|
const syncRoutes = await this.prisma.syncRoute.findMany({
|
||||||
|
where: { sourceGroupId: message.sourceGroupId, isActive: true },
|
||||||
|
include: {
|
||||||
|
targetGroup: { select: { id: true, name: true, isActive: true } },
|
||||||
|
assignedRules: {
|
||||||
|
include: {
|
||||||
|
tenantRule: {
|
||||||
|
select: { matchType: true, matchValue: true, ruleIntent: true, isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return syncRoutes
|
||||||
|
.filter((r: any) => r.targetGroup?.isActive)
|
||||||
|
.map((route: any) => {
|
||||||
|
const activeRules = route.assignedRules
|
||||||
|
.filter((m: any) => m.tenantRule.isActive)
|
||||||
|
.map((m: any) => m.tenantRule);
|
||||||
|
|
||||||
|
let ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE' = 'NONE';
|
||||||
|
|
||||||
|
if (activeRules.length > 0) {
|
||||||
|
const negativeRules = activeRules.filter((r: any) => r.ruleIntent === 'NEGATIVE');
|
||||||
|
const positiveRules = activeRules.filter((r: any) => r.ruleIntent === 'POSITIVE');
|
||||||
|
|
||||||
|
if (negativeRules.length > 0 && this.matchesAny(message.content, negativeRules)) {
|
||||||
|
ruleDecision = 'BLOCK';
|
||||||
|
} else if (positiveRules.length > 0 && this.matchesAny(message.content, positiveRules)) {
|
||||||
|
ruleDecision = 'FORWARD';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routeId: route.id,
|
||||||
|
targetGroupId: route.targetGroup.id,
|
||||||
|
targetGroupName: route.targetGroup.name,
|
||||||
|
approvalMode: route.approvalMode,
|
||||||
|
forwardFormat: route.forwardFormat,
|
||||||
|
withAttachment: route.withAttachment,
|
||||||
|
frequency: route.frequency,
|
||||||
|
ruleDecision,
|
||||||
|
} as RouteForMessage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesAny(content: string, rules: Array<{ matchType: string; matchValue: string }>): boolean {
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.matchType === 'HASHTAG') {
|
||||||
|
const escaped = rule.matchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
if (new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i').test(content)) return true;
|
||||||
|
} else if (rule.matchType === 'PREFIX') {
|
||||||
|
if (content.trimStart().startsWith(rule.matchValue)) return true;
|
||||||
|
} else if (rule.matchType === 'REACTION_EMOJI') {
|
||||||
|
if (content.includes(rule.matchValue)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(tenantId: string, adminId: string, messageId: string, targetGroupIds?: string[]): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
|
||||||
const message = await this.prisma.message.findUnique({
|
const message = await this.prisma.message.findUnique({
|
||||||
where: { id: messageId },
|
where: { id: messageId },
|
||||||
include: {
|
include: {
|
||||||
@@ -160,10 +229,14 @@ export class MessagesService {
|
|||||||
throw new ConflictException('Message could not be approved (concurrent update)');
|
throw new ConflictException('Message could not be approved (concurrent update)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
|
const allRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
|
||||||
(r: any) => r.targetGroup != null,
|
(r: any) => r.targetGroup != null,
|
||||||
);
|
);
|
||||||
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({
|
const selectedRoutes = targetGroupIds
|
||||||
|
? allRoutes.filter((r: any) => targetGroupIds.includes(r.targetGroupId))
|
||||||
|
: allRoutes;
|
||||||
|
|
||||||
|
const forwardJobs: ForwardJobData[] = selectedRoutes.map((route: any) => ({
|
||||||
tenantId: message.tenantId,
|
tenantId: message.tenantId,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
@@ -204,6 +277,7 @@ export class MessagesService {
|
|||||||
resourceId: message.id,
|
resourceId: message.id,
|
||||||
payload: {
|
payload: {
|
||||||
routesForwarded: forwardJobs.length,
|
routesForwarded: forwardJobs.length,
|
||||||
|
targetGroupIds: targetGroupIds ?? null,
|
||||||
contentPreview: message.content.slice(0, 80),
|
contentPreview: message.content.slice(0, 80),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { MyService } from './my.service';
|
import { MyService } from './my.service';
|
||||||
|
import { SevaService } from '../seva/seva.service';
|
||||||
|
import { CirclesService } from '../circles/circles.service';
|
||||||
|
import { AskAIService } from '../ask-ai/ask-ai.service';
|
||||||
|
import { KnowledgeService } from '../knowledge/knowledge.service';
|
||||||
|
import { GalleryService } from '../gallery/gallery.service';
|
||||||
import { MemberAuth } from '../auth/member-auth.decorator';
|
import { MemberAuth } from '../auth/member-auth.decorator';
|
||||||
import { CurrentMember } from '../auth/current-member.decorator';
|
import { CurrentMember } from '../auth/current-member.decorator';
|
||||||
import type { MemberJwtPayload } from '@tower/types';
|
import type { MemberJwtPayload } from '@tower/types';
|
||||||
@@ -22,13 +27,126 @@ class OptInDto {
|
|||||||
@Controller('my')
|
@Controller('my')
|
||||||
@MemberAuth()
|
@MemberAuth()
|
||||||
export class MyController {
|
export class MyController {
|
||||||
constructor(private readonly service: MyService) {}
|
constructor(
|
||||||
|
private readonly service: MyService,
|
||||||
|
private readonly seva: SevaService,
|
||||||
|
private readonly circles: CirclesService,
|
||||||
|
private readonly askAI: AskAIService,
|
||||||
|
private readonly knowledge: KnowledgeService,
|
||||||
|
private readonly gallery: GalleryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('knowledge')
|
||||||
|
knowledgeList(@CurrentMember() member: MemberJwtPayload, @Query('q') q?: string) {
|
||||||
|
return this.knowledge.listForMember(member.tenantId, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('memories')
|
||||||
|
memoriesList(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.gallery.listForMember(member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('memories/:id')
|
||||||
|
memoriesAlbum(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.gallery.getAlbumForMember(member.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ask')
|
||||||
|
askHistory(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.askAI.history(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ask')
|
||||||
|
ask(@CurrentMember() member: MemberJwtPayload, @Body() body: { question: string }) {
|
||||||
|
return this.askAI.ask(member.sub, member.tenantId, body.question);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('seva')
|
||||||
|
sevaList(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.seva.listForMember(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('seva/:id/signup')
|
||||||
|
sevaSignup(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.seva.signup(member.sub, member.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('seva/:id/cancel')
|
||||||
|
sevaCancel(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.seva.cancel(member.sub, member.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('directory')
|
||||||
|
directory(
|
||||||
|
@CurrentMember() member: MemberJwtPayload,
|
||||||
|
@Query('q') q?: string,
|
||||||
|
@Query('interest') interest?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getDirectory(member.sub, member.tenantId, q, interest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('circles')
|
||||||
|
circlesList(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.circles.listForMember(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('circles/:id/join')
|
||||||
|
circleJoin(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.circles.join(member.sub, member.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('circles/:id/leave')
|
||||||
|
circleLeave(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.circles.leave(member.sub, member.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
dashboard(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.getDashboard(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('scope')
|
||||||
|
scope(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.getScope(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('digest')
|
||||||
|
digests(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.getDigests(member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('events')
|
||||||
|
listEvents(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.listEvents(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('events/:id/rsvp')
|
||||||
|
rsvp(
|
||||||
|
@CurrentMember() member: MemberJwtPayload,
|
||||||
|
@Param('id') eventId: string,
|
||||||
|
@Body() body: { status: 'GOING' | 'NOT_GOING' | 'MAYBE'; note?: string },
|
||||||
|
) {
|
||||||
|
return this.service.upsertRsvp(member.sub, member.tenantId, eventId, body.status, body.note);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
profile(@CurrentMember() member: MemberJwtPayload) {
|
profile(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.getProfile(member.sub, member.tenantId);
|
return this.service.getProfile(member.sub, member.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('profile')
|
||||||
|
updateProfile(@CurrentMember() member: MemberJwtPayload, @Body() body: {
|
||||||
|
displayName?: string;
|
||||||
|
hometown?: string;
|
||||||
|
currentLocation?: string;
|
||||||
|
interests?: string[];
|
||||||
|
language?: string;
|
||||||
|
digestPreference?: string;
|
||||||
|
directoryVisible?: boolean;
|
||||||
|
}) {
|
||||||
|
return this.service.updateProfile(member.sub, member.tenantId, body);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('groups')
|
@Get('groups')
|
||||||
listGroups(@CurrentMember() member: MemberJwtPayload) {
|
listGroups(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.listGroups(member.sub, member.tenantId);
|
return this.service.listGroups(member.sub, member.tenantId);
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { Module } from '@nestjs/common';
|
|||||||
import { MyController } from './my.controller';
|
import { MyController } from './my.controller';
|
||||||
import { MyService } from './my.service';
|
import { MyService } from './my.service';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SevaModule } from '../seva/seva.module';
|
||||||
|
import { GamificationModule } from '../gamification/gamification.module';
|
||||||
|
import { CirclesModule } from '../circles/circles.module';
|
||||||
|
import { AskAIModule } from '../ask-ai/ask-ai.module';
|
||||||
|
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
||||||
|
import { GalleryModule } from '../gallery/gallery.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule, GalleryModule],
|
||||||
controllers: [MyController],
|
controllers: [MyController],
|
||||||
providers: [MyService],
|
providers: [MyService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Unauthorize
|
|||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AuditAction } from '../audit/audit.types';
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
|
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
|
||||||
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
|
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export class MyService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly gamification: GamificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
|
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
|
||||||
@@ -26,6 +28,227 @@ export class MyService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listEvents(userId: string, tenantId: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const events = await this.prisma.event.findMany({
|
||||||
|
where: { tenantId, isPublished: true, startsAt: { gte: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) } },
|
||||||
|
orderBy: { startsAt: 'asc' },
|
||||||
|
take: 20,
|
||||||
|
include: {
|
||||||
|
rsvps: {
|
||||||
|
where: { userId },
|
||||||
|
select: { status: true },
|
||||||
|
},
|
||||||
|
_count: { select: { rsvps: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
description: e.description,
|
||||||
|
location: e.location,
|
||||||
|
startsAt: e.startsAt.toISOString(),
|
||||||
|
endsAt: e.endsAt?.toISOString() ?? null,
|
||||||
|
rsvpCount: e._count.rsvps,
|
||||||
|
myRsvp: e.rsvps[0]?.status ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRsvp(userId: string, tenantId: string, eventId: string, status: 'GOING' | 'NOT_GOING' | 'MAYBE', note?: string) {
|
||||||
|
const event = await this.prisma.event.findFirst({ where: { id: eventId, tenantId, isPublished: true } });
|
||||||
|
if (!event) throw new NotFoundException('Event not found');
|
||||||
|
|
||||||
|
const rsvp = await this.prisma.eventRsvp.upsert({
|
||||||
|
where: { eventId_userId: { eventId, userId } },
|
||||||
|
create: { eventId, userId, status, note: note ?? null },
|
||||||
|
update: { status, note: note ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, rsvpId: rsvp.id, status: rsvp.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(userId: string, tenantId: string, body: {
|
||||||
|
displayName?: string;
|
||||||
|
hometown?: string;
|
||||||
|
currentLocation?: string;
|
||||||
|
interests?: string[];
|
||||||
|
language?: string;
|
||||||
|
digestPreference?: string;
|
||||||
|
directoryVisible?: boolean;
|
||||||
|
}) {
|
||||||
|
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
const updated = await this.prisma.towerUser.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
...(body.displayName !== undefined && { displayName: body.displayName }),
|
||||||
|
...(body.hometown !== undefined && { hometown: body.hometown }),
|
||||||
|
...(body.currentLocation !== undefined && { currentLocation: body.currentLocation }),
|
||||||
|
...(body.interests !== undefined && { interests: body.interests }),
|
||||||
|
...(body.language !== undefined && { language: body.language }),
|
||||||
|
...(body.digestPreference !== undefined && { digestPreference: body.digestPreference }),
|
||||||
|
...(body.directoryVisible !== undefined && { directoryVisible: body.directoryVisible }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true, jid: true, displayName: true, avatar: true,
|
||||||
|
hometown: true, currentLocation: true, interests: true,
|
||||||
|
language: true, digestPreference: true, directoryVisible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Award PROFILE_COMPLETED once the profile carries real signal (name + hometown + an interest).
|
||||||
|
// Idempotent via refId=userId, so re-saving never double-awards.
|
||||||
|
if (updated.displayName && updated.hometown && updated.interests.length > 0) {
|
||||||
|
await this.gamification.award(tenantId, userId, 'PROFILE_COMPLETED', {
|
||||||
|
refType: 'TowerUser',
|
||||||
|
refId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDigests(tenantId: string) {
|
||||||
|
const digests = await this.prisma.digest.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { digestDate: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: { id: true, digestDate: true, content: true, messageIds: true, sentAt: true },
|
||||||
|
});
|
||||||
|
return digests.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
digestDate: d.digestDate.toISOString(),
|
||||||
|
summary: d.content.slice(0, 300),
|
||||||
|
messageCount: (d.messageIds as string[]).length,
|
||||||
|
sentAt: d.sentAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDirectory(userId: string, tenantId: string, q?: string, interest?: string) {
|
||||||
|
const where: any = {
|
||||||
|
tenantId,
|
||||||
|
directoryVisible: true,
|
||||||
|
id: { not: userId },
|
||||||
|
};
|
||||||
|
if (interest) where.interests = { has: interest };
|
||||||
|
if (q && q.trim()) {
|
||||||
|
const term = q.trim();
|
||||||
|
where.OR = [
|
||||||
|
{ displayName: { contains: term, mode: 'insensitive' } },
|
||||||
|
{ hometown: { contains: term, mode: 'insensitive' } },
|
||||||
|
{ currentLocation: { contains: term, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [members, allVisible] = await Promise.all([
|
||||||
|
this.prisma.towerUser.findMany({
|
||||||
|
where,
|
||||||
|
take: 100,
|
||||||
|
orderBy: { displayName: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
hometown: true,
|
||||||
|
currentLocation: true,
|
||||||
|
interests: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Aggregate interest filter chips across the whole visible directory.
|
||||||
|
this.prisma.towerUser.findMany({
|
||||||
|
where: { tenantId, directoryVisible: true, id: { not: userId } },
|
||||||
|
select: { interests: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const interestCounts = new Map<string, number>();
|
||||||
|
for (const u of allVisible) {
|
||||||
|
for (const i of u.interests) interestCounts.set(i, (interestCounts.get(i) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const topInterests = [...interestCounts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(([name, count]) => ({ name, count }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: members.length,
|
||||||
|
members: members.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
displayName: m.displayName ?? 'Member',
|
||||||
|
avatar: m.avatar,
|
||||||
|
hometown: m.hometown,
|
||||||
|
currentLocation: m.currentLocation,
|
||||||
|
interests: m.interests,
|
||||||
|
})),
|
||||||
|
interests: topInterests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScope(userId: string, tenantId: string) {
|
||||||
|
const user = await this.prisma.towerUser.findFirst({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
select: {
|
||||||
|
displayName: true,
|
||||||
|
tenant: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
organization: { select: { id: true, slug: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return {
|
||||||
|
member: { displayName: user.displayName },
|
||||||
|
chapter: { id: user.tenant.id, slug: user.tenant.slug, name: user.tenant.name },
|
||||||
|
organization: user.tenant.organization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDashboard(userId: string, tenantId: string) {
|
||||||
|
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
const [activeConsents, messagesCount] = await Promise.all([
|
||||||
|
this.prisma.consentRecord.findMany({
|
||||||
|
where: { userId, tenantId, status: 'GRANTED' },
|
||||||
|
include: { group: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.message.count({ where: { senderTowerUserId: userId, tenantId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
id: user.id,
|
||||||
|
jid: user.jid,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
hometown: user.hometown,
|
||||||
|
currentLocation: user.currentLocation,
|
||||||
|
interests: user.interests,
|
||||||
|
language: user.language,
|
||||||
|
digestPreference: user.digestPreference,
|
||||||
|
directoryVisible: user.directoryVisible,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
groupsJoined: activeConsents.length,
|
||||||
|
messagesContributed: messagesCount,
|
||||||
|
activeConsents: activeConsents.length,
|
||||||
|
},
|
||||||
|
groups: activeConsents.map((c) => ({
|
||||||
|
id: c.group.id,
|
||||||
|
name: c.group.name,
|
||||||
|
joinedAt: c.effectiveAt.toISOString(),
|
||||||
|
consentStatus: c.status,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
|
async listGroups(userId: string, tenantId: string): Promise<MemberGroupSummary[]> {
|
||||||
const consents = await this.prisma.consentRecord.findMany({
|
const consents = await this.prisma.consentRecord.findMany({
|
||||||
where: { userId, tenantId },
|
where: { userId, tenantId },
|
||||||
|
|||||||
@@ -224,6 +224,89 @@ export class OnboardingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> {
|
||||||
|
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
|
||||||
|
const phoneHash = hashPhone(phone, pepper);
|
||||||
|
|
||||||
|
const user = await this.prisma.towerUser.findFirst({
|
||||||
|
where: { phoneHash },
|
||||||
|
select: { id: true, tenantId: true, jid: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException('No portal account found for this number. Use your original invite link to register first.');
|
||||||
|
|
||||||
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
|
||||||
|
const challengeId = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
await this.prisma.otpChallenge.create({
|
||||||
|
data: {
|
||||||
|
id: challengeId,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
jid: user.jid,
|
||||||
|
phoneHash,
|
||||||
|
code,
|
||||||
|
scopes: DEFAULT_SCOPES,
|
||||||
|
retentionDays: DEFAULT_RETENTION_DAYS,
|
||||||
|
policyVersion: this.policyVersion,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
action: AuditAction.OTP_REQUESTED,
|
||||||
|
resourceType: 'TowerUser',
|
||||||
|
resourceId: user.id,
|
||||||
|
payload: { jid: user.jid, source: 'member-login' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { challengeId, expiresInSeconds: OTP_TTL_MIN * 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async memberVerify(challengeId: string, phone: string, code: string): Promise<{
|
||||||
|
memberToken: string;
|
||||||
|
user: { id: string; tenantId: string; jid: string; displayName: string | null };
|
||||||
|
}> {
|
||||||
|
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
|
||||||
|
const phoneHash = hashPhone(phone, pepper);
|
||||||
|
|
||||||
|
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
|
||||||
|
if (!challenge) throw new NotFoundException('Challenge not found');
|
||||||
|
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
|
||||||
|
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Code expired');
|
||||||
|
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
|
||||||
|
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
|
||||||
|
|
||||||
|
await this.prisma.otpChallenge.update({
|
||||||
|
where: { id: challengeId },
|
||||||
|
data: { consumedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await this.prisma.towerUser.findFirst({
|
||||||
|
where: { phoneHash, tenantId: challenge.tenantId },
|
||||||
|
select: { id: true, tenantId: true, jid: true, displayName: true, phoneHash: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
action: AuditAction.OTP_VERIFIED,
|
||||||
|
resourceType: 'TowerUser',
|
||||||
|
resourceId: user.id,
|
||||||
|
payload: { jid: user.jid, source: 'member-login' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberToken = await this.jwt.signAsync({
|
||||||
|
kind: 'member',
|
||||||
|
sub: user.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
jid: user.jid,
|
||||||
|
phoneHash: user.phoneHash,
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName } };
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeJid(jid: string): string {
|
private normalizeJid(jid: string): string {
|
||||||
return jid.trim();
|
return jid.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
import { OnboardingService } from './onboarding.service';
|
import { OnboardingService } from './onboarding.service';
|
||||||
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
import { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||||
import { ConsentScope } from '@tower/types';
|
import { ConsentScope } from '@tower/types';
|
||||||
@@ -18,6 +18,16 @@ class VerifyOtpDto {
|
|||||||
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
|
@IsInt() @Min(1) @IsOptional() retentionDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MemberLoginDto {
|
||||||
|
@IsString() @MinLength(6) phone!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberVerifyDto {
|
||||||
|
@IsString() challengeId!: string;
|
||||||
|
@IsString() @MinLength(6) phone!: string;
|
||||||
|
@IsString() @MinLength(6) code!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('public')
|
@Controller('public')
|
||||||
export class PublicOnboardingController {
|
export class PublicOnboardingController {
|
||||||
constructor(private readonly service: OnboardingService) {}
|
constructor(private readonly service: OnboardingService) {}
|
||||||
@@ -46,4 +56,16 @@ export class PublicOnboardingController {
|
|||||||
body.retentionDays,
|
body.retentionDays,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('auth/member-login')
|
||||||
|
@Public()
|
||||||
|
memberLogin(@Body() body: MemberLoginDto) {
|
||||||
|
return this.service.memberLogin(body.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auth/member-verify')
|
||||||
|
@Public()
|
||||||
|
memberVerify(@Body() body: MemberVerifyDto) {
|
||||||
|
return this.service.memberVerify(body.challengeId, body.phone, body.code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { OrgAdminJwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
export const CurrentOrgAdmin = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): OrgAdminJwtPayload => {
|
||||||
|
return ctx.switchToHttp().getRequest().user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrgAdminGuard implements CanActivate {
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(token);
|
||||||
|
if (payload.kind !== 'orgadmin') throw new UnauthorizedException('Not an org admin');
|
||||||
|
req.user = payload;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { OrgService } from './org.service';
|
||||||
|
import { OrgAdminGuard } from './org-admin.guard';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
|
||||||
|
@Controller('auth/org')
|
||||||
|
export class OrgAuthController {
|
||||||
|
constructor(private readonly orgService: OrgService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() body: { email: string; password: string }) {
|
||||||
|
return this.orgService.login(body.email, body.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(OrgAdminGuard)
|
||||||
|
@Get('me')
|
||||||
|
me(@Req() req: any) {
|
||||||
|
return this.orgService.me(req.user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { OrgService } from './org.service';
|
||||||
|
import { OrgAdminGuard } from './org-admin.guard';
|
||||||
|
import { CurrentOrgAdmin } from './org-admin.decorator';
|
||||||
|
import { OrgAdminJwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
@Controller('org')
|
||||||
|
@UseGuards(OrgAdminGuard)
|
||||||
|
export class OrgController {
|
||||||
|
constructor(private readonly orgService: OrgService) {}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
getDashboard(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
|
||||||
|
return this.orgService.getDashboard(admin.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tenants')
|
||||||
|
getTenants(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
|
||||||
|
return this.orgService.getTenants(admin.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tenants')
|
||||||
|
createTenant(
|
||||||
|
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
|
||||||
|
@Body() body: { name: string; slug: string },
|
||||||
|
) {
|
||||||
|
return this.orgService.createTenant(admin.organizationId, body, admin.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tenants/:id')
|
||||||
|
getTenant(@CurrentOrgAdmin() admin: OrgAdminJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.orgService.getTenant(admin.organizationId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tenants/:id/admins')
|
||||||
|
createTenantAdmin(
|
||||||
|
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { email: string; password: string; role?: string },
|
||||||
|
) {
|
||||||
|
return this.orgService.createTenantAdmin(admin.organizationId, id, body.email, body.password, body.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('rules')
|
||||||
|
getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) {
|
||||||
|
return this.orgService.getRules(admin.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('rules')
|
||||||
|
createRule(
|
||||||
|
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
|
||||||
|
@Body() body: { matchType: string; matchValue: string; action: string; priority?: number },
|
||||||
|
) {
|
||||||
|
return this.orgService.createRule(admin.organizationId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('rules/:id')
|
||||||
|
updateRule(
|
||||||
|
@CurrentOrgAdmin() admin: OrgAdminJwtPayload,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { matchValue?: string; action?: string; priority?: number; isActive?: boolean },
|
||||||
|
) {
|
||||||
|
return this.orgService.updateRule(admin.organizationId, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('rules/:id')
|
||||||
|
deleteRule(@CurrentOrgAdmin() admin: OrgAdminJwtPayload, @Param('id') id: string) {
|
||||||
|
return this.orgService.deleteRule(admin.organizationId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,187 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { OrgAdminJwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrgService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
const admin = await this.prisma.orgAdmin.findFirst({ where: { email } });
|
||||||
|
if (!admin) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
if (!valid) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
const payload: OrgAdminJwtPayload = {
|
||||||
|
kind: 'orgadmin',
|
||||||
|
sub: admin.id,
|
||||||
|
organizationId: admin.organizationId,
|
||||||
|
email: admin.email,
|
||||||
|
role: admin.role as 'ORG_OWNER' | 'ORG_ADMIN',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = this.jwtService.sign(payload, {
|
||||||
|
secret: this.config.get<string>('JWT_SECRET'),
|
||||||
|
expiresIn: '7d',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
orgAdmin: { id: admin.id, email: admin.email, name: admin.name, role: admin.role, organizationId: admin.organizationId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(adminId: string) {
|
||||||
|
const admin = await this.prisma.orgAdmin.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
include: { organization: { select: { id: true, slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new UnauthorizedException('Org admin not found');
|
||||||
|
return { id: admin.id, email: admin.email, name: admin.name, role: admin.role, organization: admin.organization };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDashboard(organizationId: string) {
|
||||||
|
const tenants = await this.prisma.tenant.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
isActive: true,
|
||||||
|
_count: { select: { messages: true, towerUsers: true, admins: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
chapterCount: tenants.length,
|
||||||
|
chapters: tenants.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
slug: t.slug,
|
||||||
|
isActive: t.isActive,
|
||||||
|
messageCount: t._count.messages,
|
||||||
|
memberCount: t._count.towerUsers,
|
||||||
|
adminCount: t._count.admins,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTenants(organizationId: string) {
|
||||||
|
return this.prisma.tenant.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { messages: true, towerUsers: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTenant(organizationId: string, tenantId: string) {
|
||||||
|
const tenant = await this.prisma.tenant.findFirst({
|
||||||
|
where: { id: tenantId, organizationId },
|
||||||
|
include: {
|
||||||
|
admins: { select: { id: true, email: true, role: true, createdAt: true } },
|
||||||
|
_count: { select: { messages: true, towerUsers: true, groups: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!tenant) throw new NotFoundException('Chapter not found');
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTenant(organizationId: string, body: { name: string; slug: string }, role: string) {
|
||||||
|
if (role !== 'ORG_OWNER') throw new ForbiddenException('Only ORG_OWNER can create chapters');
|
||||||
|
|
||||||
|
const existing = await this.prisma.tenant.findUnique({ where: { slug: body.slug } });
|
||||||
|
if (existing) throw new ConflictException('Tenant slug already taken');
|
||||||
|
|
||||||
|
return this.prisma.tenant.create({
|
||||||
|
data: { name: body.name, slug: body.slug, organizationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRules(organizationId: string) {
|
||||||
|
return this.prisma.orgRule.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRule(organizationId: string, body: { matchType: string; matchValue: string; action: string; priority?: number }) {
|
||||||
|
const existing = await this.prisma.orgRule.findUnique({
|
||||||
|
where: { organizationId_matchType_matchValue: { organizationId, matchType: body.matchType as any, matchValue: body.matchValue } },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException('Rule already exists');
|
||||||
|
|
||||||
|
return this.prisma.orgRule.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
matchType: body.matchType as any,
|
||||||
|
matchValue: body.matchValue,
|
||||||
|
action: body.action as any,
|
||||||
|
priority: body.priority ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRule(organizationId: string, ruleId: string, body: { matchValue?: string; action?: string; priority?: number; isActive?: boolean }) {
|
||||||
|
const rule = await this.prisma.orgRule.findFirst({ where: { id: ruleId, organizationId } });
|
||||||
|
if (!rule) throw new NotFoundException('Rule not found');
|
||||||
|
|
||||||
|
return this.prisma.orgRule.update({
|
||||||
|
where: { id: ruleId },
|
||||||
|
data: {
|
||||||
|
...(body.matchValue !== undefined && { matchValue: body.matchValue }),
|
||||||
|
...(body.action !== undefined && { action: body.action as any }),
|
||||||
|
...(body.priority !== undefined && { priority: body.priority }),
|
||||||
|
...(body.isActive !== undefined && { isActive: body.isActive }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRule(organizationId: string, ruleId: string) {
|
||||||
|
const rule = await this.prisma.orgRule.findFirst({ where: { id: ruleId, organizationId } });
|
||||||
|
if (!rule) throw new NotFoundException('Rule not found');
|
||||||
|
await this.prisma.orgRule.delete({ where: { id: ruleId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTenantAdmin(
|
||||||
|
organizationId: string,
|
||||||
|
tenantId: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
role?: string,
|
||||||
|
) {
|
||||||
|
const tenant = await this.prisma.tenant.findFirst({ where: { id: tenantId, organizationId } });
|
||||||
|
if (!tenant) throw new NotFoundException('Chapter not found or does not belong to this org');
|
||||||
|
|
||||||
|
const existing = await this.prisma.admin.findUnique({
|
||||||
|
where: { tenantId_email: { tenantId, email } },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException('An admin with this email already exists for this chapter');
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
return this.prisma.admin.create({
|
||||||
|
data: { tenantId, email, passwordHash, ...(role ? { role: role as any } : {}) },
|
||||||
|
select: { id: true, email: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
import { TenantContext } from '../../common/tenant-context';
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
|
|
||||||
class CreateRouteDto {
|
class CreateRouteDto {
|
||||||
@IsString() sourceGroupId!: string;
|
@IsString() sourceGroupId!: string;
|
||||||
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
|
|||||||
@IsString({ each: true }) targetGroupIds!: string[];
|
@IsString({ each: true }) targetGroupIds!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UpdateRouteDto {
|
||||||
|
@IsEnum(['THREADED', 'DIGEST', 'SUMMARY']) @IsOptional() forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||||
|
@IsBoolean() @IsOptional() withAttachment?: boolean;
|
||||||
|
@IsEnum(['INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS']) @IsOptional() frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||||
|
@IsEnum(['MANUAL', 'AUTO']) @IsOptional() approvalMode?: 'MANUAL' | 'AUTO';
|
||||||
|
@IsBoolean() @IsOptional() isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('routes')
|
@Controller('routes')
|
||||||
export class RoutesController {
|
export class RoutesController {
|
||||||
constructor(private readonly routesService: RoutesService) {}
|
constructor(private readonly routesService: RoutesService) {}
|
||||||
@@ -36,6 +44,35 @@ export class RoutesController {
|
|||||||
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
|
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string, @Body() body: UpdateRouteDto) {
|
||||||
|
return this.routesService.update(ctx.tenantId, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/rules')
|
||||||
|
listRules(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.routesService.listAssignedRules(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/rules/:tenantRuleId')
|
||||||
|
assignRule(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('tenantRuleId') tenantRuleId: string,
|
||||||
|
) {
|
||||||
|
return this.routesService.assignRule(ctx.tenantId, id, tenantRuleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/rules/:tenantRuleId')
|
||||||
|
@HttpCode(204)
|
||||||
|
async unassignRule(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('tenantRuleId') tenantRuleId: string,
|
||||||
|
) {
|
||||||
|
await this.routesService.unassignRule(ctx.tenantId, id, tenantRuleId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
|
|||||||
skipped: string[];
|
skipped: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateRouteInput {
|
||||||
|
forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||||
|
withAttachment?: boolean;
|
||||||
|
frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||||
|
approvalMode?: 'MANUAL' | 'AUTO';
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const routeInclude = {
|
const routeInclude = {
|
||||||
sourceGroup: { select: { name: true, tenantId: true } },
|
sourceGroup: { select: { name: true, tenantId: true } },
|
||||||
targetGroup: { select: { name: true, tenantId: true } },
|
targetGroup: { select: { name: true, tenantId: true } },
|
||||||
@@ -30,11 +38,70 @@ export class RoutesService {
|
|||||||
include: {
|
include: {
|
||||||
sourceGroup: { select: { name: true } },
|
sourceGroup: { select: { name: true } },
|
||||||
targetGroup: { select: { name: true } },
|
targetGroup: { select: { name: true } },
|
||||||
|
assignedRules: {
|
||||||
|
include: {
|
||||||
|
tenantRule: {
|
||||||
|
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(tenantId: string, id: string, dto: UpdateRouteInput) {
|
||||||
|
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!existing) throw new NotFoundException(`Route ${id} not found`);
|
||||||
|
return this.prisma.syncRoute.update({ where: { id }, data: dto as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignRule(tenantId: string, routeId: string, tenantRuleId: string) {
|
||||||
|
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
|
||||||
|
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||||||
|
const rule = await this.prisma.tenantRule.findFirst({ where: { id: tenantRuleId, tenantId } });
|
||||||
|
if (!rule) throw new NotFoundException(`Rule ${tenantRuleId} not found`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.syncRouteRuleMap.create({
|
||||||
|
data: { id: `${routeId}_${tenantRuleId}`.slice(0, 25) + Math.random().toString(36).slice(2, 8), syncRouteId: routeId, tenantRuleId },
|
||||||
|
include: { tenantRule: { select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true } } },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Rule is already assigned to this route');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignRule(tenantId: string, routeId: string, tenantRuleId: string) {
|
||||||
|
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
|
||||||
|
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||||||
|
const map = await this.prisma.syncRouteRuleMap.findUnique({
|
||||||
|
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
|
||||||
|
});
|
||||||
|
if (!map) throw new NotFoundException('Assignment not found');
|
||||||
|
await this.prisma.syncRouteRuleMap.delete({
|
||||||
|
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAssignedRules(tenantId: string, routeId: string) {
|
||||||
|
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
|
||||||
|
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||||||
|
const maps = await this.prisma.syncRouteRuleMap.findMany({
|
||||||
|
where: { syncRouteId: routeId },
|
||||||
|
include: {
|
||||||
|
tenantRule: {
|
||||||
|
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return maps.map((m: any) => ({ ...m.tenantRule, assignedAt: m.createdAt }));
|
||||||
|
}
|
||||||
|
|
||||||
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
|
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
|
||||||
if (!sourceGroupId || !targetGroupId) {
|
if (!sourceGroupId || !targetGroupId) {
|
||||||
throw new BadRequestException('sourceGroupId and targetGroupId are required');
|
throw new BadRequestException('sourceGroupId and targetGroupId are required');
|
||||||
|
|||||||
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
export class RulesService {
|
export class RulesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async list(tenantId: string): Promise<TenantRuleData[]> {
|
private toData(r: any): TenantRuleData {
|
||||||
const rows = await this.prisma.tenantRule.findMany({
|
return {
|
||||||
where: { tenantId },
|
|
||||||
orderBy: { priority: 'asc' },
|
|
||||||
});
|
|
||||||
return rows.map((r: any) => ({
|
|
||||||
id: r.id,
|
id: r.id,
|
||||||
tenantId: r.tenantId,
|
tenantId: r.tenantId,
|
||||||
matchType: r.matchType,
|
matchType: r.matchType,
|
||||||
matchValue: r.matchValue,
|
matchValue: r.matchValue,
|
||||||
action: r.action,
|
action: r.action,
|
||||||
|
ruleIntent: r.ruleIntent,
|
||||||
priority: r.priority,
|
priority: r.priority,
|
||||||
isActive: r.isActive,
|
isActive: r.isActive,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
updatedAt: r.updatedAt.toISOString(),
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
}));
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(tenantId: string): Promise<TenantRuleData[]> {
|
||||||
|
const rows = await this.prisma.tenantRule.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { priority: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.map((r: any) => this.toData(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
|
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
|
||||||
const existing = await this.prisma.tenantRule.findUnique({
|
const existing = await this.prisma.tenantRule.findUnique({
|
||||||
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } },
|
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType as any, matchValue: req.matchValue } },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('A rule with this matchType + matchValue already exists');
|
throw new ConflictException('A rule with this matchType + matchValue already exists');
|
||||||
@@ -35,25 +40,16 @@ export class RulesService {
|
|||||||
const row = await this.prisma.tenantRule.create({
|
const row = await this.prisma.tenantRule.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
matchType: req.matchType,
|
matchType: req.matchType as any,
|
||||||
matchValue: req.matchValue,
|
matchValue: req.matchValue,
|
||||||
action: req.action,
|
action: req.action as any,
|
||||||
|
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
|
||||||
priority: req.priority ?? 0,
|
priority: req.priority ?? 0,
|
||||||
isActive: req.isActive ?? true,
|
isActive: req.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return this.toData(row);
|
||||||
id: row.id,
|
|
||||||
tenantId: row.tenantId,
|
|
||||||
matchType: row.matchType as any,
|
|
||||||
matchValue: row.matchValue,
|
|
||||||
action: row.action as any,
|
|
||||||
priority: row.priority,
|
|
||||||
isActive: row.isActive,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
|
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
|
||||||
@@ -64,22 +60,13 @@ export class RulesService {
|
|||||||
if (req.matchType !== undefined) data.matchType = req.matchType;
|
if (req.matchType !== undefined) data.matchType = req.matchType;
|
||||||
if (req.matchValue !== undefined) data.matchValue = req.matchValue;
|
if (req.matchValue !== undefined) data.matchValue = req.matchValue;
|
||||||
if (req.action !== undefined) data.action = req.action;
|
if (req.action !== undefined) data.action = req.action;
|
||||||
|
if (req.ruleIntent !== undefined) data.ruleIntent = req.ruleIntent;
|
||||||
if (req.priority !== undefined) data.priority = req.priority;
|
if (req.priority !== undefined) data.priority = req.priority;
|
||||||
if (req.isActive !== undefined) data.isActive = req.isActive;
|
if (req.isActive !== undefined) data.isActive = req.isActive;
|
||||||
|
|
||||||
const row = await this.prisma.tenantRule.update({ where: { id }, data });
|
const row = await this.prisma.tenantRule.update({ where: { id }, data });
|
||||||
|
|
||||||
return {
|
return this.toData(row);
|
||||||
id: row.id,
|
|
||||||
tenantId: row.tenantId,
|
|
||||||
matchType: row.matchType as any,
|
|
||||||
matchValue: row.matchValue,
|
|
||||||
action: row.action as any,
|
|
||||||
priority: row.priority,
|
|
||||||
isActive: row.isActive,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(tenantId: string, id: string): Promise<void> {
|
async remove(tenantId: string, id: string): Promise<void> {
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [SearchController],
|
controllers: [SearchController],
|
||||||
providers: [SearchService],
|
providers: [SearchService],
|
||||||
|
exports: [SearchService],
|
||||||
})
|
})
|
||||||
export class SearchModule {}
|
export class SearchModule {}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { SevaService } from './seva.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
@Controller('admin/seva')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class SevaController {
|
||||||
|
constructor(private readonly service: SevaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.listAdmin(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
slots?: number;
|
||||||
|
startsAt?: string;
|
||||||
|
pointsAward?: number;
|
||||||
|
isPublished?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.service.create(ctx.tenantId, ctx.adminId!, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
slots?: number;
|
||||||
|
startsAt?: string;
|
||||||
|
pointsAward?: number;
|
||||||
|
isPublished?: boolean;
|
||||||
|
status?: 'OPEN' | 'CLOSED';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/entries')
|
||||||
|
entries(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||||
|
return this.service.listEntries(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/entries/:entryId/complete')
|
||||||
|
complete(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('entryId') entryId: string,
|
||||||
|
@Body() body: { hours?: number },
|
||||||
|
) {
|
||||||
|
return this.service.completeEntry(ctx.tenantId, ctx.adminId!, id, entryId, body.hours);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,52 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
import { OrgAdminRole } from '@prisma/client';
|
||||||
|
import { SuperAdminGuard } from './super-admin.guard';
|
||||||
|
import { SuperAdminService } from './super-admin.service';
|
||||||
|
|
||||||
|
class CreateOrgDto {
|
||||||
|
@IsString() @MinLength(2) slug!: string;
|
||||||
|
@IsString() @MinLength(1) name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateOrgAdminDto {
|
||||||
|
@IsString() email!: string;
|
||||||
|
@IsString() @IsOptional() name?: string;
|
||||||
|
@IsString() @MinLength(8) password!: string;
|
||||||
|
@IsEnum(OrgAdminRole) @IsOptional() role?: OrgAdminRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssignTenantOrgDto {
|
||||||
|
@IsString() @IsOptional() organizationId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
@Controller('admin')
|
||||||
|
export class AdminOrgsController {
|
||||||
|
constructor(private readonly service: SuperAdminService) {}
|
||||||
|
|
||||||
|
@Get('orgs')
|
||||||
|
listOrgs() {
|
||||||
|
return this.service.listOrgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('orgs')
|
||||||
|
createOrg(@Body() body: CreateOrgDto) {
|
||||||
|
return this.service.createOrg(body.slug, body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('orgs/:id')
|
||||||
|
getOrg(@Param('id') id: string) {
|
||||||
|
return this.service.getOrg(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('orgs/:id/admins')
|
||||||
|
createOrgAdmin(@Param('id') id: string, @Body() body: CreateOrgAdminDto) {
|
||||||
|
return this.service.createOrgAdmin(id, body.email, body.name, body.password, body.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('tenants/:id/org')
|
||||||
|
assignTenantToOrg(@Param('id') id: string, @Body() body: AssignTenantOrgDto) {
|
||||||
|
return this.service.assignTenantToOrg(id, body.organizationId ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { SuperAdminController } from './super-admin.controller';
|
import { SuperAdminController } from './super-admin.controller';
|
||||||
|
import { AdminOrgsController } from './admin-orgs.controller';
|
||||||
import { SuperAdminService } from './super-admin.service';
|
import { SuperAdminService } from './super-admin.service';
|
||||||
import { SuperAdminGuard } from './super-admin.guard';
|
import { SuperAdminGuard } from './super-admin.guard';
|
||||||
import { PrismaModule } from '../../prisma/prisma.module';
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
@@ -18,7 +19,7 @@ import { PrismaModule } from '../../prisma/prisma.module';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [SuperAdminController],
|
controllers: [SuperAdminController, AdminOrgsController],
|
||||||
providers: [SuperAdminService, SuperAdminGuard],
|
providers: [SuperAdminService, SuperAdminGuard],
|
||||||
exports: [SuperAdminGuard, JwtModule],
|
exports: [SuperAdminGuard, JwtModule],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
@@ -32,4 +32,81 @@ export class SuperAdminService {
|
|||||||
if (!admin) throw new UnauthorizedException('Super admin not found');
|
if (!admin) throw new UnauthorizedException('Super admin not found');
|
||||||
return { id: admin.id, email: admin.email, name: admin.name };
|
return { id: admin.id, email: admin.email, name: admin.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listOrgs() {
|
||||||
|
return this.prisma.organization.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { tenants: true, orgAdmins: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrg(slug: string, name: string) {
|
||||||
|
const existing = await this.prisma.organization.findUnique({ where: { slug } });
|
||||||
|
if (existing) throw new ConflictException(`Organization with slug "${slug}" already exists`);
|
||||||
|
return this.prisma.organization.create({
|
||||||
|
data: { slug, name },
|
||||||
|
select: { id: true, slug: true, name: true, createdAt: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrg(id: string) {
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
tenants: {
|
||||||
|
select: { id: true, slug: true, name: true, isActive: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
orgAdmins: {
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
return org;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrgAdmin(orgId: string, email: string, name: string | undefined, password: string, role?: string) {
|
||||||
|
const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
|
||||||
|
const existing = await this.prisma.orgAdmin.findUnique({
|
||||||
|
where: { organizationId_email: { organizationId: orgId, email } },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException('An admin with this email already exists in this organization');
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const admin = await this.prisma.orgAdmin.create({
|
||||||
|
data: { organizationId: orgId, email, name, passwordHash, ...(role ? { role: role as any } : {}) },
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignTenantToOrg(tenantId: string, organizationId: string | null) {
|
||||||
|
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
|
||||||
|
if (organizationId !== null) {
|
||||||
|
const org = await this.prisma.organization.findUnique({ where: { id: organizationId } });
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.tenant.update({
|
||||||
|
where: { id: tenantId },
|
||||||
|
data: { organizationId },
|
||||||
|
select: { id: true, slug: true, name: true, organizationId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { ThreadsService } from './threads.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
@Controller('admin/threads')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class ThreadsController {
|
||||||
|
constructor(private readonly threads: ThreadsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.threads.listThreads(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
get(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.threads.getThread(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ThreadsController } from './threads.controller';
|
||||||
|
import { ThreadsService } from './threads.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ThreadsController],
|
||||||
|
providers: [ThreadsService],
|
||||||
|
})
|
||||||
|
export class ThreadsModule {}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThreadsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async listThreads(tenantId: string) {
|
||||||
|
const threads = await this.prisma.thread.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { lastActivityAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
_count: { select: { messages: true } },
|
||||||
|
sourceGroup: { select: { name: true, platformId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return threads.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
topic: t.topic,
|
||||||
|
sourceGroupName: t.sourceGroup.name,
|
||||||
|
messageCount: t._count.messages,
|
||||||
|
lastActivityAt: t.lastActivityAt.toISOString(),
|
||||||
|
createdAt: t.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getThread(tenantId: string, threadId: string) {
|
||||||
|
const thread = await this.prisma.thread.findUnique({
|
||||||
|
where: { id: threadId },
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
senderJid: true,
|
||||||
|
senderName: true,
|
||||||
|
tags: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourceGroup: { select: { name: true, platformId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!thread || thread.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('Thread not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: thread.id,
|
||||||
|
topic: thread.topic,
|
||||||
|
sourceGroupName: thread.sourceGroup.name,
|
||||||
|
messageCount: thread.messageCount,
|
||||||
|
lastActivityAt: thread.lastActivityAt.toISOString(),
|
||||||
|
createdAt: thread.createdAt.toISOString(),
|
||||||
|
messages: thread.messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
content: m.content,
|
||||||
|
senderJid: m.senderJid,
|
||||||
|
senderName: m.senderName,
|
||||||
|
tags: m.tags,
|
||||||
|
status: m.status,
|
||||||
|
createdAt: m.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
|
.vercel
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getToken } from '@/app/_lib/api';
|
||||||
|
import { DraftCard } from '@/app/_components/draft-card';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type DraftType = 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id: string;
|
||||||
|
type: DraftType;
|
||||||
|
status: string;
|
||||||
|
proposedJson: Record<string, any>;
|
||||||
|
confidence: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
sourceMessage: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
senderName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { label: string; value: DraftType | 'ALL' }[] = [
|
||||||
|
{ label: 'All', value: 'ALL' },
|
||||||
|
{ label: 'Events', value: 'EVENT' },
|
||||||
|
{ label: 'Seva Tasks', value: 'SEVA_TASK' },
|
||||||
|
{ label: 'FAQs', value: 'FAQ_ITEM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchDrafts(type?: DraftType): Promise<Draft[]> {
|
||||||
|
const query = new URLSearchParams({ status: 'PENDING_REVIEW' });
|
||||||
|
if (type) query.set('type', type);
|
||||||
|
const res = await apiFetch(`/admin/drafts?${query}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return (await res.json()) as Draft[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TenantDraftsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ type?: string }>;
|
||||||
|
}) {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) redirect('/login');
|
||||||
|
|
||||||
|
const { type } = await searchParams;
|
||||||
|
const activeType = (type as DraftType) ?? undefined;
|
||||||
|
const drafts = await fetchDrafts(activeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">AI Content Drafts</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
Events, seva tasks, and FAQs auto-detected from your group messages
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 border-b border-gray-200">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const href = tab.value === 'ALL' ? '/drafts' : `/drafts?type=${tab.value}`;
|
||||||
|
const isActive = (tab.value === 'ALL' && !activeType) || tab.value === activeType;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.value}
|
||||||
|
href={href}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-indigo-600 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drafts.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<p className="text-sm font-medium">No pending drafts</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Drafts appear here when the AI detects events, volunteer asks, or FAQ patterns in your group messages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{drafts.map((d) => (
|
||||||
|
<DraftCard key={d.id} draft={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { RouteSettingsDialog } from './RouteSettingsDialog';
|
||||||
|
import { RouteRulesDialog } from './RouteRulesDialog';
|
||||||
|
|
||||||
|
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||||
|
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||||
|
type ApprovalMode = 'MANUAL' | 'AUTO';
|
||||||
|
|
||||||
|
interface AssignedRule {
|
||||||
|
id: string;
|
||||||
|
matchType: string;
|
||||||
|
matchValue: string;
|
||||||
|
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
id: string;
|
||||||
|
sourceGroupId: string;
|
||||||
|
targetGroupId: string;
|
||||||
|
sourceGroup: { name: string };
|
||||||
|
targetGroup: { name: string };
|
||||||
|
forwardFormat: ForwardFormat;
|
||||||
|
withAttachment: boolean;
|
||||||
|
frequency: ForwardFrequency;
|
||||||
|
approvalMode: ApprovalMode;
|
||||||
|
isActive: boolean;
|
||||||
|
assignedRules?: Array<{ tenantRule: AssignedRule }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_LABELS: Record<ForwardFrequency, string> = {
|
||||||
|
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
|
||||||
|
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
|
||||||
|
};
|
||||||
|
const FORMAT_LABELS: Record<ForwardFormat, string> = {
|
||||||
|
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RouteManager({
|
||||||
|
groups,
|
||||||
|
initialRoutes,
|
||||||
|
}: {
|
||||||
|
groups: Group[];
|
||||||
|
initialRoutes: Route[];
|
||||||
|
}) {
|
||||||
|
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
|
||||||
|
const [sourceId, setSourceId] = useState('');
|
||||||
|
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
|
||||||
|
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
|
||||||
|
|
||||||
|
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
|
||||||
|
for (const r of routes) {
|
||||||
|
const key = r.sourceGroup.name;
|
||||||
|
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
|
||||||
|
grouped.get(key)!.targets.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTarget(id: string) {
|
||||||
|
setTargetIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRoutes() {
|
||||||
|
if (!sourceId || targetIds.size === 0) return;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/routes/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
|
||||||
|
});
|
||||||
|
if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
|
||||||
|
if (!res.ok) { setError('Failed to create routes'); return; }
|
||||||
|
const created: Route[] = await res.json();
|
||||||
|
setRoutes((prev) => [...created, ...prev]);
|
||||||
|
setSourceId(''); setTargetIds(new Set());
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRoute(id: string) {
|
||||||
|
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No routes configured.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-2.5">Source</th>
|
||||||
|
<th className="px-4 py-2.5">Target</th>
|
||||||
|
<th className="px-4 py-2.5">Rules</th>
|
||||||
|
<th className="px-4 py-2.5">Frequency</th>
|
||||||
|
<th className="px-4 py-2.5">Format</th>
|
||||||
|
<th className="px-4 py-2.5">Approval</th>
|
||||||
|
<th className="px-4 py-2.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{routes.map((r) => {
|
||||||
|
const ruleCount = r.assignedRules?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
|
||||||
|
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600">
|
||||||
|
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
|
||||||
|
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
|
||||||
|
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsRoute(r)}
|
||||||
|
className="text-xs text-gray-400 hover:text-blue-600"
|
||||||
|
title="Edit settings"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRulesRoute(r)}
|
||||||
|
className="text-xs text-gray-400 hover:text-blue-600"
|
||||||
|
title="Manage rules"
|
||||||
|
>
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void deleteRoute(r.id)}
|
||||||
|
className="text-xs text-gray-400 hover:text-red-600"
|
||||||
|
title="Delete route"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold mb-3">Add routes</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<select
|
||||||
|
value={sourceId}
|
||||||
|
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
|
||||||
|
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Source group…</option>
|
||||||
|
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-gray-400 text-sm">→</span>
|
||||||
|
<span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceId && eligibleTargets.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
|
||||||
|
{eligibleTargets.map((g) => (
|
||||||
|
<label
|
||||||
|
key={g.id}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
|
||||||
|
targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={targetIds.has(g.id)}
|
||||||
|
onChange={() => toggleTarget(g.id)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
{g.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => void addRoutes()}
|
||||||
|
disabled={!sourceId || targetIds.size === 0 || busy}
|
||||||
|
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{settingsRoute && (
|
||||||
|
<RouteSettingsDialog
|
||||||
|
route={settingsRoute}
|
||||||
|
onSaved={(updates) => {
|
||||||
|
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
|
||||||
|
}}
|
||||||
|
onClose={() => setSettingsRoute(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rulesRoute && (
|
||||||
|
<RouteRulesDialog
|
||||||
|
routeId={rulesRoute.id}
|
||||||
|
targetGroupName={rulesRoute.targetGroup.name}
|
||||||
|
onClose={() => setRulesRoute(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface AssignedRule {
|
||||||
|
id: string;
|
||||||
|
matchType: string;
|
||||||
|
matchValue: string;
|
||||||
|
action: string;
|
||||||
|
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||||
|
isActive: boolean;
|
||||||
|
assignedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantRule {
|
||||||
|
id: string;
|
||||||
|
matchType: string;
|
||||||
|
matchValue: string;
|
||||||
|
action: string;
|
||||||
|
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteRulesDialog({
|
||||||
|
routeId,
|
||||||
|
targetGroupName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
routeId: string;
|
||||||
|
targetGroupName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [assigned, setAssigned] = useState<AssignedRule[]>([]);
|
||||||
|
const [allRules, setAllRules] = useState<TenantRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/routes/${routeId}/rules`).then((r) => r.json()),
|
||||||
|
fetch('/api/rules').then((r) => r.json()),
|
||||||
|
]).then(([a, all]) => {
|
||||||
|
setAssigned(Array.isArray(a) ? a : []);
|
||||||
|
setAllRules(Array.isArray(all) ? all : []);
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
|
}, [routeId]);
|
||||||
|
|
||||||
|
const assignedIds = new Set(assigned.map((r) => r.id));
|
||||||
|
const available = allRules.filter((r) => r.isActive && !assignedIds.has(r.id));
|
||||||
|
|
||||||
|
async function assign(tenantRuleId: string) {
|
||||||
|
setBusy(tenantRuleId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const rule = allRules.find((r) => r.id === tenantRuleId)!;
|
||||||
|
setAssigned((prev) => [...prev, { ...rule, assignedAt: new Date().toISOString() }]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unassign(tenantRuleId: string) {
|
||||||
|
setBusy(tenantRuleId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok || res.status === 204) {
|
||||||
|
setAssigned((prev) => prev.filter((r) => r.id !== tenantRuleId));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-5 flex flex-col gap-4 max-h-[80vh]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">Rules for route</h2>
|
||||||
|
<p className="text-xs text-gray-500">→ {targetGroupName}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-400">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Assigned ({assigned.length})
|
||||||
|
</h3>
|
||||||
|
{assigned.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No rules assigned — route uses approval mode only.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{assigned.map((rule) => (
|
||||||
|
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
|
||||||
|
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{rule.ruleIntent}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-mono">{rule.matchValue}</span>
|
||||||
|
<span className="text-xs text-gray-400">{rule.matchType}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void unassign(rule.id)}
|
||||||
|
disabled={busy === rule.id}
|
||||||
|
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{available.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Add from your rules
|
||||||
|
</h3>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{available.map((rule) => (
|
||||||
|
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-dashed border-gray-200 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
|
||||||
|
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{rule.ruleIntent}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-mono">{rule.matchValue}</span>
|
||||||
|
<span className="text-xs text-gray-400">{rule.matchType}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void assign(rule.id)}
|
||||||
|
disabled={busy === rule.id}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{available.length === 0 && assigned.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400">All active rules are assigned to this route.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||||
|
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||||
|
type ApprovalMode = 'MANUAL' | 'AUTO';
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
id: string;
|
||||||
|
forwardFormat: ForwardFormat;
|
||||||
|
withAttachment: boolean;
|
||||||
|
frequency: ForwardFrequency;
|
||||||
|
approvalMode: ApprovalMode;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_OPTIONS: { value: ForwardFormat; label: string }[] = [
|
||||||
|
{ value: 'THREADED', label: 'Threaded' },
|
||||||
|
{ value: 'DIGEST', label: 'Digest' },
|
||||||
|
{ value: 'SUMMARY', label: 'Summary' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREQ_OPTIONS: { value: ForwardFrequency; label: string }[] = [
|
||||||
|
{ value: 'INSTANT', label: 'Instant' },
|
||||||
|
{ value: 'FIVE_MIN', label: '5 min' },
|
||||||
|
{ value: 'FIFTEEN_MIN', label: '15 min' },
|
||||||
|
{ value: 'ONE_HOUR', label: '1 hour' },
|
||||||
|
{ value: 'ONE_DAY', label: '1 day' },
|
||||||
|
{ value: 'SEVEN_DAYS', label: '7 days' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RouteSettingsDialog({
|
||||||
|
route,
|
||||||
|
onSaved,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
route: Route;
|
||||||
|
onSaved: (updated: Partial<Route>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [forwardFormat, setForwardFormat] = useState<ForwardFormat>(route.forwardFormat);
|
||||||
|
const [withAttachment, setWithAttachment] = useState(route.withAttachment);
|
||||||
|
const [frequency, setFrequency] = useState<ForwardFrequency>(route.frequency);
|
||||||
|
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(route.approvalMode);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/routes/${route.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ forwardFormat, withAttachment, frequency, approvalMode }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
setError(err.message ?? 'Failed to save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSaved({ forwardFormat, withAttachment, frequency, approvalMode });
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Route settings</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Forward format</label>
|
||||||
|
<select
|
||||||
|
value={forwardFormat}
|
||||||
|
onChange={(e) => setForwardFormat(e.target.value as ForwardFormat)}
|
||||||
|
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{FORMAT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withAttachment}
|
||||||
|
onChange={(e) => setWithAttachment(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
Include attachments
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Frequency</label>
|
||||||
|
<select
|
||||||
|
value={frequency}
|
||||||
|
onChange={(e) => setFrequency(e.target.value as ForwardFrequency)}
|
||||||
|
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{FREQ_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Approval mode</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApprovalMode(m)}
|
||||||
|
className={`flex-1 rounded px-3 py-2 text-sm font-medium border transition-colors ${
|
||||||
|
approvalMode === m
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'MANUAL' ? 'Manual' : 'Auto'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { RouteManager } from './RouteManager';
|
import { RouteManager } from './RouteManager';
|
||||||
import { GroupsTabs } from './GroupsTabs';
|
import { GroupsTabs } from './GroupsTabs';
|
||||||
import { apiFetch } from '../_lib/api';
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,6 +22,12 @@ interface Route {
|
|||||||
targetGroupId: string;
|
targetGroupId: string;
|
||||||
sourceGroup: { name: string };
|
sourceGroup: { name: string };
|
||||||
targetGroup: { name: string };
|
targetGroup: { name: string };
|
||||||
|
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||||
|
withAttachment: boolean;
|
||||||
|
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||||
|
approvalMode: 'MANUAL' | 'AUTO';
|
||||||
|
isActive: boolean;
|
||||||
|
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
|
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/app/_lib/auth-context';
|
||||||
|
import { PortalShell } from '@/app/_components/portal-shell';
|
||||||
|
import { ScopeCard } from '@/app/_components/scope-card';
|
||||||
|
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ href: '/search', label: 'Search', icon: <Search /> },
|
||||||
|
{ href: '/groups', label: 'Groups & Routes', icon: <Users /> },
|
||||||
|
{ href: '/messages/pending', label: 'Pending', icon: <Inbox /> },
|
||||||
|
{ href: '/drafts', label: 'AI Drafts', icon: <FileText /> },
|
||||||
|
{ href: '/settings/rules', label: 'Rules', icon: <SlidersHorizontal /> },
|
||||||
|
{ href: '/settings/bot', label: 'Bot', icon: <Bot /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ChapterLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { admin, loading, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !admin) router.replace(`/login?next=${encodeURIComponent(pathname)}`);
|
||||||
|
}, [admin, loading, pathname, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalShell
|
||||||
|
portalName="Chapter"
|
||||||
|
theme="blue"
|
||||||
|
navItems={NAV}
|
||||||
|
loading={loading}
|
||||||
|
authed={!!admin}
|
||||||
|
user={admin ? { email: admin.email, name: admin.name, role: admin.role, subtitle: admin.tenantName } : undefined}
|
||||||
|
scope={admin ? <ScopeCard accent="text-blue-600" organization={admin.organization} chapter={admin.tenantName ? { name: admin.tenantName } : null} /> : undefined}
|
||||||
|
onLogout={() => { void logout(); router.replace('/login'); }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PortalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface RouteForMessage {
|
||||||
|
routeId: string;
|
||||||
|
targetGroupId: string;
|
||||||
|
targetGroupName: string;
|
||||||
|
approvalMode: 'MANUAL' | 'AUTO';
|
||||||
|
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||||
|
withAttachment: boolean;
|
||||||
|
frequency: string;
|
||||||
|
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForwardTargetDialog({
|
||||||
|
messageId,
|
||||||
|
onApproved,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
messageId: string;
|
||||||
|
onApproved: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [routes, setRoutes] = useState<RouteForMessage[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/messages/${messageId}/routes`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: RouteForMessage[]) => {
|
||||||
|
setRoutes(Array.isArray(data) ? data : []);
|
||||||
|
// Pre-select: FORWARD decision, or AUTO approvalMode with no BLOCK
|
||||||
|
const preSelected = new Set(
|
||||||
|
(Array.isArray(data) ? data : [])
|
||||||
|
.filter((r) => r.ruleDecision === 'FORWARD' || (r.ruleDecision === 'NONE' && r.approvalMode === 'AUTO'))
|
||||||
|
.map((r) => r.targetGroupId),
|
||||||
|
);
|
||||||
|
setSelected(preSelected);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [messageId]);
|
||||||
|
|
||||||
|
function toggleGroup(groupId: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/messages/${messageId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetGroupIds: [...selected] }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onApproved();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = routes.filter((r) =>
|
||||||
|
r.targetGroupName.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 flex flex-col max-h-[80vh]">
|
||||||
|
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100">
|
||||||
|
<h2 className="text-base font-semibold">Approve & Forward</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-b border-gray-100">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search groups…"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-3">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-gray-400">Loading routes…</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No target groups found.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const isBlocked = r.ruleDecision === 'BLOCK';
|
||||||
|
const isChecked = selected.has(r.targetGroupId);
|
||||||
|
return (
|
||||||
|
<li key={r.targetGroupId}>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
|
||||||
|
isBlocked
|
||||||
|
? 'opacity-50 cursor-default'
|
||||||
|
: isChecked
|
||||||
|
? 'bg-blue-50'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => !isBlocked && toggleGroup(r.targetGroupId)}
|
||||||
|
disabled={isBlocked}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-800 truncate">{r.targetGroupName}</div>
|
||||||
|
{isBlocked && (
|
||||||
|
<div className="text-[10px] text-red-500">Blocked by assigned rule</div>
|
||||||
|
)}
|
||||||
|
{r.ruleDecision === 'FORWARD' && (
|
||||||
|
<div className="text-[10px] text-green-600">Auto-forward by rule</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500">{selected.size} group{selected.size !== 1 ? 's' : ''} selected</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void confirm()}
|
||||||
|
disabled={busy || selected.size === 0}
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? 'Approving…' : 'Approve & Forward'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { apiFetch } from '../../_lib/api';
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
interface MessageDetail {
|
interface MessageDetail {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ForwardTargetDialog } from '../ForwardTargetDialog';
|
||||||
|
|
||||||
|
interface PendingMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
senderJid: string;
|
||||||
|
senderName: string | null;
|
||||||
|
tags: string[];
|
||||||
|
createdAt: string;
|
||||||
|
sourceGroupId: string;
|
||||||
|
sourceGroupName: string;
|
||||||
|
sourceGroupPlatformId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PendingMessagesPage() {
|
||||||
|
const [messages, setMessages] = useState<PendingMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/messages/pending')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}`);
|
||||||
|
return res.json() as Promise<PendingMessage[]>;
|
||||||
|
})
|
||||||
|
.then((data) => { setMessages(data); setError(null); })
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<h1 className="text-xl font-semibold mb-2">Pending messages</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Messages waiting for admin approval. Click "Approve & Forward" to choose which groups to send to.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
Failed to load: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && messages.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
|
||||||
|
No pending messages right now.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<PendingMessageRow
|
||||||
|
key={m.id}
|
||||||
|
message={m}
|
||||||
|
onApproved={() => setMessages((prev) => prev.filter((x) => x.id !== m.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingMessageRow({
|
||||||
|
message,
|
||||||
|
onApproved,
|
||||||
|
}: {
|
||||||
|
message: PendingMessage;
|
||||||
|
onApproved: () => void;
|
||||||
|
}) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
|
||||||
|
<div className="text-xs text-gray-500">{new Date(message.createdAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
|
||||||
|
{message.tags.length > 0 && (
|
||||||
|
<span className="ml-2 inline-flex flex-wrap gap-1">
|
||||||
|
{message.tags.map((t) => (
|
||||||
|
<span key={t} className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">{message.content}</div>
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Approve & Forward
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogOpen && (
|
||||||
|
<ForwardTargetDialog
|
||||||
|
messageId={message.id}
|
||||||
|
onApproved={onApproved}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { apiFetch } from '../_lib/api';
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
interface MeiliHit {
|
interface MeiliHit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BotSettingsCard } from './BotSettingsCard';
|
import { BotSettingsCard } from './BotSettingsCard';
|
||||||
import { apiFetch } from '../../_lib/api';
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
interface BotSummary {
|
interface BotSummary {
|
||||||
id: string;
|
id: string;
|
||||||
+89
-19
@@ -2,48 +2,71 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
|
||||||
|
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
||||||
|
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
|
||||||
|
|
||||||
interface RuleData {
|
interface RuleData {
|
||||||
id: string;
|
id: string;
|
||||||
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
|
matchType: RuleMatchType;
|
||||||
matchValue: string;
|
matchValue: string;
|
||||||
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
action: RuleAction;
|
||||||
|
ruleIntent: RuleIntent;
|
||||||
priority: number;
|
priority: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MATCH_TYPE_LABELS: Record<string, string> = {
|
const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
|
||||||
HASHTAG: 'Hashtag',
|
HASHTAG: 'Hashtag',
|
||||||
PREFIX: 'Prefix',
|
PREFIX: 'Prefix',
|
||||||
REACTION_EMOJI: 'Reaction Emoji',
|
REACTION_EMOJI: 'Reaction Emoji',
|
||||||
|
INTEREST: 'Interest',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<RuleAction, string> = {
|
||||||
FLAG: 'Flag (Pending)',
|
FLAG: 'Flag (Pending)',
|
||||||
AUTO_APPROVE: 'Auto-approve',
|
AUTO_APPROVE: 'Auto-approve',
|
||||||
SKIP: 'Skip (Silent Drop)',
|
SKIP: 'Skip (Silent Drop)',
|
||||||
REJECT: 'Reject (Visible)',
|
REJECT: 'Reject (Visible)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REACTION_OPTIONS = [
|
||||||
|
{ value: '👍', label: '👍 Thumbs Up' },
|
||||||
|
{ value: '👎', label: '👎 Thumbs Down' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getValuePlaceholder(matchType: RuleMatchType): string {
|
||||||
|
if (matchType === 'HASHTAG') return '#important';
|
||||||
|
if (matchType === 'PREFIX') return '!!';
|
||||||
|
if (matchType === 'INTEREST') return '#education';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
export function RuleManager({ initial }: { initial: RuleData[] }) {
|
export function RuleManager({ initial }: { initial: RuleData[] }) {
|
||||||
const [rules, setRules] = useState<RuleData[]>(initial);
|
const [rules, setRules] = useState<RuleData[]>(initial);
|
||||||
const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG');
|
const [matchType, setMatchType] = useState<RuleMatchType>('HASHTAG');
|
||||||
const [matchValue, setMatchValue] = useState('');
|
const [matchValue, setMatchValue] = useState('');
|
||||||
const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG');
|
const [action, setAction] = useState<RuleAction>('FLAG');
|
||||||
|
const [ruleIntent, setRuleIntent] = useState<RuleIntent>('POSITIVE');
|
||||||
const [priority, setPriority] = useState(0);
|
const [priority, setPriority] = useState(0);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
function handleMatchTypeChange(type: RuleMatchType) {
|
||||||
|
setMatchType(type);
|
||||||
|
setMatchValue('');
|
||||||
|
}
|
||||||
|
|
||||||
async function addRule() {
|
async function addRule() {
|
||||||
if (!matchValue.trim()) return;
|
if (!matchValue.trim()) return;
|
||||||
setBusy(true);
|
setBusy(true); setError('');
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/rules', {
|
const res = await fetch('/api/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, priority }),
|
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, ruleIntent, priority }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
|
const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
|
||||||
@@ -52,12 +75,8 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
|||||||
}
|
}
|
||||||
const created: RuleData = await res.json();
|
const created: RuleData = await res.json();
|
||||||
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
|
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
|
||||||
setMatchValue('');
|
setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
|
||||||
setAction('FLAG');
|
} finally { setBusy(false); }
|
||||||
setPriority(0);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRule(rule: RuleData) {
|
async function toggleRule(rule: RuleData) {
|
||||||
@@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
|||||||
<label className="text-xs text-gray-500">Type</label>
|
<label className="text-xs text-gray-500">Type</label>
|
||||||
<select
|
<select
|
||||||
value={matchType}
|
value={matchType}
|
||||||
onChange={(e) => setMatchType(e.target.value as any)}
|
onChange={(e) => handleMatchTypeChange(e.target.value as RuleMatchType)}
|
||||||
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="HASHTAG">Hashtag</option>
|
<option value="HASHTAG">Hashtag</option>
|
||||||
<option value="PREFIX">Prefix</option>
|
<option value="PREFIX">Prefix</option>
|
||||||
<option value="REACTION_EMOJI">Reaction Emoji</option>
|
<option value="REACTION_EMOJI">Reaction Emoji</option>
|
||||||
|
<option value="INTEREST">Interest</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500">Value</label>
|
<label className="text-xs text-gray-500">Value</label>
|
||||||
|
{matchType === 'REACTION_EMOJI' ? (
|
||||||
|
<select
|
||||||
|
value={matchValue}
|
||||||
|
onChange={(e) => setMatchValue(e.target.value)}
|
||||||
|
className="border border-gray-300 rounded px-3 py-2 text-sm w-44"
|
||||||
|
>
|
||||||
|
<option value="">Select emoji…</option>
|
||||||
|
{REACTION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
value={matchValue}
|
value={matchValue}
|
||||||
onChange={(e) => setMatchValue(e.target.value)}
|
onChange={(e) => setMatchValue(e.target.value)}
|
||||||
placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'}
|
placeholder={getValuePlaceholder(matchType)}
|
||||||
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
|
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{matchType === 'INTEREST' && (
|
||||||
|
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Intent</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRuleIntent(i)}
|
||||||
|
className={`px-3 py-2 text-sm rounded border transition-colors ${
|
||||||
|
ruleIntent === i
|
||||||
|
? i === 'POSITIVE'
|
||||||
|
? 'bg-green-600 text-white border-green-600'
|
||||||
|
: 'bg-red-600 text-white border-red-600'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i === 'POSITIVE' ? '+ Pos' : '− Neg'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500">Action</label>
|
<label className="text-xs text-gray-500">Action</label>
|
||||||
<select
|
<select
|
||||||
value={action}
|
value={action}
|
||||||
onChange={(e) => setAction(e.target.value as any)}
|
onChange={(e) => setAction(e.target.value as RuleAction)}
|
||||||
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="FLAG">Flag (Pending)</option>
|
<option value="FLAG">Flag (Pending)</option>
|
||||||
@@ -116,6 +176,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
|||||||
<option value="REJECT">Reject (Visible)</option>
|
<option value="REJECT">Reject (Visible)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500">Priority</label>
|
<label className="text-xs text-gray-500">Priority</label>
|
||||||
<input
|
<input
|
||||||
@@ -125,6 +186,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
|||||||
className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
|
className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void addRule()}
|
onClick={() => void addRule()}
|
||||||
@@ -140,13 +202,14 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-base font-semibold mb-3">Active Rules</h2>
|
<h2 className="text-base font-semibold mb-3">Active Rules</h2>
|
||||||
{rules.length === 0 ? (
|
{rules.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400">No rules configured. Messages without matching rules are ignored.</p>
|
<p className="text-sm text-gray-400">No rules configured. Assign rules to sync routes to control auto-forwarding.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 text-left text-gray-500">
|
<tr className="border-b border-gray-200 text-left text-gray-500">
|
||||||
<th className="pb-2 pr-3">Type</th>
|
<th className="pb-2 pr-3">Type</th>
|
||||||
<th className="pb-2 pr-3">Value</th>
|
<th className="pb-2 pr-3">Value</th>
|
||||||
|
<th className="pb-2 pr-3">Intent</th>
|
||||||
<th className="pb-2 pr-3">Action</th>
|
<th className="pb-2 pr-3">Action</th>
|
||||||
<th className="pb-2 pr-3">Priority</th>
|
<th className="pb-2 pr-3">Priority</th>
|
||||||
<th className="pb-2 pr-3">Active</th>
|
<th className="pb-2 pr-3">Active</th>
|
||||||
@@ -158,6 +221,13 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
|||||||
<tr key={rule.id} className="border-b border-gray-100">
|
<tr key={rule.id} className="border-b border-gray-100">
|
||||||
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
|
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
|
||||||
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
|
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<span className={`text-xs rounded px-2 py-0.5 ${
|
||||||
|
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{rule.ruleIntent}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
|
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
|
||||||
<td className="py-2 pr-3">{rule.priority}</td>
|
<td className="py-2 pr-3">{rule.priority}</td>
|
||||||
<td className="py-2 pr-3">
|
<td className="py-2 pr-3">
|
||||||
+3
-2
@@ -1,11 +1,12 @@
|
|||||||
import { RuleManager } from './RuleManager';
|
import { RuleManager } from './RuleManager';
|
||||||
import { apiFetch } from '../../_lib/api';
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
interface RuleData {
|
interface RuleData {
|
||||||
id: string;
|
id: string;
|
||||||
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
|
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
|
||||||
matchValue: string;
|
matchValue: string;
|
||||||
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
||||||
|
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||||
priority: number;
|
priority: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
|
interface ThreadMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
senderJid: string;
|
||||||
|
senderName: string | null;
|
||||||
|
tags: string[];
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadDetail {
|
||||||
|
id: string;
|
||||||
|
topic: string | null;
|
||||||
|
sourceGroupName: string;
|
||||||
|
messageCount: number;
|
||||||
|
lastActivityAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
messages: ThreadMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
APPROVED: 'text-green-600',
|
||||||
|
PENDING: 'text-amber-600',
|
||||||
|
REJECTED: 'text-red-600',
|
||||||
|
RAW: 'text-gray-400',
|
||||||
|
DNC: 'text-gray-400',
|
||||||
|
DISTRIBUTED: 'text-blue-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ThreadDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
let thread: ThreadDetail | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/admin/threads/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
thread = await res.json();
|
||||||
|
} else {
|
||||||
|
error = `API returned ${res.status}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Failed to load thread';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !thread) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<Link href="/threads" className="text-sm text-blue-600 hover:underline mb-4 inline-block">← Back to threads</Link>
|
||||||
|
<p className="text-red-600 text-sm">{error ?? 'Thread not found'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<Link href="/threads" className="text-sm text-blue-600 hover:underline mb-4 inline-block">← Back to threads</Link>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{thread.topic ?? `Thread ${thread.id.slice(0, 8)}`}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{thread.sourceGroupName} · {thread.messageCount} messages · last activity {new Date(thread.lastActivityAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{thread.messages.map((msg) => (
|
||||||
|
<div key={msg.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<span className="font-medium text-gray-700">{msg.senderName ?? msg.senderJid}</span>
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
<span>{new Date(msg.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs font-medium ${STATUS_COLORS[msg.status] ?? 'text-gray-500'}`}>
|
||||||
|
{msg.status}
|
||||||
|
</span>
|
||||||
|
<Link href={`/messages/${msg.id}`} className="text-xs text-blue-600 hover:underline">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-900 whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
{msg.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{msg.tags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/app/_lib/api';
|
||||||
|
|
||||||
|
interface ThreadSummary {
|
||||||
|
id: string;
|
||||||
|
topic: string | null;
|
||||||
|
sourceGroupName: string;
|
||||||
|
messageCount: number;
|
||||||
|
lastActivityAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ThreadsPage() {
|
||||||
|
let threads: ThreadSummary[] = [];
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/admin/threads');
|
||||||
|
if (res.ok) {
|
||||||
|
threads = await res.json();
|
||||||
|
} else {
|
||||||
|
error = `API returned ${res.status}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Failed to load threads';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<h1 className="text-xl font-semibold mb-6">Threads</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-600 text-sm mb-4">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && threads.length === 0 && (
|
||||||
|
<p className="text-gray-500 text-sm">No threads yet. Threads are created automatically when members reply to messages.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{threads.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||||
|
{threads.map((thread) => (
|
||||||
|
<Link
|
||||||
|
key={thread.id}
|
||||||
|
href={`/threads/${thread.id}`}
|
||||||
|
className="flex items-start justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{thread.topic ?? `Thread ${thread.id.slice(0, 8)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{thread.sourceGroupName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 shrink-0 text-right">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{thread.messageCount} {thread.messageCount === 1 ? 'message' : 'messages'}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{new Date(thread.lastActivityAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface SourceMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
senderName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id: string;
|
||||||
|
type: 'EVENT' | 'SEVA_TASK' | 'FAQ_ITEM';
|
||||||
|
status: string;
|
||||||
|
proposedJson: Record<string, any>;
|
||||||
|
confidence: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
sourceMessage: SourceMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<Draft['type'], string> = {
|
||||||
|
EVENT: 'Event',
|
||||||
|
SEVA_TASK: 'Seva Task',
|
||||||
|
FAQ_ITEM: 'FAQ',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLOR: Record<Draft['type'], string> = {
|
||||||
|
EVENT: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||||
|
SEVA_TASK: 'bg-green-50 text-green-700 border-green-200',
|
||||||
|
FAQ_ITEM: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProposedPreview({ type, data }: { type: Draft['type']; data: Record<string, any> }) {
|
||||||
|
if (type === 'EVENT') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><span className="font-medium text-gray-700">Title:</span> {data.title ?? '—'}</p>
|
||||||
|
{data.date && <p><span className="font-medium text-gray-700">Date:</span> {data.date} {data.time ?? ''}</p>}
|
||||||
|
{data.location && <p><span className="font-medium text-gray-700">Location:</span> {data.location}</p>}
|
||||||
|
{data.description && <p><span className="font-medium text-gray-700">Description:</span> {data.description}</p>}
|
||||||
|
{data.rsvpNeeded && <p className="text-indigo-600 text-xs">RSVP required</p>}
|
||||||
|
{data.sevaActionItems?.length > 0 && (
|
||||||
|
<p className="text-green-700 text-xs">+{data.sevaActionItems.length} seva task(s) will also be created</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'SEVA_TASK') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><span className="font-medium text-gray-700">Title:</span> {data.title ?? '—'}</p>
|
||||||
|
{data.parentEventTitle && <p><span className="font-medium text-gray-700">Event:</span> {data.parentEventTitle}</p>}
|
||||||
|
{data.slots && <p><span className="font-medium text-gray-700">Slots:</span> {data.slots}</p>}
|
||||||
|
{data.skills?.length > 0 && <p><span className="font-medium text-gray-700">Skills:</span> {data.skills.join(', ')}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><span className="font-medium text-gray-700">Q:</span> {data.question ?? '—'}</p>
|
||||||
|
<p><span className="font-medium text-gray-700">A:</span> {data.answer ?? '—'}</p>
|
||||||
|
{data.tags?.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap mt-1">
|
||||||
|
{data.tags.map((t: string) => (
|
||||||
|
<span key={t} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraftCard({ draft }: { draft: Draft }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
|
||||||
|
async function act(action: 'approve' | 'reject') {
|
||||||
|
setLoading(action);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/admin/drafts/${draft.id}/${action}`, { method: 'POST' });
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidence = draft.confidence != null ? Math.round(draft.confidence * 100) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${TYPE_COLOR[draft.type]}`}>
|
||||||
|
{TYPE_LABEL[draft.type]}
|
||||||
|
</span>
|
||||||
|
{confidence != null && (
|
||||||
|
<span className="text-xs text-gray-400">{confidence}% confidence</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source message */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-1">
|
||||||
|
Source message{draft.sourceMessage.senderName ? ` — ${draft.sourceMessage.senderName}` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 line-clamp-3">{draft.sourceMessage.content}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI proposed content */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-2">AI proposed</p>
|
||||||
|
<ProposedPreview type={draft.type} data={draft.proposedJson} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => act('approve')}
|
||||||
|
disabled={loading !== null}
|
||||||
|
className="flex-1 py-2 text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading === 'approve' ? 'Approving…' : 'Approve'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => act('reject')}
|
||||||
|
disabled={loading !== null}
|
||||||
|
className="flex-1 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading === 'reject' ? 'Rejecting…' : 'Reject'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { cn } from '../_lib/utils';
|
||||||
|
import { PORTAL_THEMES, type PortalTheme } from './portal-theme';
|
||||||
|
import { Check, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PortalLoginShellProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
theme: PortalTheme;
|
||||||
|
/** Short value-prop bullets shown on the branding panel */
|
||||||
|
highlights?: string[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalLoginShell({ title, subtitle, theme, highlights = [], children, footer }: PortalLoginShellProps) {
|
||||||
|
const t = PORTAL_THEMES[theme];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid lg:grid-cols-2">
|
||||||
|
{/* Branding panel */}
|
||||||
|
<div className={cn('relative hidden lg:flex flex-col justify-between p-12 text-white overflow-hidden', t.gradient)}>
|
||||||
|
{/* decorative pattern */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
|
||||||
|
backgroundSize: '24px 24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative flex items-center gap-3">
|
||||||
|
<div className="size-9 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center font-bold">T</div>
|
||||||
|
<span className="font-semibold text-lg tracking-tight">TOWER</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold leading-tight max-w-sm">{title}</h2>
|
||||||
|
{highlights.length > 0 && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{highlights.map((h) => (
|
||||||
|
<li key={h} className="flex items-center gap-3 text-sm text-white/90">
|
||||||
|
<span className="size-5 rounded-full bg-white/15 flex items-center justify-center shrink-0">
|
||||||
|
<Check className="size-3" />
|
||||||
|
</span>
|
||||||
|
{h}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="relative text-sm text-white/60">Community Knowledge Infrastructure Platform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form panel */}
|
||||||
|
<div className="flex items-center justify-center p-6 sm:p-8 bg-background">
|
||||||
|
<div className="w-full max-w-sm space-y-8">
|
||||||
|
{/* Mobile logo */}
|
||||||
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
|
<div className={cn('size-8 rounded-lg flex items-center justify-center text-white font-bold text-sm', t.gradient)}>T</div>
|
||||||
|
<span className="font-semibold">TOWER</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
{footer}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3" />
|
||||||
|
Back to portal selector
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user