Compare commits
51 Commits
ff4d0f90e8
...
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 | |||
| 4f1df96b34 | |||
| 1b63f62ca0 | |||
| e66f198785 | |||
| d6da151d16 | |||
| 50026c8a95 | |||
| 9761564c22 | |||
| 9ac3e29a20 | |||
| c8943e8d88 | |||
| 000a06fa0e | |||
| f9d5749dba |
@@ -7,3 +7,5 @@ coverage
|
||||
*.env.local
|
||||
.env.production
|
||||
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.
|
||||
+19
-3
@@ -20,23 +20,39 @@ RUN pnpm install --frozen-lockfile
|
||||
FROM installer AS builder
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm exec prisma generate --schema=apps/api/prisma/schema.prisma
|
||||
RUN pnpm turbo build --filter=@tower/api
|
||||
|
||||
# Build internal workspace packages that api depends on (in dependency order)
|
||||
RUN pnpm --filter @tower/types run build
|
||||
RUN pnpm --filter @tower/config run build
|
||||
RUN pnpm --filter @tower/logger run build
|
||||
RUN pnpm --filter @tower/search run build
|
||||
|
||||
# Build the API via its package script (runs nest build from apps/api/)
|
||||
RUN pnpm --filter @tower/api run build
|
||||
|
||||
# Hard verify: fail the Docker build if dist wasn't produced
|
||||
RUN test -f apps/api/dist/main.js || (echo "ERROR: apps/api/dist/main.js not found after build!" && exit 1)
|
||||
|
||||
# ─── Production runner ───
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=builder /app/packages/config/dist ./packages/config/dist
|
||||
COPY --from=builder /app/packages/logger/dist ./packages/logger/dist
|
||||
COPY --from=builder /app/packages/search/dist ./packages/search/dist
|
||||
COPY --from=builder /app/packages/types/dist ./packages/types/dist
|
||||
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=pruner /app/apps/api/prisma ./apps/api/prisma
|
||||
RUN pnpm exec prisma generate --schema=apps/api/prisma/schema.prisma
|
||||
|
||||
COPY apps/api/docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
pnpm exec prisma migrate deploy --schema=apps/api/prisma/schema.prisma
|
||||
for i in $(seq 1 30); do
|
||||
if pnpm exec prisma migrate deploy --schema=apps/api/prisma/schema.prisma; then
|
||||
echo "Migrations complete"
|
||||
break
|
||||
fi
|
||||
echo "Migration attempt $i failed, retrying in 2s..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Starting TOWER API..."
|
||||
exec "$@"
|
||||
|
||||
+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")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Organization layer (above Tenancy)
|
||||
// ============================================================================
|
||||
|
||||
enum OrgAdminRole {
|
||||
ORG_OWNER
|
||||
ORG_ADMIN
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
settings Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenants Tenant[]
|
||||
orgAdmins OrgAdmin[]
|
||||
orgRules OrgRule[]
|
||||
}
|
||||
|
||||
model OrgAdmin {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
email String
|
||||
passwordHash String
|
||||
name String?
|
||||
role OrgAdminRole @default(ORG_ADMIN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, email])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model OrgRule {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
matchType RuleMatchType
|
||||
matchValue String
|
||||
action RuleAction
|
||||
priority Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, matchType, matchValue])
|
||||
@@index([organizationId, isActive])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tenancy
|
||||
// ============================================================================
|
||||
@@ -18,6 +71,8 @@ model Tenant {
|
||||
isActive Boolean @default(true)
|
||||
isForwardingPaused Boolean @default(false)
|
||||
settings Json @default("{}")
|
||||
organizationId String?
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -33,6 +88,19 @@ model Tenant {
|
||||
auditEvents AuditEvent[]
|
||||
rules TenantRule[]
|
||||
groupAccesses GroupAccess[]
|
||||
digestConfig DigestConfig?
|
||||
digests Digest[]
|
||||
events Event[]
|
||||
threads Thread[]
|
||||
sevaOpportunities SevaOpportunity[]
|
||||
gamificationEvents GamificationEvent[]
|
||||
circles Circle[]
|
||||
askAIQueries AskAIQuery[]
|
||||
knowledgeBoards KnowledgeBoard[]
|
||||
knowledgeItems KnowledgeItem[]
|
||||
galleryAlbums GalleryAlbum[]
|
||||
galleryItems GalleryItem[]
|
||||
contentDrafts ContentDraft[]
|
||||
}
|
||||
|
||||
enum AdminRole {
|
||||
@@ -177,6 +245,7 @@ model Group {
|
||||
consents ConsentRecord[]
|
||||
claimTokens GroupClaimToken[]
|
||||
groupAccesses GroupAccess[]
|
||||
threads Thread[]
|
||||
|
||||
@@unique([platform, platformId])
|
||||
@@index([accountId])
|
||||
@@ -204,22 +273,48 @@ model Message {
|
||||
mediaUrl String?
|
||||
tags String[]
|
||||
status MessageStatus @default(PENDING)
|
||||
expiresAt DateTime?
|
||||
quotedPlatformMsgId String?
|
||||
threadId String?
|
||||
thread Thread? @relation(fields: [threadId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
approval Approval?
|
||||
contentDrafts ContentDraft[]
|
||||
|
||||
@@unique([platform, platformMsgId])
|
||||
@@index([tenantId])
|
||||
@@index([senderTowerUserId])
|
||||
@@index([status, expiresAt])
|
||||
@@index([threadId])
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
RAW
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
DISTRIBUTED
|
||||
ARCHIVED
|
||||
DNC
|
||||
}
|
||||
|
||||
model Thread {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
sourceGroupId String
|
||||
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||
lastActivityAt DateTime @default(now())
|
||||
messageCount Int @default(0)
|
||||
topic String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
messages Message[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([sourceGroupId, lastActivityAt])
|
||||
}
|
||||
|
||||
model Approval {
|
||||
@@ -250,12 +345,32 @@ model SyncRoute {
|
||||
targetGroupId String
|
||||
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
forwardFormat ForwardFormat @default(THREADED)
|
||||
withAttachment Boolean @default(true)
|
||||
frequency ForwardFrequency @default(INSTANT)
|
||||
approvalMode ApprovalMode @default(MANUAL)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
assignedRules SyncRouteRuleMap[]
|
||||
|
||||
@@unique([sourceGroupId, targetGroupId])
|
||||
@@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
|
||||
// ============================================================================
|
||||
@@ -316,6 +431,13 @@ model TowerUser {
|
||||
phoneHash String
|
||||
jid String
|
||||
displayName String?
|
||||
avatar String?
|
||||
hometown String?
|
||||
currentLocation String?
|
||||
interests String[]
|
||||
language String @default("en")
|
||||
digestPreference String @default("daily")
|
||||
directoryVisible Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -323,6 +445,11 @@ model TowerUser {
|
||||
optOuts MemberOptOut[]
|
||||
sessions TowerSession[]
|
||||
messages Message[] @relation("senderTowerUser")
|
||||
rsvps EventRsvp[]
|
||||
sevaEntries SevaEntry[]
|
||||
gamificationEvents GamificationEvent[]
|
||||
circleMemberships CircleMembership[]
|
||||
askAIQueries AskAIQuery[]
|
||||
|
||||
@@unique([tenantId, phoneHash])
|
||||
@@index([phoneHash])
|
||||
@@ -389,7 +516,7 @@ model OtpChallenge {
|
||||
scopes ConsentScope[]
|
||||
retentionDays Int @default(90)
|
||||
policyVersion String
|
||||
groupId String
|
||||
groupId String?
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
sentAt DateTime?
|
||||
@@ -408,6 +535,7 @@ enum RuleMatchType {
|
||||
HASHTAG
|
||||
PREFIX
|
||||
REACTION_EMOJI
|
||||
INTEREST
|
||||
}
|
||||
|
||||
enum RuleAction {
|
||||
@@ -417,6 +545,31 @@ enum RuleAction {
|
||||
REJECT
|
||||
}
|
||||
|
||||
enum RuleIntent {
|
||||
POSITIVE
|
||||
NEGATIVE
|
||||
}
|
||||
|
||||
enum ForwardFormat {
|
||||
THREADED
|
||||
DIGEST
|
||||
SUMMARY
|
||||
}
|
||||
|
||||
enum ForwardFrequency {
|
||||
INSTANT
|
||||
FIVE_MIN
|
||||
FIFTEEN_MIN
|
||||
ONE_HOUR
|
||||
ONE_DAY
|
||||
SEVEN_DAYS
|
||||
}
|
||||
|
||||
enum ApprovalMode {
|
||||
MANUAL
|
||||
AUTO
|
||||
}
|
||||
|
||||
model TenantRule {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
@@ -424,12 +577,351 @@ model TenantRule {
|
||||
matchType RuleMatchType
|
||||
matchValue String
|
||||
action RuleAction
|
||||
ruleIntent RuleIntent @default(POSITIVE)
|
||||
priority Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
syncRouteRuleMaps SyncRouteRuleMap[]
|
||||
|
||||
@@unique([tenantId, matchType, matchValue])
|
||||
@@index([tenantId, isActive])
|
||||
@@index([tenantId, matchType])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Digest
|
||||
// ============================================================================
|
||||
|
||||
model DigestConfig {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
targetGroupJid String
|
||||
targetAccountId String
|
||||
scheduleHour Int @default(20)
|
||||
scheduleMinute Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
lastSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Digest {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
content String
|
||||
messageIds String[]
|
||||
digestDate DateTime
|
||||
sentAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, digestDate])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events + RSVP
|
||||
// ============================================================================
|
||||
|
||||
enum RsvpStatus {
|
||||
GOING
|
||||
NOT_GOING
|
||||
MAYBE
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime?
|
||||
createdBy String
|
||||
isPublished Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
rsvps EventRsvp[]
|
||||
|
||||
@@index([tenantId, startsAt])
|
||||
@@index([tenantId, isPublished])
|
||||
}
|
||||
|
||||
model EventRsvp {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
status RsvpStatus
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([eventId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seva (volunteering) + Gamification
|
||||
// ============================================================================
|
||||
|
||||
enum SevaStatus {
|
||||
OPEN
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum SevaEntryStatus {
|
||||
SIGNED_UP
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model SevaOpportunity {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
slots Int?
|
||||
startsAt DateTime?
|
||||
pointsAward Int @default(20)
|
||||
status SevaStatus @default(OPEN)
|
||||
createdBy String
|
||||
isPublished Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
entries SevaEntry[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, isPublished])
|
||||
}
|
||||
|
||||
model SevaEntry {
|
||||
id String @id @default(cuid())
|
||||
opportunityId String
|
||||
opportunity SevaOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
status SevaEntryStatus @default(SIGNED_UP)
|
||||
hours Float?
|
||||
note String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([opportunityId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Multi-signal gamification — points come from many event types, not just seva.
|
||||
enum GamificationEventType {
|
||||
HELPFUL_ANSWER
|
||||
SEVA_COMPLETED
|
||||
EVENT_ATTENDANCE
|
||||
FAQ_APPROVED
|
||||
WELCOME
|
||||
PROFILE_COMPLETED
|
||||
BUSINESS_ADDED
|
||||
}
|
||||
|
||||
model GamificationEvent {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
type GamificationEventType
|
||||
points Int
|
||||
refType String?
|
||||
refId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId, userId])
|
||||
@@index([userId, createdAt])
|
||||
// One award per (user, type, ref) — prevents double-awarding the same source.
|
||||
@@unique([userId, type, refId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Circles (interest / affinity sub-groups within a chapter)
|
||||
// ============================================================================
|
||||
|
||||
enum CircleRole {
|
||||
MEMBER
|
||||
LEAD
|
||||
}
|
||||
|
||||
model Circle {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
isPublic Boolean @default(true)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships CircleMembership[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@index([tenantId, isPublic])
|
||||
}
|
||||
|
||||
model CircleMembership {
|
||||
id String @id @default(cuid())
|
||||
circleId String
|
||||
circle Circle @relation(fields: [circleId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
role CircleRole @default(MEMBER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([circleId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ask AI (RAG over the message archive)
|
||||
// ============================================================================
|
||||
|
||||
model AskAIQuery {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userId String
|
||||
user TowerUser @relation(fields: [userId], references: [id])
|
||||
question String
|
||||
answer String
|
||||
citations Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Knowledge Base (admin-curated FAQ boards)
|
||||
// ============================================================================
|
||||
|
||||
enum KnowledgeItemStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model KnowledgeBoard {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
name String
|
||||
description String?
|
||||
slug String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items KnowledgeItem[]
|
||||
|
||||
@@unique([tenantId, slug])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model KnowledgeItem {
|
||||
id String @id @default(cuid())
|
||||
boardId String
|
||||
board KnowledgeBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
question String
|
||||
answer String
|
||||
tags String[]
|
||||
status KnowledgeItemStatus @default(DRAFT)
|
||||
sortOrder Int @default(0)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([boardId, status])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memories / Gallery
|
||||
// ============================================================================
|
||||
|
||||
model GalleryAlbum {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
title String
|
||||
description String?
|
||||
coverUrl String?
|
||||
isPublished Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items GalleryItem[]
|
||||
|
||||
@@index([tenantId, isPublished])
|
||||
}
|
||||
|
||||
model GalleryItem {
|
||||
id String @id @default(cuid())
|
||||
albumId String
|
||||
album GalleryAlbum @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
mediaUrl String
|
||||
caption String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([albumId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Drafts — AI-proposed Event/Seva/FAQ pending admin review
|
||||
// ============================================================================
|
||||
|
||||
enum DraftType {
|
||||
EVENT
|
||||
SEVA_TASK
|
||||
FAQ_ITEM
|
||||
}
|
||||
|
||||
enum DraftStatus {
|
||||
PENDING_REVIEW
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model ContentDraft {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
type DraftType
|
||||
status DraftStatus @default(PENDING_REVIEW)
|
||||
sourceMessageId String
|
||||
sourceMessage Message @relation(fields: [sourceMessageId], references: [id])
|
||||
proposedJson Json
|
||||
confidence Float?
|
||||
reviewedBy String?
|
||||
reviewedAt DateTime?
|
||||
publishedId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, type, status])
|
||||
@@index([sourceMessageId])
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ import { MessagesModule } from './modules/messages/messages.module';
|
||||
import { RulesModule } from './modules/rules/rules.module';
|
||||
import { SuperAdminModule } from './modules/super-admin/super-admin.module';
|
||||
import { TenantModule } from './modules/tenant/tenant.module';
|
||||
import { DigestModule } from './modules/digest/digest.module';
|
||||
import { OrgModule } from './modules/org/org.module';
|
||||
import { ThreadsModule } from './modules/threads/threads.module';
|
||||
import { EventsModule } from './modules/events/events.module';
|
||||
import { GamificationModule } from './modules/gamification/gamification.module';
|
||||
import { SevaModule } from './modules/seva/seva.module';
|
||||
import { CirclesModule } from './modules/circles/circles.module';
|
||||
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
|
||||
import { GalleryModule } from './modules/gallery/gallery.module';
|
||||
import { DraftsModule } from './modules/drafts/drafts.module';
|
||||
import { AiStreamModule } from './modules/ai-stream/ai-stream.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +43,17 @@ import { TenantModule } from './modules/tenant/tenant.module';
|
||||
RulesModule,
|
||||
SuperAdminModule,
|
||||
TenantModule,
|
||||
DigestModule,
|
||||
OrgModule,
|
||||
ThreadsModule,
|
||||
EventsModule,
|
||||
GamificationModule,
|
||||
SevaModule,
|
||||
CirclesModule,
|
||||
KnowledgeModule,
|
||||
GalleryModule,
|
||||
DraftsModule,
|
||||
AiStreamModule,
|
||||
],
|
||||
})
|
||||
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',
|
||||
OTP_REQUESTED: 'OTP_REQUESTED',
|
||||
OTP_VERIFIED: 'OTP_VERIFIED',
|
||||
EVENT_CREATED: 'EVENT_CREATED',
|
||||
EVENT_UPDATED: 'EVENT_UPDATED',
|
||||
EVENT_DELETED: 'EVENT_DELETED',
|
||||
DIGEST_SENT: 'DIGEST_SENT',
|
||||
SEVA_CREATED: 'SEVA_CREATED',
|
||||
SEVA_DELETED: 'SEVA_DELETED',
|
||||
SEVA_COMPLETED: 'SEVA_COMPLETED',
|
||||
CIRCLE_CREATED: 'CIRCLE_CREATED',
|
||||
CIRCLE_DELETED: 'CIRCLE_DELETED',
|
||||
DRAFT_APPROVED: 'DRAFT_APPROVED',
|
||||
DRAFT_REJECTED: 'DRAFT_REJECTED',
|
||||
} as const;
|
||||
|
||||
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
||||
|
||||
@@ -26,10 +26,13 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
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) {
|
||||
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
|
||||
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
|
||||
if (!tenant) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
@@ -48,7 +51,7 @@ export class AuthService {
|
||||
'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) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
@@ -93,6 +96,8 @@ export class AuthService {
|
||||
role: admin.role as AdminRole,
|
||||
tenantId: admin.tenantId,
|
||||
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'] }> {
|
||||
const admin = await this.prisma.admin.findFirst({
|
||||
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');
|
||||
return {
|
||||
@@ -110,6 +115,8 @@ export class AuthService {
|
||||
role: admin.role as AdminRole,
|
||||
tenantId: admin.tenantId,
|
||||
tenantSlug: admin.tenant.slug,
|
||||
tenantName: admin.tenant.name,
|
||||
organization: admin.tenant.organization,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -192,6 +199,8 @@ export class AuthService {
|
||||
role: PrismaAdminRole.OWNER,
|
||||
tenantId: tenant.id,
|
||||
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 { IsArray, IsOptional, IsString } from 'class-validator';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.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 { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
class ApproveMessageDto {
|
||||
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
|
||||
}
|
||||
|
||||
@Controller('admin/messages')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
@@ -22,6 +27,11 @@ export class MessagesController {
|
||||
return this.messagesService.pendingCount(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id/routes')
|
||||
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@@ -34,8 +44,9 @@ export class MessagesController {
|
||||
approve(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@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')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue } from 'bullmq';
|
||||
import { ForwardJobData, IndexJobData } from '@tower/types';
|
||||
import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
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({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
@@ -160,10 +229,14 @@ export class MessagesService {
|
||||
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,
|
||||
);
|
||||
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,
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
@@ -204,6 +277,7 @@ export class MessagesService {
|
||||
resourceId: message.id,
|
||||
payload: {
|
||||
routesForwarded: forwardJobs.length,
|
||||
targetGroupIds: targetGroupIds ?? null,
|
||||
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 { SevaService } from '../seva/seva.service';
|
||||
import { CirclesService } from '../circles/circles.service';
|
||||
import { AskAIService } from '../ask-ai/ask-ai.service';
|
||||
import { KnowledgeService } from '../knowledge/knowledge.service';
|
||||
import { GalleryService } from '../gallery/gallery.service';
|
||||
import { MemberAuth } from '../auth/member-auth.decorator';
|
||||
import { CurrentMember } from '../auth/current-member.decorator';
|
||||
import type { MemberJwtPayload } from '@tower/types';
|
||||
@@ -22,13 +27,126 @@ class OptInDto {
|
||||
@Controller('my')
|
||||
@MemberAuth()
|
||||
export class MyController {
|
||||
constructor(private readonly service: MyService) {}
|
||||
constructor(
|
||||
private readonly service: MyService,
|
||||
private readonly seva: SevaService,
|
||||
private readonly circles: CirclesService,
|
||||
private readonly askAI: AskAIService,
|
||||
private readonly knowledge: KnowledgeService,
|
||||
private readonly gallery: GalleryService,
|
||||
) {}
|
||||
|
||||
@Get('knowledge')
|
||||
knowledgeList(@CurrentMember() member: MemberJwtPayload, @Query('q') q?: string) {
|
||||
return this.knowledge.listForMember(member.tenantId, q);
|
||||
}
|
||||
|
||||
@Get('memories')
|
||||
memoriesList(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.gallery.listForMember(member.tenantId);
|
||||
}
|
||||
|
||||
@Get('memories/:id')
|
||||
memoriesAlbum(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||
return this.gallery.getAlbumForMember(member.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('ask')
|
||||
askHistory(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.askAI.history(member.sub, member.tenantId);
|
||||
}
|
||||
|
||||
@Post('ask')
|
||||
ask(@CurrentMember() member: MemberJwtPayload, @Body() body: { question: string }) {
|
||||
return this.askAI.ask(member.sub, member.tenantId, body.question);
|
||||
}
|
||||
|
||||
@Get('seva')
|
||||
sevaList(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.seva.listForMember(member.sub, member.tenantId);
|
||||
}
|
||||
|
||||
@Post('seva/:id/signup')
|
||||
sevaSignup(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||
return this.seva.signup(member.sub, member.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('seva/:id/cancel')
|
||||
sevaCancel(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||
return this.seva.cancel(member.sub, member.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('directory')
|
||||
directory(
|
||||
@CurrentMember() member: MemberJwtPayload,
|
||||
@Query('q') q?: string,
|
||||
@Query('interest') interest?: string,
|
||||
) {
|
||||
return this.service.getDirectory(member.sub, member.tenantId, q, interest);
|
||||
}
|
||||
|
||||
@Get('circles')
|
||||
circlesList(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.circles.listForMember(member.sub, member.tenantId);
|
||||
}
|
||||
|
||||
@Post('circles/:id/join')
|
||||
circleJoin(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||
return this.circles.join(member.sub, member.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('circles/:id/leave')
|
||||
circleLeave(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
|
||||
return this.circles.leave(member.sub, member.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.service.getDashboard(member.sub, member.tenantId);
|
||||
}
|
||||
|
||||
@Get('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')
|
||||
profile(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.service.getProfile(member.sub, member.tenantId);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
updateProfile(@CurrentMember() member: MemberJwtPayload, @Body() body: {
|
||||
displayName?: string;
|
||||
hometown?: string;
|
||||
currentLocation?: string;
|
||||
interests?: string[];
|
||||
language?: string;
|
||||
digestPreference?: string;
|
||||
directoryVisible?: boolean;
|
||||
}) {
|
||||
return this.service.updateProfile(member.sub, member.tenantId, body);
|
||||
}
|
||||
|
||||
@Get('groups')
|
||||
listGroups(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.service.listGroups(member.sub, member.tenantId);
|
||||
|
||||
@@ -2,9 +2,15 @@ import { Module } from '@nestjs/common';
|
||||
import { MyController } from './my.controller';
|
||||
import { MyService } from './my.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SevaModule } from '../seva/seva.module';
|
||||
import { GamificationModule } from '../gamification/gamification.module';
|
||||
import { CirclesModule } from '../circles/circles.module';
|
||||
import { AskAIModule } from '../ask-ai/ask-ai.module';
|
||||
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
||||
import { GalleryModule } from '../gallery/gallery.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule, GalleryModule],
|
||||
controllers: [MyController],
|
||||
providers: [MyService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Unauthorize
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { ConsentScope, MemberGroupSummary, MemberOptOutReason, MemberProfile, OptInRequest, OptOutRequest } from '@tower/types';
|
||||
import { ConsentStatus, MemberOptOutReason as MemberOptOutReasonEnum } from '@prisma/client';
|
||||
|
||||
@@ -12,6 +13,7 @@ export class MyService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly gamification: GamificationService,
|
||||
) {}
|
||||
|
||||
async getProfile(userId: string, tenantId: string): Promise<MemberProfile> {
|
||||
@@ -26,6 +28,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[]> {
|
||||
const consents = await this.prisma.consentRecord.findMany({
|
||||
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 {
|
||||
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 { IsArray, IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||
import { ConsentScope } from '@tower/types';
|
||||
@@ -18,6 +18,16 @@ class VerifyOtpDto {
|
||||
@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')
|
||||
export class PublicOnboardingController {
|
||||
constructor(private readonly service: OnboardingService) {}
|
||||
@@ -46,4 +56,16 @@ export class PublicOnboardingController {
|
||||
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 { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
class CreateRouteDto {
|
||||
@IsString() sourceGroupId!: string;
|
||||
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
|
||||
@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')
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
@@ -36,6 +44,35 @@ export class RoutesController {
|
||||
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')
|
||||
@HttpCode(204)
|
||||
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
|
||||
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 = {
|
||||
sourceGroup: { select: { name: true, tenantId: true } },
|
||||
targetGroup: { select: { name: true, tenantId: true } },
|
||||
@@ -30,11 +38,70 @@ export class RoutesService {
|
||||
include: {
|
||||
sourceGroup: { 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' },
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!sourceGroupId || !targetGroupId) {
|
||||
throw new BadRequestException('sourceGroupId and targetGroupId are required');
|
||||
|
||||
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
export class RulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(tenantId: string): Promise<TenantRuleData[]> {
|
||||
const rows = await this.prisma.tenantRule.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
return rows.map((r: any) => ({
|
||||
private toData(r: any): TenantRuleData {
|
||||
return {
|
||||
id: r.id,
|
||||
tenantId: r.tenantId,
|
||||
matchType: r.matchType,
|
||||
matchValue: r.matchValue,
|
||||
action: r.action,
|
||||
ruleIntent: r.ruleIntent,
|
||||
priority: r.priority,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt.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> {
|
||||
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) {
|
||||
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({
|
||||
data: {
|
||||
tenantId,
|
||||
matchType: req.matchType,
|
||||
matchType: req.matchType as any,
|
||||
matchValue: req.matchValue,
|
||||
action: req.action,
|
||||
action: req.action as any,
|
||||
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
|
||||
priority: req.priority ?? 0,
|
||||
isActive: req.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
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(),
|
||||
};
|
||||
return this.toData(row);
|
||||
}
|
||||
|
||||
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.matchValue !== undefined) data.matchValue = req.matchValue;
|
||||
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.isActive !== undefined) data.isActive = req.isActive;
|
||||
|
||||
const row = await this.prisma.tenantRule.update({ where: { id }, data });
|
||||
|
||||
return {
|
||||
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(),
|
||||
};
|
||||
return this.toData(row);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
|
||||
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { SevaService } from './seva.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
@Controller('admin/seva')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class SevaController {
|
||||
constructor(private readonly service: SevaService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.service.listAdmin(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
slots?: number;
|
||||
startsAt?: string;
|
||||
pointsAward?: number;
|
||||
isPublished?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.service.create(ctx.tenantId, ctx.adminId!, body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
slots?: number;
|
||||
startsAt?: string;
|
||||
pointsAward?: number;
|
||||
isPublished?: boolean;
|
||||
status?: 'OPEN' | 'CLOSED';
|
||||
},
|
||||
) {
|
||||
return this.service.update(ctx.tenantId, ctx.adminId!, id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.remove(ctx.tenantId, ctx.adminId!, id);
|
||||
}
|
||||
|
||||
@Get(':id/entries')
|
||||
entries(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.listEntries(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post(':id/entries/:entryId/complete')
|
||||
complete(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Param('entryId') entryId: string,
|
||||
@Body() body: { hours?: number },
|
||||
) {
|
||||
return this.service.completeEntry(ctx.tenantId, ctx.adminId!, id, entryId, body.hours);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SevaController } from './seva.controller';
|
||||
import { SevaService } from './seva.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { GamificationModule } from '../gamification/gamification.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, GamificationModule],
|
||||
controllers: [SevaController],
|
||||
providers: [SevaService],
|
||||
exports: [SevaService],
|
||||
})
|
||||
export class SevaModule {}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
|
||||
@Injectable()
|
||||
export class SevaService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gamification: GamificationService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────────
|
||||
|
||||
async listAdmin(tenantId: string) {
|
||||
return this.prisma.sevaOpportunity.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { entries: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, adminId: string, dto: {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
slots?: number;
|
||||
startsAt?: string;
|
||||
pointsAward?: number;
|
||||
isPublished?: boolean;
|
||||
}) {
|
||||
const opp = await this.prisma.sevaOpportunity.create({
|
||||
data: {
|
||||
tenantId,
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
location: dto.location ?? null,
|
||||
slots: dto.slots ?? null,
|
||||
startsAt: dto.startsAt ? new Date(dto.startsAt) : null,
|
||||
pointsAward: dto.pointsAward ?? 20,
|
||||
isPublished: dto.isPublished ?? false,
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.SEVA_CREATED,
|
||||
resourceType: 'SevaOpportunity',
|
||||
resourceId: opp.id,
|
||||
payload: { title: opp.title },
|
||||
});
|
||||
return opp;
|
||||
}
|
||||
|
||||
async update(tenantId: string, adminId: string, id: string, dto: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
slots?: number;
|
||||
startsAt?: string;
|
||||
pointsAward?: number;
|
||||
isPublished?: boolean;
|
||||
status?: 'OPEN' | 'CLOSED';
|
||||
}) {
|
||||
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id, tenantId } });
|
||||
if (!opp) throw new NotFoundException('Opportunity not found');
|
||||
return this.prisma.sevaOpportunity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.location !== undefined && { location: dto.location }),
|
||||
...(dto.slots !== undefined && { slots: dto.slots }),
|
||||
...(dto.startsAt !== undefined && { startsAt: new Date(dto.startsAt) }),
|
||||
...(dto.pointsAward !== undefined && { pointsAward: dto.pointsAward }),
|
||||
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, adminId: string, id: string) {
|
||||
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id, tenantId } });
|
||||
if (!opp) throw new NotFoundException('Opportunity not found');
|
||||
await this.prisma.sevaOpportunity.delete({ where: { id } });
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.SEVA_DELETED,
|
||||
resourceType: 'SevaOpportunity',
|
||||
resourceId: id,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listEntries(tenantId: string, opportunityId: string) {
|
||||
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id: opportunityId, tenantId } });
|
||||
if (!opp) throw new NotFoundException('Opportunity not found');
|
||||
return this.prisma.sevaEntry.findMany({
|
||||
where: { opportunityId },
|
||||
include: { user: { select: { id: true, displayName: true, jid: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin marks an entry completed → awards SEVA_COMPLETED points (idempotent). */
|
||||
async completeEntry(tenantId: string, adminId: string, opportunityId: string, entryId: string, hours?: number) {
|
||||
const opp = await this.prisma.sevaOpportunity.findFirst({ where: { id: opportunityId, tenantId } });
|
||||
if (!opp) throw new NotFoundException('Opportunity not found');
|
||||
|
||||
const entry = await this.prisma.sevaEntry.findFirst({ where: { id: entryId, opportunityId } });
|
||||
if (!entry) throw new NotFoundException('Entry not found');
|
||||
|
||||
const updated = await this.prisma.sevaEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { status: 'COMPLETED', completedAt: new Date(), ...(hours !== undefined && { hours }) },
|
||||
});
|
||||
|
||||
await this.gamification.award(tenantId, entry.userId, 'SEVA_COMPLETED', {
|
||||
points: opp.pointsAward,
|
||||
refType: 'SevaEntry',
|
||||
refId: entry.id,
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.SEVA_COMPLETED,
|
||||
resourceType: 'SevaEntry',
|
||||
resourceId: entry.id,
|
||||
payload: { opportunityId, userId: entry.userId, points: opp.pointsAward },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Member ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async listForMember(userId: string, tenantId: string) {
|
||||
const opportunities = await this.prisma.sevaOpportunity.findMany({
|
||||
where: { tenantId, isPublished: true, status: 'OPEN' },
|
||||
orderBy: [{ startsAt: 'asc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
entries: { where: { userId }, select: { id: true, status: true } },
|
||||
_count: { select: { entries: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const myEntries = await this.prisma.sevaEntry.findMany({
|
||||
where: { userId, opportunity: { tenantId } },
|
||||
include: { opportunity: { select: { title: true, pointsAward: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [score, leaderboard] = await Promise.all([
|
||||
this.gamification.getUserScore(userId),
|
||||
this.gamification.leaderboard(tenantId, 5),
|
||||
]);
|
||||
|
||||
return {
|
||||
score,
|
||||
leaderboard,
|
||||
opportunities: opportunities.map((o) => ({
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
description: o.description,
|
||||
location: o.location,
|
||||
slots: o.slots,
|
||||
startsAt: o.startsAt?.toISOString() ?? null,
|
||||
pointsAward: o.pointsAward,
|
||||
signupCount: o._count.entries,
|
||||
myEntryStatus: o.entries[0]?.status ?? null,
|
||||
})),
|
||||
myEntries: myEntries.map((e) => ({
|
||||
id: e.id,
|
||||
opportunityTitle: e.opportunity.title,
|
||||
status: e.status,
|
||||
hours: e.hours,
|
||||
pointsAward: e.opportunity.pointsAward,
|
||||
completedAt: e.completedAt?.toISOString() ?? null,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async signup(userId: string, tenantId: string, opportunityId: string) {
|
||||
const opp = await this.prisma.sevaOpportunity.findFirst({
|
||||
where: { id: opportunityId, tenantId, isPublished: true, status: 'OPEN' },
|
||||
include: { _count: { select: { entries: true } } },
|
||||
});
|
||||
if (!opp) throw new NotFoundException('Opportunity not available');
|
||||
|
||||
if (opp.slots != null && opp._count.entries >= opp.slots) {
|
||||
const existing = await this.prisma.sevaEntry.findUnique({
|
||||
where: { opportunityId_userId: { opportunityId, userId } },
|
||||
});
|
||||
if (!existing) throw new BadRequestException('This opportunity is full');
|
||||
}
|
||||
|
||||
const entry = await this.prisma.sevaEntry.upsert({
|
||||
where: { opportunityId_userId: { opportunityId, userId } },
|
||||
create: { opportunityId, userId, status: 'SIGNED_UP' },
|
||||
update: { status: 'SIGNED_UP' },
|
||||
});
|
||||
return { ok: true, entryId: entry.id, status: entry.status };
|
||||
}
|
||||
|
||||
async cancel(userId: string, tenantId: string, opportunityId: string) {
|
||||
const entry = await this.prisma.sevaEntry.findFirst({
|
||||
where: { opportunityId, userId, opportunity: { tenantId } },
|
||||
});
|
||||
if (!entry) throw new NotFoundException('You are not signed up for this');
|
||||
if (entry.status === 'COMPLETED') throw new BadRequestException('Cannot cancel a completed seva');
|
||||
|
||||
await this.prisma.sevaEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { SuperAdminController } from './super-admin.controller';
|
||||
import { AdminOrgsController } from './admin-orgs.controller';
|
||||
import { SuperAdminService } from './super-admin.service';
|
||||
import { SuperAdminGuard } from './super-admin.guard';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
@@ -18,7 +19,7 @@ import { PrismaModule } from '../../prisma/prisma.module';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [SuperAdminController],
|
||||
controllers: [SuperAdminController, AdminOrgsController],
|
||||
providers: [SuperAdminService, SuperAdminGuard],
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -32,4 +32,81 @@ export class SuperAdminService {
|
||||
if (!admin) throw new UnauthorizedException('Super admin not found');
|
||||
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],
|
||||
};
|
||||
@@ -9,10 +9,12 @@
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -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 { GroupsTabs } from './GroupsTabs';
|
||||
import { apiFetch } from '../_lib/api';
|
||||
import { apiFetch } from '@/app/_lib/api';
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
@@ -22,6 +22,12 @@ interface Route {
|
||||
targetGroupId: string;
|
||||
sourceGroup: { 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 };
|
||||
@@ -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 { apiFetch } from '../../_lib/api';
|
||||
import { apiFetch } from '@/app/_lib/api';
|
||||
|
||||
interface MessageDetail {
|
||||
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 { apiFetch } from '../_lib/api';
|
||||
import { apiFetch } from '@/app/_lib/api';
|
||||
|
||||
interface MeiliHit {
|
||||
id: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BotSettingsCard } from './BotSettingsCard';
|
||||
import { apiFetch } from '../../_lib/api';
|
||||
import { apiFetch } from '@/app/_lib/api';
|
||||
|
||||
interface BotSummary {
|
||||
id: string;
|
||||
+89
-19
@@ -2,48 +2,71 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
|
||||
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
||||
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
|
||||
|
||||
interface RuleData {
|
||||
id: string;
|
||||
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
|
||||
matchType: RuleMatchType;
|
||||
matchValue: string;
|
||||
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
||||
action: RuleAction;
|
||||
ruleIntent: RuleIntent;
|
||||
priority: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const MATCH_TYPE_LABELS: Record<string, string> = {
|
||||
const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
|
||||
HASHTAG: 'Hashtag',
|
||||
PREFIX: 'Prefix',
|
||||
REACTION_EMOJI: 'Reaction Emoji',
|
||||
INTEREST: 'Interest',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
const ACTION_LABELS: Record<RuleAction, string> = {
|
||||
FLAG: 'Flag (Pending)',
|
||||
AUTO_APPROVE: 'Auto-approve',
|
||||
SKIP: 'Skip (Silent Drop)',
|
||||
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[] }) {
|
||||
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 [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 [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleMatchTypeChange(type: RuleMatchType) {
|
||||
setMatchType(type);
|
||||
setMatchValue('');
|
||||
}
|
||||
|
||||
async function addRule() {
|
||||
if (!matchValue.trim()) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const res = await fetch('/api/rules', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
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();
|
||||
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
|
||||
setMatchValue('');
|
||||
setAction('FLAG');
|
||||
setPriority(0);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function toggleRule(rule: RuleData) {
|
||||
@@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
||||
<label className="text-xs text-gray-500">Type</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="HASHTAG">Hashtag</option>
|
||||
<option value="PREFIX">Prefix</option>
|
||||
<option value="REACTION_EMOJI">Reaction Emoji</option>
|
||||
<option value="INTEREST">Interest</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<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
|
||||
value={matchValue}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{matchType === 'INTEREST' && (
|
||||
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
|
||||
)}
|
||||
</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">
|
||||
<label className="text-xs text-gray-500">Action</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="FLAG">Flag (Pending)</option>
|
||||
@@ -116,6 +176,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
||||
<option value="REJECT">Reject (Visible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Priority</label>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addRule()}
|
||||
@@ -140,13 +202,14 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
|
||||
<section>
|
||||
<h2 className="text-base font-semibold mb-3">Active Rules</h2>
|
||||
{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">
|
||||
<thead>
|
||||
<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">Value</th>
|
||||
<th className="pb-2 pr-3">Intent</th>
|
||||
<th className="pb-2 pr-3">Action</th>
|
||||
<th className="pb-2 pr-3">Priority</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">
|
||||
<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">
|
||||
<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">{rule.priority}</td>
|
||||
<td className="py-2 pr-3">
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { RuleManager } from './RuleManager';
|
||||
import { apiFetch } from '../../_lib/api';
|
||||
import { apiFetch } from '@/app/_lib/api';
|
||||
|
||||
interface RuleData {
|
||||
id: string;
|
||||
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI';
|
||||
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
|
||||
matchValue: string;
|
||||
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
|
||||
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||
priority: number;
|
||||
isActive: boolean;
|
||||
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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user