Compare commits
61 Commits
18edce7552
..
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 | |||
| ff4d0f90e8 | |||
| 249d759e6a | |||
| 801c1d7121 | |||
| afff6fdbdf | |||
| 2f88e883b2 | |||
| 952a0e9b49 | |||
| e8aaae4188 | |||
| 759b49159e | |||
| 1dba77959d | |||
| 02dad1347c |
@@ -73,7 +73,44 @@
|
|||||||
"Bash(grep -v \"^$\")",
|
"Bash(grep -v \"^$\")",
|
||||||
"Bash(npm info *)",
|
"Bash(npm info *)",
|
||||||
"Bash(pnpm --filter @tower/search test)",
|
"Bash(pnpm --filter @tower/search test)",
|
||||||
"Bash(pnpm --filter @tower/search build)"
|
"Bash(pnpm --filter @tower/search build)",
|
||||||
|
"Bash(pnpm --filter @tower/worker test -- --testPathPattern approval)",
|
||||||
|
"Bash(pnpm --filter @tower/worker test -- approval)",
|
||||||
|
"Bash(xargs ls -la)",
|
||||||
|
"Bash(xargs ls)",
|
||||||
|
"Bash(pnpm --filter @tower/worker test -- --testPathPattern=index.processor)",
|
||||||
|
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('dependencies',{}\\), indent=2\\)\\)\")",
|
||||||
|
"Bash(pnpm --filter @tower/worker test index.processor)",
|
||||||
|
"Bash(pnpm --filter @tower/api test)",
|
||||||
|
"Bash(pnpm --filter @tower/api build)",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- search.controller.spec.ts)",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- search.service.spec.ts)",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- --testPathPattern=groups.service)",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- --testPathPattern=groups)",
|
||||||
|
"Bash(pnpm --filter @tower/api exec jest --testPathPattern=groups)",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- --testPathPattern=routes.service)",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- --testPathPattern=\"routes/routes.service\")",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- --testPathPattern=\"routes\")",
|
||||||
|
"Bash(pnpm --filter @tower/api test -- --testPathPattern=routes)",
|
||||||
|
"Bash(pnpm --filter @tower/api exec jest --testPathPattern=routes)",
|
||||||
|
"Bash(pnpm --filter @tower/web test -- --testPathPattern=app/page)",
|
||||||
|
"Bash(pnpm --filter @tower/web test -- --testPathPattern 'app/page')",
|
||||||
|
"Bash(pnpm --filter @tower/web test -- --testPathPattern=search/page)",
|
||||||
|
"Bash(pnpm --filter @tower/web test -- apps/web/app/search/page.test.tsx)",
|
||||||
|
"Bash(pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager)",
|
||||||
|
"Bash(git -C /Users/maaz/Documents/insignia-work/tower status)",
|
||||||
|
"Bash(git -C /Users/maaz/Documents/insignia-work/tower show --stat HEAD)",
|
||||||
|
"Bash(pnpm -r build)",
|
||||||
|
"Bash(pnpm exec *)",
|
||||||
|
"Bash(pnpm add *)",
|
||||||
|
"Bash(mkdir -p /Users/maaz/Documents/insignia-work/tower/apps/web/app/api/accounts/\\\\[id\\\\]/qr)",
|
||||||
|
"Bash(pnpm jest *)",
|
||||||
|
"Bash(cd /Users/maaz/Documents/insignia-work/tower/apps/api && pnpm test --no-coverage 2>&1 | tail -15 && cd ../worker && pnpm test --no-coverage 2>&1 | tail -15 && cd ../web && pnpm test --no-coverage 2>&1 | tail -15)",
|
||||||
|
"Read(//Users/maaz/Documents/insignia-work/**)",
|
||||||
|
"Bash(psql postgresql://tower:tower_dev@localhost:5433/tower_dev -c \"SELECT id, name, platform, \\\\\"accountId\\\\\" FROM \\\\\"Group\\\\\" LIMIT 10;\")"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/Users/maaz/Documents/insignia-work/tower/apps/web/app/api/accounts/[id]"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
coverage
|
||||||
|
.turbo
|
||||||
|
sessions
|
||||||
+22
-1
@@ -20,4 +20,25 @@ LOG_LEVEL=debug
|
|||||||
|
|
||||||
# WhatsApp
|
# WhatsApp
|
||||||
WHATSAPP_SESSION_PATH=./sessions
|
WHATSAPP_SESSION_PATH=./sessions
|
||||||
TOWER_ADMIN_JIDS=
|
|
||||||
|
# TOWER Portal (used by worker command-handler to construct onboarding links)
|
||||||
|
TOWER_PORTAL_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
BCRYPT_ROUNDS=10
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
MEMBER_JWT_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# Default seed admin (only used in dev)
|
||||||
|
SEED_ADMIN_EMAIL=admin@tower.local
|
||||||
|
SEED_ADMIN_PASSWORD=tower_dev_password
|
||||||
|
|
||||||
|
# SMTP (optional — leave SMTP_HOST blank to skip email notifications).
|
||||||
|
# Defaults shown are for Ethereal (https://ethereal.email), a fake SMTP for testing.
|
||||||
|
# Generate fresh creds at https://ethereal.email/create and paste them below.
|
||||||
|
SMTP_HOST=smtp.ethereal.email
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=garrett.padberg@ethereal.email
|
||||||
|
SMTP_PASS=c93RRyQMb9WFysYZ6q
|
||||||
|
SMTP_FROM=TOWER <noreply@tower.local>
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ dist
|
|||||||
coverage
|
coverage
|
||||||
.env
|
.env
|
||||||
*.env.local
|
*.env.local
|
||||||
|
.env.production
|
||||||
sessions/
|
sessions/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.vercel
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.turbo
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
apps/worker/sessions
|
||||||
|
apps/api/sessions
|
||||||
|
sessions
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# TOWER — Claude Code Rules
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
TOWER is a multi-tenant WhatsApp community management platform. It has four portals served by a single Next.js app and a single NestJS API:
|
||||||
|
|
||||||
|
| Portal | Path prefix | Auth cookie | Guard |
|
||||||
|
|--------|------------|-------------|-------|
|
||||||
|
| Portal selector | `/` | — | public |
|
||||||
|
| Chapter admin | `/`, `/search`, `/groups`, `/messages`, `/drafts`, `/settings`, `/threads`, `/claim-group` | `tower_token` | `JwtAuthGuard` |
|
||||||
|
| Organisation admin | `/org/*` | `tower_org_token` | `OrgAdminGuard` |
|
||||||
|
| Super admin | `/admin/*` | `tower_super_token` | `SuperAdminGuard` |
|
||||||
|
| Member | `/my/*`, `/onboard`, `/member-login` | `tower_member_token` | `MemberAuthGuard` |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 16** (App Router, TypeScript) — `apps/web`
|
||||||
|
- **NestJS** (TypeScript) — `apps/api`
|
||||||
|
- **Prisma** — ORM, PostgreSQL
|
||||||
|
- **BullMQ** + **Redis** — job queues
|
||||||
|
- **Tailwind CSS v4** — utility-first CSS
|
||||||
|
- **shadcn/ui** — component library (new-york style, CSS variables)
|
||||||
|
- **TanStack React Query** — all data fetching and mutations, no raw `fetch` in components
|
||||||
|
- **Zod** — all schema validation (forms, API response shapes)
|
||||||
|
- **react-hook-form** + **@hookform/resolvers/zod** — all forms
|
||||||
|
- **lucide-react** — icons
|
||||||
|
|
||||||
|
## File & Folder Conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/app/
|
||||||
|
_lib/ # Shared utilities and contexts
|
||||||
|
api.ts # getApiBaseUrl, jsonResponse, cookie helpers
|
||||||
|
query-client.tsx # QueryClientProvider wrapper
|
||||||
|
auth-context.tsx # Chapter admin auth (tower_token)
|
||||||
|
super-admin-context.tsx # Super admin auth (tower_super_token)
|
||||||
|
org-admin-context.tsx # Org admin auth (tower_org_token)
|
||||||
|
utils.ts # cn() helper
|
||||||
|
_components/ # Shared UI components (shadcn + custom)
|
||||||
|
ui/ # Raw shadcn components (never edit directly)
|
||||||
|
portal-shell.tsx # Shared portal layout wrapper
|
||||||
|
nav-user.tsx # User dropdown in nav
|
||||||
|
data-table.tsx # Shared table component
|
||||||
|
api/ # Next.js BFF route handlers only
|
||||||
|
auth/…
|
||||||
|
admin/…
|
||||||
|
org/…
|
||||||
|
my/…
|
||||||
|
(public)/ # Portal selector + all login pages (no auth)
|
||||||
|
admin/ # Super admin portal
|
||||||
|
org/ # Org admin portal
|
||||||
|
my/ # Member portal
|
||||||
|
[chapter pages]/ # Chapter admin pages at root level
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Rules
|
||||||
|
|
||||||
|
### General
|
||||||
|
- **No raw `fetch` in components.** All API calls go through TanStack Query hooks. Define queries in a co-located `_queries.ts` or inline `useQuery`/`useMutation`.
|
||||||
|
- **No `useState` for server data.** Use React Query. `useState` is for UI-only state (modal open, tab selection, etc.).
|
||||||
|
- **All forms use react-hook-form + Zod.** Schema first: define a `z.object({...})` schema, derive the type with `z.infer`, pass resolver to `useForm`.
|
||||||
|
- **Shared components live in `app/_components/`.** Never duplicate a button, input, or card across portals.
|
||||||
|
- **Each portal has its own layout** with nav/sidebar. Do not put portal UI logic in the root layout.
|
||||||
|
- **No `any` types.** Use proper types or `unknown` + type narrowing.
|
||||||
|
|
||||||
|
### TanStack React Query
|
||||||
|
```tsx
|
||||||
|
// Define query key factories
|
||||||
|
export const tenantKeys = {
|
||||||
|
all: ['tenants'] as const,
|
||||||
|
list: () => [...tenantKeys.all, 'list'] as const,
|
||||||
|
detail: (id: string) => [...tenantKeys.all, 'detail', id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// In component
|
||||||
|
const { data, isPending } = useQuery({
|
||||||
|
queryKey: tenantKeys.list(),
|
||||||
|
queryFn: () => api.get<Tenant[]>('/api/admin/tenants'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations always invalidate related queries
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: CreateTenantInput) => api.post('/api/admin/tenants', body),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: tenantKeys.all }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
```tsx
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function MyForm() {
|
||||||
|
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField control={form.control} name="email" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Helper
|
||||||
|
Use the typed `api` helper from `_lib/api-client.ts` — never call `fetch` directly in components or hooks:
|
||||||
|
```ts
|
||||||
|
// app/_lib/api-client.ts
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
|
||||||
|
post: <T>(url: string, body: unknown) => fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
|
||||||
|
patch: <T>(url: string, body: unknown) => fetch(url, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.ok ? r.json() as T : Promise.reject(r)),
|
||||||
|
del: (url: string) => fetch(url, { method: 'DELETE' }).then(r => { if (!r.ok) return Promise.reject(r); }),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Contexts
|
||||||
|
Each portal's auth context provides:
|
||||||
|
- `user` / `admin` — the authenticated entity (undefined when loading)
|
||||||
|
- `loading` — boolean
|
||||||
|
- `login(...)` — sets cookie via BFF, updates context
|
||||||
|
- `logout()` — clears cookie, redirects to portal login
|
||||||
|
|
||||||
|
Auth guard pattern (in portal layout, server component):
|
||||||
|
```tsx
|
||||||
|
// In a portal layout — always server component
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default async function PortalLayout({ children }) {
|
||||||
|
const token = (await cookies()).get('tower_token')?.value;
|
||||||
|
if (!token) redirect('/login');
|
||||||
|
return <PortalShell>{children}</PortalShell>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Use shadcn/ui components as the base. Extend with Tailwind utilities.
|
||||||
|
- **No inline styles.** All styles via Tailwind classes.
|
||||||
|
- **No magic numbers in class names** — use design tokens (`text-sm`, `p-4`, `gap-2` — not arbitrary `p-[13px]`).
|
||||||
|
- Colors: use semantic tokens (`text-foreground`, `bg-muted`, `border`) not raw colors.
|
||||||
|
- Dark mode: set up via `class` strategy but only if explicitly requested.
|
||||||
|
|
||||||
|
### NestJS API Rules
|
||||||
|
- All protected endpoints use a Guard decorator — never trust the request without it.
|
||||||
|
- DTOs use `class-validator` decorators. `ValidationPipe` with `whitelist: true, forbidNonWhitelisted: true` is global.
|
||||||
|
- Always use `select` in Prisma queries to avoid leaking `passwordHash` or other sensitive fields.
|
||||||
|
- Org-scoped queries always include `organizationId` in the `where` clause.
|
||||||
|
- Tenant-scoped queries always include `tenantId` in the `where` clause.
|
||||||
|
|
||||||
|
### Redirect Rules (IMPORTANT)
|
||||||
|
After successful login, each portal redirects to its own home:
|
||||||
|
- Chapter admin: `/search` (or `next` param if set)
|
||||||
|
- Org admin: `/org`
|
||||||
|
- Super admin: `/admin`
|
||||||
|
- Member: `/my`
|
||||||
|
|
||||||
|
Never redirect to `/` after login — that is the public portal selector.
|
||||||
|
|
||||||
|
### Commit Style
|
||||||
|
Conventional commits: `feat:`, `fix:`, `refactor:`, `chore:`
|
||||||
|
Co-author every commit with Claude.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ─── Prune: extract only @tower/api + its workspace deps ───
|
||||||
|
FROM node:22-alpine AS pruner
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json tsconfig.base.json ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm exec turbo prune --scope=@tower/api --docker
|
||||||
|
|
||||||
|
# ─── Install ALL deps (layer cached by lockfile) ───
|
||||||
|
FROM node:22-alpine AS installer
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
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
|
||||||
|
|
||||||
|
# ─── Build ───
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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/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
|
||||||
|
|
||||||
|
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/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "apps/api/dist/main"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
platformId: true,
|
||||||
|
claimStatus: true,
|
||||||
|
accountId: true,
|
||||||
|
tenantId: true,
|
||||||
|
claimExpiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Found ${groups.length} groups:`);
|
||||||
|
for (const g of groups) {
|
||||||
|
console.log(JSON.stringify(g, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { isBot: true },
|
||||||
|
select: { id: true, jid: true, status: true, displayName: true, createdAt: true },
|
||||||
|
});
|
||||||
|
console.log(`\nFound ${accounts.length} bot accounts:`);
|
||||||
|
for (const a of accounts) {
|
||||||
|
console.log(JSON.stringify(a, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const audits = await prisma.auditEvent.findMany({
|
||||||
|
where: { action: { in: ['GROUP_PENDING_CLAIM', 'BOT_PAIRED', 'BOT_INITIATED'] } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
select: { action: true, resourceId: true, createdAt: true, payload: true, tenantId: true },
|
||||||
|
});
|
||||||
|
console.log(`\nFound ${audits.length} relevant audit events:`);
|
||||||
|
for (const a of audits) {
|
||||||
|
console.log(JSON.stringify(a, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const total = await prisma.group.count({ where: { claimStatus: 'PENDING_CLAIM' } });
|
||||||
|
const perTenant = await prisma.tenant.findMany({
|
||||||
|
where: { groups: { some: { claimStatus: 'PENDING_CLAIM' } } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
_count: { select: { groups: { where: { claimStatus: 'PENDING_CLAIM' } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sample = await prisma.group.findMany({
|
||||||
|
where: { claimStatus: 'PENDING_CLAIM' },
|
||||||
|
take: 3,
|
||||||
|
select: { id: true, name: true, platform: true, platformId: true, createdAt: true },
|
||||||
|
});
|
||||||
|
console.log('TOTAL PENDING_CLAIM:', total);
|
||||||
|
console.log('PER TENANT:', JSON.stringify(perTenant, null, 2));
|
||||||
|
console.log('SAMPLE:', JSON.stringify(sample, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => { console.error(e); process.exit(1); })
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
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 "$@"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const p = new PrismaClient();
|
||||||
|
(async () => {
|
||||||
|
const tenants = await p.tenant.findMany({ select: { id: true, slug: true, name: true } });
|
||||||
|
const admins = await p.admin.findMany({ select: { id: true, email: true, role: true, tenant: { select: { slug: true } } } });
|
||||||
|
console.log('TENANTS:', JSON.stringify(tenants, null, 2));
|
||||||
|
console.log('ADMINS:', JSON.stringify(admins, null, 2));
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
+19
-1
@@ -6,18 +6,32 @@
|
|||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"start": "node dist/main",
|
"start": "node dist/main",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"db:seed": "DOTENV_CONFIG_PATH=.env ts-node -r dotenv/config prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"@tower/config": "workspace:*",
|
"@tower/config": "workspace:*",
|
||||||
"@tower/logger": "workspace:*",
|
"@tower/logger": "workspace:*",
|
||||||
"@tower/search": "workspace:*",
|
"@tower/search": "workspace:*",
|
||||||
"@tower/types": "workspace:*",
|
"@tower/types": "workspace:*",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"bullmq": "^5.0.0",
|
||||||
|
"ioredis": "^5.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0"
|
"rxjs": "^7.8.0"
|
||||||
},
|
},
|
||||||
@@ -25,12 +39,16 @@
|
|||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jest": "^29.0.0",
|
"@types/jest": "^29.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"jest": "^29.0.0",
|
"jest": "^29.0.0",
|
||||||
"prisma": "^6.0.0",
|
"prisma": "^6.0.0",
|
||||||
"ts-jest": "^29.0.0",
|
"ts-jest": "^29.0.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AdminRole" AS ENUM ('OWNER', 'ADMIN', 'VIEWER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActorType" AS ENUM ('ADMIN', 'SYSTEM', 'ADAPTER');
|
||||||
|
|
||||||
|
-- CreateTable: Tenant (must come first — referenced by everything else)
|
||||||
|
CREATE TABLE "Tenant" (
|
||||||
|
"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 "Tenant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
|
||||||
|
|
||||||
|
-- Insert default tenant so existing rows can be backfilled
|
||||||
|
INSERT INTO "Tenant" ("id", "slug", "name", "settings", "updatedAt")
|
||||||
|
VALUES ('default', 'default', 'Default Tenant', '{}', CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
-- Add tenantId columns as nullable first
|
||||||
|
ALTER TABLE "Account" ADD COLUMN "tenantId" TEXT;
|
||||||
|
ALTER TABLE "Approval" ADD COLUMN "tenantId" TEXT;
|
||||||
|
ALTER TABLE "ConsentRecord" ADD COLUMN "tenantId" TEXT;
|
||||||
|
ALTER TABLE "Group" ADD COLUMN "tenantId" TEXT;
|
||||||
|
ALTER TABLE "Message" ADD COLUMN "tenantId" TEXT;
|
||||||
|
ALTER TABLE "SyncRoute" ADD COLUMN "tenantId" TEXT;
|
||||||
|
|
||||||
|
-- Backfill all existing rows to the default tenant
|
||||||
|
UPDATE "Account" SET "tenantId" = 'default';
|
||||||
|
UPDATE "Approval" SET "tenantId" = 'default';
|
||||||
|
UPDATE "ConsentRecord" SET "tenantId" = 'default';
|
||||||
|
UPDATE "Group" SET "tenantId" = 'default';
|
||||||
|
UPDATE "Message" SET "tenantId" = 'default';
|
||||||
|
UPDATE "SyncRoute" SET "tenantId" = 'default';
|
||||||
|
|
||||||
|
-- Now enforce NOT NULL
|
||||||
|
ALTER TABLE "Account" ALTER COLUMN "tenantId" SET NOT NULL;
|
||||||
|
ALTER TABLE "Approval" ALTER COLUMN "tenantId" SET NOT NULL;
|
||||||
|
ALTER TABLE "ConsentRecord" ALTER COLUMN "tenantId" SET NOT NULL;
|
||||||
|
ALTER TABLE "Group" ALTER COLUMN "tenantId" SET NOT NULL;
|
||||||
|
ALTER TABLE "Message" ALTER COLUMN "tenantId" SET NOT NULL;
|
||||||
|
ALTER TABLE "SyncRoute" ALTER COLUMN "tenantId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable: Admin (new — NOT NULL tenantId is fine, we'll seed default admin later)
|
||||||
|
CREATE TABLE "Admin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" "AdminRole" NOT NULL DEFAULT 'ADMIN',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable: AuditEvent (new — NOT NULL tenantId is fine)
|
||||||
|
CREATE TABLE "AuditEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"actorType" "ActorType" NOT NULL,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resourceType" TEXT NOT NULL,
|
||||||
|
"resourceId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"traceId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Admin_tenantId_idx" ON "Admin"("tenantId");
|
||||||
|
CREATE UNIQUE INDEX "Admin_tenantId_email_key" ON "Admin"("tenantId", "email");
|
||||||
|
|
||||||
|
CREATE INDEX "AuditEvent_tenantId_createdAt_idx" ON "AuditEvent"("tenantId", "createdAt");
|
||||||
|
CREATE INDEX "AuditEvent_resourceType_resourceId_idx" ON "AuditEvent"("resourceType", "resourceId");
|
||||||
|
|
||||||
|
CREATE INDEX "Account_tenantId_idx" ON "Account"("tenantId");
|
||||||
|
CREATE INDEX "Approval_tenantId_idx" ON "Approval"("tenantId");
|
||||||
|
CREATE INDEX "ConsentRecord_tenantId_idx" ON "ConsentRecord"("tenantId");
|
||||||
|
CREATE INDEX "Group_tenantId_idx" ON "Group"("tenantId");
|
||||||
|
CREATE INDEX "Message_tenantId_idx" ON "Message"("tenantId");
|
||||||
|
CREATE INDEX "SyncRoute_tenantId_idx" ON "SyncRoute"("tenantId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Approval" ADD CONSTRAINT "Approval_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SyncRoute" ADD CONSTRAINT "SyncRoute_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ConsentRecord" ADD CONSTRAINT "ConsentRecord_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GroupClaimStatus" AS ENUM ('PENDING_CLAIM', 'CLAIMED', 'RELEASED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ConsentScope" AS ENUM ('INGEST', 'ARCHIVE', 'REPLICATE', 'DISPLAY');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ConsentStatus" AS ENUM ('GRANTED', 'REVOKED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MemberOptOutReason" AS ENUM ('STOP_KEYWORD', 'SELF_PORTAL', 'ADMIN_ACTION');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AccountStatus" ADD VALUE 'PAIRING';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ActorType" ADD VALUE 'MEMBER';
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Account" DROP CONSTRAINT "Account_tenantId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Group" DROP CONSTRAINT "Group_tenantId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Account_tenantId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ConsentRecord_groupId_memberJid_consentType_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ConsentRecord_tenantId_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Account" DROP COLUMN "tenantId",
|
||||||
|
ADD COLUMN "isBot" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "pairingExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "pairingToken" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConsentRecord" DROP COLUMN "consentType",
|
||||||
|
DROP COLUMN "grantedAt",
|
||||||
|
DROP COLUMN "memberJid",
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "effectiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "policyVersion" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "proofEventId" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "retentionDays" INTEGER NOT NULL DEFAULT 90,
|
||||||
|
ADD COLUMN "scopes" "ConsentScope"[],
|
||||||
|
ADD COLUMN "status" "ConsentStatus" NOT NULL DEFAULT 'GRANTED',
|
||||||
|
ADD COLUMN "userId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Group" ADD COLUMN "claimExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "claimStatus" "GroupClaimStatus" NOT NULL DEFAULT 'PENDING_CLAIM',
|
||||||
|
ADD COLUMN "claimedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "claimedByAdminId" TEXT,
|
||||||
|
ALTER COLUMN "tenantId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Message" ADD COLUMN "senderTowerUserId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TenantBot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TenantBot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TowerUser" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"phoneHash" TEXT NOT NULL,
|
||||||
|
"jid" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TowerUser_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TowerSession" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TowerSession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MemberOptOut" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT,
|
||||||
|
"reason" "MemberOptOutReason" NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MemberOptOut_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TenantBot_accountId_idx" ON "TenantBot"("accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TenantBot_tenantId_accountId_key" ON "TenantBot"("tenantId", "accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TowerUser_phoneHash_idx" ON "TowerUser"("phoneHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TowerUser_tenantId_idx" ON "TowerUser"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TowerUser_jid_idx" ON "TowerUser"("jid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TowerUser_tenantId_phoneHash_key" ON "TowerUser"("tenantId", "phoneHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TowerSession_tokenHash_key" ON "TowerSession"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TowerSession_userId_idx" ON "TowerSession"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TowerSession_expiresAt_idx" ON "TowerSession"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MemberOptOut_tenantId_userId_idx" ON "MemberOptOut"("tenantId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MemberOptOut_groupId_idx" ON "MemberOptOut"("groupId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MemberOptOut_userId_idx" ON "MemberOptOut"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_pairingToken_key" ON "Account"("pairingToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Account_isBot_idx" ON "Account"("isBot");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Account_status_idx" ON "Account"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConsentRecord_tenantId_groupId_userId_idx" ON "ConsentRecord"("tenantId", "groupId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConsentRecord_status_idx" ON "ConsentRecord"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConsentRecord_userId_idx" ON "ConsentRecord"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Group_claimStatus_idx" ON "Group"("claimStatus");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Message_senderTowerUserId_idx" ON "Message"("senderTowerUserId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TenantBot" ADD CONSTRAINT "TenantBot_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TenantBot" ADD CONSTRAINT "TenantBot_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Group" ADD CONSTRAINT "Group_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Group" ADD CONSTRAINT "Group_claimedByAdminId_fkey" FOREIGN KEY ("claimedByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderTowerUserId_fkey" FOREIGN KEY ("senderTowerUserId") REFERENCES "TowerUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TowerUser" ADD CONSTRAINT "TowerUser_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TowerSession" ADD CONSTRAINT "TowerSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConsentRecord" ADD CONSTRAINT "ConsentRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MemberOptOut" ADD CONSTRAINT "MemberOptOut_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MemberOptOut" ADD CONSTRAINT "MemberOptOut_userId_fkey" FOREIGN KEY ("userId") REFERENCES "TowerUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OtpChallenge" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"jid" TEXT NOT NULL,
|
||||||
|
"phoneHash" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"scopes" "ConsentScope"[],
|
||||||
|
"retentionDays" INTEGER NOT NULL DEFAULT 90,
|
||||||
|
"policyVersion" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumedAt" TIMESTAMP(3),
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OtpChallenge_tenantId_jid_idx" ON "OtpChallenge"("tenantId", "jid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OtpChallenge_expiresAt_idx" ON "OtpChallenge"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OtpChallenge_sentAt_idx" ON "OtpChallenge"("sentAt");
|
||||||
|
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tenant" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "isForwardingPaused" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SuperAdmin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SuperAdmin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SuperAdmin_email_key" ON "SuperAdmin"("email");
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RuleMatchType" AS ENUM ('HASHTAG', 'PREFIX', 'REACTION_EMOJI');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RuleAction" AS ENUM ('FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TenantRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" 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 "TenantRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TenantRule_tenantId_isActive_idx" ON "TenantRule"("tenantId", "isActive");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TenantRule_tenantId_matchType_idx" ON "TenantRule"("tenantId", "matchType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TenantRule_tenantId_matchType_matchValue_key" ON "TenantRule"("tenantId", "matchType", "matchValue");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TenantRule" ADD CONSTRAINT "TenantRule_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- CreateTable: GroupClaimToken
|
||||||
|
CREATE TABLE "GroupClaimToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"creatorJid" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"consumedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GroupClaimToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable: GroupAccess
|
||||||
|
CREATE TABLE "GroupAccess" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"grantedBy" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GroupAccess_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GroupClaimToken_token_key" ON "GroupClaimToken"("token");
|
||||||
|
CREATE INDEX "GroupClaimToken_expiresAt_idx" ON "GroupClaimToken"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GroupAccess_groupId_tenantId_key" ON "GroupAccess"("groupId", "tenantId");
|
||||||
|
CREATE INDEX "GroupAccess_tenantId_idx" ON "GroupAccess"("tenantId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GroupClaimToken" ADD CONSTRAINT "GroupClaimToken_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GroupAccess" ADD CONSTRAINT "GroupAccess_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "GroupAccess" ADD CONSTRAINT "GroupAccess_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+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;
|
||||||
+829
-19
@@ -7,8 +7,223 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Organization layer (above Tenancy)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum OrgAdminRole {
|
||||||
|
ORG_OWNER
|
||||||
|
ORG_ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model Organization {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
settings Json @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenants Tenant[]
|
||||||
|
orgAdmins OrgAdmin[]
|
||||||
|
orgRules OrgRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrgAdmin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
email String
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
role OrgAdminRole @default(ORG_ADMIN)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([organizationId, email])
|
||||||
|
@@index([organizationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrgRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
matchType RuleMatchType
|
||||||
|
matchValue String
|
||||||
|
action RuleAction
|
||||||
|
priority Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([organizationId, matchType, matchValue])
|
||||||
|
@@index([organizationId, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenancy
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model Tenant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
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
|
||||||
|
|
||||||
|
admins Admin[]
|
||||||
|
tenantBots TenantBot[]
|
||||||
|
groups Group[]
|
||||||
|
messages Message[]
|
||||||
|
approvals Approval[]
|
||||||
|
syncRoutes SyncRoute[]
|
||||||
|
consentRecords ConsentRecord[]
|
||||||
|
memberOptOuts MemberOptOut[]
|
||||||
|
towerUsers TowerUser[]
|
||||||
|
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 {
|
||||||
|
OWNER
|
||||||
|
ADMIN
|
||||||
|
VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
email String
|
||||||
|
passwordHash String
|
||||||
|
role AdminRole @default(ADMIN)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
claimedGroups Group[] @relation("claimer")
|
||||||
|
|
||||||
|
@@unique([tenantId, email])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SuperAdmin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActorType {
|
||||||
|
ADMIN
|
||||||
|
SYSTEM
|
||||||
|
ADAPTER
|
||||||
|
MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
actorType ActorType
|
||||||
|
actorId String?
|
||||||
|
action String
|
||||||
|
resourceType String
|
||||||
|
resourceId String
|
||||||
|
payload Json @default("{}")
|
||||||
|
traceId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([resourceType, resourceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WhatsApp accounts (Phase 2B: bots only, tenant-less, accessed via TenantBot)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum AccountStatus {
|
||||||
|
ACTIVE
|
||||||
|
DISCONNECTED
|
||||||
|
BANNED
|
||||||
|
PAIRING
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
// tenantId REMOVED in Phase 2B — bots are global, access scoped via TenantBot
|
||||||
|
platform String
|
||||||
|
jid String
|
||||||
|
sessionPath String
|
||||||
|
displayName String?
|
||||||
|
status AccountStatus @default(ACTIVE)
|
||||||
|
qrCode String?
|
||||||
|
isBot Boolean @default(true)
|
||||||
|
pairingToken String? @unique
|
||||||
|
pairingExpiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenants TenantBot[]
|
||||||
|
groups Group[]
|
||||||
|
|
||||||
|
@@unique([platform, jid])
|
||||||
|
@@index([isBot])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Many-to-many: which tenants may claim groups from which bot.
|
||||||
|
// Phase 2B ships with implicit "all tenants" (UI auto-grants on first claim),
|
||||||
|
// but the table is wired so multi-bot + restricted sharing works in later phases.
|
||||||
|
model TenantBot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
accountId String
|
||||||
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([tenantId, accountId])
|
||||||
|
@@index([accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Groups + claim lifecycle
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum GroupClaimStatus {
|
||||||
|
PENDING_CLAIM
|
||||||
|
CLAIMED
|
||||||
|
RELEASED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
model Group {
|
model Group {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
// tenantId nullable: null while PENDING_CLAIM/EXPIRED, set once CLAIMED
|
||||||
|
tenantId String?
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
platform String
|
platform String
|
||||||
platformId String
|
platformId String
|
||||||
name String
|
name String
|
||||||
@@ -16,54 +231,104 @@ model Group {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
accountId String?
|
accountId String?
|
||||||
account Account? @relation(fields: [accountId], references: [id])
|
account Account? @relation(fields: [accountId], references: [id])
|
||||||
|
claimStatus GroupClaimStatus @default(PENDING_CLAIM)
|
||||||
|
claimedAt DateTime?
|
||||||
|
claimedByAdminId String?
|
||||||
|
claimedByAdmin Admin? @relation("claimer", fields: [claimedByAdminId], references: [id])
|
||||||
|
claimExpiresAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
messages Message[]
|
messages Message[]
|
||||||
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
|
syncRoutesFrom SyncRoute[] @relation("sourceGroup")
|
||||||
syncRoutesTo SyncRoute[] @relation("targetGroup")
|
syncRoutesTo SyncRoute[] @relation("targetGroup")
|
||||||
consentRecords ConsentRecord[]
|
consents ConsentRecord[]
|
||||||
|
claimTokens GroupClaimToken[]
|
||||||
|
groupAccesses GroupAccess[]
|
||||||
|
threads Thread[]
|
||||||
|
|
||||||
@@unique([platform, platformId])
|
@@unique([platform, platformId])
|
||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([claimStatus])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message ingest + approval
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
platform String
|
platform String
|
||||||
platformMsgId String
|
platformMsgId String
|
||||||
sourceGroupId String
|
sourceGroupId String
|
||||||
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||||
senderJid String
|
senderJid String
|
||||||
senderName String?
|
senderName String?
|
||||||
|
senderTowerUserId String?
|
||||||
|
senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id])
|
||||||
content String
|
content String
|
||||||
mediaUrl String?
|
mediaUrl String?
|
||||||
tags String[]
|
tags String[]
|
||||||
status MessageStatus @default(PENDING)
|
status MessageStatus @default(PENDING)
|
||||||
|
expiresAt DateTime?
|
||||||
|
quotedPlatformMsgId String?
|
||||||
|
threadId String?
|
||||||
|
thread Thread? @relation(fields: [threadId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
approval Approval?
|
approval Approval?
|
||||||
|
contentDrafts ContentDraft[]
|
||||||
|
|
||||||
@@unique([platform, platformMsgId])
|
@@unique([platform, platformMsgId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([senderTowerUserId])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@index([threadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MessageStatus {
|
enum MessageStatus {
|
||||||
|
RAW
|
||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
REJECTED
|
REJECTED
|
||||||
DISTRIBUTED
|
DISTRIBUTED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
|
DNC
|
||||||
|
}
|
||||||
|
|
||||||
|
model Thread {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
sourceGroupId String
|
||||||
|
sourceGroup Group @relation(fields: [sourceGroupId], references: [id])
|
||||||
|
lastActivityAt DateTime @default(now())
|
||||||
|
messageCount Int @default(0)
|
||||||
|
topic String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
messages Message[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([sourceGroupId, lastActivityAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Approval {
|
model Approval {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
messageId String @unique
|
messageId String @unique
|
||||||
message Message @relation(fields: [messageId], references: [id])
|
message Message @relation(fields: [messageId], references: [id])
|
||||||
adminId String
|
adminId String
|
||||||
decision ApprovalDecision
|
decision ApprovalDecision
|
||||||
notes String?
|
notes String?
|
||||||
decidedAt DateTime @default(now())
|
decidedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ApprovalDecision {
|
enum ApprovalDecision {
|
||||||
@@ -73,45 +338,590 @@ enum ApprovalDecision {
|
|||||||
|
|
||||||
model SyncRoute {
|
model SyncRoute {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
sourceGroupId String
|
sourceGroupId String
|
||||||
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
|
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
|
||||||
targetGroupId String
|
targetGroupId String
|
||||||
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
forwardFormat ForwardFormat @default(THREADED)
|
||||||
|
withAttachment Boolean @default(true)
|
||||||
|
frequency ForwardFrequency @default(INSTANT)
|
||||||
|
approvalMode ApprovalMode @default(MANUAL)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
assignedRules SyncRouteRuleMap[]
|
||||||
|
|
||||||
@@unique([sourceGroupId, targetGroupId])
|
@@unique([sourceGroupId, targetGroupId])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model GroupClaimToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
groupId String
|
||||||
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
|
token String @unique
|
||||||
|
creatorJid String
|
||||||
|
expiresAt DateTime
|
||||||
|
consumedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GroupAccess {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
groupId String
|
||||||
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
grantedBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([groupId, tenantId])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Member onboarding (Phase 2B)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum ConsentScope {
|
||||||
|
INGEST
|
||||||
|
ARCHIVE
|
||||||
|
REPLICATE
|
||||||
|
DISPLAY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConsentStatus {
|
||||||
|
GRANTED
|
||||||
|
REVOKED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemberOptOutReason {
|
||||||
|
STOP_KEYWORD
|
||||||
|
SELF_PORTAL
|
||||||
|
ADMIN_ACTION
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET).
|
||||||
|
model TowerUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
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
|
||||||
|
|
||||||
|
consents ConsentRecord[]
|
||||||
|
optOuts MemberOptOut[]
|
||||||
|
sessions TowerSession[]
|
||||||
|
messages Message[] @relation("senderTowerUser")
|
||||||
|
rsvps EventRsvp[]
|
||||||
|
sevaEntries SevaEntry[]
|
||||||
|
gamificationEvents GamificationEvent[]
|
||||||
|
circleMemberships CircleMembership[]
|
||||||
|
askAIQueries AskAIQuery[]
|
||||||
|
|
||||||
|
@@unique([tenantId, phoneHash])
|
||||||
|
@@index([phoneHash])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([jid])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TowerSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ConsentRecord {
|
model ConsentRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
groupId String
|
groupId String
|
||||||
group Group @relation(fields: [groupId], references: [id])
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
memberJid String
|
userId String
|
||||||
consentType String
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
grantedAt DateTime @default(now())
|
scopes ConsentScope[]
|
||||||
|
retentionDays Int @default(90)
|
||||||
|
policyVersion String
|
||||||
|
status ConsentStatus @default(GRANTED)
|
||||||
|
proofEventId String
|
||||||
|
effectiveAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([groupId, memberJid, consentType])
|
@@index([tenantId, groupId, userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountStatus {
|
model MemberOptOut {
|
||||||
ACTIVE
|
|
||||||
DISCONNECTED
|
|
||||||
BANNED
|
|
||||||
}
|
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
platform String
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
userId String
|
||||||
|
user TowerUser @relation(fields: [userId], references: [id])
|
||||||
|
groupId String?
|
||||||
|
reason MemberOptOutReason
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId, userId])
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OtpChallenge {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
jid String
|
jid String
|
||||||
sessionPath String
|
phoneHash String
|
||||||
displayName String?
|
code String
|
||||||
status AccountStatus @default(ACTIVE)
|
scopes ConsentScope[]
|
||||||
qrCode String?
|
retentionDays Int @default(90)
|
||||||
groups Group[]
|
policyVersion String
|
||||||
|
groupId String?
|
||||||
|
expiresAt DateTime
|
||||||
|
consumedAt DateTime?
|
||||||
|
sentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([tenantId, jid])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@index([sentAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Rules Engine
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum RuleMatchType {
|
||||||
|
HASHTAG
|
||||||
|
PREFIX
|
||||||
|
REACTION_EMOJI
|
||||||
|
INTEREST
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuleAction {
|
||||||
|
FLAG
|
||||||
|
AUTO_APPROVE
|
||||||
|
SKIP
|
||||||
|
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
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
matchType RuleMatchType
|
||||||
|
matchValue String
|
||||||
|
action RuleAction
|
||||||
|
ruleIntent RuleIntent @default(POSITIVE)
|
||||||
|
priority Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([platform, jid])
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
import { PrismaClient, AdminRole } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEFAULT_TENANT_SLUG = 'default';
|
||||||
|
const SEED_ADMIN_EMAIL = process.env['SEED_ADMIN_EMAIL'] ?? 'admin@tower.local';
|
||||||
|
const SEED_ADMIN_PASSWORD = process.env['SEED_ADMIN_PASSWORD'] ?? 'tower_dev_password';
|
||||||
|
const SUPER_ADMIN_EMAIL = process.env['SUPER_ADMIN_EMAIL'] ?? 'super@tower.local';
|
||||||
|
const SUPER_ADMIN_PASSWORD = process.env['SUPER_ADMIN_PASSWORD'] ?? 'super_dev_password';
|
||||||
|
const BCRYPT_ROUNDS = Number(process.env['BCRYPT_ROUNDS'] ?? '10');
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const tenant = await prisma.tenant.upsert({
|
||||||
|
where: { slug: DEFAULT_TENANT_SLUG },
|
||||||
|
update: {},
|
||||||
|
create: { slug: DEFAULT_TENANT_SLUG, name: 'Default Tenant' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(SEED_ADMIN_PASSWORD, BCRYPT_ROUNDS);
|
||||||
|
const admin = await prisma.admin.upsert({
|
||||||
|
where: { tenantId_email: { tenantId: tenant.id, email: SEED_ADMIN_EMAIL } },
|
||||||
|
update: { passwordHash, role: AdminRole.OWNER },
|
||||||
|
create: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
email: SEED_ADMIN_EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
role: AdminRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const superPasswordHash = await bcrypt.hash(SUPER_ADMIN_PASSWORD, BCRYPT_ROUNDS);
|
||||||
|
const superAdmin = await prisma.superAdmin.upsert({
|
||||||
|
where: { email: SUPER_ADMIN_EMAIL },
|
||||||
|
update: { passwordHash: superPasswordHash },
|
||||||
|
create: {
|
||||||
|
email: SUPER_ADMIN_EMAIL,
|
||||||
|
passwordHash: superPasswordHash,
|
||||||
|
name: 'Super Admin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seed complete:');
|
||||||
|
console.log(` Tenant: ${tenant.slug} (${tenant.id})`);
|
||||||
|
console.log(` Admin: ${admin.email} (${admin.id}) role=${admin.role}`);
|
||||||
|
console.log(` SuperAdmin: ${superAdmin.email} (${superAdmin.id})`);
|
||||||
|
console.log(` Password: ${SEED_ADMIN_PASSWORD} (dev only — change for production)`);
|
||||||
|
console.log(` Super pwd: ${SUPER_ADMIN_PASSWORD} (dev only — change for production)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -1,19 +1,59 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { SearchModule } from './modules/search/search.module';
|
import { SearchModule } from './modules/search/search.module';
|
||||||
import { GroupsModule } from './modules/groups/groups.module';
|
import { GroupsModule } from './modules/groups/groups.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
import { BotModule } from './modules/bot/bot.module';
|
||||||
|
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||||
|
import { MyModule } from './modules/my/my.module';
|
||||||
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
AuditModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
GroupsModule,
|
GroupsModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
|
BotModule,
|
||||||
|
OnboardingModule,
|
||||||
|
MyModule,
|
||||||
|
MessagesModule,
|
||||||
|
RulesModule,
|
||||||
|
SuperAdminModule,
|
||||||
|
TenantModule,
|
||||||
|
DigestModule,
|
||||||
|
OrgModule,
|
||||||
|
ThreadsModule,
|
||||||
|
EventsModule,
|
||||||
|
GamificationModule,
|
||||||
|
SevaModule,
|
||||||
|
CirclesModule,
|
||||||
|
KnowledgeModule,
|
||||||
|
GalleryModule,
|
||||||
|
DraftsModule,
|
||||||
|
AiStreamModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||||
|
const DEFAULT_MODEL = 'google/gemini-3.5-flash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal OpenRouter chat completion. Mirrors the worker's llm-client so digest
|
||||||
|
* and Ask AI share one calling convention. Returns the assistant message text.
|
||||||
|
*/
|
||||||
|
export async function callLLM(
|
||||||
|
prompt: string,
|
||||||
|
apiKey: string,
|
||||||
|
model = DEFAULT_MODEL,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': 'https://tower.insignia.app',
|
||||||
|
'X-Title': 'TOWER Community Platform',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`OpenRouter error ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { choices: Array<{ message: { content: string } }> };
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
if (!content) throw new Error('OpenRouter returned empty content');
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { AdminRole } from '@tower/types';
|
||||||
|
|
||||||
|
export interface TenantContext {
|
||||||
|
tenantId: string;
|
||||||
|
adminId: string | null;
|
||||||
|
role: AdminRole | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyTenantContext: TenantContext = {
|
||||||
|
tenantId: '',
|
||||||
|
adminId: null,
|
||||||
|
role: null,
|
||||||
|
};
|
||||||
+17
-1
@@ -1,9 +1,25 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory, Reflector } from '@nestjs/core';
|
||||||
|
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { JwtAuthGuard } from './modules/auth/jwt-auth.guard';
|
||||||
|
import { validateEnv } from '@tower/config';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
validateEnv();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)));
|
||||||
|
|
||||||
const port = process.env['API_PORT'] ?? 3001;
|
const port = process.env['API_PORT'] ?? 3001;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`TOWER API running on port ${port}`);
|
console.log(`TOWER API running on port ${port}`);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ActorType } from '@prisma/client';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditAction } from './audit.types';
|
||||||
|
|
||||||
|
describe('AuditService', () => {
|
||||||
|
let service: AuditService;
|
||||||
|
let prisma: { auditEvent: { create: jest.Mock } };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prisma = { auditEvent: { create: jest.fn().mockResolvedValue({}) } };
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuditService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(AuditService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an audit event with the explicit tenantId and admin actor by default', async () => {
|
||||||
|
await service.log({
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
actorId: 'adm-1',
|
||||||
|
action: AuditAction.ROUTE_CREATED,
|
||||||
|
resourceType: 'SyncRoute',
|
||||||
|
resourceId: 'r-1',
|
||||||
|
payload: { foo: 'bar' },
|
||||||
|
});
|
||||||
|
expect(prisma.auditEvent.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
actorType: ActorType.ADMIN,
|
||||||
|
actorId: 'adm-1',
|
||||||
|
action: 'ROUTE_CREATED',
|
||||||
|
resourceType: 'SyncRoute',
|
||||||
|
resourceId: 'r-1',
|
||||||
|
payload: { foo: 'bar' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows explicit tenantId override (e.g. system actor)', async () => {
|
||||||
|
await service.log({
|
||||||
|
tenantId: 'tnt-override',
|
||||||
|
actorType: ActorType.SYSTEM,
|
||||||
|
actorId: null,
|
||||||
|
action: AuditAction.AUTH_LOGIN,
|
||||||
|
resourceType: 'Admin',
|
||||||
|
resourceId: 'a-1',
|
||||||
|
});
|
||||||
|
expect(prisma.auditEvent.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tenantId: 'tnt-override',
|
||||||
|
actorType: ActorType.SYSTEM,
|
||||||
|
actorId: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { ActorType } from '@prisma/client';
|
||||||
|
import { AuditActionValue } from './audit.types';
|
||||||
|
|
||||||
|
export interface AuditLogInput {
|
||||||
|
action: AuditActionValue;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
actorType?: ActorType;
|
||||||
|
actorId?: string | null;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
traceId?: string | null;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async log(input: AuditLogInput): Promise<void> {
|
||||||
|
await this.prisma.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
actorType: input.actorType ?? ActorType.ADMIN,
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
action: input.action,
|
||||||
|
resourceType: input.resourceType,
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
payload: (input.payload ?? {}) as object,
|
||||||
|
traceId: input.traceId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ActorType } from '@prisma/client';
|
||||||
|
|
||||||
|
export { ActorType };
|
||||||
|
|
||||||
|
// Initial set of audit actions — expand in later phases
|
||||||
|
export const AuditAction = {
|
||||||
|
AUTH_LOGIN: 'AUTH_LOGIN',
|
||||||
|
AUTH_LOGIN_FAILED: 'AUTH_LOGIN_FAILED',
|
||||||
|
AUTH_LOGOUT: 'AUTH_LOGOUT',
|
||||||
|
AUTH_SIGNUP: 'AUTH_SIGNUP',
|
||||||
|
ROUTE_CREATED: 'ROUTE_CREATED',
|
||||||
|
ROUTE_DELETED: 'ROUTE_DELETED',
|
||||||
|
ACCOUNT_CREATED: 'ACCOUNT_CREATED',
|
||||||
|
MESSAGE_INGESTED: 'MESSAGE_INGESTED',
|
||||||
|
MESSAGE_APPROVED: 'MESSAGE_APPROVED',
|
||||||
|
MESSAGE_FORWARDED: 'MESSAGE_FORWARDED',
|
||||||
|
MESSAGE_INDEXED: 'MESSAGE_INDEXED',
|
||||||
|
BOT_INITIATED: 'BOT_INITIATED',
|
||||||
|
BOT_PAIRED: 'BOT_PAIRED',
|
||||||
|
BOT_REVEALED: 'BOT_REVEALED',
|
||||||
|
BOT_REMOVED: 'BOT_REMOVED',
|
||||||
|
BOT_ACCESS_GRANTED: 'BOT_ACCESS_GRANTED',
|
||||||
|
GROUP_PENDING_CLAIM: 'GROUP_PENDING_CLAIM',
|
||||||
|
GROUP_CLAIMED: 'GROUP_CLAIMED',
|
||||||
|
GROUP_RELEASED: 'GROUP_RELEASED',
|
||||||
|
GROUP_EXPIRED: 'GROUP_EXPIRED',
|
||||||
|
GROUP_CLAIM_TOKEN_SENT: 'GROUP_CLAIM_TOKEN_SENT',
|
||||||
|
GROUP_CLAIMED_WITH_TOKEN: 'GROUP_CLAIMED_WITH_TOKEN',
|
||||||
|
GROUP_SHARED: 'GROUP_SHARED',
|
||||||
|
GROUP_UNSHARED: 'GROUP_UNSHARED',
|
||||||
|
GROUP_CLAIM_TOKEN_REGENERATED: 'GROUP_CLAIM_TOKEN_REGENERATED',
|
||||||
|
GROUP_BOT_REMOVED: 'GROUP_BOT_REMOVED',
|
||||||
|
GROUP_BOT_RE_ADDED: 'GROUP_BOT_RE_ADDED',
|
||||||
|
MEMBER_ONBOARDED: 'MEMBER_ONBOARDED',
|
||||||
|
MEMBER_OPT_OUT: 'MEMBER_OPT_OUT',
|
||||||
|
MEMBER_OPT_IN: 'MEMBER_OPT_IN',
|
||||||
|
MEMBER_DELETED: 'MEMBER_DELETED',
|
||||||
|
OTP_REQUESTED: 'OTP_REQUESTED',
|
||||||
|
OTP_VERIFIED: 'OTP_VERIFIED',
|
||||||
|
EVENT_CREATED: 'EVENT_CREATED',
|
||||||
|
EVENT_UPDATED: 'EVENT_UPDATED',
|
||||||
|
EVENT_DELETED: 'EVENT_DELETED',
|
||||||
|
DIGEST_SENT: 'DIGEST_SENT',
|
||||||
|
SEVA_CREATED: 'SEVA_CREATED',
|
||||||
|
SEVA_DELETED: 'SEVA_DELETED',
|
||||||
|
SEVA_COMPLETED: 'SEVA_COMPLETED',
|
||||||
|
CIRCLE_CREATED: 'CIRCLE_CREATED',
|
||||||
|
CIRCLE_DELETED: 'CIRCLE_DELETED',
|
||||||
|
DRAFT_APPROVED: 'DRAFT_APPROVED',
|
||||||
|
DRAFT_REJECTED: 'DRAFT_REJECTED',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuditActionValue = (typeof AuditAction)[keyof typeof AuditAction];
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { SignupDto } from './dto/signup.dto';
|
||||||
|
import { Public } from './public.decorator';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
import { CurrentAdmin } from './current-admin.decorator';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('signup')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async signup(@Body() dto: SignupDto) {
|
||||||
|
return this.authService.signup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async logout(@CurrentAdmin() admin: any) {
|
||||||
|
return this.authService.logout(admin.sub, admin.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('me')
|
||||||
|
async me(@CurrentAdmin() admin: any) {
|
||||||
|
return this.authService.me(admin.sub, admin.tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
import { RolesGuard } from './roles.guard';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { BotModule } from '../bot/bot.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AuditModule,
|
||||||
|
forwardRef(() => BotModule),
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('JWT_SECRET') ?? '',
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: (config.get<string>('JWT_EXPIRES_IN') ?? '7d') as `${number}d` | `${number}h` | `${number}m` | `${number}s`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy, JwtAuthGuard, RolesGuard],
|
||||||
|
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { BotService } from '../bot/bot.service';
|
||||||
|
import { verifyPassword, hashPassword } from './password.util';
|
||||||
|
|
||||||
|
jest.mock('./password.util', () => ({
|
||||||
|
verifyPassword: jest.fn(),
|
||||||
|
hashPassword: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: any;
|
||||||
|
let jwt: { signAsync: jest.Mock };
|
||||||
|
let audit: { log: jest.Mock };
|
||||||
|
let bot: { assignBotToTenant: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prisma = {
|
||||||
|
tenant: { findUnique: jest.fn(), create: jest.fn() },
|
||||||
|
admin: { findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), create: jest.fn() },
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
jwt = { signAsync: jest.fn().mockResolvedValue('signed-jwt-token') };
|
||||||
|
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
bot = { assignBotToTenant: jest.fn().mockResolvedValue({ id: 'bot-1', status: 'ACTIVE' }) };
|
||||||
|
(verifyPassword as jest.Mock).mockReset();
|
||||||
|
(hashPassword as jest.Mock).mockReset().mockResolvedValue('hashed-pw');
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
{ provide: JwtService, useValue: jwt },
|
||||||
|
{ provide: AuditService, useValue: audit },
|
||||||
|
{ provide: BotService, useValue: bot },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('returns token + admin on valid credentials', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
|
||||||
|
prisma.admin.findUnique.mockResolvedValue({
|
||||||
|
id: 'adm-1',
|
||||||
|
email: 'admin@tower.local',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
});
|
||||||
|
(verifyPassword as jest.Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await service.login({
|
||||||
|
tenantSlug: 'default',
|
||||||
|
email: 'admin@tower.local',
|
||||||
|
password: 'secret123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.token).toBe('signed-jwt-token');
|
||||||
|
expect(res.admin).toEqual({
|
||||||
|
id: 'adm-1',
|
||||||
|
email: 'admin@tower.local',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
tenantSlug: 'default',
|
||||||
|
});
|
||||||
|
expect(audit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'AUTH_LOGIN', resourceId: 'adm-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown tenant', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.login({ tenantSlug: 'nope', email: 'a@b.c', password: 'secret123' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown admin', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
|
||||||
|
prisma.admin.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.login({ tenantSlug: 'default', email: 'a@b.c', password: 'secret123' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
expect(audit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'AUTH_LOGIN_FAILED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bad password', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-1', slug: 'default' });
|
||||||
|
prisma.admin.findUnique.mockResolvedValue({
|
||||||
|
id: 'adm-1',
|
||||||
|
email: 'a@b.c',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
});
|
||||||
|
(verifyPassword as jest.Mock).mockResolvedValue(false);
|
||||||
|
await expect(
|
||||||
|
service.login({ tenantSlug: 'default', email: 'a@b.c', password: 'wrong1234' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in by email alone when no tenantSlug is given and the email is unique', async () => {
|
||||||
|
prisma.admin.findMany.mockResolvedValue([{ tenantId: 'tnt-2' }]);
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-2', slug: 'other' });
|
||||||
|
prisma.admin.findUnique.mockResolvedValue({
|
||||||
|
id: 'adm-2',
|
||||||
|
email: 'a@b.c',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-2',
|
||||||
|
});
|
||||||
|
(verifyPassword as jest.Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await service.login({ email: 'a@b.c', password: 'secret123' });
|
||||||
|
|
||||||
|
expect(res.token).toBe('signed-jwt-token');
|
||||||
|
expect(res.admin.tenantSlug).toBe('other');
|
||||||
|
expect(res.admin.tenantId).toBe('tnt-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when email belongs to multiple tenants', async () => {
|
||||||
|
prisma.admin.findMany.mockResolvedValue([{ tenantId: 'tnt-1' }, { tenantId: 'tnt-2' }]);
|
||||||
|
await expect(
|
||||||
|
service.login({ email: 'shared@x.com', password: 'secret123' }),
|
||||||
|
).rejects.toThrow(/multiple tenants/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when email matches no admin', async () => {
|
||||||
|
prisma.admin.findMany.mockResolvedValue([]);
|
||||||
|
await expect(
|
||||||
|
service.login({ email: 'nobody@x.com', password: 'secret123' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('me', () => {
|
||||||
|
it('returns admin profile', async () => {
|
||||||
|
prisma.admin.findFirst.mockResolvedValue({
|
||||||
|
id: 'adm-1',
|
||||||
|
email: 'a@b.c',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
tenant: { slug: 'default' },
|
||||||
|
});
|
||||||
|
const res = await service.me('adm-1', 'tnt-1');
|
||||||
|
expect(res).toEqual({
|
||||||
|
admin: {
|
||||||
|
id: 'adm-1',
|
||||||
|
email: 'a@b.c',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-1',
|
||||||
|
tenantSlug: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when admin not found', async () => {
|
||||||
|
prisma.admin.findFirst.mockResolvedValue(null);
|
||||||
|
await expect(service.me('x', 'y')).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('writes audit and returns ok', async () => {
|
||||||
|
const res = await service.logout('adm-1', 'tnt-1');
|
||||||
|
expect(res).toEqual({ ok: true });
|
||||||
|
expect(audit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'AUTH_LOGOUT', resourceId: 'adm-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signup', () => {
|
||||||
|
const baseReq = {
|
||||||
|
tenantName: 'Delhi Traders',
|
||||||
|
tenantSlug: 'delhi',
|
||||||
|
email: 'priya@delhi.test',
|
||||||
|
password: 'strongpass1',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates tenant + owner admin atomically and returns token', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.$transaction.mockImplementation(async (cb: any) =>
|
||||||
|
cb({
|
||||||
|
tenant: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'tnt-new', slug: 'delhi', name: 'Delhi Traders' }),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
create: jest.fn().mockResolvedValue({
|
||||||
|
id: 'adm-new',
|
||||||
|
tenantId: 'tnt-new',
|
||||||
|
email: 'priya@delhi.test',
|
||||||
|
role: 'OWNER',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tenantRule: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await service.signup(baseReq);
|
||||||
|
|
||||||
|
expect(res.token).toBe('signed-jwt-token');
|
||||||
|
expect(res.admin).toEqual({
|
||||||
|
id: 'adm-new',
|
||||||
|
email: 'priya@delhi.test',
|
||||||
|
role: 'OWNER',
|
||||||
|
tenantId: 'tnt-new',
|
||||||
|
tenantSlug: 'delhi',
|
||||||
|
});
|
||||||
|
expect(audit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'AUTH_SIGNUP',
|
||||||
|
tenantId: 'tnt-new',
|
||||||
|
resourceId: 'tnt-new',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a taken slug with 409', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue({ id: 'tnt-existing', slug: 'delhi' });
|
||||||
|
await expect(service.signup(baseReq)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashes the password before storing', async () => {
|
||||||
|
prisma.tenant.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.$transaction.mockImplementation(async (cb: any) =>
|
||||||
|
cb({
|
||||||
|
tenant: { create: jest.fn().mockResolvedValue({ id: 'tnt-x', slug: 'x', name: 'X' }) },
|
||||||
|
admin: {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }: any) => ({
|
||||||
|
id: 'adm-x',
|
||||||
|
tenantId: 'tnt-x',
|
||||||
|
email: data.email,
|
||||||
|
role: data.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
tenantRule: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.signup({ ...baseReq, password: 'plaintext' });
|
||||||
|
expect(hashPassword).toHaveBeenCalledWith('plaintext');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import {
|
||||||
|
AdminRole,
|
||||||
|
JwtPayload,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
SignupRequest,
|
||||||
|
SignupResponse,
|
||||||
|
} from '@tower/types';
|
||||||
|
import { AdminRole as PrismaAdminRole } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { verifyPassword } from './password.util';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
import { hashPassword } from './password.util';
|
||||||
|
import { BotService } from '../bot/bot.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly bot: BotService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(req: LoginRequest): Promise<LoginResponse> {
|
||||||
|
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 }, include: tenantInclude });
|
||||||
|
if (!tenant) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Look up by email across all tenants. Most users belong to one tenant.
|
||||||
|
const matches = await this.prisma.admin.findMany({
|
||||||
|
where: { email: req.email },
|
||||||
|
select: { tenantId: true },
|
||||||
|
});
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
// Disambiguate by requiring the client to send tenantSlug.
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Email is registered in multiple tenants — please specify tenantSlug',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId }, include: tenantInclude });
|
||||||
|
if (!found) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
tenant = found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await this.prisma.admin.findUnique({
|
||||||
|
where: { tenantId_email: { tenantId: tenant.id, email: req.email } },
|
||||||
|
});
|
||||||
|
if (!admin) {
|
||||||
|
await this.recordFailedLogin(req.email, 'no_admin', tenant.id);
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
const ok = await verifyPassword(req.password, admin.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
await this.recordFailedLogin(req.email, 'bad_password', tenant.id, admin.id);
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
kind: 'admin',
|
||||||
|
sub: admin.id,
|
||||||
|
tenantId: admin.tenantId,
|
||||||
|
role: admin.role as AdminRole,
|
||||||
|
email: admin.email,
|
||||||
|
};
|
||||||
|
const token = await this.jwt.signAsync(payload);
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
actorId: admin.id,
|
||||||
|
action: AuditAction.AUTH_LOGIN,
|
||||||
|
resourceType: 'Admin',
|
||||||
|
resourceId: admin.id,
|
||||||
|
payload: { email: admin.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: admin.role as AdminRole,
|
||||||
|
tenantId: admin.tenantId,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
organization: tenant.organization,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
|
||||||
|
const admin = await this.prisma.admin.findFirst({
|
||||||
|
where: { id: adminId, tenantId },
|
||||||
|
include: { tenant: { include: { organization: { select: { id: true, slug: true, name: true } } } } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new UnauthorizedException('Admin not found');
|
||||||
|
return {
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: admin.role as AdminRole,
|
||||||
|
tenantId: admin.tenantId,
|
||||||
|
tenantSlug: admin.tenant.slug,
|
||||||
|
tenantName: admin.tenant.name,
|
||||||
|
organization: admin.tenant.organization,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(adminId: string, tenantId: string): Promise<{ ok: true }> {
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.AUTH_LOGOUT,
|
||||||
|
resourceType: 'Admin',
|
||||||
|
resourceId: adminId,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async signup(req: SignupRequest): Promise<SignupResponse> {
|
||||||
|
const existingSlug = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
|
||||||
|
if (existingSlug) {
|
||||||
|
throw new ConflictException('That tenant slug is already taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(req.password);
|
||||||
|
|
||||||
|
const { tenant, admin } = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const tenant = await tx.tenant.create({
|
||||||
|
data: { slug: req.tenantSlug, name: req.tenantName },
|
||||||
|
});
|
||||||
|
const admin = await tx.admin.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
email: req.email,
|
||||||
|
passwordHash,
|
||||||
|
role: PrismaAdminRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed default rules: FLAG for #important and #event hashtags, PREFIX for /tower
|
||||||
|
const defaults: Array<{ matchType: any; matchValue: string; action: any; priority: number }> = [
|
||||||
|
{ matchType: 'HASHTAG' as const, matchValue: '#important', action: 'FLAG' as const, priority: 0 },
|
||||||
|
{ matchType: 'HASHTAG' as const, matchValue: '#event', action: 'FLAG' as const, priority: 1 },
|
||||||
|
{ matchType: 'PREFIX' as const, matchValue: '/tower', action: 'FLAG' as const, priority: 2 },
|
||||||
|
];
|
||||||
|
for (const rule of defaults) {
|
||||||
|
await tx.tenantRule.create({
|
||||||
|
data: { tenantId: tenant.id, ...rule },
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore duplicate errors; rules are best-effort during signup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tenant, admin };
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
kind: 'admin',
|
||||||
|
sub: admin.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
role: PrismaAdminRole.OWNER,
|
||||||
|
email: admin.email,
|
||||||
|
};
|
||||||
|
const token = await this.jwt.signAsync(payload);
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
actorId: admin.id,
|
||||||
|
action: AuditAction.AUTH_SIGNUP,
|
||||||
|
resourceType: 'Tenant',
|
||||||
|
resourceId: tenant.id,
|
||||||
|
payload: { email: admin.email, tenantSlug: tenant.slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-assign the least-loaded bot
|
||||||
|
await this.bot.assignBotToTenant(tenant.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: PrismaAdminRole.OWNER,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
organization: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordFailedLogin(
|
||||||
|
email: string,
|
||||||
|
reason: string,
|
||||||
|
tenantId?: string,
|
||||||
|
adminId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!tenantId) return; // cannot audit without a tenant
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId ?? null,
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAILED,
|
||||||
|
resourceType: 'Admin',
|
||||||
|
resourceId: adminId ?? email,
|
||||||
|
payload: { email, reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper used by the seed script
|
||||||
|
static hashForSeed = hashPassword;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { JwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
export const CurrentAdmin = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): JwtPayload | null => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return (request.user as JwtPayload | undefined) ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { MemberJwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
export const CurrentMember = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): MemberJwtPayload => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user as MemberJwtPayload;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
export const CurrentTenantContext = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): TenantContext => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.tenantContext as TenantContext;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tenantSlug?: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
tenantName!: string;
|
||||||
|
|
||||||
|
// Lowercase, alphanumeric + dashes, 2-40 chars, must start with a letter
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z][a-z0-9-]{1,39}$/, {
|
||||||
|
message:
|
||||||
|
'tenantSlug must be 2-40 chars, start with a letter, and contain only lowercase letters, digits, and dashes',
|
||||||
|
})
|
||||||
|
tenantSlug!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(128)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AdminJwtPayload, JwtPayload, MemberJwtPayload } from '@tower/types';
|
||||||
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private readonly reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After JWT validation, attach a TenantContext to the request so that
|
||||||
|
// controllers, services, and the AuditService can read tenantId/adminId/role
|
||||||
|
// without re-parsing the token.
|
||||||
|
handleRequest<TUser = JwtPayload>(err: unknown, user: TUser, info: unknown, context: ExecutionContext): TUser {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err ?? new UnauthorizedException(info instanceof Error ? info.message : 'Invalid or missing token');
|
||||||
|
}
|
||||||
|
const payload = user as unknown as JwtPayload;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
if (payload.kind === 'admin') {
|
||||||
|
const admin = payload as AdminJwtPayload;
|
||||||
|
request.tenantContext = {
|
||||||
|
tenantId: admin.tenantId,
|
||||||
|
adminId: admin.sub,
|
||||||
|
role: admin.role,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const member = payload as MemberJwtPayload;
|
||||||
|
request.tenantContext = {
|
||||||
|
tenantId: member.tenantId,
|
||||||
|
adminId: null,
|
||||||
|
role: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { JwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: config.get<string>('JWT_SECRET') ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
|
||||||
|
import { MemberAuthGuard } from './member-auth.guard';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
export const MEMBER_AUTH_KEY = 'member-auth';
|
||||||
|
|
||||||
|
export const MemberAuth = () =>
|
||||||
|
applyDecorators(SetMetadata(MEMBER_AUTH_KEY, true), UseGuards(JwtAuthGuard, MemberAuthGuard));
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { MemberJwtPayload } from '@tower/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MemberAuthGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user as MemberJwtPayload | undefined;
|
||||||
|
if (!user || user.kind !== 'member') {
|
||||||
|
throw new UnauthorizedException('Member authentication required');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
jest.mock('bcryptjs', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
hash: jest.fn(),
|
||||||
|
compare: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { hashPassword, verifyPassword } from './password.util';
|
||||||
|
|
||||||
|
const mockedBcrypt = bcrypt as unknown as { hash: jest.Mock; compare: jest.Mock };
|
||||||
|
|
||||||
|
describe('password util', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashes and verifies a password roundtrip', async () => {
|
||||||
|
mockedBcrypt.hash.mockResolvedValue('$2a$10$hashedvalue');
|
||||||
|
mockedBcrypt.compare.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const hash = await hashPassword('secret', 4);
|
||||||
|
expect(hash).toBe('$2a$10$hashedvalue');
|
||||||
|
expect(mockedBcrypt.hash).toHaveBeenCalledWith('secret', 4);
|
||||||
|
|
||||||
|
const ok = await verifyPassword('secret', hash);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong password', async () => {
|
||||||
|
mockedBcrypt.compare.mockResolvedValue(false);
|
||||||
|
const ok = await verifyPassword('wrong', '$2a$10$hashedvalue');
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function hashPassword(plain: string, rounds: number = 10): Promise<string> {
|
||||||
|
return bcrypt.hash(plain, rounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(plain, hash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { AdminRole } from '@tower/types';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: AdminRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AdminRole } from '@tower/types';
|
||||||
|
import { ROLES_KEY } from './roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const required = this.reflector.getAllAndOverride<AdminRole[] | undefined>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!required || required.length === 0) return true;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const role = request.tenantContext?.role as AdminRole | null | undefined;
|
||||||
|
if (!role || !required.includes(role)) {
|
||||||
|
throw new ForbiddenException(`Role ${role ?? 'none'} not in required: ${required.join(', ')}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { BotService } from './bot.service';
|
||||||
|
import { SuperAdminGuard } from '../super-admin/super-admin.guard';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
class InitiateBotDto {
|
||||||
|
@IsOptional() @IsString() displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/bots')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
export class BotAdminController {
|
||||||
|
constructor(private readonly service: BotService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.service.listAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('initiate')
|
||||||
|
initiate(@Body() body: InitiateBotDto) {
|
||||||
|
return this.service.superInitiate(body.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('qr/:token')
|
||||||
|
getQr(@Param('token') token: string) {
|
||||||
|
return this.service.superGetQr(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/assign')
|
||||||
|
assign(@Param('id') id: string, @Body() body: { tenantId: string }) {
|
||||||
|
return this.service.assignTenant(body.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.superRemove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { BotService } from './bot.service';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
|
||||||
|
@Controller('admin/bot')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('OWNER', 'ADMIN')
|
||||||
|
export class BotController {
|
||||||
|
constructor(private readonly service: BotService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.get(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reveal')
|
||||||
|
reveal(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.service.reveal(ctx.tenantId, ctx.adminId ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { BotController } from './bot.controller';
|
||||||
|
import { BotAdminController } from './bot-admin.controller';
|
||||||
|
import { BotService } from './bot.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SuperAdminModule } from '../super-admin/super-admin.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, forwardRef(() => AuthModule), SuperAdminModule],
|
||||||
|
controllers: [BotController, BotAdminController],
|
||||||
|
providers: [BotService],
|
||||||
|
exports: [BotService],
|
||||||
|
})
|
||||||
|
export class BotModule {}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { BotService } from './bot.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
describe('BotService', () => {
|
||||||
|
let service: BotService;
|
||||||
|
const mockPrisma: any = {
|
||||||
|
account: {
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
tenantBot: {
|
||||||
|
create: jest.fn(),
|
||||||
|
deleteMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockAudit = { log: jest.fn() };
|
||||||
|
const mockConfig = { get: jest.fn().mockReturnValue('./sessions') };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
BotService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: AuditService, useValue: mockAudit },
|
||||||
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = module.get<BotService>(BotService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initiate', () => {
|
||||||
|
it('creates Account + TenantBot and returns pairingToken', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue(null);
|
||||||
|
const created = { id: 'acc-1', jid: 'pending_x@placeholder', displayName: null };
|
||||||
|
mockPrisma.account.create.mockResolvedValue(created);
|
||||||
|
mockPrisma.tenantBot.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await service.initiate('tnt-1', 'adm-1', 'MyBot');
|
||||||
|
|
||||||
|
expect(res.pairingToken).toBeTruthy();
|
||||||
|
expect(res.expiresAt).toBeTruthy();
|
||||||
|
expect(mockPrisma.account.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
isBot: true,
|
||||||
|
status: 'PAIRING',
|
||||||
|
displayName: 'MyBot',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockPrisma.tenantBot.create).toHaveBeenCalledWith({
|
||||||
|
data: { tenantId: 'tnt-1', accountId: 'acc-1', isActive: true },
|
||||||
|
});
|
||||||
|
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'BOT_INITIATED', resourceId: 'acc-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects if any account already exists', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue({ id: 'acc-existing' });
|
||||||
|
await expect(service.initiate('tnt-1', 'adm-1')).rejects.toThrow(/already configured/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
it('returns { bot: null, shared: false } when no bot paired', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue(null);
|
||||||
|
expect(await service.get('tnt-1')).toEqual({ bot: null, shared: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides jid when bot is not ACTIVE', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue({
|
||||||
|
id: 'acc-1',
|
||||||
|
platform: 'whatsapp',
|
||||||
|
jid: 'pending_x@placeholder',
|
||||||
|
displayName: null,
|
||||||
|
status: 'PAIRING',
|
||||||
|
isBot: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
const res = await service.get('tnt-1');
|
||||||
|
expect(res.bot?.jid).toBeNull();
|
||||||
|
expect(res.bot?.status).toBe('PAIRING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows jid when bot is ACTIVE', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue({
|
||||||
|
id: 'acc-1',
|
||||||
|
platform: 'whatsapp',
|
||||||
|
jid: '1234567890:12@s.whatsapp.net',
|
||||||
|
displayName: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isBot: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
const res = await service.get('tnt-1');
|
||||||
|
expect(res.bot?.jid).toBe('1234567890:12@s.whatsapp.net');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports shared=true when caller has no own bot but another tenant does', async () => {
|
||||||
|
mockPrisma.account.findFirst
|
||||||
|
.mockResolvedValueOnce(null) // own bot lookup: none
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'acc-shared',
|
||||||
|
platform: 'whatsapp',
|
||||||
|
jid: 'shared:1@s.whatsapp.net',
|
||||||
|
displayName: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isBot: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
const res = await service.get('tnt-new');
|
||||||
|
expect(res.shared).toBe(true);
|
||||||
|
expect(res.bot).toBeNull();
|
||||||
|
expect(res.sharedBotId).toBe('acc-shared');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullAccount = (overrides: Partial<any> = {}) => ({
|
||||||
|
id: 'acc-1',
|
||||||
|
platform: 'whatsapp',
|
||||||
|
jid: 'shared:1@s.whatsapp.net',
|
||||||
|
displayName: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isBot: true,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attach', () => {
|
||||||
|
it('creates TenantBot link and writes audit', async () => {
|
||||||
|
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-shared' }));
|
||||||
|
mockPrisma.tenantBot.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await service.attach('tnt-new', 'adm-new', 'acc-shared');
|
||||||
|
expect(res.bot.id).toBe('acc-shared');
|
||||||
|
expect(mockPrisma.tenantBot.create).toHaveBeenCalledWith({
|
||||||
|
data: { tenantId: 'tnt-new', accountId: 'acc-shared', isActive: true },
|
||||||
|
});
|
||||||
|
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'BOT_ACCESS_GRANTED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent when TenantBot link already exists', async () => {
|
||||||
|
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-shared' }));
|
||||||
|
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-new', accountId: 'acc-shared' });
|
||||||
|
|
||||||
|
await service.attach('tnt-new', 'adm-new', 'acc-shared');
|
||||||
|
expect(mockPrisma.tenantBot.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects banned bots', async () => {
|
||||||
|
mockPrisma.account.findUnique.mockResolvedValue(fullAccount({ id: 'acc-banned', status: 'BANNED' }));
|
||||||
|
await expect(service.attach('tnt-new', 'adm-new', 'acc-banned')).rejects.toThrow(/banned/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reveal', () => {
|
||||||
|
it('throws when no active bot', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue({ id: 'acc-1', status: 'PAIRING', jid: 'x' });
|
||||||
|
await expect(service.reveal('tnt-1', 'adm-1')).rejects.toThrow(/No active bot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns jid and writes audit event', async () => {
|
||||||
|
mockPrisma.account.findFirst.mockResolvedValue({
|
||||||
|
id: 'acc-1',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
jid: '1234567890:12@s.whatsapp.net',
|
||||||
|
});
|
||||||
|
const res = await service.reveal('tnt-1', 'adm-1');
|
||||||
|
expect(res.jid).toBe('1234567890:12@s.whatsapp.net');
|
||||||
|
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'BOT_REVEALED', payload: { jid: '1234567890:12@s.whatsapp.net' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
import type { BotInitiateResponse, BotQrResponse, BotRevealResponse, BotStatus, BotSummary } from '@tower/types';
|
||||||
|
|
||||||
|
const PAIRING_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotService {
|
||||||
|
private readonly logger = new Logger(BotService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async initiate(tenantId: string, adminId: string, displayName?: string): Promise<BotInitiateResponse> {
|
||||||
|
const existing = await this.prisma.account.findFirst({
|
||||||
|
where: { isBot: true, status: { in: ['PAIRING', 'ACTIVE', 'DISCONNECTED'] } },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('A bot is already configured. Remove it before pairing a new one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
|
||||||
|
const uid = randomUUID();
|
||||||
|
const pairingToken = randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
|
||||||
|
|
||||||
|
const account = await this.prisma.account.create({
|
||||||
|
data: {
|
||||||
|
platform: 'whatsapp',
|
||||||
|
jid: `pending_${uid}@placeholder`,
|
||||||
|
sessionPath: `${sessionBase}/${uid}`,
|
||||||
|
displayName: displayName ?? null,
|
||||||
|
status: 'PAIRING',
|
||||||
|
isBot: true,
|
||||||
|
pairingToken,
|
||||||
|
pairingExpiresAt: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.tenantBot.create({
|
||||||
|
data: { tenantId, accountId: account.id, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.BOT_INITIATED,
|
||||||
|
resourceType: 'Account',
|
||||||
|
resourceId: account.id,
|
||||||
|
payload: { displayName: account.displayName },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pairingToken,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
qrDataUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQr(tenantId: string, pairingToken: string): Promise<BotQrResponse> {
|
||||||
|
const account = await this.prisma.account.findFirst({
|
||||||
|
where: { pairingToken, tenants: { some: { tenantId } } },
|
||||||
|
});
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundException('Pairing token not found for this tenant');
|
||||||
|
}
|
||||||
|
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
|
||||||
|
return {
|
||||||
|
status: account.status as BotStatus,
|
||||||
|
qrDataUrl: null,
|
||||||
|
pairingToken: account.pairingToken ?? '',
|
||||||
|
expiresAt: account.pairingExpiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!account.qrCode) {
|
||||||
|
return {
|
||||||
|
status: account.status as BotStatus,
|
||||||
|
qrDataUrl: null,
|
||||||
|
pairingToken: account.pairingToken ?? '',
|
||||||
|
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
|
||||||
|
return {
|
||||||
|
status: account.status as BotStatus,
|
||||||
|
qrDataUrl,
|
||||||
|
pairingToken: account.pairingToken ?? '',
|
||||||
|
expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(tenantId: string): Promise<{ bot: BotSummary | null; shared: boolean; sharedBotId?: string }> {
|
||||||
|
const own = await this.prisma.account.findFirst({
|
||||||
|
where: { tenants: { some: { tenantId } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
if (own) {
|
||||||
|
return { bot: this.toSummary(own), shared: false };
|
||||||
|
}
|
||||||
|
// No own bot — is there a shared bot we could attach to?
|
||||||
|
const shared = await this.prisma.account.findFirst({
|
||||||
|
where: {
|
||||||
|
isBot: true,
|
||||||
|
status: { in: ['ACTIVE', 'DISCONNECTED', 'PAIRING'] },
|
||||||
|
tenants: { some: {} },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
if (shared) {
|
||||||
|
return { bot: null, shared: true, sharedBotId: shared.id };
|
||||||
|
}
|
||||||
|
return { bot: null, shared: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(tenantId: string, adminId: string, accountId: string): Promise<{ bot: BotSummary }> {
|
||||||
|
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
|
||||||
|
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
|
||||||
|
if (account.status === 'BANNED') {
|
||||||
|
throw new ConflictException('Bot is banned and cannot be shared');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.tenantBot.findUnique({
|
||||||
|
where: { tenantId_accountId: { tenantId, accountId } },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await this.prisma.tenantBot.create({
|
||||||
|
data: { tenantId, accountId, isActive: true },
|
||||||
|
});
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.BOT_ACCESS_GRANTED,
|
||||||
|
resourceType: 'Account',
|
||||||
|
resourceId: accountId,
|
||||||
|
payload: { reason: 'tenant attached to shared bot' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { bot: this.toSummary(account) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(account: any): BotSummary {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
platform: account.platform,
|
||||||
|
jid: account.status === 'ACTIVE' ? account.jid : null,
|
||||||
|
displayName: account.displayName,
|
||||||
|
status: account.status as BotStatus,
|
||||||
|
isBot: account.isBot,
|
||||||
|
createdAt: account.createdAt.toISOString(),
|
||||||
|
updatedAt: account.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the least-loaded ACTIVE bot and assign it to the tenant.
|
||||||
|
* Returns null if no bot is available in the pool.
|
||||||
|
* Idempotent — skips if the tenant already has a TenantBot.
|
||||||
|
*/
|
||||||
|
async assignBotToTenant(tenantId: string): Promise<BotSummary | null> {
|
||||||
|
const existing = await this.prisma.tenantBot.findFirst({
|
||||||
|
where: { tenantId },
|
||||||
|
include: { account: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return this.toSummary(existing.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await this.prisma.account.findMany({
|
||||||
|
where: { isBot: true, status: 'ACTIVE' },
|
||||||
|
include: { _count: { select: { tenants: true } } },
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
this.logger.warn({ tenantId }, 'No ACTIVE bot available to assign');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = candidates.reduce((a, b) =>
|
||||||
|
a._count.tenants <= b._count.tenants ? a : b,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.tenantBot.create({
|
||||||
|
data: { tenantId, accountId: best.id, isActive: true },
|
||||||
|
});
|
||||||
|
this.logger.log({ tenantId, accountId: best.id, tenantCount: best._count.tenants }, 'Bot auto-assigned');
|
||||||
|
|
||||||
|
return this.toSummary(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reveal(tenantId: string, adminId: string): Promise<BotRevealResponse> {
|
||||||
|
const account = await this.prisma.account.findFirst({
|
||||||
|
where: { tenants: { some: { tenantId } } },
|
||||||
|
});
|
||||||
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
|
throw new NotFoundException('No active bot to reveal');
|
||||||
|
}
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.BOT_REVEALED,
|
||||||
|
resourceType: 'Account',
|
||||||
|
resourceId: account.id,
|
||||||
|
payload: { jid: account.jid },
|
||||||
|
});
|
||||||
|
return { jid: account.jid, revealedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Super admin bot management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listAll(): Promise<any[]> {
|
||||||
|
const bots = await this.prisma.account.findMany({
|
||||||
|
where: { isBot: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { _count: { select: { tenants: true } } },
|
||||||
|
});
|
||||||
|
return bots.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
jid: b.status === 'ACTIVE' ? b.jid : null,
|
||||||
|
displayName: b.displayName,
|
||||||
|
status: b.status,
|
||||||
|
platform: b.platform,
|
||||||
|
tenantCount: b._count.tenants,
|
||||||
|
createdAt: b.createdAt.toISOString(),
|
||||||
|
updatedAt: b.updatedAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async superInitiate(displayName?: string): Promise<{ pairingToken: string; expiresAt: string }> {
|
||||||
|
const sessionBase = this.config.get<string>('WHATSAPP_SESSION_PATH', './sessions');
|
||||||
|
const uid = randomUUID();
|
||||||
|
const pairingToken = randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + PAIRING_TTL_MS);
|
||||||
|
|
||||||
|
await this.prisma.account.create({
|
||||||
|
data: {
|
||||||
|
platform: 'whatsapp',
|
||||||
|
jid: `pending_${uid}@placeholder`,
|
||||||
|
sessionPath: `${sessionBase}/${uid}`,
|
||||||
|
displayName: displayName ?? null,
|
||||||
|
status: 'PAIRING',
|
||||||
|
isBot: true,
|
||||||
|
pairingToken,
|
||||||
|
pairingExpiresAt: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pairingToken, expiresAt: expiresAt.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async superGetQr(pairingToken: string): Promise<any> {
|
||||||
|
const account = await this.prisma.account.findFirst({
|
||||||
|
where: { pairingToken },
|
||||||
|
});
|
||||||
|
if (!account) throw new NotFoundException('Pairing token not found');
|
||||||
|
if (account.pairingExpiresAt && account.pairingExpiresAt < new Date()) {
|
||||||
|
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt.toISOString() };
|
||||||
|
}
|
||||||
|
if (!account.qrCode) {
|
||||||
|
return { status: account.status, qrDataUrl: null, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
|
||||||
|
}
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(account.qrCode);
|
||||||
|
return { status: account.status, qrDataUrl, pairingToken, expiresAt: account.pairingExpiresAt?.toISOString() ?? new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignTenant(tenantId: string, accountId: string): Promise<any> {
|
||||||
|
const account = await this.prisma.account.findUnique({ where: { id: accountId } });
|
||||||
|
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
|
||||||
|
if (account.status !== 'ACTIVE') throw new ConflictException('Bot is not ACTIVE');
|
||||||
|
|
||||||
|
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
|
||||||
|
const existing = await this.prisma.tenantBot.findFirst({ where: { tenantId } });
|
||||||
|
if (existing) throw new ConflictException('Tenant already has a bot assigned');
|
||||||
|
|
||||||
|
await this.prisma.tenantBot.create({
|
||||||
|
data: { tenantId, accountId: account.id, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, accountId: account.id, jid: account.jid };
|
||||||
|
}
|
||||||
|
|
||||||
|
async superRemove(accountId: string): Promise<{ ok: true }> {
|
||||||
|
const account = await this.prisma.account.findUnique({
|
||||||
|
where: { id: accountId },
|
||||||
|
include: { _count: { select: { tenants: true } } },
|
||||||
|
});
|
||||||
|
if (!account || !account.isBot) throw new NotFoundException('Bot not found');
|
||||||
|
if (account._count.tenants > 0) {
|
||||||
|
throw new ConflictException(`Cannot remove bot — ${account._count.tenants} tenant(s) still assigned. Reassign them first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.account.delete({ where: { id: accountId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { GroupsController } from './groups.controller';
|
import { GroupsController } from './groups.controller';
|
||||||
import { GroupsService } from './groups.service';
|
import { GroupsService } from './groups.service';
|
||||||
|
import type { TenantContext } from '../../common/tenant-context';
|
||||||
|
|
||||||
|
const ctx: TenantContext = { tenantId: 'tnt-A', adminId: 'adm_1', role: 'OWNER' };
|
||||||
|
|
||||||
const mockGroups = [
|
const mockGroups = [
|
||||||
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
|
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' },
|
||||||
];
|
];
|
||||||
const mockService = { list: jest.fn().mockResolvedValue(mockGroups) };
|
|
||||||
|
const mockService = {
|
||||||
|
list: jest.fn().mockResolvedValue(mockGroups),
|
||||||
|
listShared: jest.fn().mockResolvedValue([]),
|
||||||
|
listSharedByMe: jest.fn().mockResolvedValue([]),
|
||||||
|
getClaimTokenInfo: jest.fn(),
|
||||||
|
claimWithToken: jest.fn(),
|
||||||
|
share: jest.fn(),
|
||||||
|
unshare: jest.fn(),
|
||||||
|
regenerateToken: jest.fn(),
|
||||||
|
listUnclaimed: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
describe('GroupsController', () => {
|
describe('GroupsController', () => {
|
||||||
let controller: GroupsController;
|
let controller: GroupsController;
|
||||||
@@ -22,9 +36,29 @@ describe('GroupsController', () => {
|
|||||||
controller = module.get<GroupsController>(GroupsController);
|
controller = module.get<GroupsController>(GroupsController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns groups from service', async () => {
|
it('list() delegates to service', async () => {
|
||||||
const result = await controller.list();
|
const result = await controller.list(ctx);
|
||||||
expect(result).toEqual(mockGroups);
|
expect(result).toEqual(mockGroups);
|
||||||
expect(mockService.list).toHaveBeenCalled();
|
expect(mockService.list).toHaveBeenCalledWith('tnt-A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listShared() delegates to service', async () => {
|
||||||
|
await controller.listShared(ctx);
|
||||||
|
expect(mockService.listShared).toHaveBeenCalledWith('tnt-A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claimWithToken() delegates to service', async () => {
|
||||||
|
await controller.claimWithToken(ctx, { token: 'abc123' });
|
||||||
|
expect(mockService.claimWithToken).toHaveBeenCalledWith('abc123', 'adm_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share() delegates to service', async () => {
|
||||||
|
await controller.share(ctx, 'grp_1', { targetTenantId: 'tnt-B' });
|
||||||
|
expect(mockService.share).toHaveBeenCalledWith('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listUnclaimed() delegates to service', async () => {
|
||||||
|
await controller.listUnclaimed();
|
||||||
|
expect(mockService.listUnclaimed).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,86 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { GroupsService } from './groups.service';
|
import { GroupsService } from './groups.service';
|
||||||
|
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||||
|
import { TenantContext } from '../../common/tenant-context';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
@Controller('groups')
|
class ClaimWithTokenDto {
|
||||||
|
@IsString() token!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareDto {
|
||||||
|
@IsString() targetTenantId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
export class GroupsController {
|
export class GroupsController {
|
||||||
constructor(private readonly groupsService: GroupsService) {}
|
constructor(private readonly groupsService: GroupsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get('groups')
|
||||||
list() {
|
list(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
return this.groupsService.list();
|
return this.groupsService.list(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/shared')
|
||||||
|
listShared(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.groupsService.listShared(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/shared-by-me')
|
||||||
|
listSharedByMe(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.groupsService.listSharedByMe(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/groups/claim-token-info')
|
||||||
|
getClaimTokenInfo(@Query('token') token: string) {
|
||||||
|
return this.groupsService.getClaimTokenInfo(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/groups/claim-with-token')
|
||||||
|
@Roles('OWNER')
|
||||||
|
claimWithToken(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Body() body: ClaimWithTokenDto,
|
||||||
|
) {
|
||||||
|
return this.groupsService.claimWithToken(body.token, ctx.adminId ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/groups/:id/share')
|
||||||
|
@Roles('OWNER')
|
||||||
|
share(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: ShareDto,
|
||||||
|
) {
|
||||||
|
return this.groupsService.share(ctx.tenantId, ctx.adminId ?? '', id, body.targetTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/groups/:id/share/:targetTenantId')
|
||||||
|
@Roles('OWNER')
|
||||||
|
unshare(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('targetTenantId') targetTenantId: string,
|
||||||
|
) {
|
||||||
|
return this.groupsService.unshare(ctx.tenantId, id, targetTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/groups/:id/regenerate-token')
|
||||||
|
@Roles('OWNER')
|
||||||
|
regenerateToken(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.groupsService.regenerateToken(ctx.tenantId, ctx.adminId ?? '', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/groups/unclaimed')
|
||||||
|
@Roles('OWNER')
|
||||||
|
listUnclaimed() {
|
||||||
|
return this.groupsService.listUnclaimed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,141 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { GroupsService } from './groups.service';
|
import { GroupsService } from './groups.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
|
||||||
const mockGroups = [
|
const mockGroup = { id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1', tenantId: 'tnt-A' };
|
||||||
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
|
|
||||||
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', platformId: '222@g.us', isActive: true, accountId: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('GroupsService', () => {
|
describe('GroupsService', () => {
|
||||||
let service: GroupsService;
|
let service: GroupsService;
|
||||||
const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } };
|
const mockPrisma: any = {
|
||||||
|
group: {
|
||||||
beforeEach(() => {
|
findMany: jest.fn().mockResolvedValue([mockGroup]),
|
||||||
jest.clearAllMocks();
|
findUnique: jest.fn().mockResolvedValue(mockGroup),
|
||||||
});
|
findFirst: jest.fn(),
|
||||||
|
update: jest.fn().mockResolvedValue(mockGroup),
|
||||||
|
},
|
||||||
|
groupClaimToken: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
groupAccess: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
tenantBot: { findUnique: jest.fn(), count: jest.fn(), create: jest.fn() },
|
||||||
|
admin: { findUnique: jest.fn() },
|
||||||
|
tenant: { findMany: jest.fn() },
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockAudit = { log: jest.fn() };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
GroupsService,
|
GroupsService,
|
||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: AuditService, useValue: mockAudit },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = module.get<GroupsService>(GroupsService);
|
service = module.get<GroupsService>(GroupsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all groups ordered by name', async () => {
|
describe('list', () => {
|
||||||
const result = await service.list();
|
it('returns groups for the given tenant including shared groups', async () => {
|
||||||
expect(result).toHaveLength(2);
|
const result = await service.list('tnt-A');
|
||||||
expect(result[0].name).toBe('Alpha');
|
expect(result).toHaveLength(1);
|
||||||
expect(mockPrisma.group.findMany).toHaveBeenCalledWith({
|
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
|
||||||
orderBy: { name: 'asc' },
|
expect.objectContaining({
|
||||||
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
|
where: expect.objectContaining({ OR: expect.any(Array) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listUnclaimed', () => {
|
||||||
|
it('returns groups with no tenantId', async () => {
|
||||||
|
await service.listUnclaimed();
|
||||||
|
expect(mockPrisma.group.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { tenantId: null } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getClaimTokenInfo', () => {
|
||||||
|
it('throws NotFound for invalid token', async () => {
|
||||||
|
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce(null);
|
||||||
|
await expect(service.getClaimTokenInfo('bad')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('claimWithToken', () => {
|
||||||
|
const mockToken = {
|
||||||
|
id: 'tok_1', groupId: 'grp_1', token: 'abc123', creatorJid: 'creator@jid',
|
||||||
|
expiresAt: new Date(Date.now() + 3600000), consumedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma.admin.findUnique.mockResolvedValue({ id: 'adm_1', tenantId: 'tnt-A' });
|
||||||
|
mockPrisma.groupClaimToken.findUnique.mockResolvedValue(mockToken);
|
||||||
|
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: null, accountId: 'acc_1' });
|
||||||
|
mockPrisma.tenantBot.findUnique.mockResolvedValue({ tenantId: 'tnt-A', accountId: 'acc_1' });
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([mockGroup]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFound when admin does not exist', async () => {
|
||||||
|
mockPrisma.admin.findUnique.mockResolvedValueOnce(null);
|
||||||
|
await expect(service.claimWithToken('abc123', 'bad_admin')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws Conflict when token is consumed', async () => {
|
||||||
|
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, consumedAt: new Date() });
|
||||||
|
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws Conflict when token is expired', async () => {
|
||||||
|
mockPrisma.groupClaimToken.findUnique.mockResolvedValueOnce({ ...mockToken, expiresAt: new Date(Date.now() - 1000) });
|
||||||
|
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws Conflict when group is already claimed', async () => {
|
||||||
|
mockPrisma.group.findUnique.mockResolvedValueOnce({ id: 'grp_1', tenantId: 'tnt-B', accountId: 'acc_1' });
|
||||||
|
await expect(service.claimWithToken('abc123', 'adm_1')).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('share / unshare', () => {
|
||||||
|
const sharedAccess = { id: 'acc_1', groupId: 'grp_1', tenantId: 'tnt-B', grantedBy: 'adm_1' };
|
||||||
|
|
||||||
|
it('share creates a GroupAccess record', async () => {
|
||||||
|
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||||
|
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.groupAccess.create.mockResolvedValue(sharedAccess);
|
||||||
|
const result = await service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B');
|
||||||
|
expect(result).toEqual(sharedAccess);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share throws Conflict if already shared', async () => {
|
||||||
|
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||||
|
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
|
||||||
|
await expect(service.share('tnt-A', 'adm_1', 'grp_1', 'tnt-B')).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unshare deletes the GroupAccess record', async () => {
|
||||||
|
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||||
|
mockPrisma.groupAccess.findUnique.mockResolvedValue(sharedAccess);
|
||||||
|
mockPrisma.groupAccess.delete.mockResolvedValue(sharedAccess);
|
||||||
|
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unshare throws NotFound if no share exists', async () => {
|
||||||
|
mockPrisma.group.findUnique.mockResolvedValue({ id: 'grp_1', tenantId: 'tnt-A' });
|
||||||
|
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.unshare('tnt-A', 'grp_1', 'tnt-B')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/audit.types';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
export interface GroupSummary {
|
export interface GroupSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,16 +11,257 @@ export interface GroupSummary {
|
|||||||
platformId: string;
|
platformId: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
tenantId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GroupsService {
|
export class GroupsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
list(): Promise<GroupSummary[]> {
|
list(tenantId: string): Promise<GroupSummary[]> {
|
||||||
return this.prisma.group.findMany({
|
return this.prisma.group.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ tenantId },
|
||||||
|
{ groupAccesses: { some: { tenantId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
|
select: {
|
||||||
|
id: true, name: true, platform: true, platformId: true,
|
||||||
|
isActive: true, accountId: true, tenantId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUnclaimed(): Promise<GroupSummary[]> {
|
||||||
|
return this.prisma.group.findMany({
|
||||||
|
where: { tenantId: null },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true, name: true, platform: true, platformId: true,
|
||||||
|
isActive: true, accountId: true, tenantId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listShared(tenantId: string): Promise<(GroupSummary & { sharedByTenantName: string })[]> {
|
||||||
|
const accesses = await this.prisma.groupAccess.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
include: {
|
||||||
|
group: {
|
||||||
|
select: {
|
||||||
|
id: true, name: true, platform: true, platformId: true,
|
||||||
|
isActive: true, accountId: true, tenantId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ownerTenantIds: string[] = [...new Set(accesses.map((a) => a.group.tenantId).filter((id): id is string => !!id))];
|
||||||
|
const tenants = ownerTenantIds.length > 0
|
||||||
|
? await this.prisma.tenant.findMany({
|
||||||
|
where: { id: { in: ownerTenantIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const tenantMap = new Map(tenants.map((t) => [t.id, t.name]));
|
||||||
|
return accesses.map((a) => ({
|
||||||
|
...a.group,
|
||||||
|
sharedByTenantName: a.group.tenantId ? tenantMap.get(a.group.tenantId) ?? 'Unknown' : 'Unknown',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSharedByMe(tenantId: string) {
|
||||||
|
const accesses = await this.prisma.groupAccess.findMany({
|
||||||
|
where: { group: { tenantId } },
|
||||||
|
include: {
|
||||||
|
group: { select: { id: true, name: true } },
|
||||||
|
tenant: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
// Group by group
|
||||||
|
const grouped = new Map<string, { groupId: string; groupName: string; sharedWith: { tenantId: string; tenantName: string; grantedAt: Date }[] }>();
|
||||||
|
for (const a of accesses) {
|
||||||
|
const key = a.group.id;
|
||||||
|
if (!grouped.has(key)) {
|
||||||
|
grouped.set(key, { groupId: a.group.id, groupName: a.group.name, sharedWith: [] });
|
||||||
|
}
|
||||||
|
grouped.get(key)!.sharedWith.push({
|
||||||
|
tenantId: a.tenantId,
|
||||||
|
tenantName: a.tenant.name,
|
||||||
|
grantedAt: a.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...grouped.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClaimTokenInfo(token: string) {
|
||||||
|
const record = await this.prisma.groupClaimToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { group: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!record) throw new NotFoundException('Invalid token');
|
||||||
|
return {
|
||||||
|
groupName: record.group.name,
|
||||||
|
expiresAt: record.expiresAt.toISOString(),
|
||||||
|
isConsumed: record.consumedAt !== null,
|
||||||
|
isExpired: record.expiresAt < new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimWithToken(token: string, adminId: string): Promise<GroupSummary> {
|
||||||
|
const admin = await this.prisma.admin.findUnique({
|
||||||
|
where: { id: adminId },
|
||||||
|
select: { tenantId: true },
|
||||||
|
});
|
||||||
|
if (!admin) throw new NotFoundException('Admin not found');
|
||||||
|
|
||||||
|
const record = await this.prisma.groupClaimToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
if (!record) throw new NotFoundException('Invalid token');
|
||||||
|
if (record.consumedAt) throw new ConflictException('Token has already been used');
|
||||||
|
if (record.expiresAt < new Date()) throw new ConflictException('Token has expired');
|
||||||
|
|
||||||
|
const group = await this.prisma.group.findUnique({
|
||||||
|
where: { id: record.groupId },
|
||||||
|
});
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
if (group.tenantId) throw new ConflictException('Group is already claimed');
|
||||||
|
|
||||||
|
// Account-binding: ensure the claiming tenant has a TenantBot link
|
||||||
|
if (group.accountId) {
|
||||||
|
const myLink = await this.prisma.tenantBot.findUnique({
|
||||||
|
where: { tenantId_accountId: { tenantId: admin.tenantId, accountId: group.accountId } },
|
||||||
|
});
|
||||||
|
if (!myLink) {
|
||||||
|
const anyLinks = await this.prisma.tenantBot.count({
|
||||||
|
where: { accountId: group.accountId },
|
||||||
|
});
|
||||||
|
if (anyLinks === 0) {
|
||||||
|
throw new ConflictException('Bot account has no tenant binding — cannot claim');
|
||||||
|
}
|
||||||
|
await this.prisma.tenantBot.create({
|
||||||
|
data: { tenantId: admin.tenantId, accountId: group.accountId, isActive: true },
|
||||||
|
});
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: admin.tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.BOT_ACCESS_GRANTED,
|
||||||
|
resourceType: 'Account',
|
||||||
|
resourceId: group.accountId,
|
||||||
|
payload: { reason: 'auto-grant on token claim', groupId: group.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.prisma.$transaction([
|
||||||
|
this.prisma.group.update({
|
||||||
|
where: { id: group.id },
|
||||||
|
data: { tenantId: admin.tenantId, claimStatus: 'CLAIMED' },
|
||||||
|
select: {
|
||||||
|
id: true, name: true, platform: true, platformId: true,
|
||||||
|
isActive: true, accountId: true, tenantId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.groupClaimToken.update({
|
||||||
|
where: { id: record.id },
|
||||||
|
data: { consumedAt: new Date() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: admin.tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.GROUP_CLAIMED_WITH_TOKEN,
|
||||||
|
resourceType: 'Group',
|
||||||
|
resourceId: group.id,
|
||||||
|
payload: { groupName: group.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async share(tenantId: string, adminId: string, groupId: string, targetTenantId: string) {
|
||||||
|
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
|
||||||
|
|
||||||
|
const existing = await this.prisma.groupAccess.findUnique({
|
||||||
|
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException('Group already shared with this tenant');
|
||||||
|
|
||||||
|
const access = await this.prisma.groupAccess.create({
|
||||||
|
data: { groupId, tenantId: targetTenantId, grantedBy: adminId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.GROUP_SHARED,
|
||||||
|
resourceType: 'Group',
|
||||||
|
resourceId: groupId,
|
||||||
|
payload: { targetTenantId, groupName: group.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return access;
|
||||||
|
}
|
||||||
|
|
||||||
|
async unshare(tenantId: string, groupId: string, targetTenantId: string) {
|
||||||
|
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
if (group.tenantId !== tenantId) throw new NotFoundException('Group not found');
|
||||||
|
|
||||||
|
const existing = await this.prisma.groupAccess.findUnique({
|
||||||
|
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Share not found');
|
||||||
|
|
||||||
|
await this.prisma.groupAccess.delete({
|
||||||
|
where: { groupId_tenantId: { groupId, tenantId: targetTenantId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId,
|
||||||
|
action: AuditAction.GROUP_UNSHARED,
|
||||||
|
resourceType: 'Group',
|
||||||
|
resourceId: groupId,
|
||||||
|
payload: { targetTenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerateToken(tenantId: string, adminId: string, groupId: string) {
|
||||||
|
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw new NotFoundException('Group not found');
|
||||||
|
// Allow regenerate for owned groups OR unclaimed groups (support case)
|
||||||
|
if (group.tenantId && group.tenantId !== tenantId) throw new NotFoundException('Group not found');
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
const record = await this.prisma.groupClaimToken.create({
|
||||||
|
data: {
|
||||||
|
groupId,
|
||||||
|
token,
|
||||||
|
creatorJid: token, // placeholder — support will need to extract jid from group metadata
|
||||||
|
expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
tenantId: group.tenantId ?? tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
action: AuditAction.GROUP_CLAIM_TOKEN_REGENERATED,
|
||||||
|
resourceType: 'Group',
|
||||||
|
resourceId: groupId,
|
||||||
|
payload: { tokenId: record.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, expiresAt: record.expiresAt.toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
check() {
|
check() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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';
|
||||||
|
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')
|
||||||
|
export class MessagesController {
|
||||||
|
constructor(private readonly messagesService: MessagesService) {}
|
||||||
|
|
||||||
|
@Get('pending')
|
||||||
|
listPending(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.messagesService.listPending(ctx.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('pending/count')
|
||||||
|
pendingCount(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
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,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.messagesService.get(ctx.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
approve(
|
||||||
|
@CurrentTenantContext() ctx: TenantContext,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: ApproveMessageDto,
|
||||||
|
) {
|
||||||
|
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reindex')
|
||||||
|
reindex(@CurrentTenantContext() ctx: TenantContext) {
|
||||||
|
return this.messagesService.reindexApproved(ctx.tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user